Skip to Content

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 at POST /mcp and GET /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> and Map<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/cancelled to each session, wait up to 5s, then exit.

Resumption

  • Mcp-Session-Id header support.
  • Reconnect with an existing session id → re-validate the bearer (key may have been rotated/revoked); refuse if the api_key_id differs from the session’s original → 401, force the client to start fresh.
  • Bump last_used_at on the key and last_seen_at on the session.

Notifications wiring

  • src/server/notifications.tspushResourceUpdate(clientId, phoneNumberId, wamid):
    • URI: wamcp://numbers/<phone_number_id>/messages.
    • Looks up sessions for clientId in the registry; sends one notifications/resources/updated per active session.
    • Payload includes minimal preview (wamid, contact_wa_id, direction, message_type) so the client knows whether to fetch. Body content lives in messages.body — clients call get_messages to retrieve.
  • src/inngest/functions/push-client-notification.ts — Phase 3 stub now fleshed out. Triggered by mcp/client.notify. Calls pushResourceUpdate.
  • Offline behaviour: if no session is open for clientId, drop the notification. The messages row is durable; clients backfill via get_messages with a since cursor on next connect. Document this contract in docs/api/mcp-tools.md.

Subscriptions (optional MCP feature, opt-in per client session)

  • Clients may resources/subscribe to wamcp://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.tsGET /health returns:
    { "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 ExecStartPost smoke check.

Transport selection

  • src/index.ts chooses transport by MCP_TRANSPORT:
    • stdioStdioServerTransport, synthetic owner context (Phase 4).
    • http → Express server with /mcp, /webhook/meta, /api/inngest, /health (and /media once Phase 6 lands).

Graceful shutdown

  • src/lifecycle.ts — single shutdown hook on SIGTERM/SIGINT:
    1. Stop accepting new sessions (set a flag; new /mcp requests get 503 with Retry-After: 5).
    2. Send notifications/cancelled to all active sessions.
    3. Drain in-flight tool handlers (wait up to 10s).
    4. Flush audit log queue.
    5. Close DB pool.
    6. Exit 0.

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.mdget_messages documented with since cursor semantics; subscription resource URI documented.

Critical files

Tests

Unit

  • tests/unit/session-registry.test.ts:
    • register / unregister round-trip.
    • Heartbeat failure unregisters.
    • findByClient returns the right set, empty when none connected.
  • tests/unit/notifications.test.ts:
    • pushResourceUpdate calls 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/list returns the registry.
    • Wrong bearer → 401.
    • Disabled client → 401.
  • 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.
  • 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.
  • tests/integration/notifications/offline-backfill.test.ts:
    • Client connects, subscribes, disconnects.
    • 5 inbound messages arrive while offline.
    • Client reconnects, calls get_messages with since = 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.
  • tests/integration/health/health.test.ts:
    • DB up → 200 with db: 'ok'.
    • Stop the Postgres container → next call returns 503 with db: 'unreachable'.

Coverage

  • Phase total ≥ 80 %; src/transport/session-registry.ts ≥ 90 %.

Code documentation

  • TSDoc with @remarks on:
    • 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.md complete.
  • docs/components/mcp-server.md extended.
  • docs/api/mcp-tools.md extended with subscription URI and since cursor semantics.
  • docs/reference/ regenerated.

Acceptance

  1. Remote connect — on a second machine, configure Claude Code with wamcp_live_... and https://wa.<yourdomain>/mcp. List tools succeeds.
  2. Send + notify — that client calls send_message → message reaches WhatsApp.
  3. Real-time inbound — reply from the phone → notification appears in the remote Claude session within ~1s (visible in MCP debug logs).
  4. Offline backfill — disconnect the remote client, send 3 messages from WhatsApp, reconnect → get_messages with since returns all 3.
  5. 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.
  6. Graceful shutdownsystemctl stop whatsapp-mcp (or local kill -TERM) → no half-written rows; in-flight tool completes; exit 0.
  7. pnpm test:ci green; coverage gates met.

Notes

  • v1 is single-instance. The in-process Map<clientId, Set<McpSession>> is acceptable. Multi-instance scale-out replaces it with Postgres LISTEN/NOTIFY (or Redis), and mcp_sessions becomes 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 in docs/api/mcp-tools.md.

Definition of Done

Streamable HTTP transport

  • src/transport/http-mcp.ts mounts the SDK transport at /mcp.
  • Auth middleware runs before MCP framing; 401 returned at HTTP layer on miss.
  • Body limit 1mb on /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-Id resumption with bearer re-validation; mismatched api_key_id → 401.

Notifications

  • src/server/notifications.ts pushResourceUpdate implemented.
  • push-client-notification Inngest function fleshed out from Phase 3 stub.
  • resources/subscribe support for wamcp://numbers/<id>/messages.
  • Offline path: no session → drop notification; client backfills via get_messages.

Health + lifecycle

  • GET /health returns DB + migrations + sessions + version.
  • src/lifecycle.ts graceful shutdown sequence (cancel notifications → drain → flush audit → close pool).

Transport selector

  • src/index.ts picks stdio or http from MCP_TRANSPORT.

Tests

  • tests/unit/session-registry.test.ts passes.
  • tests/unit/notifications.test.ts passes.
  • 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.ts passes.
  • tests/integration/notifications/push.test.ts (subscribed vs not) passes.
  • tests/integration/notifications/offline-backfill.test.ts passes.
  • tests/integration/transport/shutdown.test.ts passes.
  • tests/integration/health/health.test.ts (DB up + down) passes.
  • Coverage: session-registry.ts ≥ 90%; phase total ≥ 80%.

Documentation

  • docs/architecture/mcp-transport.md written.
  • docs/components/mcp-server.md extended for HTTP transport.
  • docs/api/mcp-tools.md documents subscription URI + since cursor + offline backfill contract.
  • TSDoc @remarks on session-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_message from 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 ✅.