feat(http): MCP-over-Streamable-HTTP + multi-token + adapters/http-tunnel#177
Conversation
…nnel Closes vouchdev#176. Promotes VEP-0004 from draft to accepted by delivering the spec-compliant HTTP transport the five Claude surfaces require (Claude.ai Custom Connectors, Claude mobile write, Anthropic Managed Agents, Messages-API mcp_servers, Computer Use). The vouch-native /rpc envelope from PR vouchdev#104 keeps working byte-for-byte; the new /mcp endpoint is what unblocks the Claude surfaces. What changes: * src/vouch/http_server.py rewritten on Starlette + uvicorn. One ASGI app mounts FastMCP's StreamableHTTPASGIApp at /mcp (and /messages alias), plus the existing /rpc, /healthz, /capabilities routes. /health is a new alias for /healthz so Claude.ai's connector validator can probe it. * Bearer auth becomes multi-token. The legacy --token still works; the new accept-list is read from config.yaml under a serve: section that supports bearer_tokens: [list] and bearer_token: env:VAR for env-var-referenced secrets. * FastMCP runs stateless + json_response mode so curl-shaped clients don't need to track Mcp-Session-Id between calls. * adapters/http-tunnel/ ships three reference deployments: a base Dockerfile, a fly.toml for managed-TLS, and a cloudflare-tunnel compose.yml for no-open-port self-hosting. Each treats the bearer token as the trust boundary -- vouch refuses to bind a non-loopback host without at least one token. * tests/test_http_server_mcp.py adds 14 tests covering MCP initialize, tools/list, tools/call kb_status round-trip, /messages alias, bearer-required-when-set, multi-token accept-list, /health alias, config.yaml YAML parsing (list + env-ref + missing-env-ref + empty section), and three regression tests that prove the legacy /rpc, /healthz, and /capabilities surface still behaves exactly as before. * tests/test_http_server.py: the negative-Content-Length test is updated to accept uvicorn's transport-layer 4xx response (which is at least as safe as the prior application-layer 400-JSON path). * proposals/VEP-0004-http-transport.md: status draft -> accepted, open-questions block resolved (bespoke REST vs MCP -> both; config vs flags -> both; default port stays 8731), endpoint table updated. Why "max-feature": vouchdev#176 is a high-leverage unblock for Bittensor/Gittensor contributors who run multiple AI agents in parallel, so the spec-compliant path, the multi-token rotation story, and the public-internet reference deployments all need to ship together -- a partial fix would leave the trust-boundary story half-documented and force every operator to figure it out from scratch. The kb.* surface is unchanged: 44 tools, same parameter and result shapes across stdio, JSONL, /rpc, /mcp. CI: ruff clean, mypy clean, 330 pytest passed (316 prior + 14 new + 0 regressions).
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ReviewSummary: This PR delivers the MCP-over-Streamable-HTTP transport that directly addresses the five blocked Claude surfaces listed in #176, rewriting What works
Suggestions
Questions
Verdictrequest changes — Two blocking issues: the |
… question fixes Closes the review feedback on PR vouchdev#177 from plind-junior. Two blocking, three non-blocking, and two question-driven fixes; the second question is answered with a new regression test. Blocking: * shutdown() no longer busy-waits 2.5 s on uvicorn.Server.started -- that flag is set True at boot and never reset during graceful shutdown, so the loop always burned the full timeout. Setting should_exit = True is the documented graceful-stop signal; the serving thread is the caller's to reap. Knock-on: HTTP test suite 48.67 s -> 0.60 s, full suite ~52 s -> 4.80 s. * IPv6 loopback ::1 was in _LOOPBACK_HOSTS but the pre-bind socket hard-coded AF_INET, so an operator passing --host ::1 would hit OSError: Invalid argument before the listener even started. Pick the socket family from the host (": " in host => AF_INET6). Non-blocking: * Drop the _Handler: type | None = None and _ = threading sentinels; they were backward-compat shims that polluted the module namespace and would have silently broken isinstance checks. The stdlib request handler is gone; downstream isinstance code was never possible against the new ASGI handler anyway. * _attach_vouch_mcp_routes -> _add_mcp_routes; drop the unused return value. The function mutates `routes` in place, the name now says so. * adapters/http-tunnel/fly.toml: add an inline comment on the `app = "REPLACEME-vouch"` line so a copy-paste-then-deploy footgun is caught before `fly deploy` errors. Question-driven: * The "global singleton mutation" concern is resolved by constructing StreamableHTTPSessionManager directly per make_app() call instead of toggling vouch_server.mcp._session_manager and .settings on the module-level FastMCP. The underlying Server (with the 44 kb.* tools) is reused; only the session-manager wrapper is fresh per app. No more type: ignore[attr-defined], no more cross-call races. * New test test_mcp_accepts_session_id_header_in_stateless_mode pins that a stateful client (one that sends Mcp-Session-Id) is silently accepted in our stateless mode rather than 4xx-d -- the regression for the reviewer's compatibility concern. ruff clean, mypy clean, 331 pytest passed (PR was 330, +1 new test).
|
Thanks for the careful review — pushed Blocking fixes
Question that turned into a refactor
Non-blocking cleanups
Compatibility test for question #2 New regression: CI: ruff clean, mypy clean, 331 passed (+1 new test, no regressions). |
Summary
Closes #176 by promoting VEP-0004 from
drafttoacceptedand delivering the spec-compliant HTTP transport the five Claude surfaces named in the issue require (Claude.ai Custom Connectors, Claude mobile write, Anthropic Managed Agents, Messages-APImcp_servers, Anthropic Computer Use). The vouch-native/rpcenvelope from PR #104 keeps working byte-for-byte; the new/mcpendpoint is what unblocks the Claude surfaces.Root cause
The HTTP transport shipped in PR #104 spoke a custom JSON-RPC envelope at
/rpc. That works for vouch-aware clients but does not satisfy MCP-over-Streamable-HTTP — the protocol Claude.ai's Custom Connectors, Claude mobile (write), Managed Agents, the Messages-APImcp_serversfield, and Computer Use all require. Each of those surfaces probes/healthand then runs a JSON-RPC 2.0initializehandshake; vouch had neither. Issue #176 lists those five surfaces and points to the gap.Fix
Rewrite
src/vouch/http_server.pyas a single Starlette ASGI application that mounts the FastMCPStreamableHTTPASGIApp(built fromvouch.server.mcp) at/mcp, plus the legacy/rpcand the existing/healthz//capabilitiesroutes, plus new/healthand/messagesaliases:POST /mcpinitialize,tools/list,tools/call)POST /messages/mcp— older Claude surfaces probe this pathPOST /rpcGET /capabilitieskb.capabilitiesJSONGET /healthz{"ok": true}livenessGET /health/healthz— Claude.ai connector validatorFastMCP is configured
stateless_http=True, json_response=Trueso curl-shaped clients don't have to trackMcp-Session-Idbetween calls. Per-request actor attribution (X-Vouch-Agent) is unchanged.Multi-token accept-list. The legacy
--tokenstill works. The new path isconfig.yaml:Both sources compose: any token in the union of
--token+config.yamlis accepted. Matching is constant-time (hmac.compare_digest) across every entry. Missing-env-var references fail loudly at startup instead of silently substituting empty string.Reference deployments.
adapters/http-tunnel/ships three drop-in templates: a baseDockerfile, afly.tomlfor managed-TLS on fly.io, and acloudflare-tunnel/compose.ymlfor self-hosting with no inbound port. Each treats the bearer token as the trust boundary — vouch's own bind-policy gate still refuses non-loopback hosts without at least one token, so an accidentally-misconfigured tunnel can't expose an unauthenticated KB.Test Plan
tests/test_http_server_mcp.py(14 new)pytest tests/test_http_server.py tests/test_http_server_mcp.pypytest— 330 passed (316 prior + 14 new), no regressionsruff checkon changed files — cleanmypy src/vouch/— cleanWhat the new tests cover
test_mcp_initialize_responds_with_spec_shapePOST /mcpwith JSON-RPC 2.0initializereturns spec-shapeprotocolVersion,capabilities,serverInfotest_mcp_alias_messages_path_worksPOST /messagesis the same handler as/mcp(historical Claude surfaces)test_mcp_tools_list_returns_kb_surfacetools/listadvertises the kb.* surface — at least 20 tools, includingkb_statustest_mcp_tools_call_kb_status_round_triptools/callwithname: kb_statusactually runs the kb.* dispatch and returns claim countstest_mcp_without_bearer_when_token_required/mcpwithoutAuthorization: Bearer …test_mcp_accepts_any_of_multiple_bearer_tokenstest_health_alias_unauthenticated_even_with_token/health,/healthz,/capabilitiesare reachable without auth even when tokens are configuredtest_serve_config_loads_bearer_tokens_listserve.bearer_tokens: [...]parsed correctlytest_serve_config_resolves_env_ref_for_bearer_tokenbearer_token: env:VARresolves from envtest_serve_config_missing_env_ref_raisestest_serve_config_empty_returns_no_tokensserve:section is benigntest_legacy_rpc_envelope_still_works/rpcenvelope is unchanged byte-for-bytetest_legacy_healthz_unchanged/healthzstill returns{"ok": true}test_legacy_capabilities_unchanged/capabilitiesstill advertises"http"intransportsOutput
Wire one of the Claude surfaces
After deploying via
adapters/http-tunnel/:https://<your-host>/mcp(or/messages— both work)VOUCH_TOKEN/health(unauth) then runs MCPinitializeagainst/mcp(with bearer). Both should be green.The Messages-API
mcp_serversform, Computer Use, and Managed Agents all take the same URL + token shape.Out of scope (per #176)
draft.adapters/http-tunnel/README.md.Closes #176