Repository navigation
[Server] Bind requestState to the request it was minted for
#625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the official PHP MCP SDK. | ||
| * | ||
| * A collaboration between Symfony and the PHP Foundation. | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Mcp\Server\Stateless; | ||
|
|
||
| use Mcp\Schema\JsonRpc\Request; | ||
| use Mcp\Schema\Request\CallToolRequest; | ||
| use Mcp\Schema\Request\GetPromptRequest; | ||
| use Mcp\Schema\Request\ReadResourceRequest; | ||
| use Mcp\Server\Authorization\AccessToken; | ||
|
|
||
| /** | ||
| * Binds a `requestState` to what the specification recommends: the | ||
| * authenticated principal and the originating request, as its method name | ||
| * plus a digest of its salient parameters. | ||
| * | ||
| * The salient parameters are an allowlist per method, so a parameter a later | ||
| * revision adds cannot silently change the binding: the tool or prompt name | ||
| * and a digest of its arguments, or the resource URI. Any other method binds | ||
| * by its name alone. The principal is the token's issuer, client and subject, | ||
| * which outlive a token refresh between two rounds; an unauthorized request | ||
| * binds to no principal, so its state does not verify on an authorized one, | ||
| * nor the other way round. | ||
| * | ||
| * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | ||
| * | ||
| * @author Björn Meyer <b.meyer@shopware.com> | ||
| */ | ||
| final class OriginatingRequestBinder implements RequestStateBinderInterface | ||
| { | ||
| public function bind(Request $request, ?AccessToken $accessToken): string | ||
| { | ||
| [$target, $arguments] = match (true) { | ||
| $request instanceof CallToolRequest => [$request->name, $request->arguments], | ||
| $request instanceof GetPromptRequest => [$request->name, $request->arguments ?? []], | ||
| $request instanceof ReadResourceRequest => [$request->uri, []], | ||
| default => [null, []], | ||
| }; | ||
|
|
||
| return json_encode([ | ||
| 'method' => $request::getMethod(), | ||
| 'target' => $target, | ||
| 'arguments' => hash('sha256', json_encode(self::canonical($arguments), \JSON_THROW_ON_ERROR)), | ||
| 'principal' => null === $accessToken ? null : [ | ||
| 'iss' => \is_string($issuer = $accessToken->getClaim('iss')) ? $issuer : null, | ||
| 'client' => $accessToken->getClientId(), | ||
| 'sub' => $accessToken->getSubject(), | ||
| ], | ||
| ], \JSON_THROW_ON_ERROR); | ||
| } | ||
|
|
||
| /** | ||
| * Sorts object members recursively, so a client re-encoding the same | ||
| * arguments in another member order still binds to the same digest. | ||
| */ | ||
| private static function canonical(mixed $value): mixed | ||
| { | ||
| if (!\is_array($value)) { | ||
| return $value; | ||
| } | ||
|
|
||
| if (!array_is_list($value)) { | ||
| ksort($value, \SORT_STRING); | ||
|
Comment on lines
+70
to
+71
|
||
| } | ||
|
|
||
| return array_map(self::canonical(...), $value); | ||
|
Comment on lines
+70
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a nested tool argument is a JSON object with numeric keys out of list order, such as Useful? React with 👍 / 👎.
Comment on lines
+70
to
+74
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the official PHP MCP SDK. | ||
| * | ||
| * A collaboration between Symfony and the PHP Foundation. | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Mcp\Server\Stateless; | ||
|
|
||
| use Mcp\Schema\JsonRpc\Request; | ||
| use Mcp\Server\Authorization\AccessToken; | ||
|
|
||
| /** | ||
| * Binds the `requestState` of a multi round-trip request to the request it | ||
| * was minted for. | ||
| * | ||
| * The SDK calls {@see self::bind()} once per request: the value it returns is | ||
| * covered by the MAC of every state minted while answering the request, and | ||
| * recomputed to verify the state the client echoes on the retry. A state | ||
| * presented on a request that binds to another value fails verification like | ||
| * a forged one. The value never goes on the wire. | ||
| * | ||
| * Bind only what stays the same across the rounds of one call: the request | ||
| * object carries neither `requestState` nor `inputResponses`, but `_meta` | ||
| * (progress token, trace context, client capabilities) may change between | ||
| * rounds and does not belong in the binding. | ||
| * | ||
| * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | ||
| * @see OriginatingRequestBinder for the binding the specification recommends | ||
| * | ||
| * @author Björn Meyer <b.meyer@shopware.com> | ||
| */ | ||
| interface RequestStateBinderInterface | ||
| { | ||
| /** | ||
| * @param Request $request the request as the client sent it, before any `RequestEvent` listener ran | ||
| * @param AccessToken|null $accessToken the token the request was authorized with, null on an unauthorized transport | ||
| */ | ||
| public function bind(Request $request, ?AccessToken $accessToken): string; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When two authorized tokens come from the same issuer/client but identify users through a claim other than
sub(or carry no standard identity claims), this serializes the same principal for both.AccessTokenpermits arbitrary claims andJwtTokenValidator::validate()does not requiresuborclient_id, so for identical method/arguments one user's state can verify under the other's token; fail closed when the standard identity is unavailable or make the stable principal claim/extractor configurable.Useful? React with 👍 / 👎.