refactor(mcp): use the bundle controller with http.allowed_hosts - #677
Conversation
symfony/mcp-bundle v0.11.0 (in since #676) adds an `http.allowed_hosts` option and an `mcp.middleware_factory` service, which is exactly what App\Mcp\McpEndpointController was written for: the bundle's own controller used to hardcode the SDK's localhost-only DNS-rebinding default, so a real domain got a 403 "Invalid Host header". Drop the `mcp.server.controller` override and configure the bundle instead. The factory rebuilds the transport's defaultMiddleware() and swaps in DnsRebindingProtectionMiddleware with the configured hosts, so the stack stays CORS -> DNS-rebinding -> protocol-version in that order, and MCP_ALLOWED_HOSTS remains the single source of truth. One behaviour had no upstream equivalent: the override carried the App\Security\ApiToken\SelfEnforcesScope marker, which exempts the /mcp endpoint from RequireScopeSubscriber's fail-closed gate (its tools each enforce their own scope via App\Mcp\ScopeGuard, so no single controller-level #[RequireScope] can express the requirement). The bundle's McpController is final and cannot implement the marker, so the subscriber now names that controller directly and the marker interface - with no remaining implementor - is removed. The override's other deviation, matching the SSE content type with str_starts_with() rather than an exact compare, is not observable: mcp/sdk sets the header to a bare "text/event-stream" in both v0.6.0 and the locked v0.7.1. tests/Mcp/McpHttpEndpointTest.php keeps all four test methods byte-identical (only its class docblock names the new mechanism). Because the test env's MCP_ALLOWED_HOSTS matches the SDK default, a green suite alone would not prove the option is wired, so the config was probed with MCP_ALLOWED_HOSTS=evil.example.com: both host tests invert, confirming the guard reads it. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de> Claude-Session: https://claude.ai/code/session_01AcqcEjgwcQfp3vpnFa3gh6
|
|
Self-review (Copilot review unavailable — monthly quota exhausted; diff reviewed manually). Checked on head a5c0358: the parity table holds against the diff — middleware set and order come from the bundle's The verification closes the trap a green suite would hide: because the test env's allowed hosts equal the SDK default, the config was probed by inverting |



Follow-up to #676. That PR brought
symfony/mcp-bundleto v0.11.0, which adds anhttp.allowed_hostsoption and anmcp.middleware_factoryservice — exactly whatApp\Mcp\McpEndpointControllerwas written for. The bundle's own controller used to hardcode the SDK's localhost-only DNS-rebinding default, so a request arriving under a real domain was answered with403 Invalid Host header; the app worked around that by overridingmcp.server.controllerwith its own controller and its own middleware list. That workaround is now redundant, so this PR removes it and configures the bundle instead.MCP_ALLOWED_HOSTSstays the single source of truth and.envis unchanged — the env var now feedsmcp.http.allowed_hostsdirectly instead of theapp.mcp_allowed_hostsparameter.Parity
Every behaviour the override carried, and where it lands upstream.
CorsMiddleware,DnsRebindingProtectionMiddleware,ProtocolVersionMiddleware, in that orderMiddlewareFactory::create()iteratesStreamableHttpTransport::defaultMiddleware(), which is that same list in that same order, and replaces the DNS-rebinding entryDnsRebindingProtectionMiddleware(allowedHosts: %env(csv:MCP_ALLOWED_HOSTS)%)mcp.http.allowed_hosts: '%env(csv:MCP_ALLOWED_HOSTS)%'→MiddlewareFactoryconstructor argfalse—falseis the documented opt-out and is not usedmaxBodyBytesleft at the SDK defaultStreamableHttpTransport::DEFAULT_MAX_BODY_BYTESmcp.server,mcp.psr_http_factory,mcp.http_foundation_factory,mcp.psr17_factory×2)McpBundle::configureClient()@loggerloggertoo, and additionally tags the controllermonolog.loggerchannelmcp— strictly more, not lesscontroller.service_argumentssetPublic(true)and the same tag/mcpvia the bundle's route loaderconfig/routes/mcp.yamlandmcp.http.pathare untouchedstr_starts_with)text/event-stream. Not observable:mcp/sdkemits the header with no parameters in both v0.6.0 and the locked v0.7.1, so the tolerance never firedimplements App\Security\ApiToken\SelfEnforcesScopeThe one gap, and how it is closed
SelfEnforcesScopeis a marker interface that exempts the MCP endpoint fromRequireScopeSubscriber's fail-closed gate. It exists because/mcpmultiplexes many tools that each enforce their own scope throughApp\Mcp\ScopeGuard, so no single controller-level#[RequireScope]can express the endpoint's requirement. The bundle'sMcpControllerisfinaland therefore cannot carry the marker.Rather than keep a 76-line controller alive for a marker interface,
RequireScopeSubscribernow names the bundle controller directly, and the marker — which would otherwise have no implementor left — is removed. The exemption is unchanged in scope: it still applies to exactly one controller, and every other controller still needs a declared scope or gets a 403. If a reviewer would rather keep the abstraction than reference a vendor class fromsrc/Security, that is the one decision in this PR worth reversing; say so and I will restore the interface alongside the vendor check.Verification
Run in the CI-equivalent container (
COMPOSE_PROFILES=e2e docker compose run --rm app-e2e …, the same recipe.github/workflows/ci.ymluses), against commita5c0358a.bin/phpunit --testsuite unitbin/phpunit --testsuite integration,controller,api-functional,mcpGetTimeSummaryActionand a data-dependent repository skip)bin/phpunit --testsuite mcpbin/phpstan analyze --no-progress(full tree, level 10)bin/phpstan analyze -c config/quality/phpat.neonbin/php-cs-fixer fix --dry-run --diffbin/rector process src --config=config/quality/rector.php --dry-runbin/console lint:container --env=testbin/console cache:warmup(test, dev, prod)tests/Mcp/McpHttpEndpointTest.phpkeeps all four test methods byte-identical; only its class docblock changed, to name the new mechanism instead of the deleted class.One caveat that a green suite would otherwise hide: the test environment's
MCP_ALLOWED_HOSTSislocalhost,127.0.0.1,[::1], which is byte-for-byte the SDK's own default. A passingMcpHttpEndpointTesttherefore does not by itself prove the new option is wired — the same result would appear if the config were ignored entirely. So the option was probed by re-running that file withMCP_ALLOWED_HOSTS=evil.example.com:testInitializeSucceedsForAnAllowedHostflipped to 403 andtestDisallowedHostIsForbiddenflipped to 200, whiletestBogusTokenIsUnauthorizedstayed green. Both host assertions invert with the configuration, which is what proves the guard reads it.debug:container mcp.server.controllerconfirms the wiring on the other side: the controller isSymfony\AI\McpBundle\Controller\McpController, takingmcp.middleware_factory, whose sole argument is%env(csv:MCP_ALLOWED_HOSTS)%.