Skip to content

Commit 7c4aec5

Browse files
committed
[Tests] Cover requestState binding and key rotation end to end
1 parent ce46033 commit 7c4aec5

12 files changed

Lines changed: 1543 additions & 1 deletion
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
/*
13+
* Server for the `requestState` tests, over `php -S` or stdio. MCP_REQUEST_STATE_KEYS holds the JSON-encoded key or
14+
* key ring, MCP_REQUEST_STATE_BINDER turns on the binder, MCP_REQUEST_STATE_ANONYMOUS drops the authorization,
15+
* MCP_REQUEST_STATE_TTL sets the TTL, MCP_REQUEST_STATE_SUBJECT_CLAIM names the claim identifying a user and
16+
* MCP_REQUEST_STATE_NO_SHIM turns off the input-required shim, MCP_REQUEST_STATE_SESSIONS keeps sessions in a directory.
17+
*/
18+
19+
use Http\Discovery\Psr17Factory;
20+
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
21+
use Mcp\Schema\Content\PromptMessage;
22+
use Mcp\Schema\Content\TextContent;
23+
use Mcp\Schema\Elicitation\ElicitationSchema;
24+
use Mcp\Schema\Elicitation\StringSchemaDefinition;
25+
use Mcp\Schema\Enum\Role;
26+
use Mcp\Schema\JsonRpc\Request;
27+
use Mcp\Schema\JsonRpc\Response;
28+
use Mcp\Schema\Request\CallToolRequest;
29+
use Mcp\Schema\Request\ElicitRequest;
30+
use Mcp\Schema\Result\CallToolResult;
31+
use Mcp\Schema\Result\InputRequiredResult;
32+
use Mcp\Server;
33+
use Mcp\Server\Authorization\AccessToken;
34+
use Mcp\Server\Handler\Request\RequestHandlerInterface;
35+
use Mcp\Server\RequestContext;
36+
use Mcp\Server\Session\FileSessionStore;
37+
use Mcp\Server\Session\SessionInterface;
38+
use Mcp\Server\Stateless\InputContext;
39+
use Mcp\Server\Stateless\OriginatingRequestBinder;
40+
use Mcp\Server\Stateless\RequestStateCodec;
41+
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
42+
use Mcp\Server\Transport\Http\Middleware\PassthroughMiddleware;
43+
use Mcp\Server\Transport\Http\OAuth\AuthorizationResult;
44+
use Mcp\Server\Transport\Http\OAuth\AuthorizationTokenValidatorInterface;
45+
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
46+
use Mcp\Server\Transport\StdioTransport;
47+
use Mcp\Server\Transport\StreamableHttpTransport;
48+
49+
require_once dirname(__DIR__, 3).'/vendor/autoload.php';
50+
51+
$subject = static fn (RequestContext $context): string => $context->getAccessToken()?->getSubject() ?? 'nobody';
52+
53+
// Two asks, so the second one has to carry the first answer in the state the SDK mints.
54+
$approver = static function (RequestContext $context): string {
55+
$gateway = $context->getClientGateway();
56+
$schema = new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']);
57+
58+
$approver = $gateway->elicit('Who approves this?', $schema, key: 'approver')->content['v'] ?? '?';
59+
$gateway->elicit('One-time code?', $schema, key: 'code');
60+
61+
return (string) $approver;
62+
};
63+
64+
$builder = Server::builder()
65+
->setServerInfo('request-state-server', '1.0.0')
66+
->addTool(
67+
static fn (RequestContext $context, string $to, int $amount): string => sprintf('%s moved %d to %s, approved by %s', $subject($context), $amount, $to, $approver($context)),
68+
name: 'transfer',
69+
description: 'Moves money after two asks.',
70+
)
71+
->addTool(
72+
static fn (RequestContext $context, string $account): string => sprintf('%s closed %s, approved by %s', $subject($context), $account, $approver($context)),
73+
name: 'close_account',
74+
description: 'Closes an account after the same two asks.',
75+
)
76+
->addTool(
77+
// Authorizes once and trusts its own signed state on the retry, the reason the state needs integrity at all.
78+
static function (RequestContext $context, int $order) use ($subject): CallToolResult|InputRequiredResult {
79+
if (true === ($context->getInputContext()?->requestState()['authorized'] ?? null)) {
80+
return new CallToolResult([new TextContent(sprintf('%s refunded order %d', $subject($context), $order))]);
81+
}
82+
83+
if (!in_array($subject($context), ['alice', 'nobody'], true)) {
84+
return new CallToolResult([new TextContent('Only a manager may refund.')], isError: true);
85+
}
86+
87+
return new InputRequiredResult(
88+
['reason' => new ElicitRequest('Why?', new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']))],
89+
requestState: $context->mintRequestState(['authorized' => true]),
90+
);
91+
},
92+
name: 'refund',
93+
description: 'Refunds an order, for managers only.',
94+
)
95+
->addTool(
96+
// Takes any JSON, so the handler sees the arguments the way the client shaped them.
97+
static fn (RequestContext $context, mixed $options): string => sprintf('%s, approved by %s', json_encode($options, depth: 1024), $approver($context)),
98+
name: 'inspect',
99+
description: 'Echoes what it was given after two asks.',
100+
)
101+
->addTool(
102+
static function (RequestContext $context, int $amount) use ($approver): string {
103+
$context->getClientGateway()->progress(0.5, 1.0);
104+
105+
return sprintf('reported %d, approved by %s', $amount, $approver($context));
106+
},
107+
name: 'report',
108+
description: 'Reports progress before the two asks, so its rounds answer as a stream.',
109+
)
110+
->addRequestHandler(new class implements RequestHandlerInterface {
111+
public function supports(Request $request): bool
112+
{
113+
return $request instanceof CallToolRequest && 'raw' === $request->name;
114+
}
115+
116+
// Skips schema validation, which refuses member names only a handler like this one gets to see.
117+
public function handle(Request $request, SessionInterface $session): Response
118+
{
119+
assert($request instanceof CallToolRequest);
120+
$input = $session->get(InputContext::class);
121+
$codec = $session->get(RequestStateCodec::class);
122+
assert($codec instanceof RequestStateCodec);
123+
124+
if ($input instanceof InputContext && true === ($input->requestState()['asked'] ?? null)) {
125+
return new Response($request->getId(), new CallToolResult([new TextContent((string) json_encode($request->arguments['options'] ?? null))]));
126+
}
127+
128+
$schema = new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']);
129+
130+
return new Response($request->getId(), new InputRequiredResult(['v' => new ElicitRequest('Sure?', $schema)], requestState: $codec->mint(['asked' => true])));
131+
}
132+
})
133+
->addPrompt(
134+
static fn (RequestContext $context, string $topic): array => [new PromptMessage(Role::User, new TextContent(sprintf('brief on %s, approved by %s', $topic, $approver($context))))],
135+
name: 'brief',
136+
description: 'A brief after two asks.',
137+
)
138+
->addPrompt(
139+
static fn (RequestContext $context, string $topic): array => [new PromptMessage(Role::User, new TextContent(sprintf('leak of %s, approved by %s', $topic, $approver($context))))],
140+
name: 'leak',
141+
description: 'Another prompt after the same two asks.',
142+
)
143+
->addResourceTemplate(
144+
static fn (string $id, RequestContext $context): string => sprintf('contents of %s, approved by %s', $id, $approver($context)),
145+
'doc://{id}',
146+
name: 'doc',
147+
mimeType: 'text/plain',
148+
)
149+
->addTool(static fn (): string => 'pong', name: 'ping', description: 'Asks nothing.');
150+
151+
$keys = json_decode((string) getenv('MCP_REQUEST_STATE_KEYS'), true, flags: \JSON_THROW_ON_ERROR);
152+
153+
$binder = null;
154+
155+
if ('' !== (string) getenv('MCP_REQUEST_STATE_BINDER')) {
156+
$claim = (string) getenv('MCP_REQUEST_STATE_SUBJECT_CLAIM');
157+
158+
$binder = new OriginatingRequestBinder('' === $claim ? null : static function (AccessToken $token) use ($claim): ?string {
159+
$value = $token->getClaim($claim);
160+
161+
return is_string($value) ? $value : null;
162+
});
163+
}
164+
165+
$builder->setRequestState($keys, (int) (getenv('MCP_REQUEST_STATE_TTL') ?: 600), $binder);
166+
167+
if ('' !== (string) getenv('MCP_REQUEST_STATE_SESSIONS')) {
168+
$builder->setSession(new FileSessionStore((string) getenv('MCP_REQUEST_STATE_SESSIONS')));
169+
}
170+
171+
if ('' !== (string) getenv('MCP_REQUEST_STATE_NO_SHIM')) {
172+
$builder->withoutInputRequiredShim();
173+
}
174+
175+
if ('cli-server' !== \PHP_SAPI) {
176+
$builder->build()->run(new StdioTransport());
177+
178+
return;
179+
}
180+
181+
$middleware = [new PassthroughMiddleware()];
182+
183+
if ('' === (string) getenv('MCP_REQUEST_STATE_ANONYMOUS')) {
184+
$middleware = [new AuthorizationMiddleware(
185+
new class implements AuthorizationTokenValidatorInterface {
186+
private const ISSUER = 'https://auth.example.com';
187+
188+
public function validate(string $accessToken): AuthorizationResult
189+
{
190+
$claims = match ($accessToken) {
191+
'alice' => ['sub' => 'alice', 'client_id' => 'app', 'jti' => 'first'],
192+
'alice-refreshed' => ['sub' => 'alice', 'client_id' => 'app', 'jti' => 'second'],
193+
'alice-other-client' => ['sub' => 'alice', 'client_id' => 'other-app', 'jti' => 'third'],
194+
'alice-other-issuer' => ['sub' => 'alice', 'client_id' => 'app', 'iss' => 'https://other.example.com', 'jti' => 'fifth'],
195+
'mallory' => ['sub' => 'mallory', 'client_id' => 'app', 'jti' => 'fourth'],
196+
'no-subject-alice' => ['client_id' => 'app', 'email' => 'alice@example.com'],
197+
'no-subject-alice-refreshed' => ['client_id' => 'app', 'email' => 'alice@example.com', 'jti' => 'sixth'],
198+
'no-subject-alice-other-client' => ['client_id' => 'other-app', 'email' => 'alice@example.com'],
199+
'no-subject-mallory' => ['client_id' => 'app', 'email' => 'mallory@example.com'],
200+
default => null,
201+
};
202+
203+
if (null === $claims) {
204+
return AuthorizationResult::unauthorized();
205+
}
206+
207+
return AuthorizationResult::allow(new AccessToken(['mcp'], ['iss' => self::ISSUER, ...$claims]));
208+
}
209+
},
210+
new ProtectedResourceMetadata('http://127.0.0.1/', ['http://127.0.0.1/']),
211+
)];
212+
}
213+
214+
$request = (new Psr17Factory())->createServerRequestFromGlobals();
215+
216+
(new SapiEmitter())->emit($builder->build()->run(new StreamableHttpTransport($request, middleware: $middleware)));

0 commit comments

Comments
 (0)