Skip to content

chore(deps): bump mcp/sdk to ^0.7.1 for GHSA-7m52-jw36-44r3 - #676

Merged
CybotTM merged 1 commit into
mainfrom
fix/mcp-sdk-0.7.1
Aug 14, 2026
Merged

chore(deps): bump mcp/sdk to ^0.7.1 for GHSA-7m52-jw36-44r3#676
CybotTM merged 1 commit into
mainfrom
fix/mcp-sdk-0.7.1

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 14, 2026

Copy link
Copy Markdown
Member

Why

composer audit has failed the Lint & Static Analysis job on every run since 2026-08-14T06:19Z — for example run 31807512759. The finding is CVE-2026-53965 / GHSA-7m52-jw36-44r3: in mcp/sdk >=0.5.0,<0.7.1 the client HttpTransport's SSE buffer grows unbounded when a server withholds the event delimiter. We were locked at v0.6.0.

^0.6 cannot reach the fix, because a caret constraint on a 0.x version pins the minor — so the constraint moves to ^0.7.1.

Why symfony/mcp-bundle moves too

symfony/mcp-bundle v0.10.0 requires mcp/sdk: ^0.6, which makes it a hard blocker for the SDK bump; it has to move in the same commit. v0.11.0 is the first release that accepts ^0.7 (^0.6|^0.7), and it is the smallest step that unblocks us — v0.12.0 would additionally replace the SDK's file-based element discovery with container-based registration, which is a behavioural change this security fix has no reason to carry. Diffing McpBundle.php v0.10.0 against v0.11.0, everything v0.11 adds is additive: an http.allowed_hosts option with its mcp.middleware_factory service, the #[AsMcpApp] MCP Apps attribute, and a MiddlewareFactory reference appended to the bundle's own mcp.server.controller arguments. We override that service with App\Mcp\McpEndpointController and supply our own argument list, so the appended reference does not reach us.

The lock diff is exactly these two packages — nothing else moves (a first attempt with --with-all-dependencies also carried symfony/uid v8.1.0 → v8.1.4, which is unrelated to the advisory, so the update was redone without it).

Compatibility, per usage site

The application imports eight SDK symbols. Mcp\Capability\Attribute\McpTool, Mcp\Capability\Attribute\Schema and Mcp\Exception\ToolCallException are used across src/Mcp/Tool/*, src/Mcp/ScopeGuard.php and src/Mcp/AdminEntityResolver.php; Mcp\Server, Mcp\Server\Transport\StreamableHttpTransport and the three Http\Middleware classes (CorsMiddleware, DnsRebindingProtectionMiddleware, ProtocolVersionMiddleware) are used in src/Mcp/McpEndpointController.php. Against the upstream CHANGELOG for 0.7.0 and 0.7.1:

  • None of the attribute, exception or middleware classes changed, and Mcp\Server::run() is unchanged.
  • StreamableHttpTransport::__construct() gained a trailing $maxBodyBytes parameter (default 4 MiB, POST bodies above it get a 413). McpEndpointController passes $logger and $middleware as named arguments, so the new trailing parameter does not shift anything at our call site.
  • 0.7.0 carries one documented BC break that touches us: element loading is lazy by default. Loaders now run on the first registry read rather than at Builder::build(), and initialize advertises capabilities from the configured sources rather than the loaded registry. Verified below.

Verification

Every command ran in the CI environment (COMPOSE_PROFILES=e2e docker compose run --rm app-e2e …, PHP 8.5.8), against the same image the Lint job uses.

Command Result
composer audit --format=plain exit 0, "No security vulnerability advisories found."
composer validate --no-check-publish ./composer.json is valid
php bin/console cache:warmup --env=dev OK (container compiles against the new bundle)
php -d memory_limit=1G bin/phpstan analyze --no-progress [OK] No errors
php -d memory_limit=1G bin/phpstan analyze -c config/quality/phpat.neon --no-progress [OK] No errors
php -d memory_limit=512M bin/php-cs-fixer fix --dry-run --diff 0 of 683 files fixable
php -d memory_limit=1G bin/rector process src --config=config/quality/rector.php --dry-run [OK] Rector is done!
php bin/console lint:twig templates/ 11 files valid
bin/phpunit --testsuite=unit OK (1938 tests, 4501 assertions)
bin/phpunit --testsuite integration,controller,api-functional,mcp OK (765 tests, 4579 assertions); the 2 deprecations are the pre-existing GetTimeSummaryAction ADR-022 notices, unrelated to MCP

The one gap this PR closes

The lazy-loading BC break is the only change that could have broken us silently, and nothing in the suite covered it: tests/Mcp/McpToolsTest.php and its siblings fetch the tool services out of the container and invoke them directly, so an MCP registry that came up empty would still have shown a green suite while tools/list returned nothing over the wire. McpHttpEndpointTest therefore gains testToolsListExposesTheRegisteredTools, which drives the real /mcp endpoint — handshake, session id, then tools/list — and asserts the registered tools are actually in the response. It passes on v0.7.1, and it is placed after a real registry read, so an empty registry fails it.

Not in scope

v0.11's new mcp.http.allowed_hosts option covers exactly what App\Mcp\McpEndpointController was written for (the bundle's own controller hardcoded the SDK's localhost-only DNS-rebinding default). Dropping our controller override in favour of the upstream option is now possible and worth doing, but it is a behavioural refactor of the MCP entry point and does not belong in a security bump.

CVE-2026-53965 affects mcp/sdk >=0.5.0,<0.7.1: the client
HttpTransport's SSE buffer grows unbounded when a server withholds
the event delimiter. composer audit has failed the "Lint & Static
Analysis" job on every run since the advisory was published.

^0.6 cannot reach the fix (a caret on a 0.x version pins the minor),
so the constraint moves to ^0.7.1. symfony/mcp-bundle v0.10 requires
mcp/sdk ^0.6 and therefore has to move too; v0.11 is the first
release accepting ^0.7, and its changes are additive (an
http.allowed_hosts option, an MCP Apps attribute, and a middleware
factory passed to the bundle's own controller).

Compatibility of the SDK surface this application uses:

- The McpTool/Schema attributes, ToolCallException, Mcp\Server and
  the three HTTP middleware classes are unchanged.
- StreamableHttpTransport gained a trailing $maxBodyBytes parameter;
  McpEndpointController passes $logger and $middleware by name, so
  the call site is unaffected.
- 0.7.0 made element loading lazy (a documented BC break): the
  registry loads on the first read instead of at Builder::build().
  Nothing covered that path, because the tool tests call the tool
  services directly, so McpHttpEndpointTest gained a tools/list case
  over the real /mcp endpoint.

Claude-Session: https://claude.ai/code/session_01AcqcEjgwcQfp3vpnFa3gh6
Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@CybotTM
CybotTM marked this pull request as ready for review August 14, 2026 16:58
Copilot AI lite review requested due to automatic review settings August 14, 2026 16:58
@sonarqubecloud

Copy link
Copy Markdown

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.

@CybotTM

CybotTM commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Self-review (Copilot review unavailable — monthly quota exhausted; diff reviewed manually).

Checked on head b340640: the constraint moves ^0.6^0.7.1 (caret on 0.x pins the minor, so this is required to reach the fix), and the lock diff is exactly the two packages it must be — mcp/sdk v0.6.0 → v0.7.1 and its hard blocker symfony/mcp-bundle v0.10.0 → v0.11.0 (first release accepting ^0.7; v0.12's behavioural discovery change correctly avoided; the unrelated symfony/uid drift from --with-all-dependencies was correctly backed out). The per-usage-site compatibility analysis matches the code: all eight imported SDK symbols verified against the 0.7.x changelog, the new trailing StreamableHttpTransport parameter is neutralised by our named arguments, and the one real BC break (lazy element loading) is exactly what the new testToolsListExposesTheRegisteredTools covers — it drives the real /mcp endpoint through handshake and tools/list, placed after a real registry read, so an empty registry fails it in position; the pre-existing tool tests could not have caught that.

Verification ran in the CI-equivalent container (PHP 8.5.8): composer audit clean, PHPStan/PHPat/php-cs-fixer/Rector clean, twig lint, 1938 unit + 765 integration/controller/api/mcp tests green. The noted follow-up (replacing our McpEndpointController override with v0.11's http.allowed_hosts) is rightly out of scope for a security bump.

@CybotTM
CybotTM merged commit 4923965 into main Aug 14, 2026
20 of 21 checks passed
@CybotTM
CybotTM deleted the fix/mcp-sdk-0.7.1 branch August 14, 2026 17:03
CybotTM added a commit that referenced this pull request Aug 15, 2026
Follow-up to
[#676](#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 `false` — `false` 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)%`.
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