v1.43.0: automatic MCP GitHub resolver agent plus stdlib-only OAuth login

- mcp_resolve_agent: bounded fork subagent fallback when the
  deterministic GitHub pass finds no run contract (MCP_RESOLVER_*).
- McpOAuth/McpOAuthCallback: discovery, DCR, PKCE loopback, token
  exchange, sealed per-profile token storage, one-shot refresh.
- McpHttpError carries status plus WWW-Authenticate through the
  client; bridge retries once after refresh, else names mcp_login.
- mcp_add stores 401-gated servers disabled; mcp_login approves,
  verifies, enables; mcp_remove drops tokens.
- Docs, conclusions, 17 new tests (1209 + 16 green).
This commit is contained in:
2026-10-18 20:38:52 +02:00
parent 66ed0f9ea6
commit 58786386cf
6 changed files with 727 additions and 29 deletions
+70 -18
View File
@@ -40,6 +40,9 @@ either side; anything asking for them gets `-32601`.
| `MCP_MAX_TOOLS` | 100 | tools bridged per server; extras are listed but not offered |
| `MCP_SESSION_IDLE` | 3600s | idle server-side MCP sessions are dropped |
| `MCP_CHAT_TIMEOUT` | 600s | deadline per served `chat` turn |
| `MCP_RESOLVER_TIMEOUT` | 300s | deadline for the fork-based GitHub resolver subagent |
| `MCP_RESOLVER_STEPS` | 12 | max agent steps for one GitHub resolution |
| `MCP_OAUTH_TIMEOUT` | 300s | how long `mcp_login` waits for the browser redirect |
## Client
@@ -96,13 +99,17 @@ leg, `enabled` to the registering profile, `enabled_tools`/
caps govern.
GitHub URLs (`github.com/<owner>/<repo>` plus anything after) are
not normalizable; they go to the resolver: registry API by repo
path, `server.json`/`mcpName`, `package.json` binaries, README run
block, Glama page, then a live `initialize` + `tools/list` probe.
The resolver is tai itself (the agent loop, usually one
`mcp_add` call after reading): it returns exactly one normalized
entry or a loud failure with the evidence gathered. Every resolved
entry is verified by a live handshake before it is stored.
not normalizable; they go to the resolver in two passes. The
deterministic pass reads `mcp.json`, `.mcp.json`, `server.json`,
then `package.json` (bare `npx -y <name>`), then the README for an
`npx`/`uvx` run block, all through the contents API. When that
finds nothing, `mcp_resolve_agent` spawns a bounded fork subagent
(same profile, `default_runner`, `MCP_RESOLVER_STEPS` steps,
`MCP_RESOLVER_TIMEOUT` seconds) with a strict brief: read the same
files plus the raw README and return exactly one fenced JSON
`command` or `url` entry, or `{"error", "evidence"}`. Anything
else is a loud failure naming both passes. Every resolved entry is
verified by a live handshake before it is stored.
### Bridge
@@ -127,6 +134,44 @@ lands in the audit trail (`mcp-call`, `server/tool`, argument
digest, outcome, duration, size). `${VAR}` secret values resolve at
spawn/call time and `Store.redact` scrubs known values from results.
HTTP failures arrive as `McpHttpError` (an `McpError` carrying the
status code and the `WWW-Authenticate` header) so the bridge and
the OAuth layer can tell 401 from anything else.
### OAuth (remote servers)
Servers that answer 401 get the OAuth treatment, all stdlib
(`urllib`, `http.server`, `secrets`, `webbrowser`), in `McpOAuth`:
1. `mcp_add` on a 401 stores the server **disabled** (no tools
cached) and points at `mcp_login`; servers with a static
`Authorization` header that 401 instead fail with a
check-your-credentials error.
2. `mcp_login` probes anonymously (an open server is refused with
"needs no login"), then discovers: `resource_metadata` from the
401 header, else the origin's
`/.well-known/oauth-authorization-server`, keeping scope from the
protected-resource document. Servers without a
`registration_endpoint` are refused: only dynamic clients.
3. Dynamic client registration (`tai-mcp`, public, loopback
redirect), then PKCE (`S256`) plus state, then the browser opens
(the URL is also printed, for headless terminals). The loopback
listener (`McpOAuthCallback`, `127.0.0.1`, ephemeral port,
`MCP_OAUTH_TIMEOUT` seconds) takes the code, which is exchanged
for tokens.
4. Tokens live in vault `secrets` as
`mcp-oauth-<server>-access`/`-refresh` (name-clamped to
`SECRET_RE`, sealed at rest, redacted from output), with the
`client_id` in the access secret's meta. `mcp_remove` drops them
for every known profile plus the current one.
5. Every HTTP connect injects `Authorization: Bearer <access>`
unless the entry configures its own. A 401 mid-call refreshes
once (re-discover, `refresh_token` grant, rotation honored) and
retries; when that fails the error names `mcp_login`.
Login is interactive-local by nature: run it in a REPL, not over
a served session, since only the local terminal shows the URL.
### Agent tools
| Tool | Args | Effect |
@@ -138,6 +183,7 @@ spawn/call time and `Store.redact` scrubs known values from results.
| `mcp_remove` | `server` | delete server, profile rows, and pins; asks first |
| `mcp_tools` | `server?` | list bridged tools with descriptions and pin state |
| `mcp_call` | `server`, `tool`, `args?` | call one tool explicitly (same path as bridged dispatch) |
| `mcp_login` | `server`, `profile?` | OAuth browser login, store sealed tokens, verify, enable; asks first |
## Server (`tai.py --serve-mcp KEY`)
@@ -160,7 +206,7 @@ Idle sessions past `MCP_SESSION_IDLE` are dropped on next access.
### Served tools
Every native tool schema is served verbatim (name, description,
`parameters` as `inputSchema`), plus two extras, 62 total:
`parameters` as `inputSchema`), plus two extras, 63 total:
| Tool | Args | Effect |
|---|---|---|
@@ -177,20 +223,24 @@ untrusted servers through tai's identity.
### Exclusions (both sides)
OAuth browser flows, dynamic client registration, token storage and
refresh; roots/sampling/elicitation serving; legacy HTTP+SSE;
`GET /mcp` streams; file-attachment content beyond text passthrough
(images/audio arrive as their JSON, unchanged); batch JSON-RPC
arrays (one message per POST).
Confidential or pre-registered OAuth clients (public DCR only),
device flow, and CIMD; roots/sampling/elicitation serving; legacy
HTTP+SSE; `GET /mcp` streams; file-attachment content beyond text
passthrough (images/audio arrive as their JSON, unchanged); batch
JSON-RPC arrays (one message per POST).
## Code map
`MCP_PROTOCOL_VERSION` pins the wire; `mcp_normalize` plus
`mcp_expand_vars` own pasted-config intake; `McpStdioTransport` and
`mcp_expand_vars` own pasted-config intake; `mcp_resolve_agent`
owns the fork-based GitHub fallback; `McpStdioTransport` and
`McpHttpTransport` own the two legs behind `McpClient`;
`McpBridge` owns enablement, drift pins, approval, caps, spill, and
audit; `McpServeContext` plus `mcp_serve_handler` own the HTTP
server; `mcp_serve_main` owns flags and boot.
`McpHttpError` carries HTTP status to the bridge; `McpOAuth` plus
`McpOAuthCallback` own discovery, DCR, PKCE loopback, refresh, and
sealed token storage; `McpBridge` owns enablement, drift pins,
approval, caps, spill, and audit; `McpServeContext` plus
`mcp_serve_handler` own the HTTP server; `mcp_serve_main` owns
flags and boot.
## Test hooks
@@ -199,4 +249,6 @@ method in the wire table, every vault table, every agent tool, and
both server extras. Transport tests drive a fake MCP server (a
`python3 -c` NDJSON stub for stdio, `http.server` in-test for the
HTTP leg); serve tests drive the real `POST /mcp` with `urllib`
against `ChatClient` fakes, never a live model.
against `ChatClient` fakes, never a live model; `McpOAuthTests`
covers discovery, PKCE, the loopback round trip, refresh, the
401 paths, and token cleanup.
@@ -49,10 +49,11 @@ Resolution is two layers. A deterministic normalizer handles pasted configs: acc
## Action taken
Nothing built yet. The normalizer rules and resolver lookup chain above are the spec the registration tool will implement; the build awaits go-ahead with the client itself.
Built in v1.42.0 (normalizer, deterministic contents-API chain, live-verify-before-store). v1.43.0 adds the fork-based fallback `mcp_resolve_agent`: when the deterministic pass finds nothing, a bounded same-profile subagent reads the repo and returns one fenced JSON entry or `{"error", "evidence"}`. The registry/Glama steps from the research stayed out: the contents API plus README reasoning covered real repos without new dependencies.
## Changelog
| Date | Change |
|---|---|
| 2026-10-18 | Initial conclusion recorded. |
| 2026-10-18 | v1.42.0 built the normalizer and deterministic chain; v1.43.0 adds the fork-based resolver subagent fallback. |
@@ -52,10 +52,11 @@ Every MCP server is untrusted until the user says otherwise, and every layer ass
## Action taken
Nothing built yet. This model constrains the client implementation (consent screens, pinning store, tier mapping, caps, audit rows); the build awaits go-ahead with the client itself.
Built in v1.42.0 (consent screens, pins, tiers, caps, audit). v1.43.0 extends it to OAuth: tokens are sealed vault secrets redacted from output, login asks approval before opening a browser, and refresh runs silently at most once per call.
## Changelog
| Date | Change |
|---|---|
| 2026-10-18 | Initial conclusion recorded. |
| 2026-10-18 | v1.42.0 built the model; v1.43.0 extends it to OAuth tokens and login approval. |
@@ -50,10 +50,11 @@ tai will implement a minimal MCP client in `tai.py` with no SDK: newline-delimit
## Action taken
Nothing built yet. This conclusion records the design the implementation will follow; the build (client, vault tables, resolver, bridge, tests, `mcp.md` contract) is the next task, awaiting go-ahead.
Built in v1.42.0 (client, vault tables, deterministic resolver, bridge, both directions, `mcp.md` contract). v1.43.0 revisits the OAuth line: browser DCR/PKCE loopback login plus sealed token storage and one-shot refresh landed as `McpOAuth`/`mcp_login`, stdlib-only, because static headers could not cover 401-gated servers; confidential and pre-registered clients stay out.
## Changelog
| Date | Change |
|---|---|
| 2026-10-18 | Initial conclusion recorded. |
| 2026-10-18 | v1.42.0 built the client as designed; v1.43.0 adds OAuth login (DCR/PKCE/refresh), superseding the static-headers-only rule. |
+453 -4
View File
@@ -24,6 +24,7 @@ import os
import queue
import random
import re
import secrets
import shutil
import shlex
import signal
@@ -41,6 +42,7 @@ import urllib.error
import urllib.parse
import urllib.request
import uuid
import webbrowser
import weakref
import zipfile
from datetime import datetime, timedelta, timezone
@@ -103,7 +105,7 @@ def _fromisoformat(text):
return parsed.replace(tzinfo=tzinfo)
raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00")
VERSION = "1.42.0"
VERSION = "1.43.0"
WORKER_STEPS = 12
CREATE_SKILL_STEPS = 40
FORK_MAX_DEPTH = 2
@@ -8717,6 +8719,7 @@ TOOL_SCHEMAS = [
tool_schema("mcp_remove", "Delete an MCP server with its profile rows, pins, and cached tools. Asks first.", {"server": {"type": "string"}}, ["server"]),
tool_schema("mcp_tools", "List cached MCP tools with descriptions and pin state, for one server or all.", {"server": {"type": "string"}}, []),
tool_schema("mcp_call", "Call one MCP tool explicitly: full path with drift check, approval, caps, and audit. Bridged mcp_<server>_<tool> names call the same path.", {"server": {"type": "string"}, "tool": {"type": "string"}, "args": {"type": "object"}}, ["server", "tool"]),
tool_schema("mcp_login", "OAuth login to a remote MCP server: opens the browser, waits for approval, stores tokens sealed in the vault, then verifies and enables. Run in a REPL, not headless.", {"server": {"type": "string"}, "profile": {"type": "string"}}, ["server"]),
]
@@ -8809,6 +8812,7 @@ TOOL_TAGS = {
"mcp_remove": ("mcp", "remove", "delete", "unregister"),
"mcp_tools": ("mcp", "tools"),
"mcp_call": ("mcp", "call", "invoke"),
"mcp_login": ("mcp", "login", "oauth", "auth"),
}
@@ -16770,6 +16774,7 @@ class Tools:
"mcp_remove": self.run_mcp_remove,
"mcp_tools": self.run_mcp_tools,
"mcp_call": self.run_mcp_call,
"mcp_login": self.run_mcp_login,
}
self.secret_grants = set()
self.read_files = set()
@@ -16845,6 +16850,32 @@ class Tools:
"enabled": None, "source": "github:%s/%s" % (owner, repo)}, notes
raise McpError("no run contract found (checked mcp.json, server.json, package.json, README)")
def mcp_resolve_agent(self, owner, repo, profile):
"""Fork-based half of GitHub resolution. Returns (entry-ish, notes) or raises McpError."""
depth = getattr(self.app, "depth", 0)
if depth >= FORK_MAX_DEPTH:
raise McpError("at fork depth limit, cannot spawn resolver")
brief = MCP_RESOLVER_BRIEF % ((owner, repo) * 7)
try:
out = default_runner(self.app.config, self.app.store.seal, profile, depth + 1,
brief, MCP_RESOLVER_TIMEOUT, max_steps=MCP_RESOLVER_STEPS)
except Exception as exc:
raise McpError("resolver subagent failed: %s" % short_error(exc))
out = out or ""
match = re.search(r"```json\s*(\{.*?\})\s*```", out, re.DOTALL) or re.search(r"(\{.*\})", out, re.DOTALL)
if not match:
raise McpError("resolver returned no JSON (first 200 chars: %r)" % out[:200])
try:
data = json.loads(match.group(1))
except ValueError:
raise McpError("resolver returned bad JSON: %.200s" % match.group(1))
if not isinstance(data, dict):
raise McpError("resolver returned non-object JSON")
if data.get("error"):
raise McpError("resolver: %s (checked: %s)" % (data.get("error"), data.get("evidence", "?")))
entry, sub = mcp_normalize(json.dumps(data), mcp_sanitize_name(repo))
return entry, ["resolver subagent settled it"] + sub
def run_mcp_add(self, args):
source = str(args.get("source") or "").strip()
profile = str(args.get("profile") or self.active_profile)
@@ -16854,7 +16885,14 @@ class Tools:
notes = []
try:
if target:
entry, notes = self.mcp_resolve_github(target[0], target[1])
try:
entry, notes = self.mcp_resolve_github(target[0], target[1])
except McpError as first:
try:
entry, notes = self.mcp_resolve_agent(target[0], target[1], profile)
notes = ["deterministic pass failed (%s), resolver subagent took over" % first] + notes
except McpError as second:
raise McpError("%s; resolver subagent also failed: %s" % (first, second))
entry["source"] = "github:%s/%s" % (target[0], target[1])
else:
text, rewritten = mcp_rewrite_placeholders(source)
@@ -16884,11 +16922,19 @@ class Tools:
bridge = McpBridge(self.app)
try:
client = bridge.connect(entry, profile)
except McpHttpError as exc:
if exc.code == 401:
return self.mcp_add_401(entry, profile, notes)
return "error: verify failed, nothing stored: %s" % exc
except McpError as exc:
return "error: verify failed, nothing stored: %s" % exc
try:
try:
tools = client.list_tools()
except McpHttpError as exc:
if exc.code == 401:
return self.mcp_add_401(entry, profile, notes)
return "error: verify failed, nothing stored: %s" % exc
except McpError as exc:
return "error: verify failed, nothing stored: %s" % exc
finally:
@@ -16905,6 +16951,25 @@ class Tools:
lines.append("- ...and %d more (cap %d offered)" % (len(tools) - MCP_MAX_TOOLS, MCP_MAX_TOOLS))
return "\n".join(lines)
def mcp_add_401(self, entry, profile, notes):
if entry["transport"] != "http":
return "error: verify failed, nothing stored: http 401"
oauth = McpOAuth(self.app.store)
access, _refresh = oauth.load_tokens(entry["name"], profile)
if access:
return "error: verify failed: stored OAuth tokens refused, run mcp_login '%s'" % entry["name"]
if any(key.lower() == "authorization" for key in entry["headers"]):
return "error: verify failed: http 401, check the configured credentials"
entry["enabled"] = False
try:
self.app.store.mcp_save_server(entry)
except ValueError as exc:
return "error: %s" % exc
self.app.store.mcp_set_enabled(entry["name"], profile, False)
lines = ["stored MCP server '%s' disabled: it answered 401, so it needs OAuth login" % entry["name"],
"run mcp_login '%s' to approve in your browser, then it verifies and enables" % entry["name"]]
return "\n".join(lines + ["(note: %s)" % note for note in notes])
def run_mcp_list(self, args):
rows = self.app.store.mcp_list_servers()
if not rows:
@@ -16945,7 +17010,12 @@ class Tools:
return "error: unknown MCP server '%s'" % server
if not self.app.ask_approval("mcp remove '%s' (server, profile rows, pins, cached tools)" % server):
return "denied by user"
profiles = [found[0] for found in self.app.store.db.execute(
"SELECT DISTINCT profile FROM mcp_server_profiles WHERE server = ?", (server,)).fetchall()]
self.app.store.mcp_remove_server(server)
oauth = McpOAuth(self.app.store)
for profile in set(profiles + [self.active_profile]):
oauth.drop_tokens(server, profile)
return "removed MCP server '%s'" % server
def run_mcp_tools(self, args):
@@ -16973,6 +17043,36 @@ class Tools:
return "error: mcp_call needs server and tool"
return McpBridge(self.app).call(server, tool, call_args, self.active_profile)
def run_mcp_login(self, args):
server = str(args.get("server") or "")
profile = str(args.get("profile") or self.active_profile)
row = self.app.store.mcp_get_server(server)
if row is None:
return "error: unknown MCP server '%s'" % server
if row["transport"] != "http":
return "error: local stdio servers need no login"
if not self.app.ask_approval("mcp login '%s' (opens a browser, waits up to %ds)" % (server, MCP_OAUTH_TIMEOUT)):
return "denied by user"
try:
note = McpOAuth(self.app.store).login(row, profile)
except McpError as exc:
return "error: %s" % exc
bridge = McpBridge(self.app)
try:
client = bridge.connect(row, profile)
except McpError as exc:
return "error: login ok but verify failed: %s" % exc
try:
try:
tools = client.list_tools()
except McpError as exc:
return "error: login ok but verify failed: %s" % exc
finally:
client.close()
self.app.store.mcp_set_enabled(server, profile, True)
self.app.store.mcp_store_tools(server, tools)
return "%s\nverified %d tools, enabled for %s" % (note, len(tools), profile)
def dispatch(self, name, raw_arguments):
self.calls += 1
try:
@@ -34810,6 +34910,24 @@ def grr_tui():
MCP_PROTOCOL_VERSION = "2025-11-25"
MCP_CLIENT_NAME = "tai"
MCP_TOOL_PREFIX = "mcp_"
MCP_RESOLVER_TIMEOUT = 300
MCP_RESOLVER_STEPS = 12
MCP_OAUTH_TIMEOUT = 300
MCP_RESOLVER_BRIEF = """You must resolve the GitHub repo %s/%s into an MCP server run contract.
Use web_fetch to read, in this order, until one tells you how to run the server:
https://api.github.com/repos/%s/%s/contents/mcp.json
https://api.github.com/repos/%s/%s/contents/.mcp.json
https://api.github.com/repos/%s/%s/contents/server.json
https://api.github.com/repos/%s/%s/contents/package.json
https://api.github.com/repos/%s/%s/contents/README.md
https://raw.githubusercontent.com/%s/%s/HEAD/README.md
(github api file blobs are base64 under content, decode them yourself.)
Prefer an explicit stdio command (command + args + env) from the repo docs; npx -y <pkg> for npm packages and uvx <pkg> for python packages are acceptable when the README shows them. If the repo is only usable as a remote http endpoint, return its url instead.
Reply with EXACTLY one fenced block and nothing else outside it:
```json
{"command": "npx", "args": ["-y", "pkg"], "env": {}}
```
or {"url": "https://..."} for remote servers, or {"error": "short reason", "evidence": "what you checked"} when nothing runnable exists. Never invent package names or urls you did not read."""
MCP_CONNECT_TIMEOUT = 20
MCP_CALL_TIMEOUT = 60
MCP_RESULT_MAX = 65536
@@ -34835,6 +34953,13 @@ class McpJsonRpcError(McpError):
self.message = message
class McpHttpError(McpError):
def __init__(self, code, www_auth=""):
super(McpHttpError, self).__init__("http %s" % code)
self.code = code
self.www_auth = www_auth or ""
def mcp_sanitize_name(text):
cleaned = re.sub(r"[^a-z0-9_]", "_", (text or "").lower()).strip("_")
return cleaned or "server"
@@ -35100,7 +35225,7 @@ class McpHttpTransport(object):
try:
response = urllib.request.urlopen(request, timeout=timeout)
except urllib.error.HTTPError as exc:
raise McpError("http %s" % exc.code)
raise McpHttpError(exc.code, (exc.headers or {}).get("WWW-Authenticate", ""))
except (urllib.error.URLError, OSError) as exc:
raise McpError("http failed: %s" % short_error(exc))
raw = response.read(MCP_HTTP_MAX_BODY + 1)
@@ -35186,6 +35311,8 @@ class McpClient(object):
result = self._request("initialize", {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {},
"clientInfo": {"name": MCP_CLIENT_NAME, "version": VERSION}},
timeout=MCP_CONNECT_TIMEOUT)
except McpHttpError:
raise
except McpError as exc:
raise McpError("initialize failed: %s" % exc)
if not isinstance(result, dict) or not isinstance(result.get("protocolVersion"), str):
@@ -35214,6 +35341,8 @@ class McpClient(object):
def call_tool(self, name, args=None):
try:
return self._request("tools/call", {"name": name, "arguments": args or {}})
except McpHttpError:
raise
except McpError as exc:
try:
self._notify("notifications/cancelled", {"requestId": self.next_id, "reason": "timeout"})
@@ -35237,6 +35366,294 @@ def mcp_bridge_name(server, tool):
return MCP_TOOL_PREFIX + mcp_sanitize_name(server) + "_" + mcp_sanitize_name(tool)
class McpOAuthCallback(http.server.BaseHTTPRequestHandler):
"""Loopback landing page for the OAuth authorization redirect."""
server_version = "tai-mcp-oauth"
def do_GET(self):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
self.server.oauth_result = {"code": params.get("code", [""])[0],
"state": params.get("state", [""])[0],
"error": params.get("error", [""])[0]}
body = b"<html><body><h1>Login complete, you can close this window.</h1></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class McpOAuth(object):
"""OAuth 2.1 + MCP authorization: discovery, DCR, PKCE loopback, refresh. Stdlib only."""
def __init__(self, store):
self.store = store
@staticmethod
def secret_name(server, kind):
core = server if len(server) <= 40 else server[:31] + "-" + hashlib.sha1(server.encode("utf-8")).hexdigest()[:8]
return "mcp-oauth-%s-%s" % (core, kind)
def load_tokens(self, server, profile):
access = self.store.load_secret(self.secret_name(server, "access"), profile)
refresh = self.store.load_secret(self.secret_name(server, "refresh"), profile)
return access, refresh
def save_tokens(self, server, access, refresh, client_id, profile):
meta = {"server": server, "kind": "mcp-oauth", "client_id": client_id or ""}
self.store.save_secret(self.secret_name(server, "access"), access, meta=meta, profile=profile)
if refresh:
self.store.save_secret(self.secret_name(server, "refresh"), refresh, meta=meta, profile=profile)
else:
self.store.delete_secret(self.secret_name(server, "refresh"), profile)
def drop_tokens(self, server, profile):
for kind in ("access", "refresh"):
self.store.delete_secret(self.secret_name(server, kind), profile)
def client_id(self, server, profile):
return self.store.secret_meta(self.secret_name(server, "access"), profile).get("client_id", "")
def expand_url(self, row, profile):
def lookup(name):
if name in os.environ and os.environ[name]:
return os.environ[name]
return self.store.load_secret(name, profile)
url, missing = mcp_expand_vars(row["url"], lookup)
if missing:
raise McpError("unresolved secrets: %s (set env or store_secret)" % ", ".join(missing))
return url
def inject_headers(self, headers, server, profile):
"""Add the OAuth bearer header unless one is already configured. Returns a copy."""
if any(key.lower() == "authorization" for key in headers):
return dict(headers)
access, _refresh = self.load_tokens(server, profile)
if not access:
return dict(headers)
heads = dict(headers)
heads["Authorization"] = "Bearer " + access
return heads
@staticmethod
def json_get(url):
request = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT)
except urllib.error.HTTPError as exc:
raise McpError("metadata fetch failed: http %s" % exc.code)
except (urllib.error.URLError, OSError) as exc:
raise McpError("metadata fetch failed: %s" % short_error(exc))
try:
data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace"))
except ValueError:
raise McpError("metadata reply is not JSON")
if not isinstance(data, dict):
raise McpError("metadata reply is not an object")
return data
@staticmethod
def form_post(url, fields):
body = urllib.parse.urlencode(fields).encode("utf-8")
request = urllib.request.Request(url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"})
try:
response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT)
except urllib.error.HTTPError as exc:
raise McpError("token endpoint refused: http %s" % exc.code)
except (urllib.error.URLError, OSError) as exc:
raise McpError("token endpoint failed: %s" % short_error(exc))
try:
data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace"))
except ValueError:
raise McpError("token reply is not JSON")
if not isinstance(data, dict):
raise McpError("token reply is not an object")
if data.get("error"):
raise McpError("token endpoint said %s: %s" % (data.get("error"), data.get("error_description", "")))
return data
@staticmethod
def resource_url(www_auth):
match = re.search(r'resource_metadata="([^"]+)"', www_auth or "")
return match.group(1) if match else ""
@staticmethod
def wellknown(issuer, name):
parts = urllib.parse.urlsplit(issuer)
base = urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
return base + "/.well-known/" + name + (parts.path or "")
def discover(self, url, www_auth=""):
"""Find the authorization server: protected-resource metadata, then server metadata."""
origin = urllib.parse.urlsplit(url)
if origin.scheme not in ("http", "https") or not origin.netloc:
raise McpError("not an http(s) url")
resource = self.resource_url(www_auth)
issuers, scope = [], ""
if resource:
try:
doc = self.json_get(resource)
except McpError as exc:
raise McpError("protected-resource metadata failed: %s" % exc)
issuers = [item for item in doc.get("authorization_servers", []) if isinstance(item, str)]
scopes = [item for item in doc.get("scopes_supported", []) if isinstance(item, str)]
scope = " ".join(scopes)
if not issuers:
issuers = [urllib.parse.urlunsplit((origin.scheme, origin.netloc, "", "", ""))]
errors = []
for issuer in issuers:
try:
doc = self.json_get(self.wellknown(issuer, "oauth-authorization-server"))
except McpError as exc:
errors.append("%s: %s" % (issuer, exc))
continue
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
errors.append("%s: metadata lacks endpoints" % issuer)
continue
if not doc.get("registration_endpoint"):
errors.append("%s: no dynamic registration endpoint" % issuer)
continue
return {"authorization_endpoint": doc["authorization_endpoint"], "token_endpoint": doc["token_endpoint"],
"registration_endpoint": doc["registration_endpoint"], "scope": scope}
raise McpError("no usable authorization server (%s)" % "; ".join(errors))
@staticmethod
def register(reg_endpoint, redirect_uri):
body = json.dumps({"client_name": "tai-mcp", "redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
"token_endpoint_auth_method": "none"}).encode("utf-8")
request = urllib.request.Request(reg_endpoint, data=body, headers={"Content-Type": "application/json",
"Accept": "application/json"})
try:
response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT)
except urllib.error.HTTPError as exc:
raise McpError("client registration refused: http %s" % exc.code)
except (urllib.error.URLError, OSError) as exc:
raise McpError("client registration failed: %s" % short_error(exc))
try:
data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace"))
except ValueError:
raise McpError("registration reply is not JSON")
if not isinstance(data, dict) or not data.get("client_id"):
raise McpError("registration reply has no client_id")
return data["client_id"]
@staticmethod
def pkce():
verifier = secrets.token_urlsafe(64)[:128]
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return verifier, challenge
@staticmethod
def authorize_url(meta, client_id, redirect_uri, scope, state, challenge, resource):
fields = {"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
"state": state, "code_challenge": challenge, "code_challenge_method": "S256",
"resource": resource}
if scope:
fields["scope"] = scope
return meta["authorization_endpoint"] + ("&" if "?" in meta["authorization_endpoint"] else "?") + urllib.parse.urlencode(fields)
@staticmethod
def start_listener():
"""Bind the loopback redirect target. Returns (server, redirect_uri)."""
server = http.server.HTTPServer(("127.0.0.1", 0), McpOAuthCallback)
server.timeout = 1.0
server.oauth_result = None
return server, "http://127.0.0.1:%d/callback" % server.server_address[1]
@staticmethod
def wait_callback(server, state, timeout=MCP_OAUTH_TIMEOUT):
"""Serve the loopback redirect until the code arrives or time runs out."""
deadline = time.time() + timeout
try:
while time.time() < deadline:
server.handle_request()
if server.oauth_result:
result = server.oauth_result
if result["error"]:
raise McpError("authorization failed: %s" % result["error"])
if result["state"] != state:
raise McpError("authorization state mismatch")
if not result["code"]:
raise McpError("authorization returned no code")
return result["code"]
finally:
server.server_close()
raise McpError("timed out after %ds waiting for the browser (rerun mcp_login to retry)" % timeout)
def exchange(self, token_endpoint, code, redirect_uri, client_id, verifier, resource):
data = self.form_post(token_endpoint, {"grant_type": "authorization_code", "code": code,
"redirect_uri": redirect_uri, "client_id": client_id,
"code_verifier": verifier, "resource": resource})
if not data.get("access_token"):
raise McpError("token reply has no access_token")
return data["access_token"], data.get("refresh_token", "")
def refresh_flow(self, row, profile):
"""Refresh the access token with the stored refresh token. Returns (access, note)."""
_access, refresh = self.load_tokens(row["name"], profile)
if not refresh:
raise McpError("no refresh token stored")
client = self.client_id(row["name"], profile)
if not client:
raise McpError("no client_id stored, rerun mcp_login")
url = self.expand_url(row, profile)
meta = self.discover(url)
data = self.form_post(meta["token_endpoint"], {"grant_type": "refresh_token", "refresh_token": refresh,
"client_id": client, "resource": url})
if not data.get("access_token"):
raise McpError("refresh reply has no access_token")
self.save_tokens(row["name"], data["access_token"], data.get("refresh_token", "") or refresh, client, profile)
return data["access_token"], "refreshed"
def login(self, row, profile, opener=None):
"""Full interactive login: probe, discover, register, browser, exchange, store."""
if row["transport"] != "http":
raise McpError("local stdio servers need no login")
url = self.expand_url(row, profile)
probe = McpClient(McpHttpTransport(url, {}))
try:
try:
probe.initialize()
except McpHttpError as exc:
if exc.code != 401:
raise McpError("server unreachable: http %s" % exc.code)
www_auth = exc.www_auth
else:
raise McpError("server needs no login (anonymous handshake worked)")
finally:
probe.close()
meta = self.discover(url, www_auth)
server, redirect_uri = self.start_listener()
try:
client = self.register(meta["registration_endpoint"], redirect_uri)
except McpError:
server.server_close()
raise
verifier, challenge = self.pkce()
state = secrets.token_urlsafe(16)
auth_url = self.authorize_url(meta, client, redirect_uri, meta["scope"], state, challenge, url)
print("mcp login for '%s': approve in your browser" % row["name"])
print(auth_url)
opened = False
try:
opened = (opener or webbrowser.open)(auth_url)
except Exception:
opened = False
if not opened:
print("(no browser opened, visit the URL above yourself)")
code = self.wait_callback(server, state)
access, refresh = self.exchange(meta["token_endpoint"], code, redirect_uri, client, verifier, url)
self.save_tokens(row["name"], access, refresh, client, profile)
note = "logged in to '%s' (tokens sealed in vault" % row["name"]
return note + ")" if refresh else note + ", no refresh token issued, rerun login when it expires)"
class McpBridge(object):
"""Client-side bridge: vault servers in, lazy tool schemas and calls out."""
@@ -35293,7 +35710,8 @@ class McpBridge(object):
if row["transport"] == "stdio":
transport = McpStdioTransport().spawn(ready["argv"], ready["env"], row["cwd"])
else:
transport = McpHttpTransport(ready["url"], ready["headers"])
heads = McpOAuth(self.store).inject_headers(ready["headers"], row["name"], profile)
transport = McpHttpTransport(ready["url"], heads)
client = McpClient(transport)
try:
client.initialize()
@@ -35394,13 +35812,41 @@ class McpBridge(object):
if not self.tool_allowed(row, tool):
return "error: MCP tool '%s' is not enabled on '%s'" % (tool, server)
started = time.time()
try:
return self._call_inner(row, server, tool, args, profile, started)
except McpHttpError as exc:
if exc.code != 401 or row["transport"] != "http":
return "error: %s" % exc
oauth = McpOAuth(self.store)
_access, refresh = oauth.load_tokens(server, profile)
if not refresh:
if any(key.lower() == "authorization" for key in row["headers"]):
return "error: MCP server '%s' refused its configured credentials (http 401)" % server
return "error: MCP server '%s' needs login: run mcp_login '%s' (http 401)" % (server, server)
try:
oauth.refresh_flow(row, profile)
except McpError as rexc:
return "error: MCP server '%s' token refresh failed (%s): run mcp_login '%s'" % (server, rexc, server)
try:
return self._call_inner(row, server, tool, args, profile, started)
except McpHttpError as exc2:
if exc2.code != 401:
return "error: %s" % exc2
return "error: MCP server '%s' still unauthorized after refresh: run mcp_login '%s'" % (server, server)
def _call_inner(self, row, server, tool, args, profile, started):
"""One attempt: connect, drift check, approval, call, cap, audit. Lets 401s bubble."""
try:
client = self.connect(row, profile)
except McpHttpError:
raise
except McpError as exc:
return "error: %s" % exc
try:
try:
fresh = client.list_tools()
except McpHttpError:
raise
except McpError as exc:
return "error: tools/list failed: %s" % exc
names = [item.get("name", "") for item in fresh]
@@ -35414,6 +35860,9 @@ class McpBridge(object):
return "denied by user"
try:
result = client.call_tool(tool, args)
except McpHttpError:
self.audit(server, tool, digest, "retry-401", started, profile)
raise
except McpError as exc:
self.audit(server, tool, digest, "error", started, profile)
return "error: %s" % exc
+198 -4
View File
@@ -16539,10 +16539,29 @@ class McpToolsTests(McpBase):
def test_add_github_guidance(self):
with mock.patch.object(tai.Tools, "mcp_resolve_github", side_effect=tai.McpError("nothing found")):
out = self.tools.dispatch("mcp_add", {"source": "https://github.com/jina-ai/MCP"})
with mock.patch.object(tai.Tools, "mcp_resolve_agent", side_effect=tai.McpError("no runner")):
out = self.tools.dispatch("mcp_add", {"source": "https://github.com/jina-ai/MCP"})
self.assertIn("could not resolve github jina-ai/MCP", out)
self.assertIn("mcpServers", out)
def test_add_github_resolver_agent(self):
entry = {"command": "npx", "args": ["-y", "@x/y"], "env": {}}
with mock.patch.object(tai.Tools, "mcp_resolve_github", side_effect=tai.McpError("nothing found")):
with mock.patch.object(tai, "default_runner", return_value="```json\n%s\n```" % json.dumps(entry)):
with mock.patch.object(tai.McpBridge, "connect") as conn:
conn.return_value.list_tools.return_value = []
out = self.tools.dispatch("mcp_add", {"source": "https://github.com/o/r"})
self.assertIn("added MCP server 'r'", out)
self.assertEqual(self.store.mcp_get_server("r")["command"], "npx")
def test_resolve_agent_bad_json(self):
with mock.patch.object(tai, "default_runner", return_value="no json here"):
with self.assertRaises(tai.McpError):
self.tools.mcp_resolve_agent("o", "r", "t")
with mock.patch.object(tai, "default_runner", return_value='{"error": "nope", "evidence": "x"}'):
with self.assertRaises(tai.McpError):
self.tools.mcp_resolve_agent("o", "r", "t")
def test_resolve_github_package(self):
payload = json.dumps({"type": "file", "content": base64.b64encode(b'{"name": "@x/y"}').decode("ascii")})
@@ -16722,6 +16741,179 @@ class McpServeTests(McpBase):
self.assertEqual(tai.mcp_serve_main(args, "k"), 2)
class McpOAuthTests(McpBase):
ENTRY = {"name": "osrv", "transport": "http", "command": "", "args": [], "env": {},
"url": "https://mcp.example.com/v1", "headers": {}, "cwd": "",
"allow": [], "deny": [], "enabled": None, "source": "t"}
def test_secret_name_fits(self):
self.assertTrue(tai.SECRET_RE.match(tai.McpOAuth.secret_name("osrv", "access")))
long = "s" * 64
name = tai.McpOAuth.secret_name(long, "refresh")
self.assertTrue(tai.SECRET_RE.match(name))
self.assertEqual(name, tai.McpOAuth.secret_name(long, "refresh"))
self.assertNotEqual(tai.McpOAuth.secret_name("s" * 63 + "a", "access"),
tai.McpOAuth.secret_name("s" * 63 + "b", "access"))
def test_token_roundtrip(self):
oauth = tai.McpOAuth(self.store)
oauth.save_tokens("osrv", "acc-1", "ref-1", "cid-1", "t")
self.assertEqual(oauth.load_tokens("osrv", "t"), ("acc-1", "ref-1"))
self.assertEqual(oauth.client_id("osrv", "t"), "cid-1")
oauth.drop_tokens("osrv", "t")
self.assertEqual(oauth.load_tokens("osrv", "t"), (None, None))
def test_inject_headers(self):
oauth = tai.McpOAuth(self.store)
self.assertEqual(oauth.inject_headers({"X": "y"}, "osrv", "t"), {"X": "y"})
oauth.save_tokens("osrv", "acc-1", "", "cid-1", "t")
self.assertEqual(oauth.inject_headers({}, "osrv", "t"), {"Authorization": "Bearer acc-1"})
static = {"Authorization": "Basic zzz"}
self.assertEqual(oauth.inject_headers(static, "osrv", "t"), static)
def test_pure_helpers(self):
self.assertEqual(tai.McpOAuth.resource_url('Bearer resource_metadata="https://h/.well-known/x", scope="a"'),
"https://h/.well-known/x")
self.assertEqual(tai.McpOAuth.resource_url("Bearer realm"), "")
self.assertEqual(tai.McpOAuth.wellknown("https://h", "oauth-authorization-server"),
"https://h/.well-known/oauth-authorization-server")
self.assertEqual(tai.McpOAuth.wellknown("https://h/tenant", "oauth-authorization-server"),
"https://h/.well-known/oauth-authorization-server/tenant")
verifier, challenge = tai.McpOAuth.pkce()
expect = base64.urlsafe_b64encode(tai.hashlib.sha256(verifier.encode("ascii")).digest()).decode("ascii").rstrip("=")
self.assertEqual(challenge, expect)
url = tai.McpOAuth.authorize_url({"authorization_endpoint": "https://h/auth"}, "cid",
"http://127.0.0.1:9/callback", "a b", "st", "ch", "https://h/mcp")
parts = tai.urllib.parse.parse_qs(tai.urllib.parse.urlsplit(url).query)
self.assertEqual(parts["code_challenge_method"], ["S256"])
self.assertEqual(parts["scope"], ["a b"])
self.assertEqual(parts["state"], ["st"])
def test_discover_header_chain(self):
oauth = tai.McpOAuth(self.store)
def fake_get(url):
if url == "https://h/.well-known/rm":
return {"authorization_servers": ["https://auth.example"], "scopes_supported": ["mcp"]}
if url == "https://auth.example/.well-known/oauth-authorization-server":
return {"authorization_endpoint": "https://auth.example/auth",
"token_endpoint": "https://auth.example/token",
"registration_endpoint": "https://auth.example/reg"}
raise AssertionError(url)
with mock.patch.object(tai.McpOAuth, "json_get", side_effect=fake_get):
meta = oauth.discover("https://h/mcp", 'Bearer resource_metadata="https://h/.well-known/rm"')
self.assertEqual(meta["scope"], "mcp")
self.assertEqual(meta["token_endpoint"], "https://auth.example/token")
def test_discover_no_registration(self):
oauth = tai.McpOAuth(self.store)
with mock.patch.object(tai.McpOAuth, "json_get",
return_value={"authorization_endpoint": "a", "token_endpoint": "b"}):
with self.assertRaises(tai.McpError):
oauth.discover("https://h/mcp")
def test_wait_callback_result(self):
server, redirect = tai.McpOAuth.start_listener()
self.assertIn("http://127.0.0.1:", redirect)
server.oauth_result = {"code": "c9", "state": "s9", "error": ""}
self.assertEqual(tai.McpOAuth.wait_callback(server, "s9", timeout=6), "c9")
server2, _uri = tai.McpOAuth.start_listener()
server2.oauth_result = {"code": "", "state": "s9", "error": "denied"}
with self.assertRaises(tai.McpError):
tai.McpOAuth.wait_callback(server2, "s9", timeout=6)
def test_wait_callback_live(self):
server, _uri = tai.McpOAuth.start_listener()
port = server.server_address[1]
def hit():
time.sleep(0.3)
urllib.request.urlopen("http://127.0.0.1:%d/callback?code=live&state=s1" % port, timeout=5).read()
worker = threading.Thread(target=hit)
worker.start()
try:
self.assertEqual(tai.McpOAuth.wait_callback(server, "s1", timeout=10), "live")
finally:
worker.join(timeout=10)
def test_login_refusals(self):
oauth = tai.McpOAuth(self.store)
stdio = dict(self.ENTRY)
stdio["transport"] = "stdio"
with self.assertRaises(tai.McpError):
oauth.login(stdio, "t")
with mock.patch.object(tai.McpClient, "initialize", return_value=None):
with self.assertRaisesRegex(tai.McpError, "needs no login"):
oauth.login(dict(self.ENTRY), "t")
def test_refresh_flow(self):
oauth = tai.McpOAuth(self.store)
oauth.save_tokens("osrv", "old", "ref-1", "cid-1", "t")
row = dict(self.ENTRY)
with mock.patch.object(tai.McpOAuth, "discover", return_value={"token_endpoint": "https://h/token"}):
with mock.patch.object(tai.McpOAuth, "form_post", return_value={"access_token": "new", "refresh_token": "ref-2"}) as posted:
access, _note = oauth.refresh_flow(row, "t")
self.assertEqual(access, "new")
self.assertEqual(posted.call_args[0][1]["refresh_token"], "ref-1")
self.assertEqual(oauth.load_tokens("osrv", "t"), ("new", "ref-2"))
def test_bridge_401_hints(self):
self.store.mcp_save_server(dict(self.ENTRY))
self.store.mcp_set_enabled("osrv", "t", True)
bridge = tai.McpBridge(self.app)
with mock.patch.object(tai.McpBridge, "connect", side_effect=tai.McpHttpError(401)):
self.assertIn("needs login", bridge.call("osrv", "echo", {}, "t"))
self.assertIn("http 401", bridge.call("osrv", "echo", {}, "t"))
with mock.patch.object(tai.McpBridge, "connect", side_effect=tai.McpHttpError(500)):
self.assertIn("http 500", bridge.call("osrv", "echo", {}, "t"))
static = dict(self.ENTRY)
static["headers"] = {"Authorization": "Bearer dead"}
self.store.mcp_save_server(static)
with mock.patch.object(tai.McpBridge, "connect", side_effect=tai.McpHttpError(401)):
self.assertIn("refused its configured credentials", bridge.call("osrv", "echo", {}, "t"))
def test_bridge_401_refresh_retry(self):
self.store.mcp_save_server(dict(self.ENTRY))
self.store.mcp_set_enabled("osrv", "t", True)
oauth = tai.McpOAuth(self.store)
oauth.save_tokens("osrv", "old", "ref-1", "cid-1", "t")
bridge = tai.McpBridge(self.app)
with mock.patch.object(tai.McpOAuth, "refresh_flow", return_value=("new", "refreshed")) as refreshed:
with mock.patch.object(tai.McpBridge, "_call_inner", side_effect=[tai.McpHttpError(401), "done"]):
self.assertEqual(bridge.call("osrv", "echo", {}, "t"), "done")
self.assertEqual(refreshed.call_count, 1)
def test_add_401_stores_disabled(self):
with mock.patch.object(tai.McpBridge, "connect", side_effect=tai.McpHttpError(401)):
out = self.tools.dispatch("mcp_add", {"source": '{"mcpServers": {"osrv": {"url": "https://h/mcp"}}}'})
self.assertIn("needs OAuth login", out)
self.assertIn("mcp_login", out)
row = self.store.mcp_get_server("osrv")
self.assertIsNotNone(row)
self.assertFalse(self.store.mcp_server_enabled("osrv", "t"))
def test_remove_drops_tokens(self):
self.store.mcp_save_server(dict(self.ENTRY))
oauth = tai.McpOAuth(self.store)
oauth.save_tokens("osrv", "acc-1", "ref-1", "cid-1", "t")
self.assertIn("removed MCP server", self.tools.dispatch("mcp_remove", {"server": "osrv"}))
self.assertEqual(oauth.load_tokens("osrv", "t"), (None, None))
def test_login_tool_wired(self):
names = [schema["function"]["name"] for schema in tai.TOOL_SCHEMAS]
self.assertIn("mcp_login", names)
self.assertIn("mcp_login", tai.TOOL_TAGS)
self.assertIn("unknown MCP server", self.tools.dispatch("mcp_login", {"server": "nope"}))
stdio = dict(self.ENTRY)
stdio["name"] = "local"
stdio["transport"] = "stdio"
stdio["command"] = "true"
self.store.mcp_save_server(stdio)
self.assertIn("need no login", self.tools.dispatch("mcp_login", {"server": "local"}))
class McpDocTests(unittest.TestCase):
def setUp(self):
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp.md"), encoding="utf-8") as handle:
@@ -16730,17 +16922,19 @@ class McpDocTests(unittest.TestCase):
def test_doc_names_everything(self):
for const in ("MCP_PROTOCOL_VERSION", "MCP_CONNECT_TIMEOUT", "MCP_CALL_TIMEOUT", "MCP_RESULT_MAX",
"MCP_HTTP_MAX_BODY", "MCP_MAX_SERVERS", "MCP_MAX_TOOLS", "MCP_SESSION_IDLE",
"MCP_CHAT_TIMEOUT", "MCP_DEFAULT_PORT"):
"MCP_CHAT_TIMEOUT", "MCP_DEFAULT_PORT", "MCP_RESOLVER_TIMEOUT", "MCP_RESOLVER_STEPS",
"MCP_OAUTH_TIMEOUT"):
self.assertIn(const, self.doc, const)
self.assertTrue(hasattr(tai, const), const)
for method in ("initialize", "tools/list", "tools/call", "ping", "notifications/cancelled"):
self.assertIn(method, self.doc, method)
for table in ("mcp_servers", "mcp_server_profiles", "mcp_tool_pins", "mcp_server_tools"):
self.assertIn(table, self.doc, table)
for tool in ("mcp_add", "mcp_list", "mcp_enable", "mcp_disable", "mcp_remove", "mcp_tools", "mcp_call"):
for tool in ("mcp_add", "mcp_list", "mcp_enable", "mcp_disable", "mcp_remove", "mcp_tools", "mcp_call", "mcp_login"):
self.assertIn(tool, self.doc, tool)
for symbol in ("McpBridge", "McpClient", "McpStdioTransport", "McpHttpTransport", "McpServeContext",
"mcp_normalize", "mcp_serve_main", "chat", "completions", "--serve-mcp", "--mcp-port", "--mcp-bind"):
"McpOAuth", "McpHttpError", "mcp_normalize", "mcp_resolve_agent", "mcp_serve_main", "chat",
"completions", "--serve-mcp", "--mcp-port", "--mcp-bind"):
self.assertIn(symbol, self.doc, symbol)
self.assertNotIn(chr(0x2014), self.doc)