Skip to content

Add remote HTTP serve mode: bearer-token auth, headless login, and Claude connector support#79

Open
arditti wants to merge 21 commits into
robcerda:mainfrom
arditti:claude/monarch-mcp-fork-pr-jhbrms
Open

Add remote HTTP serve mode: bearer-token auth, headless login, and Claude connector support#79
arditti wants to merge 21 commits into
robcerda:mainfrom
arditti:claude/monarch-mcp-fork-pr-jhbrms

Conversation

@arditti

@arditti arditti commented Jul 10, 2026

Copy link
Copy Markdown

Summary

  • New serve subcommand/run mode exposing the server over MCP streamable
    HTTP (in addition to the existing stdio mode — unaffected), authenticated
    with a static bearer token, so it can be deployed on a VPS/Raspberry
    Pi/Docker host and reached remotely behind a reverse proxy or tunnel.
  • Docker packaging (Dockerfile, docker-compose.yml, .dockerignore)
    and a Cloudflare Tunnel example for running the server remotely.
  • mcp upgraded to >=1.28,<2 so streamable HTTP sessions are bound to
    their credentials (fixes a session-replay gap present in 1.13.1).
  • Bearer token verification uses constant-time digest comparison and a
    global failure throttle to resist timing/brute-force attacks; the
    interactive login tools are only registered in stdio mode — credentials
    never flow through an LLM in serve mode.
  • login-cookies / login-password non-interactive subcommands: seed a
    Monarch session from a browser Cookie header or email+password (with
    email-OTP/TOTP support) piped over stdin, for headless hosts where
    login_setup.py's interactive prompts aren't available (e.g. docker exec from a CI runner). A supplied one-time code now rides the first
    login request — a bug where a no-code probe attempt would trigger a
    fresh OTP email and invalidate the code just supplied is fixed.
  • / (root): an optional self-service copy-paste client-config page —
    Claude Code command, Claude Desktop config JSON, a ready-to-paste
    connector URL, and the raw bearer token. It has no auth of its own, so
    deployers should put it behind their own access control (reverse-proxy
    auth, an identity-aware proxy, etc.) if serving to more than themselves.
  • ?token= query-param bearer-token fallback: Claude's native "Add custom
    connector" UI (web/desktop/mobile) only accepts a URL, not a custom
    header, and leaving its OAuth fields blank triggers a full OAuth
    discovery/dynamic-registration flow this static-token resource server
    doesn't implement. Implemented as a small ASGI middleware wrapping
    FastMCP's app (checked only when no Authorization header is present, so
    an explicit header always wins) — the mcp SDK itself is untouched.

Design docs included

  • docs/superpowers/specs/2026-07-08-remote-http-phase1a-design.md
    design spec (reviewed for security, SDK feasibility, and architecture)
  • docs/superpowers/plans/2026-07-08-remote-http-phase1a.md
    implementation plan

Test plan

  • 281 tests passing: run-mode resolution, serve config, bearer token
    verifier, HTTP auth-boundary (real subprocess server, real HTTP),
    secure session, login-cookies/login-password (including the OTP-timing
    fix), the setup page, and the query-token middleware (unit + live-server
    integration).
  • scripts/e2e_http.py — end-to-end check against a running server
    using the real MCP client.
  • Manually verified against a real deployment: Docker Compose behind a
    reverse proxy/tunnel, live Monarch login, and the native-connector
    ?token= flow all confirmed working end to end.

arditti and others added 21 commits July 8, 2026 10:26
`monarch-mcp-server login-cookies` reads a browser Cookie header from
stdin, verifies it against the live Monarch API, and saves the session —
login_setup.py option 1 without the prompts, for remote hosts where the
session must be seeded via docker exec from a CI runner. Stdin keeps the
cookie out of argv and shell history; nothing derived from it is printed.

Also make ServeConfig tolerate a set-but-absent MONARCH_MCP_AUTH_TOKEN_FILE
(fall back to the env token, or fail with the path in the error) so the
container can point at a token file that an init step creates after first
boot instead of crashing on the read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
`monarch-mcp-server login-password` reads newline-separated credentials
from stdin (email, password, optional one-time code), logs in via the
current-auth flow, verifies against the live API, and saves the session
— login_setup.py option 2 without the prompts, for the same headless
docker-exec deployments login-cookies serves. Monarch's new-device email
OTP is handled as a two-run flow: the first attempt triggers the email
and exits with instructions, the re-run passes the code as line 3 (a
TOTP MFA code uses the same slot). CAPTCHA blocks point the operator at
the cookie flow.

The verify-and-save tail is factored out of login_cookies for reuse, and
the conftest monarchmoney stub gains a real CaptchaRequiredException
class (an except clause on a MagicMock is a TypeError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
The two-run OTP flow was broken: even when handed a code, the login did
a no-code attempt first to detect the challenge — but that attempt makes
Monarch send a fresh email OTP, invalidating the code the operator just
supplied ("Invalid code" every time). Now a supplied code rides the
initial login call (as email_otp, falling back to the TOTP slot if
Monarch signals MFA), so no fresh email is triggered and the operator's
code is the one actually used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
Renders the Claude Code command, Claude Desktop config JSON, MCP URL,
and bearer token as ready-to-copy blocks, with copy buttons. No auth of
its own — this path is meant to sit behind Cloudflare Access at the
edge (home-infra wires that up), same trust model as the existing
/healthz custom route.

Reads the token live off disk each request (via the new
ServeConfig.auth_token_file field) rather than the value cached at
process start, so a same-container rotation shows up without a
restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
Confirmed no route conflict: FastMCP's streamable-HTTP transport only
claims settings.streamable_http_path (/mcp) plus .well-known/* OAuth
metadata routes, never bare "/" — verified with a live local server
(GET / -> 200 with the setup page, POST /mcp still 401 without a
token / 200 with one). Added integration tests locking this in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
Claude's native "Add custom connector" UI (web, desktop, mobile) only
takes a URL — no custom-header field like Claude Code's CLI has, and
leaving its OAuth fields blank triggers a full OAuth discovery/dynamic-
registration flow that a static-bearer-token resource server can't
answer. mindbody-mcp already solved this the same way in its own
hand-rolled HTTP layer (?token= query param, Authorization header still
authoritative when present); the MCP SDK's built-in BearerAuthBackend
only reads the header, so this adds a small ASGI middleware
(QueryTokenAuthMiddleware) that copies ?token= into the Authorization
header before FastMCP's own auth check runs — the mcp package itself is
untouched, still a stock general-purpose dependency.

Wiring this in required switching serve mode from app.mcp.run(transport=
"streamable-http") to running the (now-wrapped) ASGI app via uvicorn
directly, since FastMCP's run() has no hook to inject middleware. Kept
the existing _app_loader-style test seam by adding a parallel
_serve_http injection point.

The /setup page's first block is now a ready-to-paste connector URL
(https://.../mcp?token=<token>) with a one-line note that OAuth Client
ID/Secret should stay blank.

Verified with a live local server: query token succeeds (200), wrong
query token 401s, and a present-but-wrong Authorization header is NOT
overridden by a valid query token (still 401) — also covered by new
unit tests (middleware in isolation) and integration tests (real
subprocess server, real HTTP). 281 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sSu34KX26gPeYmdZz6Dfs
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