Phase 5 — Remote MCP Transport (Streamable HTTP / SSE)
Effort: M
Goal
External Claude clients (Claude Code on another machine, claude.ai once configured) can connect over HTTPS using their API key, call any granted tool, and receive real-time inbound-message notifications via notifications/resources/updated.
Deliverables
MCP Streamable HTTP transport
src/transport/http-mcp.ts— mounts the SDK’s official Streamable HTTP transport atPOST /mcpandGET /mcp(SSE upgrade for server→client stream). Do not roll a custom transport.- Auth uses the Phase 4 middleware (
Authorization: Bearer wamcp_live_...). Rejection happens at HTTP layer, never inside MCP framing. - Per-session idle timeout: 30 minutes.
- Body limit on
/mcp: 1 MB (zod schemas enforce inner bounds).
Session registry
src/transport/session-registry.ts:Map<sessionId, McpSession>andMap<clientId, Set<McpSession>>(the second is the lookup used by notifications).register(session)on connect (post-auth).unregister(sessionId)on SSE close, error, or idle timeout.- Heartbeat ping every 30s; failed write → unregister.
- On process SIGTERM: drain — send
notifications/cancelledto each session, wait up to 5s, then exit.
Resumption
Mcp-Session-Idheader support.- Reconnect with an existing session id → re-validate the bearer (key may have been rotated/revoked); refuse if the
api_key_iddiffers from the session’s original → 401, force the client to start fresh. - Bump
last_used_aton the key andlast_seen_aton the session.
Notifications wiring
src/server/notifications.ts—pushResourceUpdate(clientId, phoneNumberId, wamid):- URI:
wamcp://numbers/<phone_number_id>/messages. - Looks up sessions for
clientIdin the registry; sends onenotifications/resources/updatedper active session. - Payload includes minimal preview (
wamid,contact_wa_id,direction,message_type) so the client knows whether to fetch. Body content lives inmessages.body— clients callget_messagesto retrieve.
- URI:
src/inngest/functions/push-client-notification.ts— Phase 3 stub now fleshed out. Triggered bymcp/client.notify. CallspushResourceUpdate.- Offline behaviour: if no session is open for
clientId, drop the notification. Themessagesrow is durable; clients backfill viaget_messageswith asincecursor on next connect. Document this contract indocs/api/mcp-tools.md.
Subscriptions (optional MCP feature, opt-in per client session)
- Clients may
resources/subscribetowamcp://numbers/<phone_number_id>/messages. The registry tracks subscriptions per session. - Notifications fan out only to subscribed sessions for a given URI. Unsubscribed sessions still see resource list changes via the broader
notifications/resources/list_changed(kept simple — no-op in v1).
Health endpoint
src/transport/http-health.ts—GET /healthreturns:{ "status": "ok", "db": "ok", "migrationsAt": "0004_audit_ratelimit", "sessions": 3, "uptimeSeconds": 12345, "version": "0.5.0" }- Reachable without auth (Phase 7 Nginx rate-limits the path).
- Used by uptime monitors and the systemd unit’s
ExecStartPostsmoke check.
Transport selection
src/index.tschooses transport byMCP_TRANSPORT:stdio→StdioServerTransport, synthetic owner context (Phase 4).http→ Express server with/mcp,/webhook/meta,/api/inngest,/health(and/mediaonce Phase 6 lands).
Graceful shutdown
src/lifecycle.ts— single shutdown hook on SIGTERM/SIGINT:- Stop accepting new sessions (set a flag; new
/mcprequests get 503 withRetry-After: 5). - Send
notifications/cancelledto all active sessions. - Drain in-flight tool handlers (wait up to 10s).
- Flush audit log queue.
- Close DB pool.
- Exit 0.
- Stop accepting new sessions (set a flag; new
Docs (extended)
docs/architecture/mcp-transport.md— Streamable HTTP design, session lifecycle, resumption rules, notification routing, offline backfill contract.docs/components/mcp-server.md— extended with the HTTP transport (the Phase 1 doc covered stdio only).docs/api/mcp-tools.md—get_messagesdocumented withsincecursor semantics; subscription resource URI documented.
Critical files
- src/transport/http-mcp.ts
- src/transport/session-registry.ts
- src/server/notifications.ts
- src/inngest/functions/push-client-notification.ts — flesh out
- src/lifecycle.ts
- src/transport/http.ts — routes mounted; ordering matters (auth before MCP)
- src/transport/http-health.ts
- src/index.ts — transport selector
Tests
Unit
tests/unit/session-registry.test.ts:register/unregisterround-trip.- Heartbeat failure unregisters.
findByClientreturns the right set, empty when none connected.
tests/unit/notifications.test.ts:pushResourceUpdatecalls send on every session for that client; no calls when no sessions.
Integration (testcontainers Postgres + a real HTTP server)
tests/integration/transport/connect.test.ts:- Valid bearer → 200, SSE stream opens,
tools/listreturns the registry. - Wrong bearer → 401.
- Disabled client → 401.
- Valid bearer → 200, SSE stream opens,
tests/integration/transport/resume.test.ts:- Connect; capture
Mcp-Session-Id; disconnect; reconnect with the same id → same session. - Reconnect with rotated key (different
api_key_id) → 401.
- Connect; capture
tests/integration/transport/idle-timeout.test.ts:- Open session, wait 31 minutes (fake-timer), session is unregistered; subsequent tool call on that session → 401.
tests/integration/notifications/push.test.ts:- Client A connects + subscribes to
wamcp://numbers/<id>/messages. - Webhook arrives → notification reaches client A within 1s (verify via test client receiving the JSON-RPC
notifications/resources/updated). - Client B (no subscription) does not receive the notification.
- Client A connects + subscribes to
tests/integration/notifications/offline-backfill.test.ts:- Client connects, subscribes, disconnects.
- 5 inbound messages arrive while offline.
- Client reconnects, calls
get_messageswithsince= last-seen wamid → returns the 5 missed.
tests/integration/transport/shutdown.test.ts:- Send SIGTERM during an in-flight
send_message(mocked Meta with delay). - Tool completes, audit row written, session receives
notifications/cancelled, process exits within 11s.
- Send SIGTERM during an in-flight
tests/integration/health/health.test.ts:- DB up → 200 with
db: 'ok'. - Stop the Postgres container → next call returns 503 with
db: 'unreachable'.
- DB up → 200 with
Coverage
- Phase total ≥ 80 %;
src/transport/session-registry.ts≥ 90 %.
Code documentation
- TSDoc with
@remarkson:session-registry.ts(lifecycle states, concurrency assumptions — single Node instance, multi-instance migration path).notifications.ts(best-effort contract, backfill responsibility).http-mcp.ts(transport selection, auth-before-MCP ordering, body limit).lifecycle.ts(shutdown sequence and timeouts).
docs/architecture/mcp-transport.mdcomplete.docs/components/mcp-server.mdextended.docs/api/mcp-tools.mdextended with subscription URI andsincecursor semantics.docs/reference/regenerated.
Acceptance
- Remote connect — on a second machine, configure Claude Code with
wamcp_live_...andhttps://wa.<yourdomain>/mcp. List tools succeeds. - Send + notify — that client calls
send_message→ message reaches WhatsApp. - Real-time inbound — reply from the phone → notification appears in the remote Claude session within ~1s (visible in MCP debug logs).
- Offline backfill — disconnect the remote client, send 3 messages from WhatsApp, reconnect →
get_messageswithsincereturns all 3. - Rotation across a live session — rotate the client’s key while connected → next reconnect with the new key works; reconnect with the old key after the grace window expires fails with 401.
- Graceful shutdown —
systemctl stop whatsapp-mcp(or localkill -TERM) → no half-written rows; in-flight tool completes; exit 0. pnpm test:cigreen; coverage gates met.
Notes
- v1 is single-instance. The in-process
Map<clientId, Set<McpSession>>is acceptable. Multi-instance scale-out replaces it with PostgresLISTEN/NOTIFY(or Redis), andmcp_sessionsbecomes a real table. Tracked in phase-8-future.md. - The Streamable HTTP transport supersedes the older standalone SSE transport. Stay on the SDK’s official transport.
- Subscriptions are opt-in. A client that doesn’t subscribe gets no real-time push and is expected to poll
get_messages. Document this indocs/api/mcp-tools.md.
Definition of Done
Streamable HTTP transport
-
src/transport/http-mcp.tsmounts the SDK transport at/mcp. - Auth middleware runs before MCP framing; 401 returned at HTTP layer on miss.
- Body limit
1mbon/mcp. - Idle timeout: 30 minutes.
Session registry
-
src/transport/session-registry.ts— Map by sessionId + Map by clientId. - Heartbeat ping every 30s; failed write unregisters.
-
Mcp-Session-Idresumption with bearer re-validation; mismatchedapi_key_id→ 401.
Notifications
-
src/server/notifications.tspushResourceUpdateimplemented. -
push-client-notificationInngest function fleshed out from Phase 3 stub. -
resources/subscribesupport forwamcp://numbers/<id>/messages. - Offline path: no session → drop notification; client backfills via
get_messages.
Health + lifecycle
-
GET /healthreturns DB + migrations + sessions + version. -
src/lifecycle.tsgraceful shutdown sequence (cancel notifications → drain → flush audit → close pool).
Transport selector
-
src/index.tspicksstdioorhttpfromMCP_TRANSPORT.
Tests
-
tests/unit/session-registry.test.tspasses. -
tests/unit/notifications.test.tspasses. -
tests/integration/transport/connect.test.ts(valid/wrong/disabled) passes. -
tests/integration/transport/resume.test.ts(same key + rotated key) passes. -
tests/integration/transport/idle-timeout.test.tspasses. -
tests/integration/notifications/push.test.ts(subscribed vs not) passes. -
tests/integration/notifications/offline-backfill.test.tspasses. -
tests/integration/transport/shutdown.test.tspasses. -
tests/integration/health/health.test.ts(DB up + down) passes. - Coverage:
session-registry.ts≥ 90%; phase total ≥ 80%.
Documentation
-
docs/architecture/mcp-transport.mdwritten. -
docs/components/mcp-server.mdextended for HTTP transport. -
docs/api/mcp-tools.mddocuments subscription URI +sincecursor + offline backfill contract. - TSDoc
@remarksonsession-registry,notifications,http-mcp,lifecycle. -
docs/reference/regenerated cleanly.
Acceptance verified
- Second machine’s Claude Code connects with the new key + URL; lists tools.
-
send_messagefrom remote client delivers to WhatsApp. - Reply on phone → notification appears in remote Claude within ~1s.
- Offline backfill: disconnect → 3 inbound messages → reconnect →
get_messages since=…returns all 3. - Key rotation across an active session works (new key on reconnect; old key after grace → 401).
- Graceful shutdown via SIGTERM completes within 11s with no half-written rows.
Phase signoff
- Phase 5 complete. README.md status table updated to ✅.