MCP Remote Transport, Authorization, and Migration
Tracker references: pm-3g3f8z, pm-v7e337, pm-3zh9s4, and pm-vzcisw.
pm exposes the same MCP 2026-07-28 dispatcher through two adapters:
pm-mcpis the local JSON-RPC/stdio process. It retains a bounded initialize-era compatibility adapter for existing local consumers.pm-mcp-httpis the canonical sessionless Streamable HTTP POST process. It accepts only modern request-local protocol metadata and never creates an MCP session.
The public @unbrained/pm-cli/sdk entrypoint owns subscription filtering,
HTTP header projection, authorization discovery and validation, issuer-keyed
credentials, bearer enforcement, and trace-context isolation. Adapters remain
thin bindings over those contracts.
Run the HTTP adapter
The safe default binds only 127.0.0.1:3000:
pm-mcp-http
Configuration is explicit and environment-only:
| Variable | Meaning | Default |
|---|---|---|
PM_MCP_HTTP_HOST |
Bind host | 127.0.0.1 |
PM_MCP_HTTP_PORT |
Bind port, including 0 for an ephemeral test port |
3000 |
PM_MCP_HTTP_ALLOWED_ORIGINS |
Comma-separated exact browser origins | none |
PM_MCP_HTTP_BEARER_TOKEN |
Opaque deployment token for the bundled verifier | none |
PM_MCP_HTTP_AUTH_ISSUER |
Exact HTTPS authorization-server issuer | none |
PM_MCP_HTTP_RESOURCE |
Canonical MCP resource/audience URI | none |
PM_MCP_HTTP_SCOPES |
Space-separated consent scopes | pm:read pm:write |
PM_MCP_DISCOVERY_CURSOR_KEY |
Shared 32-byte-or-longer secret for discovery pages | process-local key |
A non-loopback bind fails closed unless token, issuer, and resource are all
present. Production deployments should normally call
createPmMcpHttpServer() with an OAuth access-token verifier backed by their
authorization server instead of using the executable's single opaque-token
bootstrap verifier.
For example, this starts a deliberately local protected endpoint without placing a real credential in documentation:
PM_MCP_HTTP_BEARER_TOKEN='<deployment-secret>' \
PM_MCP_HTTP_AUTH_ISSUER='https://auth.example.test' \
PM_MCP_HTTP_RESOURCE='http://127.0.0.1:3000/mcp' \
PM_MCP_DISCOVERY_CURSOR_KEY='<32-byte-or-longer-random-secret>' \
pm-mcp-http
Single-process deployments may omit the discovery cursor key. That selects a random process-local HMAC key and deliberately makes pagination process-affine: a restart or another worker rejects the cursor as stale. Multi-worker or restart-continuous deployments must provide the same high-entropy value to every worker. Rotating it invalidates outstanding discovery cursors without exposing the key in MCP input, output, errors, traces, or cache identities.
The adapter serves RFC 9728 protected-resource metadata at both
/.well-known/oauth-protected-resource and the path-qualified
/.well-known/oauth-protected-resource/mcp location.
Stream and header contract
Every HTTP request is a POST to /mcp and negotiates both
application/json and text/event-stream. Normal finite requests return
JSON. subscriptions/listen returns an SSE response whose first message is
notifications/subscriptions/acknowledged; every later subscription
notification carries the listen request's JSON-RPC id in
io.modelcontextprotocol/subscriptionId.
The supported opt-ins are tool-list, prompt-list, resource-list, and exact
resource-update notifications. Each stream serializes writes and drops a sink
that fails or remains backpressured beyond the bounded write deadline; tool
calls do not await subscriber delivery. Disconnecting deletes the
request-scoped subscription. A broken stream has no replay cursor: there are no
SSE event ids and Last-Event-ID is rejected. The caller retries the lost
operation with a new JSON-RPC request id.
MCP-Protocol-Version and Mcp-Method are built and validated against every
JSON-RPC request body. Mcp-Name is required only for prompts/get,
resources/read, and tools/call; methods such as tools/list omit it. Tool
properties may declare x-mcp-header in their JSON Schema; the SDK validates
the header name, rejects reserved or duplicate mappings, encodes non-ASCII and
ambiguous values with the MCP Base64 sentinel, and compares the decoded header
with the argument value before dispatch. CR/LF and control-character values
are always rejected.
Current pm handlers do not emit request progress or deprecated MCP log-message
notifications. A progressToken or request-local
io.modelcontextprotocol/logLevel is therefore never promoted to a shared
subscription. Work that returns a durable task remains observable through the
task lifecycle; remote operational logging belongs in the deployment's
OpenTelemetry pipeline.
Authorization lifecycle
Remote hosts can compose the public SDK primitives into a complete OAuth client/resource lifecycle:
- Build or read protected-resource metadata and choose an advertised authorization-server issuer.
- Probe OAuth and OpenID discovery URLs in the specified order. Require the
metadata
issuerto match exactly and require S256 PKCE. - Prefer a pre-registered client, then a validated Client ID Metadata Document, then bounded Dynamic Client Registration, and finally explicit user-supplied registration.
- Validate a returned
isswhen present or advertised, bind stored credentials to the exact issuer, and never reuse them across issuers. - Request only the scopes needed for the operation. The resource verifies bearer location, issuer, audience, and every required consent scope before MCP dispatch.
- Replace an issuer's stored credential after refresh. Delete only that issuer's entry on revocation, invalid grant, or re-registration; discovery and consent then run again without affecting other issuers.
PmMcpIssuerCredentialStore deliberately provides cloned set, get, and
delete operations rather than owning token refresh network traffic. This
keeps refresh, revocation, persistence encryption, and user interaction in the
host that owns the authorization relationship.
Trace and privacy boundary
Modern request _meta may carry W3C traceparent, tracestate, and baggage.
The SDK validates syntax and byte bounds, drops every baggage member that is
not on the host-provided allowlist, and stores the resulting context in an
AsyncLocalStorage scope for only that request. Concurrent requests cannot
inherit one another's trace context. Raw bearer tokens are hashed for
constant-time comparison by the bundled verifier and are never included in
claims, errors, traces, or JSON-RPC result data.
| Threat | Enforced boundary |
|---|---|
| DNS rebinding/browser drive-by | Loopback default plus exact Origin allowlist |
| Token passthrough | Bearer is verified at pm and never forwarded to another service |
| Issuer mix-up | Exact discovery/response issuer checks and issuer-keyed credentials |
| Confused audience | Exact protected-resource audience check |
| Excess authority | Required-scope intersection before dispatch |
| Query/log credential leak | Query tokens rejected; challenges and errors omit token material |
| Header injection | Schema-derived allowlist, reserved-name checks, control-byte rejection |
| Trace privacy leak | Syntax/size validation, baggage-key allowlist, request-local storage |
| Proxy cache disclosure | MCP and metadata errors use explicit content types; MCP results use no-store |
| Replay after disconnect | No session, event id, resume cursor, or redelivery; retry uses a new request id |
Deprecated-feature inventory and sunset
Run the generated ratchet locally or in CI:
pnpm quality:mcp-deprecations
The inventory classifies every match as canonical source, the isolated legacy
adapter, migration documentation, a negative control, or
bounded_source_control. The last disposition applies only to exact,
single-use source lines in the reviewed fixed allowlist; an adjacent marker or
an unlisted source path cannot create that exemption. Any canonical match for a
removed method, session header, SSE resume mechanism, legacy
resource-subscription method, or deprecated server policy fails the gate.
The compatibility adapter supports the initialize-era revisions declared by
PM_MCP_LEGACY_PROTOCOL_VERSIONS (2025-11-25 and 2025-06-18) on local
stdio.
It may be removed after telemetry and installed-consumer probes show no
required legacy clients for two consecutive release windows. Deprecated
2026-07-28 fields remain available only where the normative registry requires
its minimum compatibility period; no pm sunset occurs earlier than
2027-07-28. A removal is always a reviewed release change with packed and
published consumer proof.
Verification
Focused release evidence is reproducible with:
node scripts/run-tests.mjs test -- tests/unit/sdk/mcp/subscriptions.spec.ts
node scripts/run-tests.mjs test -- tests/unit/sdk/mcp/transport.spec.ts
node scripts/run-tests.mjs test -- tests/unit/sdk/mcp/authorization.spec.ts
node scripts/run-tests.mjs test -- tests/integration/mcp-streamable-http.spec.ts
node scripts/run-tests.mjs test -- tests/unit/scripts/release/mcp-deprecation-gate.spec.ts
pnpm quality:mcp-deprecations
The full release gates, packed artifact probes, installed npx/bunx
consumers, and published artifact checks remain distinct closeout evidence.