chad-code 0.1.0__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.
chad/mcp.py ADDED
@@ -0,0 +1,719 @@
1
+ """Model Context Protocol (MCP) client — external tool servers as chad tools.
2
+
3
+ An *MCP server* is a separate process (or hosted HTTP endpoint) that exposes tools
4
+ (and resources/prompts we don't use yet). Connecting one lets chad call tools it
5
+ doesn't ship: a GitHub server, a Postgres server, Linear/Slack/Atlassian, a
6
+ company's internal API, etc. This is the same extension mechanism Claude Code uses.
7
+
8
+ Transport is provided by the official `mcp` Python SDK (`stdio_client` for local
9
+ subprocess servers, `streamablehttp_client` for hosted HTTP servers). The SDK is
10
+ **async (anyio)**; chad's tool layer is **synchronous and blocking**. The bridge is
11
+ a single `anyio.from_thread.BlockingPortal` running one event loop in a background
12
+ thread, owned by the registry for its lifetime. Each server's transport +
13
+ `ClientSession` scopes are kept open on that loop (a connect task that awaits a stop
14
+ event), and every `tools/call` is marshalled onto the loop via `portal.call`. The
15
+ per-call timeout lives *inside* the coroutine (`anyio.fail_after`) so a hung remote
16
+ server cancels the task and frees the agent thread — it can never wedge the agent.
17
+
18
+ Four client responsibilities, mirroring `skills.py`:
19
+
20
+ 1. Configure — read `.mcp.json` (project) and `~/.chad/mcp.json` (user); project
21
+ servers override user servers of the same name.
22
+ 2. Connect — for each server, open the transport, run `initialize`, list tools.
23
+ Connects run in parallel and time-bounded so one dead endpoint can't
24
+ stall the others or the first turn.
25
+ 3. Expose — `schemas()` returns OpenAI/Qwen tool definitions named
26
+ `mcp__<server>__<tool>` (namespaced so they can't collide with
27
+ chad's builtins or each other), appended to `tools.active_schemas()`.
28
+ 4. Dispatch — `call(name, args)` routes a model tool-call to the owning server and
29
+ returns the result text.
30
+
31
+ Everything degrades gracefully: a server that's missing, misconfigured, slow to
32
+ connect, or that crashes mid-session contributes no tools and never takes the agent
33
+ down — exactly like a language server that won't start in `lsp.py`.
34
+
35
+ State (the cwd-keyed registry of live connections) lives at module level like
36
+ `skills._registry`, is rebuilt when cwd changes, and is torn down by
37
+ `reset_session()` on a new Agent / `/reset` and by an atexit hook on process exit so
38
+ server subprocesses and HTTP sessions never leak.
39
+ """
40
+
41
+ import atexit
42
+ import json
43
+ import os
44
+ import threading
45
+ from functools import partial
46
+ from typing import Any, Optional
47
+
48
+ import anyio
49
+ from anyio.from_thread import start_blocking_portal
50
+ from mcp import ClientSession, StdioServerParameters
51
+ from mcp.client.stdio import stdio_client
52
+
53
+ # `streamablehttp_client(url, headers=..., auth=...)` is the documented HTTP entry
54
+ # point that takes headers (and, in plan 017, an `auth=` OAuth provider) directly,
55
+ # building the httpx client via MCP's own factory with the right defaults. mcp 1.28.1
56
+ # marks it deprecated in favour of `streamable_http_client(url, *, http_client=...)`,
57
+ # but that variant has no `headers=`/`auth=` — you'd hand-build an httpx client and
58
+ # risk dropping MCP's required client config — so we keep this one until the new API
59
+ # grows an equivalent. (Confirmed against mcp==1.28.1.)
60
+ from mcp.client.streamable_http import streamablehttp_client
61
+
62
+ from . import mcp_oauth
63
+ from .diag import log, warn_footer
64
+
65
+ # Namespacing: a model-visible MCP tool is `mcp__<server>__<tool>`. The separator is
66
+ # the Claude-Code convention; it keeps server tools from colliding with chad builtins
67
+ # (bash/read/…) or with each other, and lets dispatch recover (server, tool) by split.
68
+ PREFIX = "mcp__"
69
+ _SEP = "__"
70
+
71
+ # Timeouts (seconds). Connect (initialize + tools/list) is generous because a stdio
72
+ # server launched via `npx`/`uvx` may download the package on first run, and an HTTP
73
+ # endpoint may be slow; per-call default is the same 120s as `tool_bash`, overridable
74
+ # per server with a "timeout" config key.
75
+ _CONNECT_TIMEOUT = 60
76
+ _CALL_TIMEOUT = 120
77
+ # Extra slack on the main-thread join over the in-coroutine connect timeout, so the
78
+ # coroutine's own fail_after fires first and reports a clean per-server error.
79
+ _CONNECT_JOIN_GRACE = 5
80
+ # A tools/list that pages forever (buggy server) shouldn't hang the session.
81
+ _MAX_LIST_PAGES = 50
82
+ # Cap a tool result so a chatty server can't blow up the context / prefill budget,
83
+ # same spirit as tool_bash's 20k clamp.
84
+ _RESULT_MAX_CHARS = 20000
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Configuration
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _config_paths(cwd: str, home: str):
92
+ """Config files to read, lowest precedence first (project overrides user), each
93
+ tagged with its trust scope: user-level is authored by the operator (trusted);
94
+ project-level (`./.mcp.json`) ships with the repo and is attacker-influenced."""
95
+ return [
96
+ (os.path.join(home, ".chad", "mcp.json"), "user"), # user-level (TRUSTED)
97
+ (os.path.join(cwd, ".mcp.json"), "project"), # project-level (UNTRUSTED)
98
+ ]
99
+
100
+
101
+ def _load_config(cwd: str = None, home: str = None):
102
+ """Merge MCP server definitions from the config files. Returns an ordered list of
103
+ (name, spec, scope) where scope is "user" or "project", plus a list of
104
+ human-readable warnings. Project entries override user entries of the same name,
105
+ and the merged entry's scope becomes "project" (it now carries attacker-influenced
106
+ fields, so it must clear the trust gate). Lenient: a malformed file warns and is
107
+ skipped, it never aborts startup."""
108
+ cwd = cwd or os.getcwd()
109
+ home = home or os.path.expanduser("~")
110
+ merged = {}
111
+ scopes = {}
112
+ order = []
113
+ warnings = []
114
+ for path, scope in _config_paths(cwd, home):
115
+ if not os.path.isfile(path):
116
+ continue
117
+ try:
118
+ with open(path, "r", errors="replace") as f:
119
+ data = json.load(f)
120
+ except (OSError, ValueError) as e:
121
+ warnings.append(f"{path}: skipped ({e})")
122
+ log.warning("mcp: cannot read config %s — %s", path, e)
123
+ continue
124
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
125
+ if not isinstance(servers, dict):
126
+ warnings.append(f"{path}: no 'mcpServers' object")
127
+ continue
128
+ for name, spec in servers.items():
129
+ if not isinstance(spec, dict):
130
+ warnings.append(f"{path}: server {name!r} is not an object")
131
+ continue
132
+ if name not in merged:
133
+ order.append(name)
134
+ merged[name] = spec
135
+ scopes[name] = scope
136
+ return [(n, merged[n], scopes[n]) for n in order], warnings
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Trust store — gate untrusted project-level servers behind an explicit opt-in
141
+ # ---------------------------------------------------------------------------
142
+ #
143
+ # A `./.mcp.json` ships with a repo and names a subprocess (command/args/env/cwd) or
144
+ # an HTTP endpoint we'd otherwise reach on the first agent turn — cloning a hostile
145
+ # repo and running chad in it would launch attacker-controlled processes or send
146
+ # tokens to an attacker URL with no confirmation. So a project-scope server is only
147
+ # started once the operator has trusted that project path (via `/mcp trust`).
148
+ # User-level config is authored by the operator and is never gated. Trust is keyed by
149
+ # absolute cwd and persisted to ~/.chad/trusted_mcp.json (mode 0600); if a project
150
+ # moves dirs it re-prompts, which is intentional (the path is the trust anchor).
151
+
152
+ def _trust_store_path() -> str:
153
+ return os.path.join(os.path.expanduser("~"), ".chad", "trusted_mcp.json")
154
+
155
+
156
+ def _load_trust() -> dict:
157
+ path = _trust_store_path()
158
+ try:
159
+ with open(path, "r", errors="replace") as f:
160
+ data = json.load(f)
161
+ return data if isinstance(data, dict) else {}
162
+ except (OSError, ValueError):
163
+ return {}
164
+
165
+
166
+ def _is_trusted(cwd: str) -> bool:
167
+ return _load_trust().get(os.path.abspath(cwd)) is True
168
+
169
+
170
+ def _set_trusted(cwd: str):
171
+ """Mark a project path as trusted, persisting to ~/.chad/trusted_mcp.json (0600)."""
172
+ path = _trust_store_path()
173
+ data = _load_trust()
174
+ data[os.path.abspath(cwd)] = True
175
+ try:
176
+ os.makedirs(os.path.dirname(path), exist_ok=True)
177
+ # Create with 0600 from the start so the trust list isn't briefly world-readable.
178
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
179
+ with os.fdopen(fd, "w") as f:
180
+ json.dump(data, f)
181
+ os.chmod(path, 0o600)
182
+ except OSError as e:
183
+ log.warning("mcp: could not persist trust store %s — %s", path, e)
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Transport build (stdio | streamable HTTP) — the only auth here is static bearer
188
+ # headers; OAuth (auth= provider) slots into the same call sites in plan 017.
189
+ # ---------------------------------------------------------------------------
190
+
191
+ # Env vars a stdio MCP server can reasonably need just to *start* — locate its binary,
192
+ # resolve HOME, set the locale, find a temp dir. Everything else in the parent
193
+ # environment (API keys, cloud tokens, provider creds) is withheld by default: a
194
+ # user-configured stdio server runs an arbitrary local command and has no business
195
+ # inheriting chad's whole secret-bearing environment. A server that genuinely needs a
196
+ # specific var declares it in its config `env:` block (merged in, and it wins); the full
197
+ # inherit is available with CHAD_MCP_FULL_ENV=1 for the rare server that needs it.
198
+ _MCP_ENV_ALLOW = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
199
+ "TMP", "TEMP", "SHELL", "USER", "LOGNAME")
200
+
201
+
202
+ def _stdio_env(environ, extra) -> dict:
203
+ """The environment handed to a stdio MCP subprocess. Minimal allowlist by default
204
+ (see `_MCP_ENV_ALLOW`) so secrets in the parent environment don't leak to arbitrary
205
+ user-configured commands; `CHAD_MCP_FULL_ENV=1` restores the full inherit. The
206
+ server's own config `env:` entries are always merged last and override."""
207
+ # Strict `== "1"` (not config.flag: any non-empty value would loosen it), and read off
208
+ # the passed-in `environ` param (tests inject a custom dict) rather than os.environ — so
209
+ # this stays inline rather than routing through config.
210
+ if environ.get("CHAD_MCP_FULL_ENV") == "1":
211
+ env = dict(environ)
212
+ else:
213
+ env = {k: environ[k] for k in _MCP_ENV_ALLOW if k in environ}
214
+ if isinstance(extra, dict):
215
+ env.update({k: str(v) for k, v in extra.items()})
216
+ return env
217
+
218
+
219
+ def _transport_for(spec: dict, auth=None):
220
+ """Return (kind, build) for a server spec, where `build()` is a zero-arg factory
221
+ that yields the SDK transport async context manager. Transport is chosen by
222
+ presence: `url` -> HTTP, else `command` -> stdio (`type` is advisory only).
223
+ `auth` is an optional OAuthClientProvider (plan 017) passed straight through to
224
+ `streamablehttp_client(..., auth=...)`; None keeps the plan-016 bearer/header path.
225
+ Returns (None, None) if neither is present."""
226
+ url = spec.get("url")
227
+ if isinstance(url, str) and url:
228
+ headers = spec.get("headers")
229
+ headers = headers if isinstance(headers, dict) else None
230
+ return "http", lambda: streamablehttp_client(url, headers=headers, auth=auth)
231
+ command = spec.get("command")
232
+ if isinstance(command, str) and command:
233
+ env = _stdio_env(os.environ, spec.get("env"))
234
+ params = StdioServerParameters(
235
+ command=command,
236
+ args=list(spec.get("args") or []),
237
+ env=env,
238
+ cwd=spec.get("cwd") or None,
239
+ )
240
+ return "stdio", lambda: stdio_client(params)
241
+ return None, None
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Async coroutines run on the portal's event loop
246
+ # ---------------------------------------------------------------------------
247
+
248
+ async def _list_all_tools(session: "ClientSession"):
249
+ """Collect every tool across paginated tools/list pages (bounded so a buggy
250
+ server that pages forever can't hang connect). Returns SDK `Tool` objects."""
251
+ out = []
252
+ cursor = None
253
+ for _ in range(_MAX_LIST_PAGES):
254
+ result = await session.list_tools(cursor) if cursor else await session.list_tools()
255
+ out += [t for t in (result.tools or []) if getattr(t, "name", None)]
256
+ cursor = result.nextCursor
257
+ if not cursor:
258
+ break
259
+ return out
260
+
261
+
262
+ async def _run_conn(conn: "_Conn", build, connect_timeout: float):
263
+ """Open one server's transport + ClientSession on the loop, run the handshake +
264
+ tool listing under a connect timeout, then hold the scopes open until the stop
265
+ event is set (so call_tool coroutines run against the live session). Any failure
266
+ records conn.error and contributes no tools (graceful)."""
267
+ try:
268
+ async with build() as streams:
269
+ read, write = streams[0], streams[1]
270
+ async with ClientSession(read, write) as session:
271
+ try:
272
+ with anyio.fail_after(connect_timeout):
273
+ await session.initialize()
274
+ tools = await _list_all_tools(session)
275
+ except Exception as e: # noqa: BLE001 — connect/handshake/list failure
276
+ conn.error = f"{type(e).__name__}: {e}"
277
+ log.warning("mcp: %s handshake/list failed: %s", conn.name, e)
278
+ conn._ready.set()
279
+ return
280
+ conn.session = session
281
+ conn.tools = tools
282
+ conn._ready.set()
283
+ log.info("mcp: %s connected (%d tool(s))", conn.name, len(tools))
284
+ await conn._stop.wait() # keep transport + session scopes open
285
+ except Exception as e: # noqa: BLE001 — transport-level failure (couldn't open)
286
+ conn.error = f"{type(e).__name__}: {e}"
287
+ log.warning("mcp: %s could not connect: %s", conn.name, e)
288
+ conn._ready.set()
289
+ finally:
290
+ conn._done.set()
291
+
292
+
293
+ async def _invoke(session: "ClientSession", raw: str, arguments: dict, timeout: float):
294
+ """Call one tool with the per-call timeout INSIDE the coroutine, so a hung server
295
+ cancels this task (freeing the agent thread) instead of wedging the loop."""
296
+ with anyio.fail_after(timeout):
297
+ return await session.call_tool(raw, arguments or {})
298
+
299
+
300
+ def _render_result(res) -> str:
301
+ """Flatten an SDK `CallToolResult` into text for the model. Concatenates text
302
+ content blocks; notes non-text blocks (images/resources) by type. Honors isError
303
+ by prefixing the text so the model treats it as a failure to react to."""
304
+ parts = []
305
+ for block in (getattr(res, "content", None) or []):
306
+ btype = getattr(block, "type", None)
307
+ if btype == "text":
308
+ parts.append(str(getattr(block, "text", "")))
309
+ elif btype == "resource":
310
+ r = getattr(block, "resource", None)
311
+ rtext = getattr(r, "text", None) if r is not None else None
312
+ if rtext:
313
+ parts.append(str(rtext))
314
+ else:
315
+ uri = getattr(r, "uri", "unknown") if r is not None else "unknown"
316
+ parts.append(f"[resource: {uri}]")
317
+ else:
318
+ parts.append(f"[{btype or 'non-text'} content omitted]")
319
+ text = "\n".join(p for p in parts if p)
320
+ # Some servers return only structuredContent (no content blocks).
321
+ sc = getattr(res, "structuredContent", None)
322
+ if not text and sc is not None:
323
+ try:
324
+ text = json.dumps(sc, ensure_ascii=False, indent=2)
325
+ except (TypeError, ValueError):
326
+ text = str(sc)
327
+ if not text:
328
+ text = "[no content]"
329
+ if getattr(res, "isError", False):
330
+ text = "[tool reported an error]\n" + text
331
+ return text[:_RESULT_MAX_CHARS]
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # One live server connection (bridged onto the registry's portal)
336
+ # ---------------------------------------------------------------------------
337
+
338
+ class _Conn:
339
+ """One MCP server. Its transport + ClientSession live on the registry's portal
340
+ loop inside `_run_conn`; this object is the synchronous handle the agent thread
341
+ uses to call tools and to tear the connection down."""
342
+
343
+ def __init__(self, name: str, spec: dict, transport: str):
344
+ self.name = name
345
+ self.spec = spec
346
+ self.transport = transport # "stdio" | "http"
347
+ self.tools = [] # SDK Tool objects (empty if connect failed)
348
+ self.error: Optional[str] = None # human string if the server is unusable
349
+ self.session: Any = None # live ClientSession (loop thread); set on connect
350
+ self.portal: Any = None # the registry's BlockingPortal
351
+ self._stop: Any = None # anyio.Event on the loop; set to unwind scopes
352
+ self._ready = threading.Event() # connect attempt finished (ok or error)
353
+ self._done = threading.Event() # connect task fully unwound
354
+
355
+ def call(self, raw: str, arguments: dict) -> str:
356
+ """Invoke one tool and return its result text. Raises on transport/timeout
357
+ error; a tool-reported error (isError) is returned as text via _render_result."""
358
+ timeout = self.spec.get("timeout")
359
+ timeout = timeout if isinstance(timeout, (int, float)) else _CALL_TIMEOUT
360
+ res = self.portal.call(partial(_invoke, self.session, raw, arguments, timeout))
361
+ return _render_result(res)
362
+
363
+ def close(self):
364
+ """Signal the connect task to unwind its session + transport on the loop
365
+ thread, then wait briefly for it so no subprocess / socket leaks."""
366
+ if self.portal is not None and self._stop is not None:
367
+ try:
368
+ self.portal.call(self._stop.set)
369
+ except Exception: # noqa: BLE001 — portal may already be shutting down
370
+ pass
371
+ self._done.wait(timeout=5)
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Registry (cwd-cached, like skills._registry) — owns ONE portal for its lifetime
376
+ # ---------------------------------------------------------------------------
377
+
378
+ class _Registry:
379
+ """All MCP servers configured for one cwd, connected once over a single shared
380
+ portal loop. Holds the live connections plus a name -> (conn, tool) index for
381
+ dispatch and a name -> schema/mutating index for validation."""
382
+
383
+ def __init__(self, cwd: str):
384
+ self.cwd = cwd
385
+ self.clients = [] # all _Conn (connected or errored)
386
+ self.by_tool = {} # mcp__server__tool -> (conn, raw_tool_name)
387
+ self._schemas = [] # OpenAI/Qwen schema per exposed tool
388
+ self._param = {} # tool name -> JSON-Schema (for validate.py)
389
+ self._mutating = set() # tool names that need confirmation
390
+ self.blocked = [] # [(name, reason)] project servers gated as untrusted
391
+ self.needs_login = [] # [(name, reason)] oauth servers awaiting /mcp login
392
+ self.warnings = []
393
+ self.portal = None
394
+ self._portal_cm = None
395
+ self._connect()
396
+
397
+ def _connect(self):
398
+ servers, warnings = _load_config(self.cwd)
399
+ self.warnings = list(warnings)
400
+ trusted = _is_trusted(self.cwd)
401
+
402
+ # Decide which servers we'll actually connect (after disabled/name/trust gates).
403
+ to_connect = []
404
+ for name, spec, scope in servers:
405
+ if spec.get("disabled"):
406
+ log.info("mcp: %s disabled in config; skipping", name)
407
+ continue
408
+ if _SEP in name:
409
+ self.warnings.append(f"{name}: server name contains '{_SEP}'; skipped")
410
+ continue
411
+ if scope == "project" and not trusted:
412
+ # Never auto-prompt here: connection is lazy/mid-turn, where there's no
413
+ # safe place to block on input. Default to not connecting + a warning;
414
+ # the operator opts in out-of-band with `/mcp trust`.
415
+ reason = "project server not started — project not trusted (run /mcp trust)"
416
+ self.blocked.append((name, reason))
417
+ self.warnings.append(f"{name}: {reason}")
418
+ log.info("mcp: %s gated — project %s not trusted", name, self.cwd)
419
+ continue
420
+ if mcp_oauth.is_oauth(spec):
421
+ # OAuth is opt-in and never auto-connects interactively. Flag off => skip
422
+ # entirely (no tools, warning). Flag on but no stored tokens => "needs
423
+ # login" (the operator runs /mcp login; we never block a turn/eval on a
424
+ # browser). Flag on with tokens => connect non-interactively (SDK
425
+ # uses/refreshes the stored token; a refresh failure degrades, never hangs).
426
+ if not mcp_oauth.oauth_enabled():
427
+ reason = "OAuth disabled — set CHAD_MCP_OAUTH=1"
428
+ self.blocked.append((name, reason))
429
+ self.warnings.append(f"{name}: {reason}")
430
+ log.info("mcp: %s skipped — OAuth feature flag off", name)
431
+ continue
432
+ url = spec.get("url")
433
+ if not (isinstance(url, str) and url):
434
+ self.warnings.append(f"{name}: oauth server needs a 'url'")
435
+ continue
436
+ if not mcp_oauth.has_tokens(name):
437
+ reason = f"needs login — run /mcp login {name}"
438
+ self.needs_login.append((name, reason))
439
+ self.warnings.append(f"{name}: {reason}")
440
+ log.info("mcp: %s needs interactive OAuth login", name)
441
+ continue
442
+ storage = mcp_oauth.FileTokenStorage(name)
443
+ provider = mcp_oauth.make_noninteractive_provider(url, storage, spec.get("scope"))
444
+ kind, build = _transport_for(spec, auth=provider)
445
+ to_connect.append((name, spec, kind, build))
446
+ continue
447
+ kind, build = _transport_for(spec)
448
+ if build is None:
449
+ self.warnings.append(f"{name}: no 'url' or 'command' in server config")
450
+ continue
451
+ to_connect.append((name, spec, kind, build))
452
+
453
+ if not to_connect:
454
+ return
455
+
456
+ # Bring up the shared portal loop once, then launch every connect as a task so
457
+ # they run in parallel — one dead/slow endpoint can't stall the others.
458
+ self._portal_cm = start_blocking_portal()
459
+ self.portal = self._portal_cm.__enter__()
460
+ pending = []
461
+ for name, spec, kind, build in to_connect:
462
+ conn = _Conn(name, spec, kind)
463
+ conn.portal = self.portal
464
+ conn._stop = self.portal.call(anyio.Event)
465
+ self.clients.append(conn)
466
+ ct = spec.get("connect_timeout")
467
+ ct = ct if isinstance(ct, (int, float)) else _CONNECT_TIMEOUT
468
+ self.portal.start_task_soon(_run_conn, conn, build, ct)
469
+ pending.append((conn, ct))
470
+
471
+ # Join each connect, time-bounded. The grace lets the coroutine's own
472
+ # fail_after report a clean error before our hard cutoff trips.
473
+ for conn, ct in pending:
474
+ if not conn._ready.wait(ct + _CONNECT_JOIN_GRACE):
475
+ conn.error = conn.error or f"connect timed out after {ct}s"
476
+ if conn.error:
477
+ self.warnings.append(f"{conn.name}: {conn.error}")
478
+ else:
479
+ self._register(conn)
480
+
481
+ def _register(self, conn: "_Conn"):
482
+ for t in conn.tools:
483
+ raw = t.name
484
+ full = f"{PREFIX}{conn.name}{_SEP}{raw}"
485
+ if full in self.by_tool: # two servers, same tool name — keep first, warn
486
+ self.warnings.append(f"{full}: duplicate tool name; later copy ignored")
487
+ continue
488
+ schema = t.inputSchema
489
+ if not isinstance(schema, dict) or schema.get("type") != "object":
490
+ schema = {"type": "object", "properties": {}}
491
+ self.by_tool[full] = (conn, raw)
492
+ self._param[full] = schema
493
+ self._schemas.append({
494
+ "type": "function",
495
+ "function": {
496
+ "name": full,
497
+ "description": _describe(conn.name, t),
498
+ "parameters": schema,
499
+ },
500
+ })
501
+ if _is_mutating(t):
502
+ self._mutating.add(full)
503
+
504
+ def schemas(self):
505
+ return self._schemas
506
+
507
+ def param_schema(self, name):
508
+ return self._param.get(name)
509
+
510
+ def is_mutating(self, name) -> bool:
511
+ return name in self._mutating
512
+
513
+ def call(self, name: str, arguments: dict) -> str:
514
+ entry = self.by_tool.get(name)
515
+ if entry is None:
516
+ return f"[unknown MCP tool {name!r}]"
517
+ conn, raw = entry
518
+ try:
519
+ return conn.call(raw, arguments)
520
+ except Exception as e: # noqa: BLE001 — surface transport/timeout errors to the model
521
+ log.warning("mcp: call %s failed: %s", name, e)
522
+ return f"[MCP error calling {name}: {type(e).__name__}: {e}]"
523
+
524
+ def tool_names(self):
525
+ return list(self.by_tool)
526
+
527
+ def close(self):
528
+ # Unwind every session on the loop thread first, then stop the loop itself.
529
+ for c in self.clients:
530
+ c.close()
531
+ if self._portal_cm is not None:
532
+ try:
533
+ self._portal_cm.__exit__(None, None, None)
534
+ except Exception: # noqa: BLE001 — best-effort teardown
535
+ pass
536
+ self.portal = None
537
+ self._portal_cm = None
538
+
539
+
540
+ def _describe(server: str, tool) -> str:
541
+ """Tool description shown to the model, tagged with its origin server so the model
542
+ (and the user reading a trace) knows it's an external MCP tool."""
543
+ desc = (getattr(tool, "description", None) or "").strip()
544
+ head = f"[MCP server '{server}'] "
545
+ return head + desc if desc else head + tool.name
546
+
547
+
548
+ def _is_mutating(tool) -> bool:
549
+ """Whether a tool needs the confirm gate. MCP `annotations.readOnlyHint == true`
550
+ means the server promises no side effects -> safe to auto-run; anything else is
551
+ treated as mutating (the safe default: an MCP tool may write files, hit an API, or
552
+ send a message, and we'd rather over-confirm than act unprompted)."""
553
+ ann = getattr(tool, "annotations", None)
554
+ if ann is not None and getattr(ann, "readOnlyHint", None) is True:
555
+ return False
556
+ return True
557
+
558
+
559
+ _registry = None
560
+
561
+
562
+ def service() -> "_Registry":
563
+ """The MCP registry for the current cwd, connected once and cached. Rebuilt if the
564
+ working directory changed (each eval task / project switch gets its own servers)."""
565
+ global _registry
566
+ cwd = os.path.abspath(os.getcwd())
567
+ if _registry is None or _registry.cwd != cwd:
568
+ if _registry is not None:
569
+ _registry.close()
570
+ _registry = _Registry(cwd)
571
+ if _registry.by_tool:
572
+ log.info("mcp: %d tool(s) from %d server(s): %s",
573
+ len(_registry.by_tool), len(_registry.clients),
574
+ ", ".join(_registry.tool_names()))
575
+ return _registry
576
+
577
+
578
+ def reset_session():
579
+ """Tear down all server connections and force reconnection on next use. Called when
580
+ a new Agent / `/reset` starts so a prior session's servers don't leak forward
581
+ (mirrors skills.reset_session and lsp's rebind-stops-old-server)."""
582
+ global _registry
583
+ if _registry is not None:
584
+ _registry.close()
585
+ _registry = None
586
+
587
+
588
+ def trust(cwd: str = None):
589
+ """Mark the current project (cwd) as trusted so its `./.mcp.json` servers connect,
590
+ then reset the session so the next `service()` reconnects with them enabled. Wired
591
+ to the `/mcp trust` command in both front-ends."""
592
+ _set_trusted(cwd or os.getcwd())
593
+ reset_session()
594
+
595
+
596
+ def login(name: str, emit=None) -> str:
597
+ """Run the interactive OAuth flow for one configured `auth: oauth` server, persist
598
+ the resulting tokens, and reset the session so the server's tools come live. Wired to
599
+ `/mcp login <server>` in both front-ends. `emit` is an optional callable(str) the
600
+ front-end passes so the browser URL / progress is shown in its own UI. Returns a
601
+ human-readable status line and NEVER raises into the caller — a failed/abandoned
602
+ login degrades to a clear message (the server simply stays unconnected).
603
+
604
+ Why this is its own command (not auto-connect): the first OAuth connect opens a
605
+ browser and blocks on a human approving, then catches a redirect. That cannot happen
606
+ silently on an agent turn and must never wedge an eval/headless run."""
607
+ say = emit or (lambda _m: None)
608
+
609
+ if not mcp_oauth.oauth_enabled():
610
+ return "OAuth is disabled — set CHAD_MCP_OAUTH=1 and retry."
611
+
612
+ servers, _warnings = _load_config()
613
+ spec = next((s for n, s, _scope in servers if n == name), None)
614
+ if spec is None:
615
+ return f"no MCP server named {name!r} in config."
616
+ if not mcp_oauth.is_oauth(spec):
617
+ return f"{name} is not an OAuth server (no \"auth\": \"oauth\" in its config)."
618
+ url = spec.get("url")
619
+ if not (isinstance(url, str) and url):
620
+ return f"{name}: oauth server needs a 'url'."
621
+
622
+ storage = mcp_oauth.FileTokenStorage(name)
623
+ loopback = mcp_oauth.LoopbackServer()
624
+ provider = mcp_oauth.make_login_provider(url, storage, loopback, spec.get("scope"), emit=say)
625
+ headers = spec.get("headers")
626
+ headers = headers if isinstance(headers, dict) else None
627
+
628
+ async def _do_login():
629
+ # initialize() triggers the SDK's OAuth flow (401 -> authorize -> browser ->
630
+ # loopback redirect -> token exchange); tokens are persisted by `storage` along
631
+ # the way. We only need the handshake to complete for that to happen.
632
+ async with streamablehttp_client(url, headers=headers, auth=provider) as streams:
633
+ async with ClientSession(streams[0], streams[1]) as session:
634
+ with anyio.fail_after(mcp_oauth._LOGIN_TIMEOUT + 30):
635
+ await session.initialize()
636
+
637
+ try:
638
+ with start_blocking_portal() as portal:
639
+ portal.call(_do_login)
640
+ except Exception as e: # noqa: BLE001 — any flow failure degrades to a message
641
+ log.warning("mcp: %s OAuth login failed: %s", name, e)
642
+ return f"{name}: login failed ({type(e).__name__}: {e})."
643
+ finally:
644
+ loopback.close()
645
+
646
+ if not mcp_oauth.has_tokens(name):
647
+ return f"{name}: login did not produce a token."
648
+ reset_session()
649
+ return f"{name}: logged in — its tools are now available."
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # Thin module-level accessors (the surface tools.py / validate.py / agent.py use)
654
+ # ---------------------------------------------------------------------------
655
+
656
+ def schemas():
657
+ """OpenAI/Qwen tool schemas for every connected MCP tool ([] if none)."""
658
+ return service().schemas()
659
+
660
+
661
+ def is_mcp_tool(name: str) -> bool:
662
+ return isinstance(name, str) and name.startswith(PREFIX)
663
+
664
+
665
+ def has_tool(name: str) -> bool:
666
+ return name in service().by_tool
667
+
668
+
669
+ def param_schema(name: str):
670
+ """JSON-Schema for an MCP tool's arguments, or None if not an MCP tool. Lets
671
+ validate.py coerce/validate MCP calls with no schema duplication."""
672
+ if not is_mcp_tool(name):
673
+ return None
674
+ return service().param_schema(name)
675
+
676
+
677
+ def is_mutating(name: str) -> bool:
678
+ return is_mcp_tool(name) and service().is_mutating(name)
679
+
680
+
681
+ def call(name: str, arguments: dict) -> str:
682
+ return service().call(name, arguments)
683
+
684
+
685
+ def summary_lines():
686
+ """Human-readable rows for the `/mcp` command: one line per server (transport +
687
+ tool count, or its connection error), each server's tools, then any warnings."""
688
+ reg = service()
689
+ if not reg.clients and not reg.blocked and not reg.needs_login:
690
+ return ["no MCP servers configured. Add one to .mcp.json (project) or "
691
+ "~/.chad/mcp.json (user): "
692
+ '{"mcpServers": {"name": {"command": "...", "args": [...]}}} '
693
+ 'or {"name": {"type": "http", "url": "https://..."}}']
694
+ out = []
695
+ for c in reg.clients:
696
+ if c.error:
697
+ out.append(f"{c.name} [{c.transport}] — ✗ {c.error}")
698
+ continue
699
+ ro = sum(1 for t in c.tools if not _is_mutating(t))
700
+ out.append(f"{c.name} [{c.transport}] — {len(c.tools)} tool(s) ({ro} read-only)")
701
+ for t in c.tools:
702
+ mark = "" if _is_mutating(t) else " (read-only)"
703
+ desc = " ".join((getattr(t, "description", None) or "").split())
704
+ if len(desc) > 70:
705
+ desc = desc[:67] + "…"
706
+ out.append(f" {PREFIX}{c.name}{_SEP}{t.name}{mark}"
707
+ + (f" — {desc}" if desc else ""))
708
+ for name, reason in reg.blocked:
709
+ out.append(f"{name} — ⊘ blocked ({reason})")
710
+ for name, reason in reg.needs_login:
711
+ out.append(f"{name} [http/oauth] — ⊷ {reason}")
712
+ out += warn_footer(reg.warnings)
713
+ return out
714
+
715
+
716
+ @atexit.register
717
+ def _shutdown():
718
+ if _registry is not None:
719
+ _registry.close()