Skip to content

[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory#307

Open
sveneld wants to merge 1 commit into
modelcontextprotocol:mainfrom
sveneld:feat/middleware-defaults-factory
Open

[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory#307
sveneld wants to merge 1 commit into
modelcontextprotocol:mainfrom
sveneld:feat/middleware-defaults-factory

Conversation

@sveneld
Copy link
Copy Markdown
Contributor

@sveneld sveneld commented May 13, 2026

Summary

Replaces the implicit instanceof + array_unshift injection of CORS handling in StreamableHttpTransport with an explicit, composable PSR-15 stack exposed through a public StreamableHttpTransport::defaultMiddleware() factory. Adds three middleware that together cover the recommended HTTP-level hardening for MCP Streamable HTTP:

  • CorsMiddleware — secure-by-default (no Access-Control-Allow-Origin header is set; cross-origin browser requests are blocked). Configurable allowedOrigins reflect a matching origin and automatically emit Vary: Origin so shared caches don't poison.
  • DnsRebindingProtectionMiddleware — validates Origin/Host against an allowlist of hostnames (localhost variants by default).
  • ProtocolVersionMiddleware — rejects requests carrying an unsupported Mcp-Protocol-Version header with 400 Bad Request (spec compliance).

The transport itself becomes oblivious to the middleware list. The $middleware constructor argument is nullable:

Passed value Effect
omitted / null Default secure stack from StreamableHttpTransport::defaultMiddleware()
[] No middleware at all (e.g. when fronted by an application that already handles CORS/host validation)
explicit list Used verbatim; mix and match by spreading ...StreamableHttpTransport::defaultMiddleware()

SESSION_HEADER and a new PROTOCOL_VERSION_HEADER are promoted to public constants on the transport so middleware can reuse them without duplicating string literals.

Why this design

The three open items below all reach for the same shape — a piece of mandatory security logic that the transport should apply unless the user opts out. PRs #260 and #277 each implement this with their own instanceof MyMiddlewareClass check + array_unshift inside the transport constructor. That pattern scales linearly with the number of "mandatory" middleware and breaks once a user supplies a custom implementation of the same concern under a different class name. Adding ProtocolVersionMiddleware for #306 would mean a third copy of the same pattern.

This PR collapses all three into a single composition model: the transport is a thin PSR-15 host, and the recommended stack lives in a factory the user can spread, filter, or replace. No instanceof magic, no surprise prepending, no position arguments to bypass.

Relation to other work

If maintainers prefer to land #260 / #277 separately first, this PR can be rebased onto them and reduced to the composition refactor + ProtocolVersionMiddleware. Happy to do that.

BC breaks

  • StreamableHttpTransport::__construct — the $corsHeaders parameter is removed. The $middleware parameter consequently shifts one position. Positional callers passing the old $corsHeaders argument will get a TypeError at construction; switch to named arguments or drop the argument. logger/middleware are now positions 4/5 instead of 5/6.
  • Default Access-Control-Allow-Origin is no longer *. Browser clients on a different origin will be blocked unless CorsMiddleware is configured with explicit allowedOrigins.

CHANGELOG.md entry added under 0.6.0.

Test plan

  • vendor/bin/phpunit --testsuite=unit — 38 new/updated tests pass (3 middleware × ~10 tests each + transport integration). Full suite: 1 error + 2 failures are pre-existing on upstream/main (DocBlockParserTest missing Composer\Semver\VersionParser dev-dep, two DiscoveryTest fixtures) — verified by running the same tests against vanilla main with the branch stashed.
  • vendor/bin/php-cs-fixer fix --dry-run — clean
  • vendor/bin/phpstan analyse — clean for changed code; the one pre-existing error in DocBlockParserTest (missing dev-dep Composer\Semver\VersionParser) is unchanged
  • Both OAuth examples (oauth-keycloak, oauth-microsoft) updated to spread the default stack so their behaviour is preserved

@sveneld sveneld force-pushed the feat/middleware-defaults-factory branch from b087c0f to 32ed494 Compare May 13, 2026 20:49
@sveneld sveneld marked this pull request as ready for review May 13, 2026 20:52
Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.

- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
  configurable allowlist, reflects matching origin with `Vary: Origin`
  to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
  a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
  `Mcp-Protocol-Version` header with `400 Bad Request`.

The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.

BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
  parameter shifts one position. Positional callers passing the old
  `corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.

Addresses modelcontextprotocol#260 (DNS rebinding), modelcontextprotocol#277 (CORS extraction) and modelcontextprotocol#306
(protocol version validation).
@sveneld sveneld force-pushed the feat/middleware-defaults-factory branch from 32ed494 to 87484a5 Compare May 14, 2026 05:25
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.

1 participant