Skip to content

[MCP] Streamable HTTP transport: session, lifecycle, Origin, contentTypes reuse #614

Description

@kylebernhardy

Scope. Implement the MCP Streamable HTTP transport (POST/GET/DELETE /mcp), session management (Mcp-Session-Id), required headers (MCP-Protocol-Version, Origin), and the full initialize / notifications/initialized lifecycle. Reuse Harper's existing Request/Headers abstraction and the text/event-stream serializer in contentTypes.ts.

Design reference. Sections "Transports → Streamable HTTP", "Security headers (MCP §transports → Security Warning)", "Server-side integration with existing Harper machinery", "Lifecycle Notes", and "HTTP Headers" in #465.

Acceptance criteria

  • POST /mcp accepts JSON-RPC, returns either application/json or upgrades to text/event-stream via the existing serializer at server/serverHelpers/contentTypes.ts:127-161. No new Fastify API surface added.
  • GET /mcp returns 405 when no SSE channel is offered, or opens a text/event-stream channel for server-initiated messages.
  • DELETE /mcp terminates the session when session.allowClientDelete: true; returns 405 otherwise.
  • Mcp-Session-Id issued on initialize (UUIDv4); enforced on subsequent requests; 400 if missing, 404 if terminated.
  • MCP-Protocol-Version required after initialize; 400 on unsupported. v1 supports 2025-06-18 (preferred) and 2025-03-26 (backcompat).
  • Origin header validated against existing http_corsAccessList / operationsApi_network_corsAccessList; 403 on mismatch. No new mcp.allowedOrigins key.
  • Client-sent JSON-RPC notifications/responses return HTTP 202 with empty body (e.g., notifications/initialized).
  • initialize returns server capabilities (tools.listChanged: true, resources.listChanged: true, logging: {}); subsequent requests reach a stub handler.
  • Unit tests cover each MUST behavior; integration test exercises full initialize → notifications/initialized over HTTP.

Out of scope. No tools, no resources, no rate limiting, no audit. Resumability via Last-Event-ID is deferred to v2 — the SSE serializer's id field is reserved but not used.

Stacks on. #613 (component scaffold).

Branch & PR conventions

Smoke test

curl -sS -X POST http://localhost:9925/mcp \
  -H 'Origin: http://localhost' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Authorization: Bearer ...' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \
  -i
# Expected: 200, `Mcp-Session-Id` response header, body with serverInfo + capabilities.

Tracking. Part of #465. Sub-issue #2 of 11.

Activity

  1. added this to the milestone on May 19, 2026
  2. added
    enhancementNew feature or request
    area:componentsComponents / applications subsystem
    area:mcpModel Context Protocol (MCP) server: protocol, profiles, stdio CLI
    feature:mcp-v1Rollout of native MCP server v1 (HarperFast/harper#465). Removed when v1 closes.
    on May 19, 2026
  3. kylebernhardy commented on May 20, 2026

    @kylebernhardy
    MemberAuthor

    Heads-up from foundation PR #649

    Foundation #649 lands a few choices that affect #614's design surface. Worth knowing before you start:

    1. registerMcpProfile API now accepts routeOptions

    The component's API signature is:

    registerMcpProfile({
      profile: 'operations' | 'application',
      host,                       // FastifyLike { post(path, opts?, handler) }
      config,                     // the merged config tree from getConfigObj()
      routeOptions?: Record<string, unknown>,
    })

    When routeOptions is provided, the host's post() is called in 3-arg form: host.post(path, routeOptions, handler). The operations call site in #649 uses this to wire preValidation: [authHandler]. #614 will be the first real consumer — pass the actual Streamable HTTP handler (instead of the stub) plus the same routeOptions shape.

    2. Application profile call site is intentionally deferred to #614

    #649 ships only the operations-profile call site. The application-port wiring has to land alongside the real transport because there's no clean global startup hook to register a do-nothing 503 listener on the HTTP port (server.http(listener, ...) callers all bring their own real listener).

    When you build the application profile, the simplest path is to either:

    • Call server.http(...) from inside a new bootstrapApplicationMcp() in components/mcp/index.ts, invoked from the same startup site that brings up REST/GraphQL routes, or
    • Follow the REST.handleApplication(scope) pattern (see server/REST.ts:288-303) and register MCP as a built-in plugin in components/componentLoader.ts:TRUSTED_RESOURCE_PLUGINS.

    Both work; pick whichever is easier to test.

    3. The stub 503 body shape is intentional placeholder, not a contract

    components/mcp/index.ts:createStubHandler returns 503 with body {"error":"mcp_not_implemented","profile":"<profile>"} plus Retry-After: 0. #614 should swap that for the real Streamable HTTP transport response. The route registration plumbing (gating, mount path resolution, auth) stays — only the handler body changes.

    4. Presence-based config is the gate

    Following team convention (matches replication), there's no enabled flag. Profile is registered iff getConfigObj().mcp?.<profile> is truthy. #614 will reuse the operations gate as-is; only needs to add the equivalent application gate at its chosen startup site.

    5. Joi-default propagation gap (Harper-wide, flagged separately)

    config/configUtils.js:483-490 only re-applies six specific Joi defaults back to the merged config object. Any Joi .default(...) under mcp.* (like mountPath: '/mcp') lives only on validation.value and is discarded — env.get(MCP_*) won't see it. The foundation routes around this by gating on nested block presence via getConfigObj() and falling back to DEFAULT_MOUNT_PATH = '/mcp' inside the component. #614 should do the same — don't rely on Joi defaults flowing into env.get(...).

    Worth keeping in mind if you add new defaulted keys under mcp.*: either re-apply them in configUtils.validateConfig, or always read via getConfigObj() + in-code fallback.

  4. modified the milestones: , v5.1 on Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:componentsComponents / applications subsystemarea:mcpModel Context Protocol (MCP) server: protocol, profiles, stdio CLIenhancementNew feature or requestfeature:mcp-v1Rollout of native MCP server v1 (HarperFast/harper#465). Removed when v1 closes.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions