From 58786386cf788c98b4a5027209e1ee71c5da1857 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 18 Oct 2026 20:38:52 +0200 Subject: [PATCH] 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). --- mcp.md | 88 +++- .../conclusions/mcp-resolver-agent-design.md | 3 +- .../mcp-trust-and-approval-model.md | 3 +- .../conclusions/tai-mcp-client-design.md | 3 +- tai.py | 457 +++++++++++++++++- test_tai.py | 202 +++++++- 6 files changed, 727 insertions(+), 29 deletions(-) diff --git a/mcp.md b/mcp.md index 7fa3b8c..f3d1bfe 100644 --- a/mcp.md +++ b/mcp.md @@ -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//` 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 `), 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--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 ` +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. diff --git a/research/mcp-client-support/conclusions/mcp-resolver-agent-design.md b/research/mcp-client-support/conclusions/mcp-resolver-agent-design.md index a0ff8c3..27cc816 100644 --- a/research/mcp-client-support/conclusions/mcp-resolver-agent-design.md +++ b/research/mcp-client-support/conclusions/mcp-resolver-agent-design.md @@ -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. | diff --git a/research/mcp-client-support/conclusions/mcp-trust-and-approval-model.md b/research/mcp-client-support/conclusions/mcp-trust-and-approval-model.md index cf4743a..41a60fb 100644 --- a/research/mcp-client-support/conclusions/mcp-trust-and-approval-model.md +++ b/research/mcp-client-support/conclusions/mcp-trust-and-approval-model.md @@ -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. | diff --git a/research/mcp-client-support/conclusions/tai-mcp-client-design.md b/research/mcp-client-support/conclusions/tai-mcp-client-design.md index 5cefdeb..48a0d23 100644 --- a/research/mcp-client-support/conclusions/tai-mcp-client-design.md +++ b/research/mcp-client-support/conclusions/tai-mcp-client-design.md @@ -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. | diff --git a/tai.py b/tai.py index afac34e..2850f3b 100755 --- a/tai.py +++ b/tai.py @@ -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__ 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 for npm packages and uvx 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"

Login complete, you can close this window.

" + 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 diff --git a/test_tai.py b/test_tai.py index bf2abc8..bf3410d 100644 --- a/test_tai.py +++ b/test_tai.py @@ -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)