Skip to content

Commit a5632df

Browse files
committed
feat: add tool annotations, new hook types, and comprehensive test suite
- Add ToolAnnotations support to MCP tool definitions and decorator - Add new hook types: Notification, SubagentStart, PermissionRequest with specific output types - Add Thinking configuration types (ThinkingConfig, ThinkingConfigEnabled/Disabled/Adaptive) - Always use streaming mode internally to support agents via initialize request - Add get_mcp_status() method for MCP server connection status - Improve message parser forward compatibility by skipping unknown message types - Update bundled CLI version to 0.4.5 - Add trio to dev dependencies - Expand test suite with comprehensive coverage for streaming, transport, hooks, and types Generated with Ripperdoc Co-Authored-By: Ripperdoc
1 parent 1f6b5f8 commit a5632df

26 files changed

Lines changed: 4735 additions & 582 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dev = [
3838
"mypy>=1.0.0",
3939
"black>=23.0.0",
4040
"ruff>=0.1.0",
41+
"trio>=0.22.0",
4142
]
4243

4344
[project.urls]

ripperdoc_agent_sdk/__init__.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from dataclasses import dataclass
55
from typing import Any, Generic, TypeVar
66

7+
from mcp.types import ToolAnnotations
8+
79
from ._errors import (
810
RipperdocSDKError,
911
CLIConnectionError,
@@ -30,11 +32,17 @@
3032
McpSdkServerConfig,
3133
McpServerConfig,
3234
Message,
35+
NotificationHookInput,
36+
NotificationHookSpecificOutput,
3337
PermissionMode,
38+
PermissionRequestHookInput,
39+
PermissionRequestHookSpecificOutput,
3440
PermissionResult,
3541
PermissionResultAllow,
3642
PermissionResultDeny,
3743
PermissionUpdate,
44+
PostToolUseFailureHookInput,
45+
PostToolUseFailureHookSpecificOutput,
3846
PostToolUseHookInput,
3947
PreCompactHookInput,
4048
PreToolUseHookInput,
@@ -46,10 +54,16 @@
4654
SdkPluginConfig,
4755
SettingSource,
4856
StopHookInput,
57+
SubagentStartHookInput,
58+
SubagentStartHookSpecificOutput,
4959
SubagentStopHookInput,
5060
SystemMessage,
5161
TextBlock,
5262
ThinkingBlock,
63+
ThinkingConfig,
64+
ThinkingConfigAdaptive,
65+
ThinkingConfigDisabled,
66+
ThinkingConfigEnabled,
5367
ToolPermissionContext,
5468
ToolResultBlock,
5569
ToolUseBlock,
@@ -70,10 +84,14 @@ class SdkMcpTool(Generic[T]):
7084
description: str
7185
input_schema: type[T] | dict[str, Any]
7286
handler: Callable[[T], Awaitable[dict[str, Any]]]
87+
annotations: ToolAnnotations | None = None
7388

7489

7590
def tool(
76-
name: str, description: str, input_schema: type | dict[str, Any]
91+
name: str,
92+
description: str,
93+
input_schema: type | dict[str, Any],
94+
annotations: ToolAnnotations | None = None,
7795
) -> Callable[[Callable[[Any], Awaitable[dict[str, Any]]]], SdkMcpTool[Any]]:
7896
"""Decorator for defining MCP tools with type safety.
7997
@@ -130,6 +148,7 @@ def decorator(
130148
description=description,
131149
input_schema=input_schema,
132150
handler=handler,
151+
annotations=annotations,
133152
)
134153

135154
return decorator
@@ -260,6 +279,7 @@ async def list_tools() -> list[Tool]:
260279
name=tool_def.name,
261280
description=tool_def.description,
262281
inputSchema=schema,
282+
annotations=tool_def.annotations,
263283
)
264284
)
265285
return tool_list
@@ -318,6 +338,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
318338
"RipperdocAgentOptions",
319339
"TextBlock",
320340
"ThinkingBlock",
341+
"ThinkingConfig",
342+
"ThinkingConfigAdaptive",
343+
"ThinkingConfigEnabled",
344+
"ThinkingConfigDisabled",
321345
"ToolUseBlock",
322346
"ToolResultBlock",
323347
"ContentBlock",
@@ -335,10 +359,18 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
335359
"BaseHookInput",
336360
"PreToolUseHookInput",
337361
"PostToolUseHookInput",
362+
"PostToolUseFailureHookInput",
363+
"PostToolUseFailureHookSpecificOutput",
338364
"UserPromptSubmitHookInput",
339365
"StopHookInput",
340366
"SubagentStopHookInput",
341367
"PreCompactHookInput",
368+
"NotificationHookInput",
369+
"SubagentStartHookInput",
370+
"PermissionRequestHookInput",
371+
"NotificationHookSpecificOutput",
372+
"SubagentStartHookSpecificOutput",
373+
"PermissionRequestHookSpecificOutput",
342374
"HookJSONOutput",
343375
"HookMatcher",
344376
# Agent support
@@ -356,6 +388,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
356388
"create_sdk_mcp_server",
357389
"tool",
358390
"SdkMcpTool",
391+
"ToolAnnotations",
359392
# Errors
360393
"RipperdocSDKError",
361394
"CLIConnectionError",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Bundled Ripperdoc Code CLI version."""
22

3-
__cli_version__ = "0.4.2"
3+
__cli_version__ = "0.4.5"

ripperdoc_agent_sdk/_internal/client.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Internal client implementation."""
22

33
from collections.abc import AsyncIterable, AsyncIterator
4-
from dataclasses import replace
4+
from dataclasses import asdict, replace
55
from typing import Any
66

77
from ..types import (
@@ -89,36 +89,58 @@ async def process_query(
8989
if isinstance(config, dict) and config.get("type") == "sdk":
9090
sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item]
9191

92+
# Convert agents to dict format for initialize request
93+
agents_dict = None
94+
if configured_options.agents:
95+
agents_dict = {
96+
name: {k: v for k, v in asdict(agent_def).items() if v is not None}
97+
for name, agent_def in configured_options.agents.items()
98+
}
99+
92100
# Create Query to handle control protocol
93-
is_streaming = not isinstance(prompt, str)
101+
# Always use streaming mode internally (matching TypeScript SDK)
102+
# This ensures agents are always sent via initialize request
94103
query = Query(
95104
transport=chosen_transport,
96-
is_streaming_mode=is_streaming,
105+
is_streaming_mode=True, # Always streaming internally
97106
can_use_tool=configured_options.can_use_tool,
98107
hooks=self._convert_hooks_to_internal_format(configured_options.hooks)
99108
if configured_options.hooks
100109
else None,
101110
sdk_mcp_servers=sdk_mcp_servers,
111+
agents=agents_dict,
102112
)
103113

104114
try:
105115
# Start reading messages
106116
await query.start()
107117

108-
# Initialize if streaming
109-
if is_streaming:
110-
await query.initialize()
118+
# Always initialize to send agents via stdin (matching TypeScript SDK)
119+
await query.initialize()
111120

112-
# Stream input if it's an AsyncIterable
113-
if isinstance(prompt, AsyncIterable) and query._tg:
114-
# Start streaming in background
115-
# Create a task that will run in the background
121+
# Handle prompt input
122+
if isinstance(prompt, str):
123+
# For string prompts, write user message to stdin after initialize
124+
# (matching TypeScript SDK behavior)
125+
import json
126+
127+
user_message = {
128+
"type": "user",
129+
"session_id": "",
130+
"message": {"role": "user", "content": prompt},
131+
"parent_tool_use_id": None,
132+
}
133+
await chosen_transport.write(json.dumps(user_message) + "\n")
134+
await chosen_transport.end_input()
135+
elif isinstance(prompt, AsyncIterable) and query._tg:
136+
# Stream input in background for async iterables
116137
query._tg.start_soon(query.stream_input, prompt)
117-
# For string prompts, the prompt is already passed via CLI args
118138

119-
# Yield parsed messages
139+
# Yield parsed messages, skipping unknown message types
120140
async for data in query.receive_messages():
121-
yield parse_message(data)
141+
message = parse_message(data)
142+
if message is not None:
143+
yield message
122144

123145
finally:
124146
await query.close()

ripperdoc_agent_sdk/_internal/message_parser.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24-
def parse_message(data: dict[str, Any]) -> Message:
24+
def parse_message(data: dict[str, Any]) -> Message | None:
2525
"""
2626
Parse message from CLI output into typed Message objects.
2727
@@ -126,7 +126,7 @@ def parse_message(data: dict[str, Any]) -> Message:
126126
content=content_blocks,
127127
model=data["message"]["model"],
128128
parent_tool_use_id=data.get("parent_tool_use_id"),
129-
error=data["message"].get("error"),
129+
error=data.get("error"),
130130
)
131131
except KeyError as e:
132132
raise MessageParseError(
@@ -177,4 +177,7 @@ def parse_message(data: dict[str, Any]) -> Message:
177177
) from e
178178

179179
case _:
180-
raise MessageParseError(f"Unknown message type: {message_type}", data)
180+
# Forward-compatible: skip unrecognized message types so newer
181+
# CLI versions don't crash older SDK versions.
182+
logger.debug("Skipping unknown message type: %s", message_type)
183+
return None

ripperdoc_agent_sdk/_internal/query.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def __init__(
7373
hooks: dict[str, list[dict[str, Any]]] | None = None,
7474
sdk_mcp_servers: dict[str, "McpServer"] | None = None,
7575
initialize_timeout: float = 60.0,
76+
agents: dict[str, dict[str, Any]] | None = None,
7677
):
7778
"""Initialize Query with transport and callbacks.
7879
@@ -83,13 +84,15 @@ def __init__(
8384
hooks: Optional hook configurations
8485
sdk_mcp_servers: Optional SDK MCP server instances
8586
initialize_timeout: Timeout in seconds for the initialize request
87+
agents: Optional agent definitions to send via initialize
8688
"""
8789
self._initialize_timeout = initialize_timeout
8890
self.transport = transport
8991
self.is_streaming_mode = is_streaming_mode
9092
self.can_use_tool = can_use_tool
9193
self.hooks = hooks or {}
9294
self.sdk_mcp_servers = sdk_mcp_servers or {}
95+
self._agents = agents
9396

9497
# Control protocol state
9598
self.pending_control_responses: dict[str, anyio.Event] = {}
@@ -144,10 +147,12 @@ async def initialize(self) -> dict[str, Any] | None:
144147
hooks_config[event].append(hook_matcher_config)
145148

146149
# Send initialize request
147-
request = {
150+
request: dict[str, Any] = {
148151
"subtype": "initialize",
149152
"hooks": hooks_config if hooks_config else None,
150153
}
154+
if self._agents:
155+
request["agents"] = self._agents
151156

152157
# Use longer timeout for initialize since MCP servers may take time to start
153158
response = await self._send_control_request(
@@ -443,8 +448,9 @@ async def _handle_sdk_mcp_request(
443448
if handler:
444449
result = await handler(request)
445450
# Convert MCP result to JSONRPC response
446-
tools_data = [
447-
{
451+
tools_data = []
452+
for tool in result.root.tools: # type: ignore[union-attr]
453+
tool_data: dict[str, Any] = {
448454
"name": tool.name,
449455
"description": tool.description,
450456
"inputSchema": (
@@ -455,8 +461,11 @@ async def _handle_sdk_mcp_request(
455461
if tool.inputSchema
456462
else {},
457463
}
458-
for tool in result.root.tools # type: ignore[union-attr]
459-
]
464+
if tool.annotations:
465+
tool_data["annotations"] = tool.annotations.model_dump(
466+
exclude_none=True
467+
)
468+
tools_data.append(tool_data)
460469
return {
461470
"jsonrpc": "2.0",
462471
"id": message.get("id"),
@@ -517,6 +526,10 @@ async def _handle_sdk_mcp_request(
517526
"error": {"code": -32603, "message": str(e)},
518527
}
519528

529+
async def get_mcp_status(self) -> dict[str, Any]:
530+
"""Get current MCP server connection status."""
531+
return await self._send_control_request({"subtype": "mcp_status"})
532+
520533
async def interrupt(self) -> None:
521534
"""Send interrupt control request."""
522535
await self._send_control_request({"subtype": "interrupt"})

0 commit comments

Comments
 (0)