fix(mcp): evaluate security when listing tools and resources - #8483
Conversation
| */ | ||
| private function isGranted(string $operationName): bool | ||
| { | ||
| \assert(null !== $this->operationMetadataFactory && null !== $this->resourceAccessChecker); |
There was a problem hiding this comment.
We usually throw a RuntimeException instead of using assert
There was a problem hiding this comment.
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.
| return $references; | ||
| } | ||
|
|
||
| return array_values(array_filter($references, fn (Tool|ResourceDefinition $reference): bool => $this->isGranted($identify($reference)))); |
There was a problem hiding this comment.
let's use a loop to reduce complexity
There was a problem hiding this comment.
Done, filterGranted() builds the list with a plain foreach now, no array_filter + array_values.
|
Thanks for this! can you check my review? |
|
Both applied, thanks for the review @soyuka. Unit tests and the functional |
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.
|
I pushed three commits here, and opened the matching upstream PR: modelcontextprotocol/php-sdk#494. Why the code movedThe original 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 The security decision then moved behind That third commit closes the actual gap: Laravel registered no list handler at all, so What upstream removes, and what it does notphp-sdk#494 lets the builder defer loading into an empty custom registry. That removes the lazy-load half of 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 The branch constraintThis targets 4.3, which pins 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.
|
Thanks @Amoifr ! |
ListHandlerreturned 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
securitydenies the current caller are now dropped fromtools/listandresources/list.The two design questions from the issue
Only
securityis evaluated.securityPostDenormalizeandsecurityPostValidationneed arguments and an object that do not exist at list time, so they cannot take part. The filter is therefore best-effort onsecurityalone, which is what the class docblock now says.Expressions that need call-time variables leave the element listed. An expression reading
object,previous_objector a uri variable cannot be evaluated before the tool runs: the expression language rejects the unknown name, and the element stays visible.tools/callstill enforces it, so nothing is granted that was not granted before. This mirrors whatAccessCheckerProvideralready does when it skips thepre_readstage 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
ListHandlerkeeps working unfiltered when security is not installed (ignoreOnInvalid()on the checker).SyntaxErroris only referenced in acatch, andapi-platform/mcpdoes not gain a dependency onsymfony/expression-language, the same wayHandleralready usesRequestStackwithout requiringsymfony/http-foundation.Tests
resources/list.McpSecuredToolsfixture: an anonymous caller no longer seessecured_toolbut still seessecured_post_denormalize_tool,secured_post_validation_toolandsecured_uri_variable_tool; an admin sees all of them. Both fail on4.3without the fix.