Skip to content

refactor(mcp): use the bundle controller with http.allowed_hosts - #677

Merged
CybotTM merged 1 commit into
mainfrom
refactor/mcp-allowed-hosts
Aug 15, 2026
Merged

refactor(mcp): use the bundle controller with http.allowed_hosts#677
CybotTM merged 1 commit into
mainfrom
refactor/mcp-allowed-hosts

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 15, 2026

Copy link
Copy Markdown
Member

Follow-up to #676. That PR brought symfony/mcp-bundle to v0.11.0, which adds an http.allowed_hosts option and an mcp.middleware_factory service — 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 request arriving under a real domain was answered with 403 Invalid Host header; the app worked around that by overriding mcp.server.controller with 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_HOSTS stays the single source of truth and .env is unchanged — the env var now feeds mcp.http.allowed_hosts directly instead of the app.mcp_allowed_hosts parameter.

Parity

Every behaviour the override carried, and where it lands upstream.

Override behaviour Upstream equivalent
Middleware CorsMiddleware, DnsRebindingProtectionMiddleware, ProtocolVersionMiddleware, in that order MiddlewareFactory::create() iterates StreamableHttpTransport::defaultMiddleware(), which is that same list in that same order, and replaces the DNS-rebinding entry
DnsRebindingProtectionMiddleware(allowedHosts: %env(csv:MCP_ALLOWED_HOSTS)%) mcp.http.allowed_hosts: '%env(csv:MCP_ALLOWED_HOSTS)%'MiddlewareFactory constructor arg
DNS-rebinding protection stays enabled (never disabled) option is an array, not falsefalse is the documented opt-out and is not used
maxBodyBytes left at the SDK default upstream also omits the argument; both get StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES
PSR factory wiring (mcp.server, mcp.psr_http_factory, mcp.http_foundation_factory, mcp.psr17_factory ×2) identical arguments, registered by McpBundle::configureClient()
Logger injected as @logger upstream injects logger too, and additionally tags the controller monolog.logger channel mcp — strictly more, not less
Public service tagged controller.service_arguments upstream sets setPublic(true) and the same tag
Route /mcp via the bundle's route loader unchanged; config/routes/mcp.yaml and mcp.http.path are untouched
SSE detection tolerant of media-type parameters (str_starts_with) upstream compares exactly against text/event-stream. Not observable: mcp/sdk emits the header with no parameters in both v0.6.0 and the locked v0.7.1, so the tolerance never fired
implements App\Security\ApiToken\SelfEnforcesScope no upstream equivalent — see below

The one gap, and how it is closed

SelfEnforcesScope is a marker interface that exempts the MCP endpoint from RequireScopeSubscriber's fail-closed gate. It exists because /mcp multiplexes many tools that each enforce their own scope through App\Mcp\ScopeGuard, so no single controller-level #[RequireScope] can express the endpoint's requirement. The bundle's McpController is final and therefore cannot carry the marker.

Rather than keep a 76-line controller alive for a marker interface, RequireScopeSubscriber now 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 from src/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.yml uses), against commit a5c0358a.

Gate Result
bin/phpunit --testsuite unit OK (1938 tests, 4501 assertions)
bin/phpunit --testsuite integration,controller,api-functional,mcp OK (766 tests, 4584 assertions; 2 deprecations + 1 skip, both pre-existing and unrelated — a deprecated v1 GetTimeSummaryAction and a data-dependent repository skip)
bin/phpunit --testsuite mcp OK (83 tests, 424 assertions)
bin/phpstan analyze --no-progress (full tree, level 10) No errors
bin/phpstan analyze -c config/quality/phpat.neon No errors
bin/php-cs-fixer fix --dry-run --diff 0 of 681 files need fixing
bin/rector process src --config=config/quality/rector.php --dry-run Rector is done, no changes
bin/console lint:container --env=test all services injected with compatible values
bin/console cache:warmup (test, dev, prod) OK in all three

tests/Mcp/McpHttpEndpointTest.php keeps 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_HOSTS is localhost,127.0.0.1,[::1], which is byte-for-byte the SDK's own default. A passing McpHttpEndpointTest therefore 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 with MCP_ALLOWED_HOSTS=evil.example.com: testInitializeSucceedsForAnAllowedHost flipped to 403 and testDisallowedHostIsForbidden flipped to 200, while testBogusTokenIsUnauthorized stayed green. Both host assertions invert with the configuration, which is what proves the guard reads it.

debug:container mcp.server.controller confirms the wiring on the other side: the controller is Symfony\AI\McpBundle\Controller\McpController, taking mcp.middleware_factory, whose sole argument is %env(csv:MCP_ALLOWED_HOSTS)%.

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
@sonarqubecloud

Copy link
Copy Markdown

@CybotTM

CybotTM commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

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 MiddlewareFactory iterating the SDK's own defaultMiddleware(), MCP_ALLOWED_HOSTS keeps feeding the DNS-rebinding guard via mcp.http.allowed_hosts, the guard stays enabled, and the PSR wiring/logger/route are the bundle's own registrations. The one real design decision — replacing the SelfEnforcesScope marker with a direct instanceof McpController check in RequireScopeSubscriber — is the right call: the vendor controller is final and cannot carry the marker, and an interface with zero implementors would be a dead abstraction; the exemption's scope is unchanged (exactly one controller) and the comment carries the reasoning. Verified independently that no SelfEnforcesScope reference remains anywhere in the tree (0 hits).

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 MCP_ALLOWED_HOSTS — both host assertions flip with the configuration, proving the option is actually read. Full gates green in the CI container (unit 1938, integration/controller/api/mcp 766, PHPStan level 10, phpat, cs-fixer, Rector, lint:container, cache warmup in test/dev/prod), plus debug:container confirming the bundle controller and its middleware-factory wiring. All 17 checks green on this head, 0 unresolved threads.

@CybotTM
CybotTM marked this pull request as ready for review August 15, 2026 05:49
Copilot AI lite review requested due to automatic review settings August 15, 2026 05:49
@CybotTM
CybotTM merged commit d7d7b41 into main Aug 15, 2026
20 checks passed
@CybotTM
CybotTM deleted the refactor/mcp-allowed-hosts branch August 15, 2026 05:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants