881 lines
30 KiB
Python
881 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import selectors
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
MCP_PROTOCOL_VERSION = '2025-11-25'
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MCPResource:
|
|
uri: str
|
|
server_name: str
|
|
source_manifest: str
|
|
name: str | None = None
|
|
description: str | None = None
|
|
mime_type: str | None = None
|
|
resolved_path: str | None = None
|
|
inline_text: str | None = None
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MCPTool:
|
|
name: str
|
|
server_name: str
|
|
source_manifest: str
|
|
description: str | None = None
|
|
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MCPServerProfile:
|
|
name: str
|
|
source_manifest: str
|
|
transport: str
|
|
command: str | None = None
|
|
args: tuple[str, ...] = ()
|
|
env: dict[str, str] = field(default_factory=dict)
|
|
cwd: str | None = None
|
|
description: str | None = None
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class MCPRuntime:
|
|
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
|
|
servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple)
|
|
|
|
@classmethod
|
|
def from_workspace(
|
|
cls,
|
|
cwd: Path,
|
|
additional_working_directories: tuple[str, ...] = (),
|
|
) -> 'MCPRuntime':
|
|
resources: list[MCPResource] = []
|
|
servers: list[MCPServerProfile] = []
|
|
for path in _discover_manifest_paths(cwd, additional_working_directories):
|
|
manifest_resources, manifest_servers = _load_manifest(path)
|
|
resources.extend(manifest_resources)
|
|
servers.extend(manifest_servers)
|
|
return cls(
|
|
resources=tuple(resources),
|
|
servers=tuple(_dedupe_servers(servers)),
|
|
)
|
|
|
|
@property
|
|
def manifests(self) -> tuple[str, ...]:
|
|
seen: list[str] = []
|
|
for entry in [*self.resources, *self.servers]:
|
|
source_manifest = entry.source_manifest
|
|
if source_manifest not in seen:
|
|
seen.append(source_manifest)
|
|
return tuple(seen)
|
|
|
|
def has_transport_servers(self) -> bool:
|
|
return any(server.transport == 'stdio' for server in self.servers)
|
|
|
|
def list_resources(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
limit: int | None = None,
|
|
) -> tuple[MCPResource, ...]:
|
|
resources = list(self.resources)
|
|
resources.extend(self._list_remote_resources())
|
|
filtered = _filter_resources(tuple(resources), query=query)
|
|
if limit is not None and limit >= 0:
|
|
filtered = filtered[:limit]
|
|
return filtered
|
|
|
|
def get_resource(self, uri: str) -> MCPResource | None:
|
|
for resource in self.resources:
|
|
if resource.uri == uri:
|
|
return resource
|
|
for resource in self._list_remote_resources():
|
|
if resource.uri == uri:
|
|
return resource
|
|
return None
|
|
|
|
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
|
for resource in self.resources:
|
|
if resource.uri != uri:
|
|
continue
|
|
if resource.inline_text is not None:
|
|
return _truncate(resource.inline_text, max_chars)
|
|
if resource.resolved_path is not None:
|
|
path = Path(resource.resolved_path)
|
|
if not path.exists() or not path.is_file():
|
|
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
|
text = path.read_text(encoding='utf-8', errors='replace')
|
|
return _truncate(text, max_chars)
|
|
last_error: Exception | None = None
|
|
candidate_servers: list[MCPServerProfile] = []
|
|
discovered = self.get_resource(uri)
|
|
if discovered is not None:
|
|
server = self.get_server(discovered.server_name)
|
|
if server is not None:
|
|
candidate_servers.append(server)
|
|
for server in self.servers:
|
|
if server.transport != 'stdio':
|
|
continue
|
|
if all(existing.name != server.name for existing in candidate_servers):
|
|
candidate_servers.append(server)
|
|
for server in candidate_servers:
|
|
try:
|
|
result = _request_stdio(server, 'resources/read', {'uri': uri})
|
|
except Exception as exc:
|
|
last_error = exc
|
|
continue
|
|
rendered = _render_resource_contents(result.get('contents'))
|
|
if rendered:
|
|
return _truncate(rendered, max_chars)
|
|
if last_error is not None:
|
|
raise FileNotFoundError(f'Unable to read MCP resource {uri}: {last_error}') from last_error
|
|
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
|
|
|
|
def list_tools(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
server_name: str | None = None,
|
|
limit: int | None = None,
|
|
) -> tuple[MCPTool, ...]:
|
|
tools = self._list_remote_tools(server_name=server_name)
|
|
if query:
|
|
needle = query.lower()
|
|
tools = tuple(
|
|
tool
|
|
for tool in tools
|
|
if needle in tool.name.lower()
|
|
or needle in (tool.description or '').lower()
|
|
or needle in tool.server_name.lower()
|
|
)
|
|
if limit is not None and limit >= 0:
|
|
tools = tools[:limit]
|
|
return tools
|
|
|
|
def call_tool(
|
|
self,
|
|
tool_name: str,
|
|
*,
|
|
arguments: dict[str, Any] | None = None,
|
|
server_name: str | None = None,
|
|
max_chars: int = 12000,
|
|
) -> tuple[str, dict[str, Any]]:
|
|
tool = self._resolve_tool(tool_name, server_name=server_name)
|
|
server = self.get_server(tool.server_name)
|
|
if server is None:
|
|
raise FileNotFoundError(f'Unknown MCP server: {tool.server_name}')
|
|
payload = {
|
|
'name': tool.name,
|
|
'arguments': dict(arguments or {}),
|
|
}
|
|
result = _request_stdio(server, 'tools/call', payload)
|
|
rendered = _truncate(_render_tool_call_result(result), max_chars)
|
|
metadata = {
|
|
'server_name': tool.server_name,
|
|
'tool_name': tool.name,
|
|
'is_error': bool(result.get('isError')),
|
|
}
|
|
return rendered, metadata
|
|
|
|
def get_server(self, name: str) -> MCPServerProfile | None:
|
|
needle = name.strip().lower()
|
|
if not needle:
|
|
return None
|
|
for server in self.servers:
|
|
if server.name.lower() == needle:
|
|
return server
|
|
return None
|
|
|
|
def render_summary(self) -> str:
|
|
if not self.resources and not self.servers:
|
|
return 'No local MCP manifests, servers, or resources discovered.'
|
|
lines = [
|
|
f'Local MCP manifests: {len(self.manifests)}',
|
|
f'Local MCP resources: {len(self.resources)}',
|
|
f'Configured MCP servers: {len(self.servers)}',
|
|
]
|
|
transport_counts: dict[str, int] = {}
|
|
for server in self.servers:
|
|
transport_counts[server.transport] = transport_counts.get(server.transport, 0) + 1
|
|
for transport, count in sorted(transport_counts.items()):
|
|
lines.append(f'- {transport}: {count} server(s)')
|
|
by_server: dict[str, int] = {}
|
|
for resource in self.resources:
|
|
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
|
|
for server_name, count in sorted(by_server.items()):
|
|
lines.append(f'- local resources for {server_name}: {count}')
|
|
for server in self.servers[:10]:
|
|
details = [server.name, server.transport]
|
|
if server.command:
|
|
details.append(server.command)
|
|
lines.append('- Server: ' + ' ; '.join(details))
|
|
return '\n'.join(lines)
|
|
|
|
def render_resource_index(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
limit: int = 20,
|
|
) -> str:
|
|
resources = self.list_resources(query=query, limit=limit)
|
|
if not resources:
|
|
return '# MCP Resources\n\nNo matching MCP resources discovered.'
|
|
lines = ['# MCP Resources', '']
|
|
for resource in resources:
|
|
details = [resource.uri]
|
|
details.append(f'server={resource.server_name}')
|
|
if resource.name:
|
|
details.append(f'name={resource.name}')
|
|
if resource.mime_type:
|
|
details.append(f'mime={resource.mime_type}')
|
|
if resource.resolved_path:
|
|
details.append(f'path={resource.resolved_path}')
|
|
elif resource.inline_text is not None:
|
|
details.append('source=inline')
|
|
else:
|
|
details.append('source=transport')
|
|
lines.append('- ' + '; '.join(details))
|
|
return '\n'.join(lines)
|
|
|
|
def render_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
|
resource = self.get_resource(uri)
|
|
if resource is None:
|
|
return f'# MCP Resource\n\nUnknown MCP resource: {uri}'
|
|
lines = [
|
|
'# MCP Resource',
|
|
'',
|
|
f'- URI: {resource.uri}',
|
|
f'- Server: {resource.server_name}',
|
|
]
|
|
if resource.name:
|
|
lines.append(f'- Name: {resource.name}')
|
|
if resource.mime_type:
|
|
lines.append(f'- MIME Type: {resource.mime_type}')
|
|
if resource.resolved_path:
|
|
lines.append(f'- Path: {resource.resolved_path}')
|
|
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
|
|
return '\n'.join(lines)
|
|
|
|
def render_tool_index(
|
|
self,
|
|
*,
|
|
query: str | None = None,
|
|
server_name: str | None = None,
|
|
limit: int = 50,
|
|
) -> str:
|
|
tools = self.list_tools(query=query, server_name=server_name, limit=limit)
|
|
if not tools:
|
|
return '# MCP Tools\n\nNo matching MCP tools discovered.'
|
|
lines = ['# MCP Tools', '']
|
|
for tool in tools:
|
|
details = [tool.name, f'server={tool.server_name}']
|
|
if tool.description:
|
|
details.append(tool.description)
|
|
lines.append('- ' + ' ; '.join(details))
|
|
return '\n'.join(lines)
|
|
|
|
def render_tool_call(
|
|
self,
|
|
tool_name: str,
|
|
*,
|
|
arguments: dict[str, Any] | None = None,
|
|
server_name: str | None = None,
|
|
max_chars: int = 12000,
|
|
) -> str:
|
|
content, metadata = self.call_tool(
|
|
tool_name,
|
|
arguments=arguments,
|
|
server_name=server_name,
|
|
max_chars=max_chars,
|
|
)
|
|
lines = [
|
|
'# MCP Tool Result',
|
|
'',
|
|
f'- Tool: {tool_name}',
|
|
f'- Server: {metadata["server_name"]}',
|
|
f'- is_error: {metadata["is_error"]}',
|
|
'',
|
|
content,
|
|
]
|
|
return '\n'.join(lines)
|
|
|
|
def _list_remote_resources(self) -> tuple[MCPResource, ...]:
|
|
discovered: list[MCPResource] = []
|
|
for server in self.servers:
|
|
if server.transport != 'stdio':
|
|
continue
|
|
try:
|
|
result = _request_stdio(server, 'resources/list', {})
|
|
except OSError:
|
|
continue
|
|
for item in _extract_remote_resources(server, result):
|
|
discovered.append(item)
|
|
return tuple(discovered)
|
|
|
|
def _list_remote_tools(self, *, server_name: str | None = None) -> tuple[MCPTool, ...]:
|
|
discovered: list[MCPTool] = []
|
|
candidate_servers = (
|
|
[self.get_server(server_name)] if server_name else list(self.servers)
|
|
)
|
|
for server in candidate_servers:
|
|
if server is None or server.transport != 'stdio':
|
|
continue
|
|
try:
|
|
result = _request_stdio(server, 'tools/list', {})
|
|
except OSError:
|
|
continue
|
|
for item in _extract_remote_tools(server, result):
|
|
discovered.append(item)
|
|
return tuple(discovered)
|
|
|
|
def _resolve_tool(self, tool_name: str, server_name: str | None = None) -> MCPTool:
|
|
tools = self.list_tools(server_name=server_name)
|
|
matches = [tool for tool in tools if tool.name == tool_name]
|
|
if server_name:
|
|
if not matches:
|
|
raise FileNotFoundError(f'Unknown MCP tool: {tool_name} on server {server_name}')
|
|
return matches[0]
|
|
if not matches:
|
|
raise FileNotFoundError(f'Unknown MCP tool: {tool_name}')
|
|
if len(matches) > 1:
|
|
raise FileNotFoundError(
|
|
f'MCP tool {tool_name} exists on multiple servers. Pass server_name to disambiguate.'
|
|
)
|
|
return matches[0]
|
|
|
|
|
|
def _discover_manifest_paths(
|
|
cwd: Path,
|
|
additional_working_directories: tuple[str, ...],
|
|
) -> tuple[Path, ...]:
|
|
candidates: list[Path] = []
|
|
seen: set[Path] = set()
|
|
|
|
def remember(path: Path) -> None:
|
|
resolved = path.resolve()
|
|
if resolved in seen or not resolved.exists() or not resolved.is_file():
|
|
return
|
|
seen.add(resolved)
|
|
candidates.append(resolved)
|
|
|
|
roots: list[Path] = []
|
|
current = cwd.resolve()
|
|
while True:
|
|
roots.append(current)
|
|
if current.parent == current:
|
|
break
|
|
current = current.parent
|
|
roots.extend(Path(path).resolve() for path in additional_working_directories)
|
|
|
|
for root in roots:
|
|
remember(root / '.claw-mcp.json')
|
|
remember(root / '.mcp.json')
|
|
remember(root / '.codex-mcp.json')
|
|
remember(root / 'mcp.json')
|
|
return tuple(candidates)
|
|
|
|
|
|
def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return [], []
|
|
if not isinstance(payload, dict):
|
|
return [], []
|
|
|
|
resources: list[MCPResource] = []
|
|
servers: list[MCPServerProfile] = []
|
|
|
|
if isinstance(payload.get('resources'), list):
|
|
resources.extend(
|
|
_extract_resources(
|
|
payload.get('name') if isinstance(payload.get('name'), str) else 'local',
|
|
payload['resources'],
|
|
manifest_path=path,
|
|
)
|
|
)
|
|
|
|
raw_servers = payload.get('servers')
|
|
if isinstance(raw_servers, list):
|
|
for item in raw_servers:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get('name')
|
|
if not isinstance(name, str) or not name.strip():
|
|
continue
|
|
server_name = name.strip()
|
|
raw_resources = item.get('resources')
|
|
if isinstance(raw_resources, list):
|
|
resources.extend(
|
|
_extract_resources(server_name, raw_resources, manifest_path=path)
|
|
)
|
|
server = _extract_server_profile(server_name, item, manifest_path=path)
|
|
if server is not None:
|
|
servers.append(server)
|
|
|
|
raw_mcp_servers = payload.get('mcpServers')
|
|
if isinstance(raw_mcp_servers, dict):
|
|
for server_name, item in raw_mcp_servers.items():
|
|
if not isinstance(server_name, str) or not server_name.strip():
|
|
continue
|
|
if not isinstance(item, dict):
|
|
continue
|
|
server = _extract_server_profile(server_name.strip(), item, manifest_path=path)
|
|
if server is not None:
|
|
servers.append(server)
|
|
|
|
return resources, servers
|
|
|
|
|
|
def _extract_server_profile(
|
|
server_name: str,
|
|
payload: dict[str, Any],
|
|
*,
|
|
manifest_path: Path,
|
|
) -> MCPServerProfile | None:
|
|
command = payload.get('command')
|
|
if not isinstance(command, str) or not command.strip():
|
|
return None
|
|
args = payload.get('args', ())
|
|
if not isinstance(args, list):
|
|
args = ()
|
|
normalized_args = tuple(
|
|
item for item in args if isinstance(item, str)
|
|
)
|
|
env = payload.get('env')
|
|
normalized_env = {
|
|
key: value
|
|
for key, value in (env.items() if isinstance(env, dict) else [])
|
|
if isinstance(key, str) and isinstance(value, str)
|
|
}
|
|
cwd = payload.get('cwd')
|
|
resolved_cwd: str | None = None
|
|
if isinstance(cwd, str) and cwd.strip():
|
|
candidate = Path(cwd).expanduser()
|
|
if not candidate.is_absolute():
|
|
candidate = manifest_path.parent / candidate
|
|
resolved_cwd = str(candidate.resolve())
|
|
description = payload.get('description') if isinstance(payload.get('description'), str) else None
|
|
transport = payload.get('transport')
|
|
if not isinstance(transport, str) or not transport.strip():
|
|
transport = 'stdio'
|
|
transport = transport.strip().lower()
|
|
if transport != 'stdio':
|
|
return None
|
|
metadata = payload.get('metadata')
|
|
return MCPServerProfile(
|
|
name=server_name,
|
|
source_manifest=str(manifest_path),
|
|
transport=transport,
|
|
command=command.strip(),
|
|
args=normalized_args,
|
|
env=normalized_env,
|
|
cwd=resolved_cwd,
|
|
description=description,
|
|
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
|
)
|
|
|
|
|
|
def _extract_resources(
|
|
server_name: str,
|
|
raw_resources: list[Any],
|
|
*,
|
|
manifest_path: Path,
|
|
) -> list[MCPResource]:
|
|
resources: list[MCPResource] = []
|
|
seen_uris: set[str] = set()
|
|
for item in raw_resources:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
uri = item.get('uri')
|
|
if not isinstance(uri, str) or not uri.strip():
|
|
continue
|
|
uri = uri.strip()
|
|
if uri in seen_uris:
|
|
continue
|
|
seen_uris.add(uri)
|
|
raw_path = item.get('path')
|
|
if raw_path is None:
|
|
raw_path = item.get('file')
|
|
resolved_path: str | None = None
|
|
if isinstance(raw_path, str) and raw_path.strip():
|
|
candidate = Path(raw_path).expanduser()
|
|
if not candidate.is_absolute():
|
|
candidate = manifest_path.parent / candidate
|
|
resolved_path = str(candidate.resolve())
|
|
inline_text = item.get('text')
|
|
if not isinstance(inline_text, str):
|
|
inline_text = None
|
|
metadata = item.get('metadata')
|
|
resources.append(
|
|
MCPResource(
|
|
uri=uri,
|
|
server_name=server_name,
|
|
source_manifest=str(manifest_path),
|
|
name=item.get('name') if isinstance(item.get('name'), str) else None,
|
|
description=(
|
|
item.get('description')
|
|
if isinstance(item.get('description'), str)
|
|
else None
|
|
),
|
|
mime_type=(
|
|
item.get('mimeType')
|
|
if isinstance(item.get('mimeType'), str)
|
|
else item.get('mime_type')
|
|
if isinstance(item.get('mime_type'), str)
|
|
else None
|
|
),
|
|
resolved_path=resolved_path,
|
|
inline_text=inline_text,
|
|
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
|
)
|
|
)
|
|
return resources
|
|
|
|
|
|
def _extract_remote_resources(
|
|
server: MCPServerProfile,
|
|
payload: dict[str, Any],
|
|
) -> tuple[MCPResource, ...]:
|
|
raw_resources = payload.get('resources')
|
|
if not isinstance(raw_resources, list):
|
|
return ()
|
|
resources: list[MCPResource] = []
|
|
for item in raw_resources:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
uri = item.get('uri')
|
|
if not isinstance(uri, str) or not uri.strip():
|
|
continue
|
|
resources.append(
|
|
MCPResource(
|
|
uri=uri.strip(),
|
|
server_name=server.name,
|
|
source_manifest=server.source_manifest,
|
|
name=item.get('name') if isinstance(item.get('name'), str) else None,
|
|
description=(
|
|
item.get('description')
|
|
if isinstance(item.get('description'), str)
|
|
else None
|
|
),
|
|
mime_type=(
|
|
item.get('mimeType')
|
|
if isinstance(item.get('mimeType'), str)
|
|
else item.get('mime_type')
|
|
if isinstance(item.get('mime_type'), str)
|
|
else None
|
|
),
|
|
metadata={
|
|
'transport': server.transport,
|
|
'server_command': server.command,
|
|
},
|
|
)
|
|
)
|
|
return tuple(resources)
|
|
|
|
|
|
def _extract_remote_tools(
|
|
server: MCPServerProfile,
|
|
payload: dict[str, Any],
|
|
) -> tuple[MCPTool, ...]:
|
|
raw_tools = payload.get('tools')
|
|
if not isinstance(raw_tools, list):
|
|
return ()
|
|
tools: list[MCPTool] = []
|
|
for item in raw_tools:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = item.get('name')
|
|
if not isinstance(name, str) or not name.strip():
|
|
continue
|
|
input_schema = item.get('inputSchema')
|
|
if not isinstance(input_schema, dict):
|
|
input_schema = item.get('input_schema')
|
|
tools.append(
|
|
MCPTool(
|
|
name=name.strip(),
|
|
server_name=server.name,
|
|
source_manifest=server.source_manifest,
|
|
description=(
|
|
item.get('description')
|
|
if isinstance(item.get('description'), str)
|
|
else None
|
|
),
|
|
input_schema=dict(input_schema) if isinstance(input_schema, dict) else {},
|
|
metadata={
|
|
'transport': server.transport,
|
|
'server_command': server.command,
|
|
},
|
|
)
|
|
)
|
|
return tuple(tools)
|
|
|
|
|
|
def _filter_resources(
|
|
resources: tuple[MCPResource, ...],
|
|
*,
|
|
query: str | None = None,
|
|
) -> tuple[MCPResource, ...]:
|
|
if not query:
|
|
return resources
|
|
needle = query.lower()
|
|
return tuple(
|
|
resource
|
|
for resource in resources
|
|
if needle in resource.uri.lower()
|
|
or needle in resource.server_name.lower()
|
|
or needle in (resource.name or '').lower()
|
|
or needle in (resource.description or '').lower()
|
|
)
|
|
|
|
|
|
def _dedupe_servers(servers: list[MCPServerProfile]) -> list[MCPServerProfile]:
|
|
seen: set[tuple[str, str, str | None, tuple[str, ...]]] = set()
|
|
deduped: list[MCPServerProfile] = []
|
|
for server in servers:
|
|
key = (server.name.lower(), server.transport, server.command, server.args)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(server)
|
|
return deduped
|
|
|
|
|
|
def _request_stdio(
|
|
server: MCPServerProfile,
|
|
method: str,
|
|
params: dict[str, Any],
|
|
*,
|
|
timeout_seconds: float = 10.0,
|
|
) -> dict[str, Any]:
|
|
with _StdioMCPConnection(server, timeout_seconds=timeout_seconds) as connection:
|
|
return connection.request(method, params)
|
|
|
|
|
|
class _StdioMCPConnection:
|
|
def __init__(self, server: MCPServerProfile, *, timeout_seconds: float = 10.0) -> None:
|
|
self.server = server
|
|
self.timeout_seconds = timeout_seconds
|
|
self.process: subprocess.Popen[str] | None = None
|
|
self.selector: selectors.BaseSelector | None = None
|
|
self.stderr_lines: list[str] = []
|
|
self._request_id = 0
|
|
|
|
def __enter__(self) -> '_StdioMCPConnection':
|
|
try:
|
|
command = [self.server.command or '', *self.server.args]
|
|
if not command[0]:
|
|
raise OSError(f'MCP server {self.server.name} has no executable command')
|
|
env = os.environ.copy()
|
|
env.update(self.server.env)
|
|
self.process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
cwd=self.server.cwd or None,
|
|
env=env,
|
|
)
|
|
self.selector = selectors.DefaultSelector()
|
|
assert self.process.stdout is not None
|
|
assert self.process.stderr is not None
|
|
self.selector.register(self.process.stdout, selectors.EVENT_READ, data='stdout')
|
|
self.selector.register(self.process.stderr, selectors.EVENT_READ, data='stderr')
|
|
self._initialize()
|
|
return self
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
self.close()
|
|
|
|
def close(self) -> None:
|
|
process = self.process
|
|
if self.selector is not None:
|
|
try:
|
|
self.selector.close()
|
|
except Exception:
|
|
pass
|
|
self.selector = None
|
|
if process is None:
|
|
return
|
|
try:
|
|
if process.stdin is not None:
|
|
process.stdin.close()
|
|
except Exception:
|
|
pass
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=1.0)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=1.0)
|
|
for stream_name in ('stdout', 'stderr'):
|
|
stream = getattr(process, stream_name, None)
|
|
if stream is not None:
|
|
try:
|
|
stream.close()
|
|
except Exception:
|
|
pass
|
|
self.process = None
|
|
|
|
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
self._request_id += 1
|
|
request_id = self._request_id
|
|
self._send(
|
|
{
|
|
'jsonrpc': '2.0',
|
|
'id': request_id,
|
|
'method': method,
|
|
'params': params,
|
|
}
|
|
)
|
|
response = self._await_response(request_id)
|
|
error = response.get('error')
|
|
if isinstance(error, dict):
|
|
message = error.get('message')
|
|
raise OSError(
|
|
f'MCP {method} failed for server {self.server.name}: {message or error}'
|
|
)
|
|
result = response.get('result')
|
|
if not isinstance(result, dict):
|
|
return {}
|
|
return result
|
|
|
|
def _initialize(self) -> None:
|
|
self._request_id += 1
|
|
request_id = self._request_id
|
|
self._send(
|
|
{
|
|
'jsonrpc': '2.0',
|
|
'id': request_id,
|
|
'method': 'initialize',
|
|
'params': {
|
|
'protocolVersion': MCP_PROTOCOL_VERSION,
|
|
'capabilities': {},
|
|
'clientInfo': {
|
|
'name': 'claw-code-agent',
|
|
'version': '0.1.0',
|
|
},
|
|
},
|
|
}
|
|
)
|
|
response = self._await_response(request_id)
|
|
error = response.get('error')
|
|
if isinstance(error, dict):
|
|
raise OSError(
|
|
f'MCP initialize failed for server {self.server.name}: {error.get("message") or error}'
|
|
)
|
|
self._send(
|
|
{
|
|
'jsonrpc': '2.0',
|
|
'method': 'notifications/initialized',
|
|
'params': {},
|
|
}
|
|
)
|
|
|
|
def _send(self, payload: dict[str, Any]) -> None:
|
|
if self.process is None or self.process.stdin is None:
|
|
raise OSError(f'MCP server {self.server.name} is not running')
|
|
self.process.stdin.write(json.dumps(payload, ensure_ascii=True) + '\n')
|
|
self.process.stdin.flush()
|
|
|
|
def _await_response(self, request_id: int) -> dict[str, Any]:
|
|
deadline = time.monotonic() + self.timeout_seconds
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
stderr = '\n'.join(self.stderr_lines[-5:])
|
|
raise TimeoutError(
|
|
f'Timed out waiting for MCP response from {self.server.name}'
|
|
+ (f' stderr={stderr}' if stderr else '')
|
|
)
|
|
if self.selector is None:
|
|
raise OSError(f'MCP selector is not available for {self.server.name}')
|
|
events = self.selector.select(timeout=remaining)
|
|
if not events:
|
|
continue
|
|
for key, _mask in events:
|
|
stream_name = key.data
|
|
line = key.fileobj.readline()
|
|
if not line:
|
|
continue
|
|
if stream_name == 'stderr':
|
|
self.stderr_lines.append(line.rstrip())
|
|
continue
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
if payload.get('id') == request_id:
|
|
return payload
|
|
|
|
|
|
def _render_resource_contents(contents: Any) -> str:
|
|
if not isinstance(contents, list):
|
|
return ''
|
|
parts: list[str] = []
|
|
for item in contents:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
text = item.get('text')
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
continue
|
|
blob = item.get('blob')
|
|
if isinstance(blob, str):
|
|
mime_type = item.get('mimeType') if isinstance(item.get('mimeType'), str) else 'application/octet-stream'
|
|
parts.append(f'[blob:{mime_type}] {blob}')
|
|
continue
|
|
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
|
|
return '\n\n'.join(parts).strip()
|
|
|
|
|
|
def _render_tool_call_result(result: dict[str, Any]) -> str:
|
|
parts: list[str] = []
|
|
content = result.get('content')
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
text = item.get('text')
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
continue
|
|
structured = item.get('structuredContent')
|
|
if structured is not None:
|
|
parts.append(json.dumps(structured, ensure_ascii=True, indent=2))
|
|
continue
|
|
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
|
|
structured_content = result.get('structuredContent')
|
|
if structured_content is not None:
|
|
parts.append(json.dumps(structured_content, ensure_ascii=True, indent=2))
|
|
if not parts:
|
|
parts.append(json.dumps(result, ensure_ascii=True, indent=2))
|
|
return '\n\n'.join(part for part in parts if part).strip()
|
|
|
|
|
|
def _truncate(text: str, limit: int) -> str:
|
|
if len(text) <= limit:
|
|
return text
|
|
head = text[: limit // 2]
|
|
tail = text[-(limit // 2) :]
|
|
return f'{head}\n...[truncated]...\n{tail}'
|