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/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
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()
|