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.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. 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
+ )