Skip to content

Commit 28f9b4b

Browse files
authored
feat: add MCP control methods and typed McpServerStatus (#620)
Adds MCP server control methods and a typed `get_mcp_status()` return, matching the TypeScript SDK. ## New client methods - `reconnect_mcp_server(server_name: str) -> None` — reconnect a running MCP server - `toggle_mcp_server(server_name: str, enabled: bool) -> None` — enable/disable an MCP server - `stop_task(task_id: str) -> None` — stop a running task (a `task_notification` with status `'stopped'` follows) ## Typed `get_mcp_status()` return Changed return type from `dict[str, Any]` → `McpStatusResponse` (TypedDict). Runtime-safe — it's still a dict — but callers now get typed access to `status["mcpServers"]` with `McpServerStatus` entries (`name`, `status`, `config`, `scope`, `tools`, `serverInfo`, `error`). Status enum: `'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled'` Wire format note: the `mcp_reconnect`/`mcp_toggle` control requests use camelCase `serverName` (verified against the CLI's Zod schemas). <!-- CHANGELOG:START --> - Add `reconnect_mcp_server()`, `toggle_mcp_server()`, `stop_task()` methods to `ClaudeSDKClient` - Add typed `McpServerStatus` / `McpStatusResponse` for `get_mcp_status()` return <!-- CHANGELOG:END --> ## Test plan - Unit: control-request JSON shape verified via mock transport (camelCase `serverName`, `enabled: bool`, `task_id: str`) - E2E: full state machine verified against real CLI with a live stdio MCP server — `connected → disabled → connected` via toggle, reconnect succeeds, `McpServerStatus` shape validated against 41 real server entries including `claudeai-proxy` config
1 parent 7219299 commit 28f9b4b

6 files changed

Lines changed: 737 additions & 6 deletions

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
HookMatcher,
3232
McpSdkServerConfig,
3333
McpServerConfig,
34+
McpServerConnectionStatus,
35+
McpServerInfo,
36+
McpServerStatus,
37+
McpServerStatusConfig,
38+
McpStatusResponse,
39+
McpToolAnnotations,
40+
McpToolInfo,
3441
Message,
3542
NotificationHookInput,
3643
NotificationHookSpecificOutput,
@@ -330,6 +337,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
330337
"PermissionMode",
331338
"McpServerConfig",
332339
"McpSdkServerConfig",
340+
"McpServerStatus",
341+
"McpServerStatusConfig",
342+
"McpServerConnectionStatus",
343+
"McpServerInfo",
344+
"McpStatusResponse",
345+
"McpToolAnnotations",
346+
"McpToolInfo",
333347
"UserMessage",
334348
"AssistantMessage",
335349
"SystemMessage",

src/claude_agent_sdk/_internal/query.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,47 @@ async def rewind_files(self, user_message_id: str) -> None:
567567
}
568568
)
569569

570+
async def reconnect_mcp_server(self, server_name: str) -> None:
571+
"""Reconnect a disconnected or failed MCP server.
572+
573+
Args:
574+
server_name: The name of the MCP server to reconnect
575+
"""
576+
await self._send_control_request(
577+
{
578+
"subtype": "mcp_reconnect",
579+
"serverName": server_name,
580+
}
581+
)
582+
583+
async def toggle_mcp_server(self, server_name: str, enabled: bool) -> None:
584+
"""Enable or disable an MCP server.
585+
586+
Args:
587+
server_name: The name of the MCP server to toggle
588+
enabled: Whether the server should be enabled
589+
"""
590+
await self._send_control_request(
591+
{
592+
"subtype": "mcp_toggle",
593+
"serverName": server_name,
594+
"enabled": enabled,
595+
}
596+
)
597+
598+
async def stop_task(self, task_id: str) -> None:
599+
"""Stop a running task.
600+
601+
Args:
602+
task_id: The task ID from task_notification events
603+
"""
604+
await self._send_control_request(
605+
{
606+
"subtype": "stop_task",
607+
"task_id": task_id,
608+
}
609+
)
610+
570611
async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None:
571612
"""Stream input messages to transport.
572613

src/claude_agent_sdk/client.py

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99
from . import Transport
1010
from ._errors import CLIConnectionError
11-
from .types import ClaudeAgentOptions, HookEvent, HookMatcher, Message, ResultMessage
11+
from .types import (
12+
ClaudeAgentOptions,
13+
HookEvent,
14+
HookMatcher,
15+
McpStatusResponse,
16+
Message,
17+
ResultMessage,
18+
)
1219

1320

1421
class ClaudeSDKClient:
@@ -304,30 +311,108 @@ async def rewind_files(self, user_message_id: str) -> None:
304311
raise CLIConnectionError("Not connected. Call connect() first.")
305312
await self._query.rewind_files(user_message_id)
306313

307-
async def get_mcp_status(self) -> dict[str, Any]:
314+
async def reconnect_mcp_server(self, server_name: str) -> None:
315+
"""Reconnect a disconnected or failed MCP server (only works with streaming mode).
316+
317+
Use this to retry connecting to an MCP server that failed to connect
318+
or was disconnected. Raises an exception if the reconnection fails.
319+
320+
Args:
321+
server_name: The name of the MCP server to reconnect
322+
323+
Example:
324+
```python
325+
async with ClaudeSDKClient(options) as client:
326+
status = await client.get_mcp_status()
327+
for server in status.get("mcpServers", []):
328+
if server["status"] == "failed":
329+
await client.reconnect_mcp_server(server["name"])
330+
```
331+
"""
332+
if not self._query:
333+
raise CLIConnectionError("Not connected. Call connect() first.")
334+
await self._query.reconnect_mcp_server(server_name)
335+
336+
async def toggle_mcp_server(self, server_name: str, enabled: bool) -> None:
337+
"""Enable or disable an MCP server (only works with streaming mode).
338+
339+
Disabling a server disconnects it and removes its tools from the
340+
available tool set. Enabling a server reconnects it and makes its
341+
tools available again. Raises an exception on failure.
342+
343+
Args:
344+
server_name: The name of the MCP server to toggle
345+
enabled: True to enable the server, False to disable it
346+
347+
Example:
348+
```python
349+
async with ClaudeSDKClient(options) as client:
350+
# Temporarily disable a server
351+
await client.toggle_mcp_server("my-server", enabled=False)
352+
await client.query("Do something without my-server tools")
353+
354+
# Re-enable it later
355+
await client.toggle_mcp_server("my-server", enabled=True)
356+
```
357+
"""
358+
if not self._query:
359+
raise CLIConnectionError("Not connected. Call connect() first.")
360+
await self._query.toggle_mcp_server(server_name, enabled)
361+
362+
async def stop_task(self, task_id: str) -> None:
363+
"""Stop a running task (only works with streaming mode).
364+
365+
After this resolves, a `task_notification` system message with
366+
status `'stopped'` will be emitted by the CLI in the message stream.
367+
368+
Args:
369+
task_id: The task ID from `task_notification` events.
370+
371+
Example:
372+
```python
373+
async with ClaudeSDKClient() as client:
374+
await client.query("Start a long-running task")
375+
376+
# Listen for task_notification to get task_id, then:
377+
await client.stop_task("task-abc123")
378+
# A task_notification with status 'stopped' will follow
379+
```
380+
"""
381+
if not self._query:
382+
raise CLIConnectionError("Not connected. Call connect() first.")
383+
await self._query.stop_task(task_id)
384+
385+
async def get_mcp_status(self) -> McpStatusResponse:
308386
"""Get current MCP server connection status (only works with streaming mode).
309387
310388
Queries the Claude Code CLI for the live connection status of all
311389
configured MCP servers.
312390
313391
Returns:
314-
Dictionary with MCP server status information. Contains a
315-
'mcpServers' key with a list of server status objects, each having:
392+
McpStatusResponse dictionary with an 'mcpServers' key containing
393+
a list of McpServerStatus entries. Each entry includes:
316394
- 'name': Server name (str)
317395
- 'status': Connection status ('connected', 'pending', 'failed',
318396
'needs-auth', 'disabled')
397+
- 'serverInfo': MCP server name/version (when connected)
398+
- 'error': Error message (when status is 'failed')
399+
- 'config': Server configuration (stdio/sse/http/sdk/claudeai-proxy)
400+
- 'scope': Configuration scope (e.g., project, user, local)
401+
- 'tools': List of tools provided by the server (when connected)
319402
320403
Example:
321404
```python
322405
async with ClaudeSDKClient(options) as client:
323406
status = await client.get_mcp_status()
324-
for server in status.get("mcpServers", []):
407+
for server in status["mcpServers"]:
325408
print(f"{server['name']}: {server['status']}")
409+
if server["status"] == "failed":
410+
print(f" Error: {server.get('error')}")
326411
```
327412
"""
328413
if not self._query:
329414
raise CLIConnectionError("Not connected. Call connect() first.")
330-
result: dict[str, Any] = await self._query.get_mcp_status()
415+
result: McpStatusResponse = await self._query.get_mcp_status()
331416
return result
332417

333418
async def get_server_info(self) -> dict[str, Any] | None:

src/claude_agent_sdk/types.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,116 @@ class McpSdkServerConfig(TypedDict):
504504
)
505505

506506

507+
# MCP Server Status types (returned by get_mcp_status)
508+
# These mirror the TypeScript SDK's McpServerStatus type and use wire-format
509+
# field names (camelCase where applicable) since they come directly from CLI
510+
# JSON output.
511+
512+
513+
class McpSdkServerConfigStatus(TypedDict):
514+
"""SDK MCP server config as returned in status responses.
515+
516+
Unlike McpSdkServerConfig (which includes the in-process `instance`),
517+
this output-only type only has serializable fields.
518+
"""
519+
520+
type: Literal["sdk"]
521+
name: str
522+
523+
524+
class McpClaudeAIProxyServerConfig(TypedDict):
525+
"""Claude.ai proxy MCP server config.
526+
527+
Output-only type that appears in status responses for servers proxied
528+
through Claude.ai.
529+
"""
530+
531+
type: Literal["claudeai-proxy"]
532+
url: str
533+
id: str
534+
535+
536+
# Broader config type for status responses (includes claudeai-proxy which is
537+
# output-only)
538+
McpServerStatusConfig = (
539+
McpStdioServerConfig
540+
| McpSSEServerConfig
541+
| McpHttpServerConfig
542+
| McpSdkServerConfigStatus
543+
| McpClaudeAIProxyServerConfig
544+
)
545+
546+
547+
class McpToolAnnotations(TypedDict, total=False):
548+
"""Tool annotations as returned in MCP server status.
549+
550+
Wire format uses camelCase field names (from CLI JSON output).
551+
"""
552+
553+
readOnly: bool
554+
destructive: bool
555+
openWorld: bool
556+
557+
558+
class McpToolInfo(TypedDict):
559+
"""Information about a tool provided by an MCP server."""
560+
561+
name: str
562+
description: NotRequired[str]
563+
annotations: NotRequired[McpToolAnnotations]
564+
565+
566+
class McpServerInfo(TypedDict):
567+
"""Server info from MCP initialize handshake (available when connected)."""
568+
569+
name: str
570+
version: str
571+
572+
573+
# Connection status values for an MCP server
574+
McpServerConnectionStatus = Literal[
575+
"connected", "failed", "needs-auth", "pending", "disabled"
576+
]
577+
578+
579+
class McpServerStatus(TypedDict):
580+
"""Status information for an MCP server connection.
581+
582+
Returned by `ClaudeSDKClient.get_mcp_status()` in the `mcpServers` list.
583+
"""
584+
585+
name: str
586+
"""Server name as configured."""
587+
588+
status: McpServerConnectionStatus
589+
"""Current connection status."""
590+
591+
serverInfo: NotRequired[McpServerInfo]
592+
"""Server information from MCP handshake (available when connected)."""
593+
594+
error: NotRequired[str]
595+
"""Error message (available when status is 'failed')."""
596+
597+
config: NotRequired[McpServerStatusConfig]
598+
"""Server configuration (includes URL for HTTP/SSE servers)."""
599+
600+
scope: NotRequired[str]
601+
"""Configuration scope (e.g., project, user, local, claudeai, managed)."""
602+
603+
tools: NotRequired[list[McpToolInfo]]
604+
"""Tools provided by this server (available when connected)."""
605+
606+
607+
class McpStatusResponse(TypedDict):
608+
"""Response from `ClaudeSDKClient.get_mcp_status()`.
609+
610+
Wraps the list of server statuses under the `mcpServers` key, matching
611+
the wire-format response shape.
612+
"""
613+
614+
mcpServers: list[McpServerStatus]
615+
616+
507617
class SdkPluginConfig(TypedDict):
508618
"""SDK plugin configuration.
509619
@@ -829,6 +939,28 @@ class SDKControlRewindFilesRequest(TypedDict):
829939
user_message_id: str
830940

831941

942+
class SDKControlMcpReconnectRequest(TypedDict):
943+
"""Reconnects a disconnected or failed MCP server."""
944+
945+
subtype: Literal["mcp_reconnect"]
946+
# Note: wire protocol uses camelCase for this field
947+
serverName: str
948+
949+
950+
class SDKControlMcpToggleRequest(TypedDict):
951+
"""Enables or disables an MCP server."""
952+
953+
subtype: Literal["mcp_toggle"]
954+
# Note: wire protocol uses camelCase for this field
955+
serverName: str
956+
enabled: bool
957+
958+
959+
class SDKControlStopTaskRequest(TypedDict):
960+
subtype: Literal["stop_task"]
961+
task_id: str
962+
963+
832964
class SDKControlRequest(TypedDict):
833965
type: Literal["control_request"]
834966
request_id: str
@@ -840,6 +972,9 @@ class SDKControlRequest(TypedDict):
840972
| SDKHookCallbackRequest
841973
| SDKControlMcpMessageRequest
842974
| SDKControlRewindFilesRequest
975+
| SDKControlMcpReconnectRequest
976+
| SDKControlMcpToggleRequest
977+
| SDKControlStopTaskRequest
843978
)
844979

845980

0 commit comments

Comments
 (0)