monkeybot 2.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
"""MCP client using the official ``mcp`` Python SDK (stdio and Streamable HTTP).
|
|
2
|
+
|
|
3
|
+
``mcp`` (and ``httpx`` for remote URLs) are imported only inside connection helpers so
|
|
4
|
+
``python -c "import monkeybot.core.mcp.mcp_client"`` succeeds without those packages installed.
|
|
5
|
+
|
|
6
|
+
**call_tool result text:** Returned strings concatenate ``text`` from each content block
|
|
7
|
+
whose ``type`` is ``text``. Any other block is JSON-serialized via ``model_dump``
|
|
8
|
+
(``by_alias=True``) when present, otherwise :func:`str`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
20
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from monkeybot.core.context.tool_output_policy import (
|
|
26
|
+
register_mcp_tool_names,
|
|
27
|
+
unregister_mcp_server_tools,
|
|
28
|
+
)
|
|
29
|
+
from monkeybot.core.context.tool_result_ingress import (
|
|
30
|
+
dump_model_or_str,
|
|
31
|
+
format_mcp_content_block,
|
|
32
|
+
sanitize_tool_result_text,
|
|
33
|
+
)
|
|
34
|
+
from monkeybot.core.logging_utils import kv
|
|
35
|
+
from monkeybot.core.types.types_tools import ToolDef
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _exception_group_leaves(exc: BaseException) -> list[BaseException]:
|
|
41
|
+
"""Flatten nested :class:`BaseExceptionGroup` leaves (Python 3.11+)."""
|
|
42
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
43
|
+
return [leaf for sub in exc.exceptions for leaf in _exception_group_leaves(sub)]
|
|
44
|
+
return [exc]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mcp_runtime_teardown_noise(exc: BaseException) -> bool:
|
|
48
|
+
"""True for known AnyIO/MCP streamable-HTTP races during forced shutdown (e.g. Ctrl+C)."""
|
|
49
|
+
if isinstance(exc, RuntimeError):
|
|
50
|
+
msg = str(exc).lower()
|
|
51
|
+
return "cancel scope" in msg or "different task" in msg
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mcp_disconnect_teardown_noise(exc: BaseException) -> bool:
|
|
56
|
+
"""Whether ``exc`` is only teardown noise from the MCP SDK (safe to log and ignore)."""
|
|
57
|
+
if _mcp_runtime_teardown_noise(exc):
|
|
58
|
+
return True
|
|
59
|
+
if type(exc) is GeneratorExit:
|
|
60
|
+
return True
|
|
61
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
62
|
+
leaves = _exception_group_leaves(exc)
|
|
63
|
+
return bool(leaves) and all(
|
|
64
|
+
_mcp_runtime_teardown_noise(leaf) or type(leaf) is GeneratorExit for leaf in leaves
|
|
65
|
+
)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MCPConnectionError(Exception):
|
|
70
|
+
"""Connecting or handshaking with an MCP server failed."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, server_name: str, message: str | None = None) -> None:
|
|
73
|
+
"""Store ``server_name`` and a human-readable message."""
|
|
74
|
+
self.server_name = server_name
|
|
75
|
+
detail = message or f"MCP connection failed for server {server_name!r}"
|
|
76
|
+
super().__init__(detail)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MCPServerNotConnectedError(Exception):
|
|
80
|
+
"""Raised when :meth:`MCPClient.call_tool` references an unknown/disconnected server."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, server_name: str) -> None:
|
|
83
|
+
"""Attach the unresolved ``server_name``."""
|
|
84
|
+
self.server_name = server_name
|
|
85
|
+
super().__init__(f"MCP server not connected: {server_name!r}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MCPDiagnosticError(Exception):
|
|
89
|
+
"""User-actionable MCP configuration or connectivity failure."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
server_name: str,
|
|
94
|
+
message: str,
|
|
95
|
+
*,
|
|
96
|
+
remedy: str | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.server_name = server_name
|
|
99
|
+
self.remedy = remedy
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MCPAuthError(MCPDiagnosticError):
|
|
104
|
+
"""OAuth/token acquisition failed for an MCP HTTP server."""
|
|
105
|
+
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MCPConnectivityError(MCPDiagnosticError):
|
|
110
|
+
"""MCP HTTP endpoint rejected the session or could not be reached."""
|
|
111
|
+
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
|
|
116
|
+
|
|
117
|
+
# Unprefixed tool name that cloud-browser MCP servers (e.g. browser-harness) expose
|
|
118
|
+
# to stop a remote session before it accrues further billing. Disconnecting the
|
|
119
|
+
# stdio transport alone never reaches the remote session, so disconnect() calls
|
|
120
|
+
# this tool first, best-effort, whenever a connected server exposes it.
|
|
121
|
+
_BROWSER_STOP_TOOL = "browser_stop"
|
|
122
|
+
_BROWSER_STOP_TIMEOUT_S = 10.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def interpolate_env_vars(value: Any) -> Any:
|
|
126
|
+
"""Replace ``${VAR_NAME}`` substrings with ``os.environ`` values (missing → empty string)."""
|
|
127
|
+
|
|
128
|
+
if isinstance(value, str):
|
|
129
|
+
|
|
130
|
+
def _sub(match: re.Match[str]) -> str:
|
|
131
|
+
return os.environ.get(match.group(1).strip(), "")
|
|
132
|
+
|
|
133
|
+
return _ENV_VAR_PATTERN.sub(_sub, value)
|
|
134
|
+
if isinstance(value, dict):
|
|
135
|
+
return {str(k): interpolate_env_vars(v) for k, v in value.items()}
|
|
136
|
+
if isinstance(value, list):
|
|
137
|
+
return [interpolate_env_vars(item) for item in value]
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def log_mcp_startup_diagnostic(
|
|
142
|
+
exc: BaseException,
|
|
143
|
+
*,
|
|
144
|
+
server_name: str,
|
|
145
|
+
mcp_json_path: Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Log a high-signal multi-line banner for MCP startup failures."""
|
|
148
|
+
lines = [
|
|
149
|
+
"",
|
|
150
|
+
"=" * 70,
|
|
151
|
+
"[MCP_STARTUP_FAILURE_DIAGNOSTIC]",
|
|
152
|
+
"=" * 70,
|
|
153
|
+
f"Config file: {mcp_json_path}",
|
|
154
|
+
f"Server name: {server_name}",
|
|
155
|
+
f"Exception: {exc.__class__.__name__}: {exc}",
|
|
156
|
+
]
|
|
157
|
+
if isinstance(exc, MCPDiagnosticError) and exc.remedy:
|
|
158
|
+
lines.append(f"Remedy: {exc.remedy}")
|
|
159
|
+
lines.append("=" * 70)
|
|
160
|
+
logger.error("\n".join(lines))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_MCP_AUTH_HANDLER_CLS: type | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _mcp_auth_handler_cls() -> Any:
|
|
167
|
+
"""Lazily define :class:`httpx.Auth` subclass so importing this module stays light."""
|
|
168
|
+
global _MCP_AUTH_HANDLER_CLS
|
|
169
|
+
if _MCP_AUTH_HANDLER_CLS is not None:
|
|
170
|
+
return _MCP_AUTH_HANDLER_CLS
|
|
171
|
+
|
|
172
|
+
import httpx
|
|
173
|
+
|
|
174
|
+
class MCPAuthHandler(httpx.Auth):
|
|
175
|
+
"""OAuth2 token acquisition for Streamable HTTP MCP (client_credentials / password)."""
|
|
176
|
+
|
|
177
|
+
requires_request_body = False
|
|
178
|
+
requires_response_body = False
|
|
179
|
+
|
|
180
|
+
def __init__(self, server_name: str, auth_config: Mapping[str, Any]) -> None:
|
|
181
|
+
self.server_name = server_name
|
|
182
|
+
self.config = dict(auth_config)
|
|
183
|
+
self.access_token: str | None = None
|
|
184
|
+
self.expires_at: float = 0.0
|
|
185
|
+
self._lock = asyncio.Lock()
|
|
186
|
+
|
|
187
|
+
def _remedy_for_oauth_error(self, error_code: str | None) -> str | None:
|
|
188
|
+
if error_code == "invalid_client":
|
|
189
|
+
return (
|
|
190
|
+
"Verify client_id and client_secret (or Basic-auth client credentials) "
|
|
191
|
+
"against your identity provider."
|
|
192
|
+
)
|
|
193
|
+
if error_code == "invalid_scope":
|
|
194
|
+
return "The requested scope is not allowed for this client; adjust `scope`."
|
|
195
|
+
if error_code in {"invalid_grant", "unauthorized_client"}:
|
|
196
|
+
return "The token server rejected this grant; check username/password or client policy."
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
async def _refresh_token_locked(self) -> None:
|
|
200
|
+
import httpx
|
|
201
|
+
|
|
202
|
+
flow_raw = self.config.get("flow")
|
|
203
|
+
flow = flow_raw if isinstance(flow_raw, str) else ""
|
|
204
|
+
flow = flow.strip().lower()
|
|
205
|
+
token_url_raw = self.config.get("token_url")
|
|
206
|
+
if not isinstance(token_url_raw, str) or not token_url_raw.strip():
|
|
207
|
+
raise MCPAuthError(
|
|
208
|
+
self.server_name,
|
|
209
|
+
"auth.token_url is required and must be a non-empty string.",
|
|
210
|
+
remedy="Set `token_url` to your OAuth2 token endpoint in mcp.json.",
|
|
211
|
+
)
|
|
212
|
+
token_url = token_url_raw.strip()
|
|
213
|
+
|
|
214
|
+
data: dict[str, str] = {}
|
|
215
|
+
if flow == "client_credentials":
|
|
216
|
+
data["grant_type"] = "client_credentials"
|
|
217
|
+
cid = self.config.get("client_id")
|
|
218
|
+
csec = self.config.get("client_secret", "")
|
|
219
|
+
if (self.config.get("client_auth_method") or "body").lower() != "basic":
|
|
220
|
+
data["client_id"] = "" if cid is None else str(cid)
|
|
221
|
+
data["client_secret"] = "" if csec is None else str(csec)
|
|
222
|
+
elif flow == "password":
|
|
223
|
+
data["grant_type"] = "password"
|
|
224
|
+
user = self.config.get("username")
|
|
225
|
+
pwd = self.config.get("password")
|
|
226
|
+
if not isinstance(user, str) or not user:
|
|
227
|
+
raise MCPAuthError(
|
|
228
|
+
self.server_name,
|
|
229
|
+
"auth.username is required for flow=password.",
|
|
230
|
+
remedy="Set `username` (and `password`) under `auth` in mcp.json.",
|
|
231
|
+
)
|
|
232
|
+
data["username"] = user
|
|
233
|
+
data["password"] = "" if pwd is None else str(pwd)
|
|
234
|
+
if (self.config.get("client_auth_method") or "body").lower() != "basic":
|
|
235
|
+
cid = self.config.get("client_id")
|
|
236
|
+
if cid is not None:
|
|
237
|
+
data["client_id"] = str(cid)
|
|
238
|
+
csec = self.config.get("client_secret")
|
|
239
|
+
if csec is not None:
|
|
240
|
+
data["client_secret"] = str(csec)
|
|
241
|
+
else:
|
|
242
|
+
raise MCPAuthError(
|
|
243
|
+
self.server_name,
|
|
244
|
+
f"Unsupported auth.flow {flow_raw!r}; use 'client_credentials' or 'password'.",
|
|
245
|
+
remedy="Set `flow` to `client_credentials` or `password`.",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
scope = self.config.get("scope")
|
|
249
|
+
if scope is not None and str(scope).strip():
|
|
250
|
+
data["scope"] = str(scope).strip()
|
|
251
|
+
for key in ("audience", "resource"):
|
|
252
|
+
val = self.config.get(key)
|
|
253
|
+
if val is not None and str(val).strip():
|
|
254
|
+
data[key] = str(val).strip()
|
|
255
|
+
|
|
256
|
+
extra = self.config.get("extra")
|
|
257
|
+
if isinstance(extra, dict):
|
|
258
|
+
for k, v in extra.items():
|
|
259
|
+
if v is None:
|
|
260
|
+
continue
|
|
261
|
+
data[str(k)] = str(v)
|
|
262
|
+
|
|
263
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
264
|
+
basic: httpx.Auth | None = None
|
|
265
|
+
if (self.config.get("client_auth_method") or "body").lower() == "basic":
|
|
266
|
+
cid = self.config.get("client_id")
|
|
267
|
+
csec = self.config.get("client_secret", "")
|
|
268
|
+
if cid is None or not str(cid):
|
|
269
|
+
raise MCPAuthError(
|
|
270
|
+
self.server_name,
|
|
271
|
+
"client_id is required when client_auth_method=basic.",
|
|
272
|
+
remedy="Set `client_id` (and `client_secret`) for HTTP Basic on the token URL.",
|
|
273
|
+
)
|
|
274
|
+
basic = httpx.BasicAuth(str(cid), str(csec))
|
|
275
|
+
|
|
276
|
+
timeout = httpx.Timeout(30.0)
|
|
277
|
+
try:
|
|
278
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
279
|
+
if basic is not None:
|
|
280
|
+
resp = await client.post(
|
|
281
|
+
token_url, data=data, headers=headers, auth=basic
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
resp = await client.post(token_url, data=data, headers=headers)
|
|
285
|
+
except httpx.RequestError as exc:
|
|
286
|
+
raise MCPAuthError(
|
|
287
|
+
self.server_name,
|
|
288
|
+
f"Could not reach token_url ({token_url}): {exc}",
|
|
289
|
+
remedy="Check network, VPN, DNS, and that token_url is correct.",
|
|
290
|
+
) from exc
|
|
291
|
+
|
|
292
|
+
ct = (resp.headers.get("content-type") or "").lower()
|
|
293
|
+
body: dict[str, Any] = {}
|
|
294
|
+
if "json" in ct or resp.text.strip().startswith("{"):
|
|
295
|
+
try:
|
|
296
|
+
parsed = resp.json()
|
|
297
|
+
if isinstance(parsed, dict):
|
|
298
|
+
body = parsed
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
body = {}
|
|
301
|
+
if resp.status_code >= 400:
|
|
302
|
+
err = body.get("error") if isinstance(body.get("error"), str) else None
|
|
303
|
+
desc = body.get("error_description")
|
|
304
|
+
desc_s = desc if isinstance(desc, str) else None
|
|
305
|
+
detail = desc_s or resp.text or f"HTTP {resp.status_code}"
|
|
306
|
+
remedy = self._remedy_for_oauth_error(err)
|
|
307
|
+
raise MCPAuthError(
|
|
308
|
+
self.server_name,
|
|
309
|
+
f"Token endpoint returned {resp.status_code} ({err or 'no_error_code'}): {detail}",
|
|
310
|
+
remedy=remedy,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
token = body.get("access_token")
|
|
314
|
+
if not isinstance(token, str) or not token:
|
|
315
|
+
raise MCPAuthError(
|
|
316
|
+
self.server_name,
|
|
317
|
+
"Token response did not include a usable access_token string.",
|
|
318
|
+
remedy="Inspect the token server response format; monkeybot expects OAuth2 JSON.",
|
|
319
|
+
)
|
|
320
|
+
self.access_token = token
|
|
321
|
+
expires_in = body.get("expires_in", 3600)
|
|
322
|
+
try:
|
|
323
|
+
ttl = float(expires_in)
|
|
324
|
+
except (TypeError, ValueError):
|
|
325
|
+
ttl = 3600.0
|
|
326
|
+
self.expires_at = time.time() + max(30.0, ttl)
|
|
327
|
+
|
|
328
|
+
async def _get_token(self) -> str:
|
|
329
|
+
async with self._lock:
|
|
330
|
+
now = time.time()
|
|
331
|
+
if self.access_token and now < self.expires_at - 60:
|
|
332
|
+
return self.access_token
|
|
333
|
+
await self._refresh_token_locked()
|
|
334
|
+
if not self.access_token:
|
|
335
|
+
raise MCPAuthError(
|
|
336
|
+
self.server_name,
|
|
337
|
+
"Token refresh completed without an access_token.",
|
|
338
|
+
remedy="Check auth configuration and token server behavior.",
|
|
339
|
+
)
|
|
340
|
+
return self.access_token
|
|
341
|
+
|
|
342
|
+
async def async_auth_flow(
|
|
343
|
+
self, request: httpx.Request
|
|
344
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
345
|
+
token = await self._get_token()
|
|
346
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
347
|
+
response = yield request
|
|
348
|
+
if response.status_code == 401:
|
|
349
|
+
async with self._lock:
|
|
350
|
+
if self.access_token == token:
|
|
351
|
+
self.access_token = None
|
|
352
|
+
self.expires_at = 0.0
|
|
353
|
+
await self._refresh_token_locked()
|
|
354
|
+
if not self.access_token:
|
|
355
|
+
raise MCPAuthError(
|
|
356
|
+
self.server_name,
|
|
357
|
+
"Token refresh after HTTP 401 did not yield an access_token.",
|
|
358
|
+
remedy="Verify the MCP server accepts this token audience/scope.",
|
|
359
|
+
)
|
|
360
|
+
request.headers["Authorization"] = f"Bearer {self.access_token}"
|
|
361
|
+
yield request
|
|
362
|
+
|
|
363
|
+
_MCP_AUTH_HANDLER_CLS = MCPAuthHandler
|
|
364
|
+
return MCPAuthHandler
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass
|
|
368
|
+
class MCPServer:
|
|
369
|
+
"""Snapshot of configured MCP stdio settings plus discovered tools for one server."""
|
|
370
|
+
|
|
371
|
+
name: str
|
|
372
|
+
command: str
|
|
373
|
+
args: list[str]
|
|
374
|
+
env: dict[str, str]
|
|
375
|
+
tools: list[ToolDef]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class _ServerRecord:
|
|
380
|
+
"""Runtime connection state per logical server."""
|
|
381
|
+
|
|
382
|
+
stack: AsyncExitStack
|
|
383
|
+
session: Any
|
|
384
|
+
tools: list[ToolDef]
|
|
385
|
+
capabilities: dict[str, bool] | None = None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Status machine values (OpenCode-inspired; progressive-disclosure aware).
|
|
389
|
+
MCP_STATUS_CONNECTED = "connected"
|
|
390
|
+
MCP_STATUS_CATALOGUED = "catalogued"
|
|
391
|
+
MCP_STATUS_DISCONNECTED = "disconnected"
|
|
392
|
+
MCP_STATUS_DISABLED = "disabled"
|
|
393
|
+
MCP_STATUS_FAILED = "failed"
|
|
394
|
+
MCP_STATUS_NEEDS_AUTH = "needs_auth"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _server_capabilities_snapshot(session: Any) -> dict[str, bool]:
|
|
398
|
+
"""Return a flat capability map from an initialized MCP session."""
|
|
399
|
+
caps = None
|
|
400
|
+
getter = getattr(session, "get_server_capabilities", None)
|
|
401
|
+
if callable(getter):
|
|
402
|
+
caps = getter()
|
|
403
|
+
if caps is None:
|
|
404
|
+
return {}
|
|
405
|
+
out: dict[str, bool] = {}
|
|
406
|
+
for key in ("tools", "resources", "prompts"):
|
|
407
|
+
val = getattr(caps, key, None)
|
|
408
|
+
out[key] = val is not None
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _dump_mcp_model(obj: Any) -> dict[str, Any]:
|
|
413
|
+
"""Best-effort JSON-friendly dump of an MCP SDK model / namespace."""
|
|
414
|
+
if obj is None:
|
|
415
|
+
return {}
|
|
416
|
+
model_dump = getattr(obj, "model_dump", None)
|
|
417
|
+
if callable(model_dump):
|
|
418
|
+
dumped = model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
419
|
+
if isinstance(dumped, dict):
|
|
420
|
+
return dumped
|
|
421
|
+
if isinstance(obj, dict):
|
|
422
|
+
return dict(obj)
|
|
423
|
+
raw = getattr(obj, "__dict__", None)
|
|
424
|
+
if not isinstance(raw, dict):
|
|
425
|
+
raw = {}
|
|
426
|
+
slots = getattr(type(obj), "__slots__", None)
|
|
427
|
+
if isinstance(slots, str):
|
|
428
|
+
slots = (slots,)
|
|
429
|
+
if slots:
|
|
430
|
+
raw = dict(raw)
|
|
431
|
+
for key in slots:
|
|
432
|
+
if key not in raw and hasattr(obj, key):
|
|
433
|
+
raw[key] = getattr(obj, key)
|
|
434
|
+
if not raw:
|
|
435
|
+
return {}
|
|
436
|
+
out: dict[str, Any] = {}
|
|
437
|
+
for key, val in raw.items():
|
|
438
|
+
if key.startswith("_") or val is None:
|
|
439
|
+
continue
|
|
440
|
+
out[key] = str(val) if key == "uri" else val
|
|
441
|
+
return out
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _normalize_resource_read(result: Any) -> dict[str, Any]:
|
|
445
|
+
"""Flatten ``read_resource`` result into text + structured contents."""
|
|
446
|
+
contents_raw = getattr(result, "contents", None) or []
|
|
447
|
+
text_chunks: list[str] = []
|
|
448
|
+
contents: list[dict[str, Any]] = []
|
|
449
|
+
for block in contents_raw:
|
|
450
|
+
entry = _dump_mcp_model(block)
|
|
451
|
+
text_val = getattr(block, "text", None)
|
|
452
|
+
blob_val = getattr(block, "blob", None)
|
|
453
|
+
if isinstance(text_val, str):
|
|
454
|
+
text_chunks.append(text_val)
|
|
455
|
+
entry["text"] = text_val
|
|
456
|
+
elif blob_val is not None:
|
|
457
|
+
# Keep a short note rather than dumping large base64 into the turn.
|
|
458
|
+
mime = getattr(block, "mimeType", None) or entry.get("mimeType") or "application/octet-stream"
|
|
459
|
+
uri = str(getattr(block, "uri", "") or entry.get("uri") or "")
|
|
460
|
+
note = f"[Binary MCP resource omitted: {uri} ({mime})]"
|
|
461
|
+
text_chunks.append(note)
|
|
462
|
+
entry["blob_omitted"] = True
|
|
463
|
+
entry["mimeType"] = mime
|
|
464
|
+
contents.append(entry)
|
|
465
|
+
text = sanitize_tool_result_text("".join(text_chunks))
|
|
466
|
+
return {"text": text, "contents": contents}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@runtime_checkable
|
|
470
|
+
class MCPStdioHooks(Protocol):
|
|
471
|
+
"""Pluggable transport/session hooks (production SDK or tests)."""
|
|
472
|
+
|
|
473
|
+
def stdio_server_parameters(
|
|
474
|
+
self,
|
|
475
|
+
command: str,
|
|
476
|
+
args: list[str],
|
|
477
|
+
env: dict[str, str] | None,
|
|
478
|
+
) -> Any:
|
|
479
|
+
"""Build stdio server parameters accepted by ``stdio_client``."""
|
|
480
|
+
...
|
|
481
|
+
|
|
482
|
+
def stdio_client(self, params: Any) -> AbstractAsyncContextManager[tuple[Any, Any]]:
|
|
483
|
+
"""Return the stdio transport async context manager (read/write streams)."""
|
|
484
|
+
...
|
|
485
|
+
|
|
486
|
+
def client_session(
|
|
487
|
+
self,
|
|
488
|
+
read_stream: Any,
|
|
489
|
+
write_stream: Any,
|
|
490
|
+
) -> AbstractAsyncContextManager[Any]:
|
|
491
|
+
"""Return ``ClientSession`` as an async context manager."""
|
|
492
|
+
...
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class _ProductionStdioHooks:
|
|
496
|
+
"""Default hooks that delegate to ``mcp`` (lazy import inside each method body)."""
|
|
497
|
+
|
|
498
|
+
def stdio_server_parameters(
|
|
499
|
+
self,
|
|
500
|
+
command: str,
|
|
501
|
+
args: list[str],
|
|
502
|
+
env: dict[str, str] | None,
|
|
503
|
+
) -> Any:
|
|
504
|
+
"""Instantiate SDK :class:`~mcp.StdioServerParameters`."""
|
|
505
|
+
from mcp import StdioServerParameters
|
|
506
|
+
|
|
507
|
+
return StdioServerParameters(command=command, args=args, env=env)
|
|
508
|
+
|
|
509
|
+
def stdio_client(self, params: Any) -> AbstractAsyncContextManager[tuple[Any, Any]]:
|
|
510
|
+
"""Open stdio subprocess transport."""
|
|
511
|
+
from mcp.client.stdio import stdio_client
|
|
512
|
+
|
|
513
|
+
return cast(
|
|
514
|
+
AbstractAsyncContextManager[tuple[Any, Any]],
|
|
515
|
+
stdio_client(params),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def client_session(
|
|
519
|
+
self,
|
|
520
|
+
read_stream: Any,
|
|
521
|
+
write_stream: Any,
|
|
522
|
+
) -> AbstractAsyncContextManager[Any]:
|
|
523
|
+
"""Open MCP client session over stdio pipes."""
|
|
524
|
+
from mcp import ClientSession
|
|
525
|
+
|
|
526
|
+
return cast(
|
|
527
|
+
AbstractAsyncContextManager[Any],
|
|
528
|
+
ClientSession(read_stream, write_stream),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _tool_input_schema(tool: object) -> dict[str, object]:
|
|
533
|
+
"""Extract JSON-schema dict from an MCP ``Tool`` object across SDK revisions."""
|
|
534
|
+
raw = getattr(tool, "inputSchema", None)
|
|
535
|
+
if raw is None:
|
|
536
|
+
raw = getattr(tool, "input_schema", None)
|
|
537
|
+
if raw is None:
|
|
538
|
+
return {}
|
|
539
|
+
if isinstance(raw, dict):
|
|
540
|
+
return raw
|
|
541
|
+
model_dump = getattr(raw, "model_dump", None)
|
|
542
|
+
if callable(model_dump):
|
|
543
|
+
dumped = raw.model_dump(mode="python", by_alias=True)
|
|
544
|
+
if isinstance(dumped, dict):
|
|
545
|
+
return dumped
|
|
546
|
+
return {}
|
|
547
|
+
return {}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _normalize_call_tool_result(result: Any) -> str:
|
|
551
|
+
"""Turn ``call_tool`` structured content into a single string."""
|
|
552
|
+
if result is None:
|
|
553
|
+
return ""
|
|
554
|
+
content = getattr(result, "content", None)
|
|
555
|
+
if not isinstance(content, list):
|
|
556
|
+
return sanitize_tool_result_text(dump_model_or_str(result))
|
|
557
|
+
|
|
558
|
+
chunks: list[str] = []
|
|
559
|
+
for block in content:
|
|
560
|
+
chunks.append(format_mcp_content_block(block))
|
|
561
|
+
if not chunks:
|
|
562
|
+
dumped = dump_model_or_str(result) if getattr(result, "model_dump", None) else ""
|
|
563
|
+
return sanitize_tool_result_text(dumped)
|
|
564
|
+
return sanitize_tool_result_text("".join(chunks))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class MCPClient:
|
|
568
|
+
"""MCP SDK client (stdio subprocesses and Streamable HTTP) for :class:`monkeybot.core.mcp.ports_mcp.MCPClientPort`."""
|
|
569
|
+
|
|
570
|
+
def __init__(self, *, hooks: MCPStdioHooks | None = None) -> None:
|
|
571
|
+
"""Create a manager; optionally pass ``hooks`` for tests."""
|
|
572
|
+
self._hooks: MCPStdioHooks = hooks if hooks is not None else _ProductionStdioHooks()
|
|
573
|
+
self._servers: dict[str, _ServerRecord] = {}
|
|
574
|
+
# Catalogued mcp.json servers; connected only via enable_mcp / connect_from_catalog.
|
|
575
|
+
self._catalog: dict[str, dict[str, Any]] = {}
|
|
576
|
+
self._config_path: Path | None = None
|
|
577
|
+
# Catalog + ever-connected names (including ad-hoc); used for tool-list refresh.
|
|
578
|
+
self._seen_servers: set[str] = set()
|
|
579
|
+
# Last-known status per server (catalogued / connected / failed / …).
|
|
580
|
+
self._statuses: dict[str, dict[str, Any]] = {}
|
|
581
|
+
|
|
582
|
+
def _set_status(self, name: str, status: str, **extra: Any) -> None:
|
|
583
|
+
"""Record lifecycle status for ``name`` (overwrites prior entry)."""
|
|
584
|
+
entry: dict[str, Any] = {"name": name, "status": status}
|
|
585
|
+
entry.update({k: v for k, v in extra.items() if v is not None})
|
|
586
|
+
self._statuses[name] = entry
|
|
587
|
+
|
|
588
|
+
def _record_connect_failure(self, name: str, exc: BaseException) -> None:
|
|
589
|
+
"""Update status after a failed connect attempt."""
|
|
590
|
+
if isinstance(exc, MCPAuthError):
|
|
591
|
+
self._set_status(name, MCP_STATUS_NEEDS_AUTH, error=str(exc))
|
|
592
|
+
else:
|
|
593
|
+
self._set_status(name, MCP_STATUS_FAILED, error=str(exc))
|
|
594
|
+
|
|
595
|
+
def all_tools(self) -> list[ToolDef]:
|
|
596
|
+
"""Return every MCP tool snapshot, grouped by sorted server then tool names."""
|
|
597
|
+
out: list[ToolDef] = []
|
|
598
|
+
for name in sorted(self._servers.keys()):
|
|
599
|
+
rec = self._servers[name]
|
|
600
|
+
out.extend(sorted(rec.tools, key=lambda t: t.name))
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
def catalog_names(self) -> list[str]:
|
|
604
|
+
"""Return sorted names of servers known from the last ``load_from_config``."""
|
|
605
|
+
return sorted(self._catalog.keys())
|
|
606
|
+
|
|
607
|
+
def known_server_names(self) -> list[str]:
|
|
608
|
+
"""Return sorted catalog + ever-connected server names."""
|
|
609
|
+
return sorted(self._seen_servers)
|
|
610
|
+
|
|
611
|
+
def is_connected(self, name: str) -> bool:
|
|
612
|
+
"""True when ``name`` has an active MCP session."""
|
|
613
|
+
return name in self._servers
|
|
614
|
+
|
|
615
|
+
def status(self, name: str | None = None) -> list[dict[str, Any]] | dict[str, Any]:
|
|
616
|
+
"""Return server status snapshot(s).
|
|
617
|
+
|
|
618
|
+
Without ``name``, returns every tracked server sorted by name. With ``name``,
|
|
619
|
+
returns that server's entry or a synthetic ``disconnected`` stub.
|
|
620
|
+
"""
|
|
621
|
+
if name is not None:
|
|
622
|
+
if name in self._statuses:
|
|
623
|
+
return dict(self._statuses[name])
|
|
624
|
+
if name in self._servers:
|
|
625
|
+
return {"name": name, "status": MCP_STATUS_CONNECTED}
|
|
626
|
+
if name in self._catalog:
|
|
627
|
+
return {"name": name, "status": MCP_STATUS_CATALOGUED}
|
|
628
|
+
return {"name": name, "status": MCP_STATUS_DISCONNECTED}
|
|
629
|
+
names = sorted(set(self._statuses) | set(self._catalog) | set(self._servers))
|
|
630
|
+
return [self.status(n) for n in names] # type: ignore[misc]
|
|
631
|
+
|
|
632
|
+
def split_prefixed_tool(self, prefixed_name: str) -> tuple[str, str] | None:
|
|
633
|
+
"""If ``prefixed_name`` matches a connected server, return ``(server_name, tool_name)``.
|
|
634
|
+
|
|
635
|
+
Longest server name wins so ``ab__x`` matches server ``ab`` before ``a``.
|
|
636
|
+
"""
|
|
637
|
+
for sname in sorted(self._servers.keys(), key=len, reverse=True):
|
|
638
|
+
p = f"{sname}__"
|
|
639
|
+
if prefixed_name.startswith(p):
|
|
640
|
+
return sname, prefixed_name[len(p) :]
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
async def connect(
|
|
644
|
+
self,
|
|
645
|
+
name: str,
|
|
646
|
+
command: str,
|
|
647
|
+
args: list[str],
|
|
648
|
+
env: dict[str, str],
|
|
649
|
+
) -> list[ToolDef]:
|
|
650
|
+
"""Bring up ``name`` via stdio, discover tools, and register ``server__tool`` names."""
|
|
651
|
+
if name in self._servers:
|
|
652
|
+
await self.disconnect(name)
|
|
653
|
+
stack = AsyncExitStack()
|
|
654
|
+
try:
|
|
655
|
+
env_arg: dict[str, str] | None = env if env else None
|
|
656
|
+
params = self._hooks.stdio_server_parameters(command, args, env_arg)
|
|
657
|
+
transport_cm = self._hooks.stdio_client(params)
|
|
658
|
+
read_write = await stack.enter_async_context(transport_cm)
|
|
659
|
+
if not isinstance(read_write, tuple) or len(read_write) != 2:
|
|
660
|
+
raise TypeError(f"stdio_client must yield a 2-tuple, got {read_write!r}")
|
|
661
|
+
read_s, write_s = read_write
|
|
662
|
+
session_cm = self._hooks.client_session(read_s, write_s)
|
|
663
|
+
session = await stack.enter_async_context(session_cm)
|
|
664
|
+
await session.initialize()
|
|
665
|
+
listing = await session.list_tools()
|
|
666
|
+
tools_raw = getattr(listing, "tools", None) or []
|
|
667
|
+
|
|
668
|
+
defs: list[ToolDef] = []
|
|
669
|
+
sorted_tools = sorted(
|
|
670
|
+
tools_raw,
|
|
671
|
+
key=lambda t: getattr(t, "name", "") or "",
|
|
672
|
+
)
|
|
673
|
+
for tool in sorted_tools:
|
|
674
|
+
tn = getattr(tool, "name", None)
|
|
675
|
+
if not isinstance(tn, str) or not tn:
|
|
676
|
+
continue
|
|
677
|
+
prefixed = f"{name}__{tn}"
|
|
678
|
+
desc_val = getattr(tool, "description", None) or ""
|
|
679
|
+
desc = desc_val if isinstance(desc_val, str) else str(desc_val)
|
|
680
|
+
schema = _tool_input_schema(tool)
|
|
681
|
+
defs.append(ToolDef(name=prefixed, description=desc, input_schema=schema))
|
|
682
|
+
|
|
683
|
+
self._servers[name] = _ServerRecord(
|
|
684
|
+
stack=stack,
|
|
685
|
+
session=session,
|
|
686
|
+
tools=list(defs),
|
|
687
|
+
capabilities=_server_capabilities_snapshot(session),
|
|
688
|
+
)
|
|
689
|
+
self._seen_servers.add(name)
|
|
690
|
+
register_mcp_tool_names(t.name for t in defs)
|
|
691
|
+
self._set_status(
|
|
692
|
+
name,
|
|
693
|
+
MCP_STATUS_CONNECTED,
|
|
694
|
+
tool_count=len(defs),
|
|
695
|
+
capabilities=self._servers[name].capabilities,
|
|
696
|
+
)
|
|
697
|
+
return list(defs)
|
|
698
|
+
except MCPConnectionError as exc:
|
|
699
|
+
await stack.aclose()
|
|
700
|
+
self._record_connect_failure(name, exc)
|
|
701
|
+
raise
|
|
702
|
+
except Exception as exc:
|
|
703
|
+
await stack.aclose()
|
|
704
|
+
wrapped = MCPConnectionError(name, str(exc))
|
|
705
|
+
self._record_connect_failure(name, wrapped)
|
|
706
|
+
raise wrapped from exc
|
|
707
|
+
|
|
708
|
+
async def connect_streamable_http(
|
|
709
|
+
self,
|
|
710
|
+
name: str,
|
|
711
|
+
url: str,
|
|
712
|
+
headers: dict[str, str] | None = None,
|
|
713
|
+
*,
|
|
714
|
+
auth: Any | None = None,
|
|
715
|
+
) -> list[ToolDef]:
|
|
716
|
+
"""Connect ``name`` to a remote MCP endpoint (Streamable HTTP); register ``server__tool`` names."""
|
|
717
|
+
if name in self._servers:
|
|
718
|
+
await self.disconnect(name)
|
|
719
|
+
stack = AsyncExitStack()
|
|
720
|
+
try:
|
|
721
|
+
import httpx
|
|
722
|
+
from mcp import ClientSession
|
|
723
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
724
|
+
|
|
725
|
+
hdr = dict(headers) if headers else {}
|
|
726
|
+
if auth is not None:
|
|
727
|
+
hdr.pop("Authorization", None)
|
|
728
|
+
hdr.pop("authorization", None)
|
|
729
|
+
http = httpx.AsyncClient(
|
|
730
|
+
headers=hdr if hdr else None,
|
|
731
|
+
auth=auth,
|
|
732
|
+
follow_redirects=True,
|
|
733
|
+
timeout=httpx.Timeout(60.0, read=600.0),
|
|
734
|
+
)
|
|
735
|
+
await stack.enter_async_context(http)
|
|
736
|
+
transport_cm = streamable_http_client(url, http_client=http)
|
|
737
|
+
read_write = await stack.enter_async_context(transport_cm)
|
|
738
|
+
if not isinstance(read_write, tuple) or len(read_write) != 3:
|
|
739
|
+
raise TypeError(f"streamable_http_client must yield a 3-tuple, got {read_write!r}")
|
|
740
|
+
read_s, write_s, _get_sid = read_write
|
|
741
|
+
session_cm = ClientSession(read_s, write_s)
|
|
742
|
+
session = await stack.enter_async_context(session_cm)
|
|
743
|
+
await session.initialize()
|
|
744
|
+
listing = await session.list_tools()
|
|
745
|
+
tools_raw = getattr(listing, "tools", None) or []
|
|
746
|
+
|
|
747
|
+
defs: list[ToolDef] = []
|
|
748
|
+
sorted_tools = sorted(
|
|
749
|
+
tools_raw,
|
|
750
|
+
key=lambda t: getattr(t, "name", "") or "",
|
|
751
|
+
)
|
|
752
|
+
for tool in sorted_tools:
|
|
753
|
+
tn = getattr(tool, "name", None)
|
|
754
|
+
if not isinstance(tn, str) or not tn:
|
|
755
|
+
continue
|
|
756
|
+
prefixed = f"{name}__{tn}"
|
|
757
|
+
desc_val = getattr(tool, "description", None) or ""
|
|
758
|
+
desc = desc_val if isinstance(desc_val, str) else str(desc_val)
|
|
759
|
+
schema = _tool_input_schema(tool)
|
|
760
|
+
defs.append(ToolDef(name=prefixed, description=desc, input_schema=schema))
|
|
761
|
+
|
|
762
|
+
self._servers[name] = _ServerRecord(
|
|
763
|
+
stack=stack,
|
|
764
|
+
session=session,
|
|
765
|
+
tools=list(defs),
|
|
766
|
+
capabilities=_server_capabilities_snapshot(session),
|
|
767
|
+
)
|
|
768
|
+
self._seen_servers.add(name)
|
|
769
|
+
register_mcp_tool_names(t.name for t in defs)
|
|
770
|
+
self._set_status(
|
|
771
|
+
name,
|
|
772
|
+
MCP_STATUS_CONNECTED,
|
|
773
|
+
tool_count=len(defs),
|
|
774
|
+
capabilities=self._servers[name].capabilities,
|
|
775
|
+
)
|
|
776
|
+
return list(defs)
|
|
777
|
+
except MCPConnectionError as exc:
|
|
778
|
+
await stack.aclose()
|
|
779
|
+
self._record_connect_failure(name, exc)
|
|
780
|
+
raise
|
|
781
|
+
except MCPAuthError as exc:
|
|
782
|
+
await stack.aclose()
|
|
783
|
+
self._record_connect_failure(name, exc)
|
|
784
|
+
raise
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
await stack.aclose()
|
|
787
|
+
wrapped = MCPConnectionError(name, str(exc))
|
|
788
|
+
self._record_connect_failure(name, wrapped)
|
|
789
|
+
raise wrapped from exc
|
|
790
|
+
|
|
791
|
+
async def _stop_remote_session_before_teardown(self, name: str, rec: _ServerRecord) -> None:
|
|
792
|
+
"""Best-effort ``browser_stop`` call so cloud-browser billing ends on disconnect.
|
|
793
|
+
|
|
794
|
+
Closing the stdio transport only kills the local MCP subprocess; a remote
|
|
795
|
+
Browser Use Cloud session (or any other server's remote resource behind a
|
|
796
|
+
``browser_stop`` tool) keeps running otherwise. Runs before ``stack.aclose()``
|
|
797
|
+
with a short timeout so a hung remote call never blocks shutdown.
|
|
798
|
+
"""
|
|
799
|
+
prefixed_stop_tool = f"{name}__{_BROWSER_STOP_TOOL}"
|
|
800
|
+
if not any(t.name == prefixed_stop_tool for t in rec.tools):
|
|
801
|
+
return
|
|
802
|
+
try:
|
|
803
|
+
async with asyncio.timeout(_BROWSER_STOP_TIMEOUT_S):
|
|
804
|
+
await rec.session.call_tool(_BROWSER_STOP_TOOL, arguments={})
|
|
805
|
+
except Exception as exc:
|
|
806
|
+
logger.warning(
|
|
807
|
+
"mcp disconnect %s: %s call failed (remote session may keep billing): %s",
|
|
808
|
+
name,
|
|
809
|
+
_BROWSER_STOP_TOOL,
|
|
810
|
+
exc,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
async def disconnect(self, name: str) -> None:
|
|
814
|
+
"""Tear down one server session; harmless if already disconnected."""
|
|
815
|
+
rec = self._servers.pop(name, None)
|
|
816
|
+
if rec is None:
|
|
817
|
+
return
|
|
818
|
+
unregister_mcp_server_tools(name)
|
|
819
|
+
try:
|
|
820
|
+
await self._stop_remote_session_before_teardown(name, rec)
|
|
821
|
+
try:
|
|
822
|
+
await rec.stack.aclose()
|
|
823
|
+
except BaseException as exc:
|
|
824
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
825
|
+
raise
|
|
826
|
+
if _mcp_disconnect_teardown_noise(exc):
|
|
827
|
+
logger.debug(
|
|
828
|
+
"mcp disconnect %s: suppressed SDK teardown noise (%s)",
|
|
829
|
+
name,
|
|
830
|
+
exc.__class__.__name__,
|
|
831
|
+
exc_info=True,
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
raise
|
|
835
|
+
finally:
|
|
836
|
+
if name in self._catalog:
|
|
837
|
+
self._set_status(name, MCP_STATUS_CATALOGUED)
|
|
838
|
+
else:
|
|
839
|
+
self._set_status(name, MCP_STATUS_DISCONNECTED)
|
|
840
|
+
|
|
841
|
+
async def disconnect_all(self) -> None:
|
|
842
|
+
"""Disconnect every connected MCP server."""
|
|
843
|
+
for name in list(self._servers.keys()):
|
|
844
|
+
await self.disconnect(name)
|
|
845
|
+
|
|
846
|
+
async def call_tool(
|
|
847
|
+
self,
|
|
848
|
+
server_name: str,
|
|
849
|
+
tool_name: str,
|
|
850
|
+
args: Mapping[str, object],
|
|
851
|
+
) -> str:
|
|
852
|
+
"""Invoke *unprefixed* ``tool_name`` on ``server_name``; return flattened text."""
|
|
853
|
+
rec = self._servers.get(server_name)
|
|
854
|
+
if rec is None:
|
|
855
|
+
raise MCPServerNotConnectedError(server_name)
|
|
856
|
+
result = await rec.session.call_tool(tool_name, arguments=dict(args))
|
|
857
|
+
return _normalize_call_tool_result(result)
|
|
858
|
+
|
|
859
|
+
def _require_connected(self, server_name: str) -> _ServerRecord:
|
|
860
|
+
rec = self._servers.get(server_name)
|
|
861
|
+
if rec is None:
|
|
862
|
+
raise MCPServerNotConnectedError(server_name)
|
|
863
|
+
return rec
|
|
864
|
+
|
|
865
|
+
def _connected_server_names(self, server_name: str | None = None) -> list[str]:
|
|
866
|
+
if server_name is not None:
|
|
867
|
+
if server_name not in self._servers:
|
|
868
|
+
raise MCPServerNotConnectedError(server_name)
|
|
869
|
+
return [server_name]
|
|
870
|
+
return sorted(self._servers.keys())
|
|
871
|
+
|
|
872
|
+
async def _list_capability_items(
|
|
873
|
+
self,
|
|
874
|
+
*,
|
|
875
|
+
server_name: str | None,
|
|
876
|
+
capability: str,
|
|
877
|
+
list_method: str,
|
|
878
|
+
result_attr: str,
|
|
879
|
+
missing_remedy: str,
|
|
880
|
+
) -> list[dict[str, Any]]:
|
|
881
|
+
"""List resources/prompts/templates from connected servers that advertise ``capability``."""
|
|
882
|
+
out: list[dict[str, Any]] = []
|
|
883
|
+
for sname in self._connected_server_names(server_name):
|
|
884
|
+
rec = self._servers[sname]
|
|
885
|
+
caps = rec.capabilities or {}
|
|
886
|
+
if caps and not caps.get(capability, False):
|
|
887
|
+
if server_name is not None:
|
|
888
|
+
raise MCPDiagnosticError(
|
|
889
|
+
sname,
|
|
890
|
+
f"MCP server {sname!r} does not advertise {capability} capability",
|
|
891
|
+
remedy=missing_remedy,
|
|
892
|
+
)
|
|
893
|
+
continue
|
|
894
|
+
listing = await getattr(rec.session, list_method)()
|
|
895
|
+
for item in getattr(listing, result_attr, None) or []:
|
|
896
|
+
entry = _dump_mcp_model(item)
|
|
897
|
+
entry["server"] = sname
|
|
898
|
+
if "uri" in entry:
|
|
899
|
+
entry["uri"] = str(entry["uri"])
|
|
900
|
+
out.append(entry)
|
|
901
|
+
return out
|
|
902
|
+
|
|
903
|
+
async def list_resources(self, server_name: str | None = None) -> list[dict[str, Any]]:
|
|
904
|
+
"""List MCP resources from one or all connected servers."""
|
|
905
|
+
return await self._list_capability_items(
|
|
906
|
+
server_name=server_name,
|
|
907
|
+
capability="resources",
|
|
908
|
+
list_method="list_resources",
|
|
909
|
+
result_attr="resources",
|
|
910
|
+
missing_remedy="Use a server that supports resources, or call mcp_status.",
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
async def list_resource_templates(
|
|
914
|
+
self, server_name: str | None = None
|
|
915
|
+
) -> list[dict[str, Any]]:
|
|
916
|
+
"""List MCP resource templates from one or all connected servers."""
|
|
917
|
+
return await self._list_capability_items(
|
|
918
|
+
server_name=server_name,
|
|
919
|
+
capability="resources",
|
|
920
|
+
list_method="list_resource_templates",
|
|
921
|
+
result_attr="resourceTemplates",
|
|
922
|
+
missing_remedy="Use a server that supports resources, or call mcp_status.",
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
async def read_resource(self, server_name: str, uri: str) -> dict[str, Any]:
|
|
926
|
+
"""Read one MCP resource by ``server_name`` + ``uri``."""
|
|
927
|
+
rec = self._require_connected(server_name)
|
|
928
|
+
caps = rec.capabilities or {}
|
|
929
|
+
if caps and not caps.get("resources", False):
|
|
930
|
+
raise MCPDiagnosticError(
|
|
931
|
+
server_name,
|
|
932
|
+
f"MCP server {server_name!r} does not advertise resources capability",
|
|
933
|
+
remedy="Pick a resource-capable server from list_mcp_resources / mcp_status.",
|
|
934
|
+
)
|
|
935
|
+
from pydantic import AnyUrl
|
|
936
|
+
|
|
937
|
+
result = await rec.session.read_resource(AnyUrl(uri))
|
|
938
|
+
normalized = _normalize_resource_read(result)
|
|
939
|
+
return {
|
|
940
|
+
"server": server_name,
|
|
941
|
+
"uri": uri,
|
|
942
|
+
"text": normalized["text"],
|
|
943
|
+
"contents": normalized["contents"],
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async def list_prompts(self, server_name: str | None = None) -> list[dict[str, Any]]:
|
|
947
|
+
"""List MCP prompts from one or all connected servers."""
|
|
948
|
+
return await self._list_capability_items(
|
|
949
|
+
server_name=server_name,
|
|
950
|
+
capability="prompts",
|
|
951
|
+
list_method="list_prompts",
|
|
952
|
+
result_attr="prompts",
|
|
953
|
+
missing_remedy="Use a server that supports prompts, or call mcp_status.",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
async def get_prompt(
|
|
957
|
+
self,
|
|
958
|
+
server_name: str,
|
|
959
|
+
prompt_name: str,
|
|
960
|
+
arguments: Mapping[str, str] | None = None,
|
|
961
|
+
) -> dict[str, Any]:
|
|
962
|
+
"""Fetch a named MCP prompt template (with optional string arguments)."""
|
|
963
|
+
rec = self._require_connected(server_name)
|
|
964
|
+
caps = rec.capabilities or {}
|
|
965
|
+
if caps and not caps.get("prompts", False):
|
|
966
|
+
raise MCPDiagnosticError(
|
|
967
|
+
server_name,
|
|
968
|
+
f"MCP server {server_name!r} does not advertise prompts capability",
|
|
969
|
+
remedy="Pick a prompt-capable server from list_mcp_prompts / mcp_status.",
|
|
970
|
+
)
|
|
971
|
+
args_out = {str(k): str(v) for k, v in dict(arguments or {}).items()}
|
|
972
|
+
result = await rec.session.get_prompt(prompt_name, arguments=args_out or None)
|
|
973
|
+
messages: list[dict[str, Any]] = []
|
|
974
|
+
for msg in getattr(result, "messages", None) or []:
|
|
975
|
+
messages.append(_dump_mcp_model(msg))
|
|
976
|
+
description = getattr(result, "description", None)
|
|
977
|
+
return {
|
|
978
|
+
"server": server_name,
|
|
979
|
+
"name": prompt_name,
|
|
980
|
+
"description": description if isinstance(description, str) else None,
|
|
981
|
+
"messages": messages,
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async def connect_from_catalog(self, name: str) -> list[ToolDef]:
|
|
985
|
+
"""Connect a server previously registered by :meth:`load_from_config`.
|
|
986
|
+
|
|
987
|
+
Already-connected servers return their current tool list (no-op reconnect).
|
|
988
|
+
Raises :class:`MCPDiagnosticError` when ``name`` is not in the catalog.
|
|
989
|
+
"""
|
|
990
|
+
if name in self._servers:
|
|
991
|
+
return list(self._servers[name].tools)
|
|
992
|
+
spec = self._catalog.get(name)
|
|
993
|
+
if spec is None:
|
|
994
|
+
known = self.catalog_names()
|
|
995
|
+
known_msg = ", ".join(known) if known else "(none)"
|
|
996
|
+
raise MCPDiagnosticError(
|
|
997
|
+
name,
|
|
998
|
+
f"Unknown MCP server {name!r}. Known configured servers: {known_msg}",
|
|
999
|
+
remedy=(
|
|
1000
|
+
"Use a name from mcp.json (after load_from_config), or connect an "
|
|
1001
|
+
"ad-hoc stdio server with add_mcp_server."
|
|
1002
|
+
),
|
|
1003
|
+
)
|
|
1004
|
+
defs = await self._connect_from_spec(
|
|
1005
|
+
name, spec, mcp_json_path=self._config_path, raise_on_error=True
|
|
1006
|
+
)
|
|
1007
|
+
return defs
|
|
1008
|
+
|
|
1009
|
+
def _log_connect_failure(
|
|
1010
|
+
self,
|
|
1011
|
+
exc: BaseException,
|
|
1012
|
+
*,
|
|
1013
|
+
server_name: str,
|
|
1014
|
+
config_path: Path,
|
|
1015
|
+
) -> None:
|
|
1016
|
+
log_mcp_startup_diagnostic(exc, server_name=server_name, mcp_json_path=config_path)
|
|
1017
|
+
logger.error(
|
|
1018
|
+
"mcp connect failed for server %s (config %s)",
|
|
1019
|
+
server_name,
|
|
1020
|
+
config_path,
|
|
1021
|
+
exc_info=True,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
async def _connect_from_spec(
|
|
1025
|
+
self,
|
|
1026
|
+
server_name: str,
|
|
1027
|
+
spec: Mapping[str, Any],
|
|
1028
|
+
*,
|
|
1029
|
+
mcp_json_path: Path | None,
|
|
1030
|
+
raise_on_error: bool,
|
|
1031
|
+
) -> list[ToolDef]:
|
|
1032
|
+
"""Connect stdio or Streamable HTTP from one mcpServers entry."""
|
|
1033
|
+
config_path = mcp_json_path if mcp_json_path is not None else Path("<unknown>")
|
|
1034
|
+
url = spec.get("url")
|
|
1035
|
+
if isinstance(url, str) and url.strip():
|
|
1036
|
+
headers_any = spec.get("headers") or {}
|
|
1037
|
+
headers_out: dict[str, str] = {}
|
|
1038
|
+
if isinstance(headers_any, dict):
|
|
1039
|
+
for k, val in headers_any.items():
|
|
1040
|
+
headers_out[str(k)] = "" if val is None else str(val)
|
|
1041
|
+
|
|
1042
|
+
auth_handler: Any | None = None
|
|
1043
|
+
auth_any = spec.get("auth")
|
|
1044
|
+
if isinstance(auth_any, dict) and auth_any:
|
|
1045
|
+
handler_cls = _mcp_auth_handler_cls()
|
|
1046
|
+
auth_handler = handler_cls(server_name, auth_any)
|
|
1047
|
+
headers_out.pop("Authorization", None)
|
|
1048
|
+
headers_out.pop("authorization", None)
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
defs = await self.connect_streamable_http(
|
|
1052
|
+
server_name,
|
|
1053
|
+
url.strip(),
|
|
1054
|
+
headers_out if headers_out else None,
|
|
1055
|
+
auth=auth_handler,
|
|
1056
|
+
)
|
|
1057
|
+
except Exception as exc:
|
|
1058
|
+
self._log_connect_failure(exc, server_name=server_name, config_path=config_path)
|
|
1059
|
+
if raise_on_error:
|
|
1060
|
+
raise
|
|
1061
|
+
return []
|
|
1062
|
+
logger.info(
|
|
1063
|
+
"mcp catalog server connected %s",
|
|
1064
|
+
kv(server=server_name, tools=len(defs), transport="http"),
|
|
1065
|
+
)
|
|
1066
|
+
return defs
|
|
1067
|
+
|
|
1068
|
+
command = spec.get("command")
|
|
1069
|
+
if not isinstance(command, str) or not command:
|
|
1070
|
+
logger.error(
|
|
1071
|
+
"mcp config server %s in %s: missing command or url",
|
|
1072
|
+
server_name,
|
|
1073
|
+
config_path,
|
|
1074
|
+
)
|
|
1075
|
+
if raise_on_error:
|
|
1076
|
+
raise MCPDiagnosticError(
|
|
1077
|
+
server_name,
|
|
1078
|
+
f"MCP server {server_name!r} is missing command or url",
|
|
1079
|
+
remedy="Add a stdio command/args or a Streamable HTTP url in mcp.json.",
|
|
1080
|
+
)
|
|
1081
|
+
return []
|
|
1082
|
+
args_raw = spec.get("args")
|
|
1083
|
+
args_list = list(args_raw) if isinstance(args_raw, list) else []
|
|
1084
|
+
args_out = [str(x) for x in args_list]
|
|
1085
|
+
|
|
1086
|
+
env_src = spec.get("env")
|
|
1087
|
+
env_out: dict[str, str] = {}
|
|
1088
|
+
if isinstance(env_src, dict):
|
|
1089
|
+
for k, val in env_src.items():
|
|
1090
|
+
env_out[str(k)] = "" if val is None else str(val)
|
|
1091
|
+
|
|
1092
|
+
try:
|
|
1093
|
+
defs = await self.connect(server_name, command, args_out, env_out)
|
|
1094
|
+
except Exception as exc:
|
|
1095
|
+
self._log_connect_failure(exc, server_name=server_name, config_path=config_path)
|
|
1096
|
+
if raise_on_error:
|
|
1097
|
+
raise
|
|
1098
|
+
return []
|
|
1099
|
+
logger.info(
|
|
1100
|
+
"mcp catalog server connected %s",
|
|
1101
|
+
kv(server=server_name, tools=len(defs), transport="stdio"),
|
|
1102
|
+
)
|
|
1103
|
+
return defs
|
|
1104
|
+
|
|
1105
|
+
async def load_from_config(
|
|
1106
|
+
self,
|
|
1107
|
+
mcp_json_path: Path,
|
|
1108
|
+
*,
|
|
1109
|
+
raise_on_error: bool = False,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
"""Load Claude/Cursor ``mcpServers`` JSON; tolerate missing paths and noisy failures.
|
|
1112
|
+
|
|
1113
|
+
Replaces ``${ENV_NAME}`` substrings with environment values (missing → empty string).
|
|
1114
|
+
|
|
1115
|
+
Streamable HTTP entries may include an ``auth`` object with ``flow`` set to
|
|
1116
|
+
``client_credentials`` or ``password`` plus ``token_url`` and related fields.
|
|
1117
|
+
|
|
1118
|
+
Every server under ``mcpServers`` is registered in the catalog for
|
|
1119
|
+
:meth:`connect_from_catalog` / ``enable_mcp`` unless ``"enabled": false``.
|
|
1120
|
+
Catalogued servers are **not** connected at startup by default (progressive
|
|
1121
|
+
disclosure — the model opts in via ``enable_mcp``). Set ``"autoConnect": true``
|
|
1122
|
+
to restore eager startup connect for that server. Remove an entry from the
|
|
1123
|
+
file to drop it from the catalog.
|
|
1124
|
+
|
|
1125
|
+
Args:
|
|
1126
|
+
mcp_json_path: Path to ``mcp.json``.
|
|
1127
|
+
raise_on_error: When True, invalid JSON or structure raises
|
|
1128
|
+
:class:`MCPDiagnosticError` instead of logging and returning.
|
|
1129
|
+
"""
|
|
1130
|
+
if not mcp_json_path.is_file():
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
raw_text = mcp_json_path.read_text(encoding="utf-8")
|
|
1134
|
+
try:
|
|
1135
|
+
raw = json.loads(raw_text)
|
|
1136
|
+
except json.JSONDecodeError as exc:
|
|
1137
|
+
logger.error("invalid mcp JSON in %s", mcp_json_path)
|
|
1138
|
+
if raise_on_error:
|
|
1139
|
+
raise MCPDiagnosticError(
|
|
1140
|
+
"<mcp.json>",
|
|
1141
|
+
f"Invalid MCP JSON in {mcp_json_path}: {exc}",
|
|
1142
|
+
remedy="Fix mcp.json so it parses as JSON.",
|
|
1143
|
+
) from exc
|
|
1144
|
+
return
|
|
1145
|
+
|
|
1146
|
+
if isinstance(raw, dict):
|
|
1147
|
+
raw = interpolate_env_vars(raw)
|
|
1148
|
+
else:
|
|
1149
|
+
logger.error("mcp config invalid in %s: expected top-level object", mcp_json_path)
|
|
1150
|
+
if raise_on_error:
|
|
1151
|
+
raise MCPDiagnosticError(
|
|
1152
|
+
"<mcp.json>",
|
|
1153
|
+
f"MCP config in {mcp_json_path} must be a JSON object",
|
|
1154
|
+
remedy="Wrap servers under a top-level object with mcpServers.",
|
|
1155
|
+
)
|
|
1156
|
+
return
|
|
1157
|
+
|
|
1158
|
+
servers_any = raw.get("mcpServers")
|
|
1159
|
+
if not isinstance(servers_any, dict):
|
|
1160
|
+
logger.error(
|
|
1161
|
+
"mcp config invalid in %s: expected object mcpServers",
|
|
1162
|
+
mcp_json_path,
|
|
1163
|
+
)
|
|
1164
|
+
if raise_on_error:
|
|
1165
|
+
raise MCPDiagnosticError(
|
|
1166
|
+
"<mcp.json>",
|
|
1167
|
+
f"MCP config in {mcp_json_path} must include an object mcpServers",
|
|
1168
|
+
remedy="Add a mcpServers object mapping server names to specs.",
|
|
1169
|
+
)
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
self._config_path = mcp_json_path
|
|
1173
|
+
self._catalog.clear()
|
|
1174
|
+
|
|
1175
|
+
for server_name in sorted(servers_any.keys()):
|
|
1176
|
+
spec = servers_any[server_name]
|
|
1177
|
+
if not isinstance(spec, dict):
|
|
1178
|
+
logger.error(
|
|
1179
|
+
"mcp config server %s in %s: expected object entry",
|
|
1180
|
+
server_name,
|
|
1181
|
+
mcp_json_path,
|
|
1182
|
+
)
|
|
1183
|
+
if raise_on_error:
|
|
1184
|
+
raise MCPDiagnosticError(
|
|
1185
|
+
server_name,
|
|
1186
|
+
f"MCP server {server_name!r} entry must be an object",
|
|
1187
|
+
remedy="Fix the server entry in mcp.json.",
|
|
1188
|
+
)
|
|
1189
|
+
continue
|
|
1190
|
+
if spec.get("enabled") is False:
|
|
1191
|
+
logger.info(
|
|
1192
|
+
"mcp server skipped (enabled: false) %s",
|
|
1193
|
+
kv(server=server_name),
|
|
1194
|
+
)
|
|
1195
|
+
self._set_status(server_name, MCP_STATUS_DISABLED)
|
|
1196
|
+
continue
|
|
1197
|
+
self._catalog[server_name] = dict(spec)
|
|
1198
|
+
self._seen_servers.add(server_name)
|
|
1199
|
+
if spec.get("autoConnect") is True:
|
|
1200
|
+
logger.info(
|
|
1201
|
+
"mcp autoConnect server=%s (connecting at startup)",
|
|
1202
|
+
server_name,
|
|
1203
|
+
)
|
|
1204
|
+
await self._connect_from_spec(
|
|
1205
|
+
server_name,
|
|
1206
|
+
spec,
|
|
1207
|
+
mcp_json_path=mcp_json_path,
|
|
1208
|
+
raise_on_error=raise_on_error,
|
|
1209
|
+
)
|
|
1210
|
+
else:
|
|
1211
|
+
self._set_status(server_name, MCP_STATUS_CATALOGUED)
|
|
1212
|
+
logger.info(
|
|
1213
|
+
"mcp catalog registered server=%s (connect via enable_mcp)",
|
|
1214
|
+
server_name,
|
|
1215
|
+
)
|