Skip to content

Commit 980ec73

Browse files
committed
feat: add typed McpServerStatus return for get_mcp_status
1 parent 041877d commit 980ec73

5 files changed

Lines changed: 385 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/client.py

Lines changed: 20 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:
@@ -375,30 +382,37 @@ async def stop_task(self, task_id: str) -> None:
375382
raise CLIConnectionError("Not connected. Call connect() first.")
376383
await self._query.stop_task(task_id)
377384

378-
async def get_mcp_status(self) -> dict[str, Any]:
385+
async def get_mcp_status(self) -> McpStatusResponse:
379386
"""Get current MCP server connection status (only works with streaming mode).
380387
381388
Queries the Claude Code CLI for the live connection status of all
382389
configured MCP servers.
383390
384391
Returns:
385-
Dictionary with MCP server status information. Contains a
386-
'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:
387394
- 'name': Server name (str)
388395
- 'status': Connection status ('connected', 'pending', 'failed',
389396
'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)
390402
391403
Example:
392404
```python
393405
async with ClaudeSDKClient(options) as client:
394406
status = await client.get_mcp_status()
395-
for server in status.get("mcpServers", []):
407+
for server in status["mcpServers"]:
396408
print(f"{server['name']}: {server['status']}")
409+
if server["status"] == "failed":
410+
print(f" Error: {server.get('error')}")
397411
```
398412
"""
399413
if not self._query:
400414
raise CLIConnectionError("Not connected. Call connect() first.")
401-
result: dict[str, Any] = await self._query.get_mcp_status()
415+
result: McpStatusResponse = await self._query.get_mcp_status()
402416
return result
403417

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

src/claude_agent_sdk/types.py

Lines changed: 110 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

tests/test_streaming_client.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,152 @@ async def _test():
682682

683683
anyio.run(_test)
684684

685+
def test_get_mcp_status(self):
686+
"""Test get_mcp_status returns McpStatusResponse shape."""
687+
688+
async def _test():
689+
with patch(
690+
"claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport"
691+
) as mock_transport_class:
692+
mock_transport = AsyncMock()
693+
mock_transport.connect = AsyncMock()
694+
mock_transport.close = AsyncMock()
695+
mock_transport.end_input = AsyncMock()
696+
mock_transport.is_ready = Mock(return_value=True)
697+
mock_transport_class.return_value = mock_transport
698+
699+
written_messages: list[str] = []
700+
701+
async def mock_write(data):
702+
written_messages.append(data)
703+
704+
mock_transport.write = AsyncMock(side_effect=mock_write)
705+
706+
# Simulated mcp_status response matching McpServerStatus shape
707+
mcp_status_response = {
708+
"mcpServers": [
709+
{
710+
"name": "my-http-server",
711+
"status": "connected",
712+
"serverInfo": {
713+
"name": "my-http-server",
714+
"version": "1.0.0",
715+
},
716+
"config": {
717+
"type": "http",
718+
"url": "https://example.com/mcp",
719+
},
720+
"scope": "project",
721+
"tools": [
722+
{
723+
"name": "greet",
724+
"description": "Greet a user",
725+
"annotations": {"readOnly": True},
726+
},
727+
{"name": "reset"},
728+
],
729+
},
730+
{
731+
"name": "failed-server",
732+
"status": "failed",
733+
"error": "Connection refused",
734+
},
735+
{
736+
"name": "proxy-server",
737+
"status": "needs-auth",
738+
"config": {
739+
"type": "claudeai-proxy",
740+
"url": "https://claude.ai/proxy",
741+
"id": "proxy-123",
742+
},
743+
},
744+
]
745+
}
746+
747+
async def control_protocol_generator():
748+
last_check = 0
749+
timeout_counter = 0
750+
while timeout_counter < 200:
751+
await asyncio.sleep(0.01)
752+
timeout_counter += 1
753+
754+
for msg_str in written_messages[last_check:]:
755+
try:
756+
msg = json.loads(msg_str.strip())
757+
if msg.get("type") == "control_request":
758+
subtype = msg.get("request", {}).get("subtype")
759+
if subtype == "initialize":
760+
yield {
761+
"type": "control_response",
762+
"response": {
763+
"request_id": msg.get("request_id"),
764+
"subtype": "success",
765+
"response": {},
766+
},
767+
}
768+
elif subtype == "mcp_status":
769+
yield {
770+
"type": "control_response",
771+
"response": {
772+
"request_id": msg.get("request_id"),
773+
"subtype": "success",
774+
"response": mcp_status_response,
775+
},
776+
}
777+
except (json.JSONDecodeError, KeyError, AttributeError):
778+
pass
779+
last_check = len(written_messages)
780+
781+
mock_transport.read_messages = control_protocol_generator
782+
783+
async with ClaudeSDKClient() as client:
784+
status = await client.get_mcp_status()
785+
786+
# Verify response conforms to McpStatusResponse shape
787+
assert "mcpServers" in status
788+
servers = status["mcpServers"]
789+
assert len(servers) == 3
790+
791+
# Connected server with full info
792+
connected = servers[0]
793+
assert connected["name"] == "my-http-server"
794+
assert connected["status"] == "connected"
795+
assert connected["serverInfo"]["version"] == "1.0.0"
796+
assert connected["config"]["type"] == "http"
797+
assert connected["config"]["url"] == "https://example.com/mcp"
798+
assert connected["scope"] == "project"
799+
assert len(connected["tools"]) == 2
800+
assert connected["tools"][0]["name"] == "greet"
801+
assert connected["tools"][0]["annotations"]["readOnly"] is True
802+
# Tool without optional fields
803+
assert connected["tools"][1]["name"] == "reset"
804+
assert "description" not in connected["tools"][1]
805+
806+
# Failed server with error
807+
failed = servers[1]
808+
assert failed["name"] == "failed-server"
809+
assert failed["status"] == "failed"
810+
assert failed["error"] == "Connection refused"
811+
812+
# Server with claudeai-proxy config
813+
proxy = servers[2]
814+
assert proxy["name"] == "proxy-server"
815+
assert proxy["status"] == "needs-auth"
816+
assert proxy["config"]["type"] == "claudeai-proxy"
817+
assert proxy["config"]["id"] == "proxy-123"
818+
819+
anyio.run(_test)
820+
821+
def test_get_mcp_status_not_connected(self):
822+
"""Test get_mcp_status when not connected raises error."""
823+
824+
async def _test():
825+
client = ClaudeSDKClient()
826+
with pytest.raises(CLIConnectionError, match="Not connected"):
827+
await client.get_mcp_status()
828+
829+
anyio.run(_test)
830+
685831
def test_client_with_options(self):
686832
"""Test client initialization with options."""
687833

0 commit comments

Comments
 (0)