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:
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user