Skip to content

feat(http): MCP-over-Streamable-HTTP + multi-token + adapters/http-tunnel#177

Merged
plind-junior merged 2 commits into
vouchdev:testfrom
dripsmvcp:fix/176-mcp-spec-http
Jun 9, 2026
Merged

feat(http): MCP-over-Streamable-HTTP + multi-token + adapters/http-tunnel#177
plind-junior merged 2 commits into
vouchdev:testfrom
dripsmvcp:fix/176-mcp-spec-http

Conversation

@dripsmvcp

Copy link
Copy Markdown
Contributor

Summary

Closes #176 by promoting VEP-0004 from draft to accepted and 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-API mcp_servers, Anthropic Computer Use). The vouch-native /rpc envelope from PR #104 keeps working byte-for-byte; the new /mcp endpoint 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-API mcp_servers field, and Computer Use all require. Each of those surfaces probes /health and then runs a JSON-RPC 2.0 initialize handshake; vouch had neither. Issue #176 lists those five surfaces and points to the gap.

Fix

Rewrite src/vouch/http_server.py as a single Starlette ASGI application that mounts the FastMCP StreamableHTTPASGIApp (built from vouch.server.mcp) at /mcp, plus the legacy /rpc and the existing /healthz / /capabilities routes, plus new /health and /messages aliases:

Method & path Purpose Auth
POST /mcp MCP-over-Streamable-HTTP (initialize, tools/list, tools/call) bearer
POST /messages alias for /mcp — older Claude surfaces probe this path bearer
POST /rpc vouch-native JSONL envelope (PR #104 contract, unchanged) bearer
GET /capabilities kb.capabilities JSON open
GET /healthz {"ok": true} liveness open
GET /health alias for /healthz — Claude.ai connector validator open

FastMCP is configured stateless_http=True, json_response=True so curl-shaped clients don't have to track Mcp-Session-Id between calls. Per-request actor attribution (X-Vouch-Agent) is unchanged.

Multi-token accept-list. The legacy --token still works. The new path is config.yaml:

serve:
  bearer_tokens:        # multi-token accept-list for fleet rotation
    - alpha
    - beta
  # OR a single token, optionally via an env-var reference:
  bearer_token: env:VOUCH_TOKEN

Both sources compose: any token in the union of --token + config.yaml is 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 base Dockerfile, a fly.toml for managed-TLS on fly.io, and a cloudflare-tunnel/compose.yml for 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

  • Regression tests added: tests/test_http_server_mcp.py (14 new)
  • All HTTP tests pass: pytest tests/test_http_server.py tests/test_http_server_mcp.py
  • Full suite: pytest — 330 passed (316 prior + 14 new), no regressions
  • ruff check on changed files — clean
  • mypy src/vouch/ — clean

What the new tests cover

Test What it pins
test_mcp_initialize_responds_with_spec_shape POST /mcp with JSON-RPC 2.0 initialize returns spec-shape protocolVersion, capabilities, serverInfo
test_mcp_alias_messages_path_works POST /messages is the same handler as /mcp (historical Claude surfaces)
test_mcp_tools_list_returns_kb_surface tools/list advertises the kb.* surface — at least 20 tools, including kb_status
test_mcp_tools_call_kb_status_round_trip tools/call with name: kb_status actually runs the kb.* dispatch and returns claim counts
test_mcp_without_bearer_when_token_required 401 on /mcp without Authorization: Bearer …
test_mcp_accepts_any_of_multiple_bearer_tokens Multi-token accept-list: any in the list works, anything else 401s
test_health_alias_unauthenticated_even_with_token /health, /healthz, /capabilities are reachable without auth even when tokens are configured
test_serve_config_loads_bearer_tokens_list YAML serve.bearer_tokens: [...] parsed correctly
test_serve_config_resolves_env_ref_for_bearer_token YAML bearer_token: env:VAR resolves from env
test_serve_config_missing_env_ref_raises Missing env-var ref fails loudly (no silent empty-token)
test_serve_config_empty_returns_no_tokens No serve: section is benign
test_legacy_rpc_envelope_still_works PR #104's /rpc envelope is unchanged byte-for-byte
test_legacy_healthz_unchanged /healthz still returns {"ok": true}
test_legacy_capabilities_unchanged /capabilities still advertises "http" in transports

Output

$ pytest tests/test_http_server.py tests/test_http_server_mcp.py
============================= test session starts ==============================
collected 26 items

tests/test_http_server.py ............                                   [ 46%]
tests/test_http_server_mcp.py ..............                             [100%]

============================= 26 passed in 48.67s ==============================

$ pytest
330 passed, 5 deselected in 51.99s

$ ruff check src/vouch/http_server.py src/vouch/cli.py \
             tests/test_http_server.py tests/test_http_server_mcp.py \
             proposals/VEP-0004-http-transport.md adapters/
All checks passed!

$ mypy src/vouch/
Success: no issues found in 35 source files

Wire one of the Claude surfaces

After deploying via adapters/http-tunnel/:

  1. Claude.ai → Settings → Custom Connectors → Add Connector
  2. URL: https://<your-host>/mcp (or /messages — both work)
  3. Auth: Bearer, paste VOUCH_TOKEN
  4. Hit "Test" — validator probes /health (unauth) then runs MCP initialize against /mcp (with bearer). Both should be green.

The Messages-API mcp_servers form, Computer Use, and Managed Agents all take the same URL + token shape.

Out of scope (per #176)

  • Multi-tenancy / mapping tokens to scoped views — depends on VEP-0005 (richer scopes), which is still draft.
  • OAuth — Bearer is the v1 surface.
  • TLS termination — done at the tunnel/proxy layer (Cloudflare, fly.io, nginx); see adapters/http-tunnel/README.md.

Closes #176

…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).
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ebe53e1a-7559-40fb-8f34-3bc4801461d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@plind-junior

Copy link
Copy Markdown
Member

Review

Summary: This PR delivers the MCP-over-Streamable-HTTP transport that directly addresses the five blocked Claude surfaces listed in #176, rewriting http_server.py from a stdlib ThreadingHTTPServer to a Starlette/uvicorn ASGI app with FastMCP integration, multi-token auth, and reference deployment templates. The issue's acceptance criteria are fully met.

What works

  • src/vouch/http_server.py:584_PUBLIC_PATHS frozenset correctly exempts /healthz, /health, and /capabilities from auth; Claude.ai's connector validator can probe liveness without a credential, exactly as the issue requires.
  • src/vouch/http_server.py:820-826BearerMiddleware.dispatch ORs all hmac.compare_digest results without short-circuiting, genuinely preventing a timing oracle on token-slot position. The comment explains why.
  • src/vouch/http_server.py:686-694_resolve_token raises ServeConfigError loudly on a missing env-var reference rather than silently substituting an empty string, closing a classic "accidental open relay" footgun.
  • src/vouch/http_server.py:893-895 — Resetting _session_manager = None before each streamable_http_app() call is a correct fix for the "single-use per instance" FastMCP limitation; the explanation in the comment is accurate and will save the next contributor from puzzlement.
  • tests/test_http_server_mcp.py:1415-1432 — Regression tests for all three legacy endpoints (/rpc, /healthz, /capabilities) are present and pin the byte-for-byte PR docs(proposals): VEP-0004 — HTTP transport for vouch serve (draft) #104 contract.
  • adapters/http-tunnel/Dockerfile:40 — Dropping to UID 10001 after chown is correct privilege reduction; the container can still write to /data/.vouch without running as root.

Suggestions

  • [blocking] src/vouch/http_server.py:989-996shutdown() busy-waits on self._server.started to go False, but uvicorn.Server.started is set to True when the server comes up and is never set back to False during a graceful shutdown — it stays True until the process ends. The loop exits only after 50 × 0.05 s = 2.5 s on every shutdown (i.e. after every test), making the 14-test suite artificially slow and masking whether shutdown actually succeeded. Use self._server.should_exit = True and then join the serving thread (which _serve() in the tests already controls) instead of polling a flag that never resets. Minimal fix: while self._server.started and not self._server.should_exit: time.sleep(0.05) won't help — check self._server.servers being empty or simply remove the poll loop and rely on the daemon thread to be reaped by the test fixture.
  • [blocking] src/vouch/http_server.py:953socket.socket(socket.AF_INET, ...) hard-codes IPv4. When host="::1" (IPv6 loopback, which is in _LOOPBACK_HOSTS) the pre-bind will fail with OSError: [Errno 22] Invalid argument. Either detect ":" in host and use socket.AF_INET6, or document that ::1 is listed in _LOOPBACK_HOSTS but not actually supported as a bind address.
  • [non-blocking] src/vouch/http_server.py:1057-1064_Handler: type | None = None and _ = threading are backward-compat shims but they pollute the module namespace with a sentinel that will confuse isinstance checks if any downstream code does if isinstance(handler, _Handler) (it will always be False in a non-obvious way). A __getattr__ deprecation shim or a simple deletion with a comment pointing to the ASGI path would be cleaner.
  • [non-blocking] src/vouch/http_server.py:833_attach_vouch_mcp_routes returns inner: ASGIApp but the return value is never used by its only caller (line 904). The return type and value can be dropped, or the function can be renamed to make it clear it mutates routes in place.
  • [non-blocking] adapters/http-tunnel/fly.toml:243app = "REPLACEME-vouch" will cause fly deploy to fail for anyone who copies the file verbatim without reading the README first. A comment on that line (e.g. # replace with your app name: fly launch --name <your-app>) would prevent the most common copy-paste error.

Questions

  • src/vouch/http_server.py:893 — Mutating vouch_server.mcp._session_manager and .settings are global side-effects on a module-level singleton. If two concurrent processes (or two test workers) call make_app() at the same time, one will clobber the other's _session_manager between the reset and the streamable_http_app() call. Is there a FastMCP-supported way to instantiate a fresh FastMCP object per make_app() call instead of mutating the shared singleton? That would also eliminate the # type: ignore[attr-defined] hints.
  • tests/test_http_server_mcp.py:1269test_mcp_tools_list_returns_kb_surface calls _initialize but discards the session headers and then issues tools/list as an independent stateless call. With stateless_http=True this is correct, but is there a test that verifies a client that does send Mcp-Session-Id (as a stateful client would) is not rejected? If FastMCP silently ignores the header in stateless mode that's fine, but it would be worth a one-liner assertion.

Verdict

request changes — Two blocking issues: the shutdown() polling condition is logically wrong (polls a flag that doesn't reset, burning 2.5 s per teardown), and the IPv6 pre-bind path silently fails for ::1 despite it being in _LOOPBACK_HOSTS. Both are small fixes; the feature work itself is solid and directly addresses everything #176 asked for.

… 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).
@dripsmvcp

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — pushed b0d28e0 addressing all seven items.

Blocking fixes

  • shutdown() busy-wait — confirmed exactly as you said: uvicorn.Server.started stays True after graceful exit, so the 50×0.05s loop always burned the full 2.5s. Removed the poll entirely; setting should_exit = True is the documented graceful-stop signal, the serving thread is the caller's to reap (tests use a daemon thread, run_http uses the main thread). Real impact: HTTP suite 48.67s → 0.60s (81× faster), full suite ~52s → 4.80s (10× faster).
  • IPv4-only socket::1 was indeed in _LOOPBACK_HOSTS but unbindable. Now family = socket.AF_INET6 if ":" in host else socket.AF_INET, so --host ::1 works as documented.

Question that turned into a refactor

  • Global-singleton mutation — you were right that mutating vouch_server.mcp._session_manager and .settings is a race waiting to happen. Resolved by constructing StreamableHTTPSessionManager directly per make_app() call, wired to the same underlying Server (which carries the 44 kb.* tool registry) but with a fresh manager wrapping it. No more _session_manager = None reset, no more type: ignore[attr-defined], no cross-call races. The eight-line "single-use per instance" comment block is gone too.

Non-blocking cleanups

  • _Handler: type | None = None and _ = threading sentinels — deleted; the stdlib request-handler path no longer exists, so isinstance against it was never valid in the new code anyway.
  • _attach_vouch_mcp_routes_add_mcp_routes; dropped the unused return value and the function name now says "mutates routes in place."
  • fly.toml app = "REPLACEME-vouch" — added a three-line comment above it pointing at fly launch --name <your-app> --copy-config so the footgun lands at edit-time, not deploy-time.

Compatibility test for question #2

New regression: test_mcp_accepts_session_id_header_in_stateless_mode — a client that pinned Mcp-Session-Id from a stateful deployment sends the header anyway; the server must accept (not 4xx) and silently ignore. Asserted by issuing tools/list with Mcp-Session-Id: stale-client-session-from-yesterday and verifying 200 + correct id echo.

CI: ruff clean, mypy clean, 331 passed (+1 new test, no regressions).

@plind-junior
plind-junior merged commit 04c86af into vouchdev:test Jun 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants