Skip to content

fix(mcp): evaluate security when listing tools and resources - #8483

Merged
soyuka merged 8 commits into
api-platform:4.3from
Amoifr:fix-8455-mcp-list-security
Sep 2, 2026
Merged

fix(mcp): evaluate security when listing tools and resources#8483
soyuka merged 8 commits into
api-platform:4.3from
Amoifr:fix-8455-mcp-list-security

Conversation

@Amoifr

@Amoifr Amoifr commented Aug 28, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? 4.3
Tickets Fix #8455
License MIT
Doc PR /

ListHandler returned every registered element, so an anonymous caller could read the name, the description and the full input schema of a tool it was not allowed to invoke. Invocation itself was already denied since #8435; only discovery leaked.

Elements whose operation-level security denies the current caller are now dropped from tools/list and resources/list.

The two design questions from the issue

Only security is evaluated. securityPostDenormalize and securityPostValidation need arguments and an object that do not exist at list time, so they cannot take part. The filter is therefore best-effort on security alone, which is what the class docblock now says.

Expressions that need call-time variables leave the element listed. An expression reading object, previous_object or a uri variable cannot be evaluated before the tool runs: the expression language rejects the unknown name, and the element stays visible. tools/call still enforces it, so nothing is granted that was not granted before. This mirrors what AccessCheckerProvider already does when it skips the pre_read stage for expressions using the object, so I did not add a configuration flag: hiding those elements would hide tools the caller is perfectly allowed to call, and no flag is needed to describe a behaviour that is already the codebase's convention. Happy to turn it into an option if you would rather have it explicit.

Notes

  • Filtering happens after paging, so a page can hold fewer elements than the page size. The cursor still walks the whole registry, so no element is skipped, and the MCP spec puts no constraint on page size.
  • The new constructor arguments are nullable and come last, so ListHandler keeps working unfiltered when security is not installed (ignoreOnInvalid() on the checker).
  • SyntaxError is only referenced in a catch, and api-platform/mcp does not gain a dependency on symfony/expression-language, the same way Handler already uses RequestStack without requiring symfony/http-foundation.

Tests

  • Unit: a denied tool is dropped, a granted one is kept, an expression needing call-time variables keeps the tool listed, and the same for resources/list.
  • Functional, on the existing McpSecuredTools fixture: an anonymous caller no longer sees secured_tool but still sees secured_post_denormalize_tool, secured_post_validation_tool and secured_uri_variable_tool; an admin sees all of them. Both fail on 4.3 without the fix.

Comment thread src/Mcp/Server/ListHandler.php Outdated
*/
private function isGranted(string $operationName): bool
{
\assert(null !== $this->operationMetadataFactory && null !== $this->resourceAccessChecker);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We usually throw a RuntimeException instead of using assert

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — isGranted() throws ApiPlatform\Metadata\Exception\RuntimeException when the metadata factory or the access checker is missing, instead of asserting.

I left the \assert($request instanceof ListToolsRequest) in handle() alone, since it predates this PR and Handler does the same thing a few lines away. Say the word if you would rather have both converted.

Comment thread src/Mcp/Server/ListHandler.php Outdated
return $references;
}

return array_values(array_filter($references, fn (Tool|ResourceDefinition $reference): bool => $this->isGranted($identify($reference))));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let's use a loop to reduce complexity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, filterGranted() builds the list with a plain foreach now, no array_filter + array_values.

@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

Thanks for this! can you check my review?

@Amoifr

Amoifr commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Both applied, thanks for the review @soyuka. Unit tests and the functional McpSecurityTest are green, PHPStan clean on src/Mcp.

Amoifr and others added 4 commits September 2, 2026 10:51
Following review: isGranted() throws instead of asserting on the optional
dependencies, and filterGranted() builds its result with a foreach.
ListHandler existed only to re-run the element loader lazily, because
Builder::build() eagerly loads a registry passed through setRegistry()
and both integrations pass one. To do that it re-implemented the SDK's
ListToolsHandler and ListResourcesHandler, and since custom handlers are
prepended it shadowed them with a hardcoded page size of 20, silently
overriding the configured mcp.pagination_limit.

A RegistryInterface decorator keeps the lazy load and the security
filtering while handing tools/list and resources/list back to the SDK,
which restores the configured page size. has*() stays unfiltered because
Builder::detectCapabilities() reads it at build time with no request, and
getTool()/getResource() stay unfiltered because DiscoveryLoader reads
them during load and AccessCheckerProvider already guards tools/call.

php-sdk#389 shipped in mcp/sdk 0.7.0, so the old TODO was stale. The
limitation that keeps the lazy load alive is the eager load of a custom
registry, still present on the SDK's main branch.
SecureRegistry decided visibility itself, by reading the operation-level
"security" expression and treating a SyntaxError as "undecidable at list
time, keep the element listed". Both halves are Symfony-only: Laravel
reads "policy" (AccessCheckerProvider) and evaluates it through Gate,
which never raises a SyntaxError, so the check was a silent no-op there.

ElementAccessCheckerInterface now owns that decision.
ExpressionAccessChecker carries the Symfony semantics unchanged;
PolicyAccessChecker reads "policy" and treats an ArgumentCountError as
undecidable, which is the signal Gate gives when a policy method needs a
model instance that does not exist yet.

No behaviour change: neither class depends on Symfony or Laravel, the
same expression reaches the same checker, and the RuntimeException guard
is gone because it was unreachable once nullability moved out.
Laravel handed its registry straight to the SDK builder and registered
no list handler, so tools/list served every tool unfiltered: a caller
could read the name, description and input schema of a tool its policy
denies. Only the Symfony integration was covered.

The registry is now decorated with SecureRegistry and a
PolicyAccessChecker. A policy method that needs a model instance cannot
answer at list time -- Gate::callPolicyMethod drops the class-string
argument and calls it with the user alone -- so those tools stay listed
and are enforced on tools/call, mirroring what the Symfony side does
with an expression that reads call-time variables.
@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

I pushed three commits here, and opened the matching upstream PR: modelcontextprotocol/php-sdk#494.

Why the code moved

The original ListHandler re-implemented the SDK's ListToolsHandler and ListResourcesHandler verbatim. It only existed because Builder::build() eagerly loads a registry passed through setRegistry(), which both of our integrations do — so setLazyLoading(true) never applied and a cold-cache boot under a persistent runtime could freeze the registry empty.

Since custom handlers are prepended and dispatch is first-match, that copy also shadowed the SDK's handlers with a hardcoded page size of 20, silently overriding the configured mcp.pagination_limit. Replacing it with a RegistryInterface decorator hands tools/list back to the SDK and restores the configured page size.

The security decision then moved behind ElementAccessCheckerInterface, because the Symfony semantics do not port: Laravel reads policy, not security, and evaluates it through Gate, which never raises a SyntaxError. Its signal for "cannot decide at list time" is an ArgumentCountErrorGate::callPolicyMethod drops the class-string argument and calls the policy method with the user alone, so a method that needs a model instance throws instead of answering. Hence ExpressionAccessChecker and PolicyAccessChecker.

That third commit closes the actual gap: Laravel registered no list handler at all, so tools/list there was leaking policy-protected tools. #8455 was only half fixed.

What upstream removes, and what it does not

php-sdk#494 lets the builder defer loading into an empty custom registry. That removes the lazy-load half of SecureRegistry — the $loaded flag, the LoaderInterface argument, and the TODO.

It does not remove the decorator. Filtering listings by security still needs a seam, and today the only ones the SDK offers are a request handler (what we just deleted) or a registry decorator. Getting rid of it properly needs a second upstream step: a reference/list filter consulted inside Registry::getTools() while it fills a page. That would also fix the artefact we currently document — we filter after paging, so a page can come back short. I'd rather propose that separately than bundle it into #494.

The branch constraint

This targets 4.3, which pins mcp/sdk: ^0.6 || ^0.7. #494 would land in 0.8.x at the earliest, so nothing here can consume it on this branch — the lazy load stays for 4.3's lifetime regardless of how fast upstream moves.

So the choice is: merge now and let 4.4/5.0 shed the lazy-load half once the SDK floor moves, or hold a security fix waiting on an upstream release. I lean towards merging, but happy to be told otherwise.

Handler let every exception bubble to the SDK, relying on it to surface
the message: mcp/sdk 0.7 wrapped an uncaught throwable as an internal
error carrying $e->getMessage(), so "Access Denied." reached the client
by accident rather than by design.

0.8 hardened that path to a fixed "Internal server error.", since an
arbitrary throwable carries file paths, class names and argument types
that must not reach the peer. Denials then became indistinguishable from
genuine faults, and the caller lost the reason a call was refused.

Caller-facing exceptions are now converted here. HttpExceptionInterface
is the existing marker for the ones whose message is meant for the
client, and both AccessDeniedException classes implement it; anything
else stays uncaught and reaches the SDK's generic handler, which leaks
nothing. The response is unchanged from 0.7 -- same error code, same
message -- but it is now produced deliberately instead of depending on
the transport to leak it.
symfony/mcp-bundle 0.12 requires mcp/sdk ^0.7 and 0.13 requires ^0.8.1,
so the two move together. 0.13 serves several named servers, which
changes the wiring: mcp.registry is gone, each server gets its own
mcp.server.<name>.registry, and the configuration moved under
mcp.servers.<name> with the HTTP path defaulting to /mcp/<name>.

The registry can no longer be decorated from configuration, since the
ids are dynamic and there may be more than one. McpRegistryPass finds
every server through its builder tag and decorates each registry, which
is also what makes the decoration keep working for a multi-server setup.

The fixture pins the path back to /mcp so the functional tests keep
their URL, and declares the mandatory registry node as "*"; that node
only drives the bundle's own attribute discovery, and API Platform
supplies its elements through the mcp.loader tag instead.

The mcp.loader and mcp.request_handler tags are unchanged and still
reach every server, so the loader, the state handlers and the event
handlers need no adaptation.
src/Laravel/composer.json is the composer root when CI links the monorepo
into the Laravel package, and mcp-bundle ^0.12 pins mcp/sdk ^0.7, which
conflicts with the api-platform/mcp requirement of ^0.8.
@soyuka
soyuka merged commit ed536df into api-platform:4.3 Sep 2, 2026
110 of 112 checks passed
@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

Thanks @Amoifr !

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