diff --git a/tests/Integration/Fixture/request_state.php b/tests/Integration/Fixture/request_state.php new file mode 100644 index 00000000..5aa7d93f --- /dev/null +++ b/tests/Integration/Fixture/request_state.php @@ -0,0 +1,216 @@ + $context->getAccessToken()?->getSubject() ?? 'nobody'; + +// Two asks, so the second one has to carry the first answer in the state the SDK mints. +$approver = static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']); + + $approver = $gateway->elicit('Who approves this?', $schema, key: 'approver')->content['v'] ?? '?'; + $gateway->elicit('One-time code?', $schema, key: 'code'); + + return (string) $approver; +}; + +$builder = Server::builder() + ->setServerInfo('request-state-server', '1.0.0') + ->addTool( + static fn (RequestContext $context, string $to, int $amount): string => sprintf('%s moved %d to %s, approved by %s', $subject($context), $amount, $to, $approver($context)), + name: 'transfer', + description: 'Moves money after two asks.', + ) + ->addTool( + static fn (RequestContext $context, string $account): string => sprintf('%s closed %s, approved by %s', $subject($context), $account, $approver($context)), + name: 'close_account', + description: 'Closes an account after the same two asks.', + ) + ->addTool( + // Authorizes once and trusts its own signed state on the retry, the reason the state needs integrity at all. + static function (RequestContext $context, int $order) use ($subject): CallToolResult|InputRequiredResult { + if (true === ($context->getInputContext()?->requestState()['authorized'] ?? null)) { + return new CallToolResult([new TextContent(sprintf('%s refunded order %d', $subject($context), $order))]); + } + + if (!in_array($subject($context), ['alice', 'nobody'], true)) { + return new CallToolResult([new TextContent('Only a manager may refund.')], isError: true); + } + + return new InputRequiredResult( + ['reason' => new ElicitRequest('Why?', new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']))], + requestState: $context->mintRequestState(['authorized' => true]), + ); + }, + name: 'refund', + description: 'Refunds an order, for managers only.', + ) + ->addTool( + // Takes any JSON, so the handler sees the arguments the way the client shaped them. + static fn (RequestContext $context, mixed $options): string => sprintf('%s, approved by %s', json_encode($options, depth: 1024), $approver($context)), + name: 'inspect', + description: 'Echoes what it was given after two asks.', + ) + ->addTool( + static function (RequestContext $context, int $amount) use ($approver): string { + $context->getClientGateway()->progress(0.5, 1.0); + + return sprintf('reported %d, approved by %s', $amount, $approver($context)); + }, + name: 'report', + description: 'Reports progress before the two asks, so its rounds answer as a stream.', + ) + ->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest && 'raw' === $request->name; + } + + // Skips schema validation, which refuses member names only a handler like this one gets to see. + public function handle(Request $request, SessionInterface $session): Response + { + assert($request instanceof CallToolRequest); + $input = $session->get(InputContext::class); + $codec = $session->get(RequestStateCodec::class); + assert($codec instanceof RequestStateCodec); + + if ($input instanceof InputContext && true === ($input->requestState()['asked'] ?? null)) { + return new Response($request->getId(), new CallToolResult([new TextContent((string) json_encode($request->arguments['options'] ?? null))])); + } + + $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']); + + return new Response($request->getId(), new InputRequiredResult(['v' => new ElicitRequest('Sure?', $schema)], requestState: $codec->mint(['asked' => true]))); + } + }) + ->addPrompt( + static fn (RequestContext $context, string $topic): array => [new PromptMessage(Role::User, new TextContent(sprintf('brief on %s, approved by %s', $topic, $approver($context))))], + name: 'brief', + description: 'A brief after two asks.', + ) + ->addPrompt( + static fn (RequestContext $context, string $topic): array => [new PromptMessage(Role::User, new TextContent(sprintf('leak of %s, approved by %s', $topic, $approver($context))))], + name: 'leak', + description: 'Another prompt after the same two asks.', + ) + ->addResourceTemplate( + static fn (string $id, RequestContext $context): string => sprintf('contents of %s, approved by %s', $id, $approver($context)), + 'doc://{id}', + name: 'doc', + mimeType: 'text/plain', + ) + ->addTool(static fn (): string => 'pong', name: 'ping', description: 'Asks nothing.'); + +$keys = json_decode((string) getenv('MCP_REQUEST_STATE_KEYS'), true, flags: \JSON_THROW_ON_ERROR); + +$binder = null; + +if ('' !== (string) getenv('MCP_REQUEST_STATE_BINDER')) { + $claim = (string) getenv('MCP_REQUEST_STATE_SUBJECT_CLAIM'); + + $binder = new OriginatingRequestBinder('' === $claim ? null : static function (AccessToken $token) use ($claim): ?string { + $value = $token->getClaim($claim); + + return is_string($value) ? $value : null; + }); +} + +$builder->setRequestState($keys, (int) (getenv('MCP_REQUEST_STATE_TTL') ?: 600), $binder); + +if ('' !== (string) getenv('MCP_REQUEST_STATE_SESSIONS')) { + $builder->setSession(new FileSessionStore((string) getenv('MCP_REQUEST_STATE_SESSIONS'))); +} + +if ('' !== (string) getenv('MCP_REQUEST_STATE_NO_SHIM')) { + $builder->withoutInputRequiredShim(); +} + +if ('cli-server' !== \PHP_SAPI) { + $builder->build()->run(new StdioTransport()); + + return; +} + +$middleware = [new PassthroughMiddleware()]; + +if ('' === (string) getenv('MCP_REQUEST_STATE_ANONYMOUS')) { + $middleware = [new AuthorizationMiddleware( + new class implements AuthorizationTokenValidatorInterface { + private const ISSUER = 'https://auth.example.com'; + + public function validate(string $accessToken): AuthorizationResult + { + $claims = match ($accessToken) { + 'alice' => ['sub' => 'alice', 'client_id' => 'app', 'jti' => 'first'], + 'alice-refreshed' => ['sub' => 'alice', 'client_id' => 'app', 'jti' => 'second'], + 'alice-other-client' => ['sub' => 'alice', 'client_id' => 'other-app', 'jti' => 'third'], + 'alice-other-issuer' => ['sub' => 'alice', 'client_id' => 'app', 'iss' => 'https://other.example.com', 'jti' => 'fifth'], + 'mallory' => ['sub' => 'mallory', 'client_id' => 'app', 'jti' => 'fourth'], + 'no-subject-alice' => ['client_id' => 'app', 'email' => 'alice@example.com'], + 'no-subject-alice-refreshed' => ['client_id' => 'app', 'email' => 'alice@example.com', 'jti' => 'sixth'], + 'no-subject-alice-other-client' => ['client_id' => 'other-app', 'email' => 'alice@example.com'], + 'no-subject-mallory' => ['client_id' => 'app', 'email' => 'mallory@example.com'], + default => null, + }; + + if (null === $claims) { + return AuthorizationResult::unauthorized(); + } + + return AuthorizationResult::allow(new AccessToken(['mcp'], ['iss' => self::ISSUER, ...$claims])); + } + }, + new ProtectedResourceMetadata('http://127.0.0.1/', ['http://127.0.0.1/']), + )]; +} + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +(new SapiEmitter())->emit($builder->build()->run(new StreamableHttpTransport($request, middleware: $middleware))); diff --git a/tests/Integration/RequestStateBindingTest.php b/tests/Integration/RequestStateBindingTest.php new file mode 100644 index 00000000..46294967 --- /dev/null +++ b/tests/Integration/RequestStateBindingTest.php @@ -0,0 +1,366 @@ + + */ + public static function provideReplays(): iterable + { + $transfer = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + + yield 'alice\'s approval under mallory\'s token' => [ + 'alice', $transfer, + 'mallory', $transfer, + 'mallory moved 10 to bob, approved by alice', + ]; + yield 'alice\'s approval under a token of another client' => [ + 'alice', $transfer, + 'alice-other-client', $transfer, + 'alice moved 10 to bob, approved by alice', + ]; + yield 'an approval of 10 for bob on a transfer of 10000 to mallory' => [ + 'alice', $transfer, + 'alice', self::tool('transfer', ['to' => 'mallory', 'amount' => 10000]), + 'alice moved 10000 to mallory, approved by alice', + ]; + yield 'an approval of a transfer on closing the account' => [ + 'alice', $transfer, + 'alice', self::tool('close_account', ['account' => 'savings']), + 'alice closed savings, approved by alice', + ]; + yield 'an approval to read one document on reading another' => [ + 'alice', self::resource('doc://public'), + 'alice', self::resource('doc://secret'), + 'contents of secret, approved by alice', + ]; + yield 'an approval of one prompt on getting another' => [ + 'alice', self::prompt('brief', ['topic' => 'roadmap']), + 'alice', self::prompt('leak', ['topic' => 'roadmap']), + 'leak of roadmap, approved by alice', + ]; + yield 'an approval of a prompt on the same prompt with other arguments' => [ + 'alice', self::prompt('brief', ['topic' => 'roadmap']), + 'alice', self::prompt('brief', ['topic' => 'salaries']), + 'brief on salaries, approved by alice', + ]; + yield 'alice\'s approval of a prompt under mallory\'s token' => [ + 'alice', self::prompt('brief', ['topic' => 'roadmap']), + 'mallory', self::prompt('brief', ['topic' => 'roadmap']), + 'brief on roadmap, approved by alice', + ]; + yield 'an approval of a map with numeric keys on the list it would sort into' => [ + 'alice', self::tool('inspect', ['options' => ['1' => 'B', '0' => 'A']]), + 'alice', self::tool('inspect', ['options' => ['A', 'B']]), + '["A","B"], approved by alice', + ]; + yield 'an approval of a list on the same list in another order' => [ + 'alice', self::tool('inspect', ['options' => ['A', 'B']]), + 'alice', self::tool('inspect', ['options' => ['B', 'A']]), + '["B","A"], approved by alice', + ]; + yield 'an approval on other values of members named with a leading NUL' => [ + 'alice', self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'preview']]), + 'alice', self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'execute']]), + '{"visible":true,"\u0000mode":"execute"}', + ]; + yield 'alice\'s approval under a token of another issuer' => [ + 'alice', $transfer, + 'alice-other-issuer', $transfer, + 'alice moved 10 to bob, approved by alice', + ]; + yield 'a manager\'s authorization check under mallory\'s token' => [ + 'alice', self::tool('refund', ['order' => 1]), + 'mallory', self::tool('refund', ['order' => 1]), + 'mallory refunded order 1', + ]; + yield 'a manager\'s authorization check on another order' => [ + 'alice', self::tool('refund', ['order' => 1]), + 'alice', self::tool('refund', ['order' => 2]), + 'alice refunded order 2', + ]; + yield 'tokens telling their users apart by a claim other than the subject' => [ + 'no-subject-alice', $transfer, + 'no-subject-mallory', $transfer, + 'nobody moved 10 to bob, approved by no-subject-alice', + ]; + } + + /** + * @param Call $minted + * @param Call $replayed + */ + #[DataProvider('provideReplays')] + #[TestDox('without a binder, a client gets away with $_dataName')] + public function testReplaySucceedsWithoutABinder(string $holder, array $minted, string $replayer, array $replayed, string $outcome): void + { + $url = $this->startServer(self::NEW_KEY); + + $this->assertCompleted($this->finish($url, $replayer, $replayed, $this->pendingState($url, $holder, $minted)), $outcome); + } + + /** + * @param Call $minted + * @param Call $replayed + */ + #[DataProvider('provideReplays')] + #[TestDox('with a binder, a client is refused $_dataName')] + public function testReplayIsRefusedWithABinder(string $holder, array $minted, string $replayer, array $replayed, string $outcome): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + + $this->assertRefused($this->finish($url, $replayer, $replayed, $this->pendingState($url, $holder, $minted))); + } + + #[TestDox('a manager\'s check still stops mallory from starting a refund, whatever the binder')] + public function testHandlerStillDecidesTheFirstRound(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + + $this->assertCompleted($this->send($url, 'mallory', self::tool('refund', ['order' => 1])), 'Only a manager may refund.'); + } + + #[TestDox('with a binder, the retry of a call completes under a refreshed token')] + public function testRetryCompletesUnderARefreshedToken(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + + $this->assertCompleted( + $this->finish($url, 'alice-refreshed', $call, $this->pendingState($url, 'alice', $call)), + 'alice moved 10 to bob, approved by alice', + ); + } + + #[TestDox('with a binder, the retry of a call completes with its arguments re-encoded, another id and other metadata')] + public function testRetryCompletesWhenOnlyItsEnvelopeChanged(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + $pending = $this->pendingState($url, 'alice', self::tool('transfer', ['to' => 'bob', 'amount' => 10])); + + $answer = $this->send($url, 'alice', self::tool('transfer', ['amount' => 10, 'to' => 'bob']), [ + 'inputResponses' => [$pending['asks'][0] => ['action' => 'accept', 'content' => ['v' => 'alice']]], + 'requestState' => $pending['state'], + ], ['progressToken' => 'another-round', 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'], id: 99); + + $this->assertCompleted($answer, 'alice moved 10 to bob, approved by alice'); + } + + /** + * @return iterable + */ + public static function provideEncodings(): iterable + { + yield 'a map with numeric keys in another member order' => [ + self::tool('inspect', ['options' => ['2' => 'C', '0' => 'A', '1' => 'B']]), + self::tool('inspect', ['options' => ['1' => 'B', '2' => 'C', '0' => 'A']]), + '{"1":"B","2":"C","0":"A"}, approved by alice', + ]; + yield 'a nested map in another member order' => [ + self::tool('inspect', ['options' => ['trip' => ['to' => 'BER', 'from' => 'HAM']]]), + self::tool('inspect', ['options' => ['trip' => ['from' => 'HAM', 'to' => 'BER']]]), + '{"trip":{"from":"HAM","to":"BER"}}, approved by alice', + ]; + yield 'a member named with a leading NUL' => [ + self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'preview']]), + self::tool('raw', ['options' => ["\0mode" => 'preview', 'visible' => true]]), + '{"\u0000mode":"preview","visible":true}', + ]; + } + + /** + * @param Call $minted + * @param Call $retried + */ + #[DataProvider('provideEncodings')] + #[TestDox('with a binder, a retry completes with $_dataName')] + public function testRetryCompletesInAnotherEncoding(array $minted, array $retried, string $outcome): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + + $this->assertCompleted($this->finish($url, 'alice', $retried, $this->pendingState($url, 'alice', $minted)), $outcome); + } + + #[TestDox('with a binder, arguments nested as deep as a request may be complete, and bind by their innermost value')] + public function testDeeplyNestedArgumentsComplete(): void + { + $nested = static function (string $leaf): array { + $value = $leaf; + + for ($depth = 0; $depth < 480; ++$depth) { + $value = ['k' => $value]; + } + + return $value; + }; + + $url = $this->startServer(self::NEW_KEY, binder: true); + $call = self::tool('inspect', ['options' => $nested('preview')]); + $pending = $this->pendingState($url, 'alice', $call); + + $this->assertRefused($this->finish($url, 'alice', self::tool('inspect', ['options' => $nested('execute')]), $pending)); + + $this->assertCompleted($this->finish($url, 'alice', $call, $pending), json_encode($nested('preview'), depth: 1024).', approved by alice'); + } + + #[TestDox('with a binder, the rounds of a call answering as a stream complete, for the call they were minted for only')] + public function testStreamedRoundsStayBound(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + $call = self::tool('report', ['amount' => 10]); + $pending = $this->pendingState($url, 'alice', $call, ['progressToken' => 'first']); + + $this->assertRefused($this->finish($url, 'mallory', $call, $pending, ['progressToken' => 'second'])); + $this->assertRefused($this->finish($url, 'alice', self::tool('report', ['amount' => 10000]), $pending, ['progressToken' => 'second'])); + + $answer = $this->finish($url, 'alice', $call, $pending, ['progressToken' => 'second']); + + $this->assertStringContainsString('notifications/progress', $answer['raw']); + $this->assertCompleted($answer, 'reported 10, approved by alice'); + } + + #[TestDox('with a binder and a subject resolver, a call completes under a refreshed token')] + public function testSubjectResolverCompletesUnderARefreshedToken(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, subjectClaim: 'email'); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + + $this->assertCompleted( + $this->finish($url, 'no-subject-alice-refreshed', $call, $this->pendingState($url, 'no-subject-alice', $call)), + 'nobody moved 10 to bob, approved by no-subject-alice', + ); + } + + #[TestDox('with a binder and a subject resolver, a state stays with its user and its client')] + public function testSubjectResolverKeepsUserAndClient(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, subjectClaim: 'email'); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($url, 'no-subject-alice', $call); + + $this->assertRefused($this->finish($url, 'no-subject-mallory', $call, $pending)); + $this->assertRefused($this->finish($url, 'no-subject-alice-other-client', $call, $pending)); + } + + #[TestDox('with a binder and a subject resolver, a token the resolver knows nobody for still fails closed')] + public function testSubjectResolverFailsClosed(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, subjectClaim: 'email'); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + + $this->assertRefused($this->finish($url, 'alice', $call, $this->pendingState($url, 'alice', $call))); + } + + #[TestDox('with a binder, a token without a subject cannot finish a call that needs a state, but everything else still works')] + public function testTokenWithoutASubjectFailsClosed(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + + $this->assertRefused($this->finish($url, 'no-subject-alice', $call, $this->pendingState($url, 'no-subject-alice', $call))); + $this->assertCompleted($this->send($url, 'no-subject-alice', self::tool('ping')), 'pong'); + } + + #[TestDox('with a binder, an endpoint without authorization still binds the call')] + public function testAnonymousEndpointBindsTheCall(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, anonymous: true); + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($url, null, $call); + + $this->assertCompleted($this->finish($url, null, $call, $pending), 'nobody moved 10 to bob, approved by '); + $this->assertRefused($this->finish($url, null, self::tool('transfer', ['to' => 'mallory', 'amount' => 10]), $pending)); + } + + #[TestDox('with a binder, the principal stays off the wire')] + public function testPrincipalStaysOffTheWire(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true); + $state = $this->pendingState($url, 'alice', self::tool('refund', ['order' => 1]))['state']; + + $this->assertCount(2, explode('.', $state)); + $this->assertSame( + ['authorized' => true], + json_decode((string) base64_decode(strtr(explode('.', $state)[0], '-_', '+/'), true), true)['data'] ?? null, + ); + } + + #[TestDox('with a binder, our own client completes the calls that take several rounds')] + public function testOwnClientCompletesThroughTheBinder(): void + { + $client = $this->client([$this->startServer(self::NEW_KEY, binder: true)], 'alice'); + + $this->assertSame('alice moved 10 to bob, approved by client', self::text($client->callTool('transfer', ['to' => 'bob', 'amount' => 10]))); + $this->assertSame('alice refunded order 1', self::text($client->callTool('refund', ['order' => 1]))); + + $client->disconnect(); + } + + /** + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + } + + #[DataProvider('provideEras')] + #[TestDox('with a binder, a client completes the calls that take several rounds over stdio on $_dataName')] + public function testStdioCompletesThroughTheBinder(ProtocolVersion $era): void + { + $client = Client::builder() + ->setClientInfo('request-state-client', '1.0.0') + ->setProtocolVersion($era) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->setRequestTimeout(10) + ->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['v' => 'client'])); + } + }) + ->build(); + + $client->connect(new StdioTransport('php', [__DIR__.'/Fixture/request_state.php'], env: array_merge(getenv(), self::environment(self::NEW_KEY, binder: true)))); + + $this->assertSame('nobody moved 10 to bob, approved by client', self::text($client->callTool('transfer', ['to' => 'bob', 'amount' => 10]))); + $this->assertSame('nobody refunded order 1', self::text($client->callTool('refund', ['order' => 1]))); + + $client->disconnect(); + } +} diff --git a/tests/Integration/RequestStateBoundRotationTest.php b/tests/Integration/RequestStateBoundRotationTest.php new file mode 100644 index 00000000..dca2f1e3 --- /dev/null +++ b/tests/Integration/RequestStateBoundRotationTest.php @@ -0,0 +1,57 @@ + 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::OLD_KEY, binder: true), 'alice', $call); + $rotated = $this->startServer([self::NEW_KEY, self::OLD_KEY], binder: true); + + $this->assertRefused($this->finish($rotated, 'mallory', $call, $pending)); + $this->assertRefused($this->finish($rotated, 'alice', self::tool('transfer', ['to' => 'mallory', 'amount' => 10]), $pending)); + $this->assertCompleted($this->finish($rotated, 'alice', $call, $pending), 'alice moved 10 to bob, approved by alice'); + } + + #[TestDox('bound calls complete across the instances of a rolling rotation')] + public function testBoundCallsCompleteAcrossARollingRotation(): void + { + $client = $this->client([ + $this->startServer([self::OLD_KEY, self::NEW_KEY], binder: true), + $this->startServer([self::NEW_KEY, self::OLD_KEY], binder: true), + ], 'alice'); + + for ($i = 0; $i < 2; ++$i) { + $this->assertSame('alice moved 10 to bob, approved by client', self::text($client->callTool('transfer', ['to' => 'bob', 'amount' => 10]))); + } + + $client->disconnect(); + } + + #[TestDox('a state minted under the new key stays bound on an instance that verifies it with the same ring')] + public function testStateOfTheNewKeyStaysBound(): void + { + $url = $this->startServer([self::NEW_KEY, self::OLD_KEY], binder: true); + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($url, 'alice', $call); + + $this->assertRefused($this->finish($this->startServer([self::OLD_KEY, self::NEW_KEY], binder: true), 'mallory', $call, $pending)); + } +} diff --git a/tests/Integration/RequestStateDeploymentTest.php b/tests/Integration/RequestStateDeploymentTest.php new file mode 100644 index 00000000..4f10f380 --- /dev/null +++ b/tests/Integration/RequestStateDeploymentTest.php @@ -0,0 +1,129 @@ +startServer(self::NEW_KEY, ttl: 1); + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($url, 'alice', $call); + + sleep(2); + + $this->assertRefused($this->finish($url, 'alice', $call, $pending)); + } + + #[TestDox('with a binder, a state is still refused past the TTL given to the builder')] + public function testExpiredBoundStateIsRefused(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, ttl: 1); + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($url, 'alice', $call); + + sleep(2); + + $this->assertRefused($this->finish($url, 'alice', $call, $pending)); + } + + #[TestDox('with a key ring and a binder, a state of the older key is still refused past the TTL')] + public function testExpiredStateOfTheOlderKeyIsRefused(): void + { + $call = self::tool('refund', ['order' => 1]); + $old = $this->startServer(self::OLD_KEY, binder: true, ttl: 2); + $rotated = $this->startServer([self::NEW_KEY, self::OLD_KEY], binder: true, ttl: 600); + $pending = $this->pendingState($old, 'alice', $call); + + $this->assertCompleted($this->finish($rotated, 'alice', $call, $pending), 'alice refunded order 1'); + + sleep(3); + + $this->assertRefused($this->finish($rotated, 'alice', $call, $pending)); + } + + #[TestDox('adding a binder refuses the states minted before it')] + public function testAddingABinderRefusesEarlierStates(): void + { + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::NEW_KEY), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer(self::NEW_KEY, binder: true), 'alice', $call, $pending)); + } + + #[TestDox('removing a binder refuses the states minted with it')] + public function testRemovingABinderRefusesEarlierStates(): void + { + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::NEW_KEY, binder: true), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer(self::NEW_KEY), 'alice', $call, $pending)); + } + + #[TestDox('a state of an endpoint without authorization is refused on one with it, sharing the key and the binder')] + public function testAnonymousStateIsRefusedUnderAToken(): void + { + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($this->startServer(self::NEW_KEY, binder: true, anonymous: true), null, $call); + + $this->assertRefused($this->finish($this->startServer(self::NEW_KEY, binder: true), 'alice', $call, $pending)); + } + + #[TestDox('a state minted under a token is refused on an endpoint without authorization, sharing the key and the binder')] + public function testStateUnderATokenIsRefusedAnonymously(): void + { + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($this->startServer(self::NEW_KEY, binder: true), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer(self::NEW_KEY, binder: true, anonymous: true), null, $call, $pending)); + } + + #[TestDox('with a key ring, removing the old key while another instance still signs with it fails the calls crossing over')] + public function testRetiringAKeyTooEarlyFailsAcrossInstances(): void + { + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer([self::OLD_KEY, self::NEW_KEY]), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer([self::NEW_KEY]), 'alice', $call, $pending)); + } + + #[TestDox('a state of the modern era means nothing on a session of the handshake era')] + public function testModernStateIsIgnoredOnAHandshakeSession(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, shim: false); + $call = self::tool('refund', ['order' => 1]); + $pending = $this->pendingState($url, 'alice', $call); + + $answer = $this->sendOnSession($url, 'mallory', $this->handshake($url, 'mallory'), $call, ['requestState' => $pending['state']]); + + $this->assertCompleted($answer, 'Only a manager may refund.'); + } + + #[TestDox('with a binder, a state a handshake session handed out cannot be spent on the modern era')] + public function testHandshakeStateIsRefusedOnTheModernEra(): void + { + $url = $this->startServer(self::NEW_KEY, binder: true, shim: false); + $call = self::tool('refund', ['order' => 1]); + + $state = $this->sendOnSession($url, 'alice', $this->handshake($url, 'alice'), $call); + $this->assertIsString($state['body']['result']['requestState'] ?? null, $state['raw']); + + $this->assertRefused($this->finish($url, 'alice', $call, ['state' => $state['body']['result']['requestState'], 'asks' => ['reason']])); + } +} diff --git a/tests/Integration/RequestStateRotationTest.php b/tests/Integration/RequestStateRotationTest.php new file mode 100644 index 00000000..c25e1c3e --- /dev/null +++ b/tests/Integration/RequestStateRotationTest.php @@ -0,0 +1,111 @@ + 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::OLD_KEY), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer(self::NEW_KEY), 'alice', $call, $pending)); + } + + #[TestDox('with a single key, a rolling deployment fails the calls crossing its instances')] + public function testRollingASingleKeyFailsAcrossInstances(): void + { + $this->assertCallsFailAcross($this->startServer(self::OLD_KEY), $this->startServer(self::NEW_KEY)); + } + + /** + * @return iterable, string|list}> + */ + public static function provideNeighbouringSteps(): iterable + { + yield 'adding the new key behind the current one' => [self::OLD_KEY, [self::OLD_KEY, self::NEW_KEY]]; + yield 'moving the new key to the front' => [[self::OLD_KEY, self::NEW_KEY], [self::NEW_KEY, self::OLD_KEY]]; + yield 'removing the old key' => [[self::NEW_KEY, self::OLD_KEY], [self::NEW_KEY]]; + } + + /** + * @param string|list $before + * @param string|list $after + */ + #[DataProvider('provideNeighbouringSteps')] + #[TestDox('with a key ring, the calls crossing instances complete while $_dataName')] + public function testRollingAKeyRingKeepsCallsWorking(string|array $before, string|array $after): void + { + $client = $this->client([$this->startServer($before), $this->startServer($after)], 'alice'); + + // Two calls of an odd number of requests: the state crosses from one instance to the other in both directions. + for ($i = 0; $i < 2; ++$i) { + $this->assertSame('alice moved 10 to bob, approved by client', self::text($client->callTool('transfer', ['to' => 'bob', 'amount' => 10]))); + } + + $client->disconnect(); + } + + #[TestDox('with a key ring, a call in flight survives the rotation')] + public function testCallInFlightSurvivesTheRotation(): void + { + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::OLD_KEY), 'alice', $call); + + $this->assertCompleted( + $this->finish($this->startServer([self::NEW_KEY, self::OLD_KEY]), 'alice', $call, $pending), + 'alice moved 10 to bob, approved by alice', + ); + } + + #[TestDox('with a key ring, skipping the first step fails the calls crossing to an instance without the new key')] + public function testSkippingTheFirstStepFailsAcrossInstances(): void + { + $this->assertCallsFailAcross($this->startServer(self::OLD_KEY), $this->startServer([self::NEW_KEY, self::OLD_KEY])); + } + + #[TestDox('with a key ring, a state of a retired key is refused')] + public function testRetiredKeyIsRefused(): void + { + $call = self::tool('transfer', ['to' => 'bob', 'amount' => 10]); + $pending = $this->pendingState($this->startServer(self::OLD_KEY), 'alice', $call); + + $this->assertRefused($this->finish($this->startServer([self::NEW_KEY]), 'alice', $call, $pending)); + } + + private function assertCallsFailAcross(string $first, string $second): void + { + $client = $this->client([$first, $second], 'alice'); + $failure = null; + + // One of the two directions works, so only both calls together prove the failure. + for ($i = 0; $i < 2; ++$i) { + try { + $client->callTool('transfer', ['to' => 'bob', 'amount' => 10]); + } catch (\Throwable $e) { + $failure = $e; + } + } + + $client->disconnect(); + + $this->assertInstanceOf(\Throwable::class, $failure); + $this->assertStringContainsString('failed verification', $failure->getMessage()); + } +} diff --git a/tests/Integration/RequestStateTestCase.php b/tests/Integration/RequestStateTestCase.php new file mode 100644 index 00000000..d4eab4ac --- /dev/null +++ b/tests/Integration/RequestStateTestCase.php @@ -0,0 +1,405 @@ +, name: string} + * @phpstan-type Answer array{status: int, body: array, raw: string, session: ?string} + * @phpstan-type Pending array{state: string, asks: list} + */ +abstract class RequestStateTestCase extends TestCase +{ + protected const OLD_KEY = 'the-key-being-rotated-out-of-the-ring'; + protected const NEW_KEY = 'the-key-being-rotated-into-the-ring-now'; + + private const FIXTURE = __DIR__.'/Fixture/request_state.php'; + private const REFUSAL = 'The supplied requestState failed verification.'; + + /** @var list */ + private array $servers = []; + + private ?string $sessions = null; + + protected function tearDown(): void + { + foreach ($this->servers as $server) { + ServerProcess::stop($server); + } + + $this->servers = []; + + if (null !== $this->sessions) { + array_map('unlink', glob($this->sessions.'/*') ?: []); + @rmdir($this->sessions); + $this->sessions = null; + } + } + + /** + * @param string|list $keys + */ + protected function startServer(string|array $keys, bool $binder = false, bool $anonymous = false, int $ttl = 600, string $subjectClaim = '', bool $shim = true): string + { + $port = ServerProcess::findFreePort(); + + // Every request of `php -S` is a process of its own, so a session has to outlive it. + $this->sessions ??= sys_get_temp_dir().'/mcp-request-state-'.bin2hex(random_bytes(8)); + + $server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $port), self::FIXTURE], null, [ + ...self::environment($keys, $binder, $anonymous, $ttl, $subjectClaim, $shim), + 'MCP_REQUEST_STATE_SESSIONS' => $this->sessions, + ]); + $server->start(); + $this->servers[] = $server; + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $port, $errno, $error, 0.1)) { + return \sprintf('http://127.0.0.1:%d/', $port); + } + + usleep(50_000); + } + + $this->fail(\sprintf('The server did not start: %s', $server->getErrorOutput())); + } + + /** + * @param string|list $keys + * + * @return array + */ + protected static function environment(string|array $keys, bool $binder = false, bool $anonymous = false, int $ttl = 600, string $subjectClaim = '', bool $shim = true): array + { + return [ + 'MCP_REQUEST_STATE_KEYS' => json_encode($keys, \JSON_THROW_ON_ERROR), + 'MCP_REQUEST_STATE_BINDER' => $binder ? '1' : '', + 'MCP_REQUEST_STATE_ANONYMOUS' => $anonymous ? '1' : '', + 'MCP_REQUEST_STATE_TTL' => (string) $ttl, + 'MCP_REQUEST_STATE_SUBJECT_CLAIM' => $subjectClaim, + 'MCP_REQUEST_STATE_NO_SHIM' => $shim ? '' : '1', + ]; + } + + /** + * @param array $arguments + * + * @return Call + */ + protected static function tool(string $name, array $arguments = []): array + { + return ['method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => (object) $arguments], 'name' => $name]; + } + + /** + * @return Call + */ + protected static function resource(string $uri): array + { + return ['method' => 'resources/read', 'params' => ['uri' => $uri], 'name' => $uri]; + } + + /** + * @param array $arguments + * + * @return Call + */ + protected static function prompt(string $name, array $arguments): array + { + return ['method' => 'prompts/get', 'params' => ['name' => $name, 'arguments' => (object) $arguments], 'name' => $name]; + } + + /** + * @param Call $call + * @param array $input `inputResponses` and `requestState` + * @param array $meta + * + * @return Answer + */ + protected function send(string $url, ?string $token, array $call, array $input = [], array $meta = [], int $id = 1): array + { + $version = ProtocolVersion::V2026_07_28->value; + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + 'MCP-Protocol-Version: '.$version, + 'Mcp-Method: '.$call['method'], + 'Mcp-Name: '.$call['name'], + ]; + + if (null !== $token) { + $headers[] = 'Authorization: Bearer '.$token; + } + + return $this->post($url, $headers, [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'method' => $call['method'], + 'params' => [ + ...$call['params'], + ...$input, + '_meta' => [ + 'io.modelcontextprotocol/protocolVersion' => $version, + 'io.modelcontextprotocol/clientCapabilities' => ['elicitation' => new \stdClass()], + 'io.modelcontextprotocol/clientInfo' => ['name' => 'request-state-test', 'version' => '1.0.0'], + ...$meta, + ], + ], + ]); + } + + /** + * Opens a session of the handshake era, as the same token on the same endpoint. + */ + protected function handshake(string $url, string $token): string + { + $session = $this->post($url, self::handshakeHeaders($token), [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => ['elicitation' => new \stdClass()], + 'clientInfo' => ['name' => 'request-state-test', 'version' => '1.0.0'], + ], + ])['session']; + + $this->assertNotNull($session, 'The server opened no session.'); + + return $session; + } + + /** + * @param Call $call + * @param array $input `requestState` + * + * @return Answer + */ + protected function sendOnSession(string $url, string $token, string $session, array $call, array $input = []): array + { + return $this->post($url, self::handshakeHeaders($token, $session), [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => $call['method'], + 'params' => [...$call['params'], ...$input], + ]); + } + + /** + * @return list + */ + private static function handshakeHeaders(string $token, ?string $session = null): array + { + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + 'Authorization: Bearer '.$token, + ]; + + if (null !== $session) { + $headers[] = 'Mcp-Session-Id: '.$session; + $headers[] = 'MCP-Protocol-Version: '.ProtocolVersion::V2025_11_25->value; + } + + return $headers; + } + + /** + * @param list $headers + * @param array $message + * + * @return Answer + */ + private function post(string $url, array $headers, array $message): array + { + $stream = fopen($url, 'r', false, stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => json_encode($message, \JSON_THROW_ON_ERROR, 2048), + 'ignore_errors' => true, + 'timeout' => 10, + ]])); + + $this->assertIsResource($stream, 'No response from the server.'); + + /** @var list $responseHeaders */ + $responseHeaders = stream_get_meta_data($stream)['wrapper_data'] ?? []; + $raw = (string) stream_get_contents($stream); + fclose($stream); + + // The answer closes a stream, behind whatever the handler reported on the way. + $frames = []; + $json = preg_match_all('/^data: (.+)$/m', $raw, $frames) > 0 ? (string) end($frames[1]) : $raw; + + $body = json_decode($json, true, 2048); + $this->assertIsArray($body, \sprintf('The server did not answer with JSON: %s', $raw)); + + $session = null; + + foreach ($responseHeaders as $header) { + if (0 === stripos($header, 'mcp-session-id:')) { + $session = trim(substr($header, 15)); + } + } + + return ['status' => (int) (explode(' ', $responseHeaders[0] ?? '')[1] ?? 0), 'body' => $body, 'raw' => $raw, 'session' => $session]; + } + + /** + * Plays `$token`'s rounds up to the one that hands out a state, and stops short of answering it. + * + * @param Call $call + * @param array $meta + * + * @return Pending + */ + protected function pendingState(string $url, ?string $token, array $call, array $meta = []): array + { + $input = []; + + for ($round = 0; $round < 5; ++$round) { + $answer = $this->send($url, $token, $call, $input, $meta); + $result = $answer['body']['result'] ?? []; + $this->assertSame('input_required', $result['resultType'] ?? null, $answer['raw']); + + $asks = array_map('strval', array_keys($result['inputRequests'] ?? [])); + + if (isset($result['requestState'])) { + $this->assertIsString($result['requestState']); + + return ['state' => $result['requestState'], 'asks' => $asks]; + } + + $input = ['inputResponses' => self::answers($asks, (string) $token)]; + } + + $this->fail('The server never handed out a requestState.'); + } + + /** + * @param Pending $pending + * @param Call $call + * @param array $meta + * + * @return Answer + */ + protected function finish(string $url, ?string $token, array $call, array $pending, array $meta = []): array + { + return $this->send($url, $token, $call, [ + 'inputResponses' => self::answers($pending['asks'], (string) $token), + 'requestState' => $pending['state'], + ], $meta); + } + + /** + * @param Answer $answer + */ + protected function assertRefused(array $answer): void + { + $this->assertSame(400, $answer['status'], $answer['raw']); + $this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code'] ?? null, $answer['raw']); + $this->assertSame(self::REFUSAL, $answer['body']['error']['message'] ?? null, $answer['raw']); + } + + /** + * @param Answer $answer + */ + protected function assertCompleted(array $answer, string $expected): void + { + $this->assertSame(200, $answer['status'], $answer['raw']); + + $result = $answer['body']['result'] ?? []; + $text = $result['content'][0]['text'] ?? $result['contents'][0]['text'] ?? $result['messages'][0]['content']['text'] ?? null; + + $this->assertSame($expected, $text, $answer['raw']); + } + + /** + * Our own client answering every ask, sending each request to the next of `$endpoints`. + * + * @param non-empty-list $endpoints + */ + protected function client(array $endpoints, ?string $token = null): Client + { + $client = Client::builder() + ->setClientInfo('request-state-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->setRequestTimeout(10) + ->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['v' => 'client'])); + } + }) + ->build(); + + $client->connect(new HttpTransport( + $endpoints[0], + null === $token ? [] : ['Authorization' => 'Bearer '.$token], + new RoundRobinHttpClient($endpoints), + )); + + return $client; + } + + protected static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + /** + * @param list $asks + * + * @return array + */ + private static function answers(array $asks, string $from): array + { + $answers = []; + + foreach ($asks as $ask) { + $answers[$ask] = ['action' => 'accept', 'content' => ['v' => $from]]; + } + + return $answers; + } +} diff --git a/tests/Interop/Server/Http/TypeScriptClientRequestStateTest.php b/tests/Interop/Server/Http/TypeScriptClientRequestStateTest.php new file mode 100644 index 00000000..61d8a028 --- /dev/null +++ b/tests/Interop/Server/Http/TypeScriptClientRequestStateTest.php @@ -0,0 +1,125 @@ + 'tools/call', 'params' => ['name' => 'transfer', 'arguments' => ['to' => 'bob', 'amount' => 10]]]; + private const COMPLETED = 'alice moved 10 to bob, approved by typescript'; + + #[TestDox('completes the calls that take several rounds')] + public function testCompletes(): void + { + $outcome = $this->complete([$this->startServer(self::NEW_KEY)]); + + $this->assertSame([self::COMPLETED, self::COMPLETED], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + } + + #[TestDox('with a single key, a rolling deployment fails the calls crossing its instances')] + public function testRollingASingleKeyFails(): void + { + $outcome = $this->complete([$this->startServer(self::OLD_KEY), $this->startServer(self::NEW_KEY)]); + + $this->assertStringContainsString('failed verification', (string) $outcome['error']); + } + + #[TestDox('with a binder, completes the calls that take several rounds')] + public function testCompletesThroughTheBinder(): void + { + $outcome = $this->complete([$this->startServer(self::NEW_KEY, binder: true)]); + + $this->assertSame([self::COMPLETED, self::COMPLETED], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + } + + #[TestDox('with a key ring, completes the calls crossing the instances of a rolling rotation')] + public function testCompletesAcrossARollingRotation(): void + { + $outcome = $this->complete([ + $this->startServer([self::OLD_KEY, self::NEW_KEY]), + $this->startServer([self::NEW_KEY, self::OLD_KEY]), + ]); + + $this->assertSame([self::COMPLETED, self::COMPLETED], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + } + + #[TestDox('with a binder and a key ring, completes the calls crossing the instances of a rolling rotation')] + public function testCompletesThroughTheBinderAcrossARollingRotation(): void + { + $outcome = $this->complete([ + $this->startServer([self::OLD_KEY, self::NEW_KEY], binder: true), + $this->startServer([self::NEW_KEY, self::OLD_KEY], binder: true), + ]); + + $this->assertSame([self::COMPLETED, self::COMPLETED], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + } + + #[TestDox('with a binder, completes prompts and resource reads that take several rounds')] + public function testCompletesPromptsAndResourcesThroughTheBinder(): void + { + $outcome = $this->complete([$this->startServer(self::NEW_KEY, binder: true)], [ + ['method' => 'prompts/get', 'params' => ['name' => 'brief', 'arguments' => ['topic' => 'roadmap']]], + ['method' => 'resources/read', 'params' => ['uri' => 'doc://public']], + ]); + + $this->assertSame([ + 'brief on roadmap, approved by typescript', + 'contents of public, approved by typescript', + ], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + } + + #[TestDox('with a binder, completes a call answering as a stream, with a progress token of its own per round')] + public function testCompletesAStreamedCallThroughTheBinder(): void + { + $outcome = $this->complete([$this->startServer(self::NEW_KEY, binder: true)], [ + ['method' => 'tools/call', 'params' => ['name' => 'report', 'arguments' => ['amount' => 10]]], + ]); + + $this->assertSame(['reported 10, approved by typescript'], $outcome['results'], (string) $outcome['error']); + $this->assertGreaterThan(0, $outcome['echoedStates']); + $this->assertGreaterThan(0, $outcome['progressed']); + } + + /** + * @param non-empty-list $endpoints + * @param list}> $calls + * + * @return array{results: list, error: ?string, echoedStates: int, progressed: int} + */ + private function complete(array $endpoints, array $calls = [self::TRANSFER, self::TRANSFER]): array + { + $client = new Process(['node', __DIR__.'/typescript-client.mjs', json_encode([ + 'endpoints' => $endpoints, + 'token' => 'alice', + 'calls' => $calls, + ], \JSON_THROW_ON_ERROR)], timeout: 30); + $client->run(); + + /** @var array{results: list, error: ?string, echoedStates: int, progressed: int} $outcome */ + $outcome = json_decode($client->getOutput(), true); + $this->assertIsArray($outcome, $client->getOutput().$client->getErrorOutput()); + + return $outcome; + } +} diff --git a/tests/Interop/Server/Http/typescript-client.mjs b/tests/Interop/Server/Http/typescript-client.mjs new file mode 100644 index 00000000..ff544a25 --- /dev/null +++ b/tests/Interop/Server/Http/typescript-client.mjs @@ -0,0 +1,61 @@ +// The TypeScript SDK's client completing calls, each request going to the next of the given endpoints. +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { endpoints, token, calls } = JSON.parse(process.argv[2]); + +let sent = 0; +let echoedStates = 0; +let progressed = 0; + +const roundRobin = async (url, init) => { + if (typeof init?.body === 'string' && undefined !== JSON.parse(init.body).params?.requestState) { + ++echoedStates; + } + + const target = new URL(url); + target.host = new URL(endpoints[sent++ % endpoints.length]).host; + + return fetch(target, init); +}; + +const client = new Client( + { name: 'request-state-interop', version: '1.0.0' }, + { capabilities: { elicitation: { form: {} } }, versionNegotiation: { mode: { pin: '2026-07-28' } } }, +); +client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { v: 'typescript' } })); + +const outcome = { results: [], error: null }; + +try { + await client.connect( + new StreamableHTTPClientTransport(new URL(endpoints[0]), { + fetch: roundRobin, + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }), + ); + + const invoke = { + 'tools/call': (params) => client.callTool(params, { onprogress: () => ++progressed }), + 'prompts/get': (params) => client.getPrompt(params), + 'resources/read': (params) => client.readResource(params), + }; + + for (const { method, params } of calls) { + try { + const result = await invoke[method](params); + outcome.results.push(result.content?.[0].text ?? result.messages?.[0].content.text ?? result.contents?.[0].text); + } catch (error) { + outcome.error = String(error.message ?? error); + } + } + + outcome.protocolVersion = client.getNegotiatedProtocolVersion?.(); +} catch (error) { + outcome.error = `connect: ${error.message ?? error}`; +} + +outcome.echoedStates = echoedStates; +outcome.progressed = progressed; +process.stdout.write(JSON.stringify(outcome)); +await client.close().catch(() => {}); +process.exit(0); diff --git a/tests/Interop/package-lock.json b/tests/Interop/package-lock.json index 1c597fa0..ef97bacc 100644 --- a/tests/Interop/package-lock.json +++ b/tests/Interop/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "devDependencies": { + "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/inspector": "2.11.0", "@modelcontextprotocol/server-everything": "2026.8.31" } diff --git a/tests/Interop/package.json b/tests/Interop/package.json index aa6dcde6..fa58286e 100644 --- a/tests/Interop/package.json +++ b/tests/Interop/package.json @@ -2,6 +2,7 @@ "private": true, "description": "Third-party MCP implementations the interop suite runs against, locked so their dependencies cannot change under the snapshots.", "devDependencies": { + "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/inspector": "2.11.0", "@modelcontextprotocol/server-everything": "2026.8.31" } diff --git a/tests/Support/RoundRobinHttpClient.php b/tests/Support/RoundRobinHttpClient.php new file mode 100644 index 00000000..92baec76 --- /dev/null +++ b/tests/Support/RoundRobinHttpClient.php @@ -0,0 +1,42 @@ + $endpoints + */ + public function __construct(private readonly array $endpoints) + { + $this->client = Psr18ClientDiscovery::find(); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $endpoint = parse_url($this->endpoints[$this->sent++ % \count($this->endpoints)]); + $uri = $request->getUri()->withHost((string) ($endpoint['host'] ?? ''))->withPort($endpoint['port'] ?? null); + + return $this->client->sendRequest($request->withUri($uri)); + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index aae8ec82..824a8a12 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -589,7 +589,7 @@ public function testUndeclaredGatewayElicitationIsRefused(): void * * @param string|list|null $key the signing key or key ring, a fixed test key by default */ - private static function twoAskProtocol(bool $signed = true, ?RequestStateBinderInterface $binder = null, string|array|null $key = null): StatelessProtocol + private static function twoAskProtocol(bool $signed = true, ?RequestStateBinderInterface $binder = null, string|array|null $key = null, ?EventDispatcherInterface $eventDispatcher = null): StatelessProtocol { $builder = Server::builder() ->setServerInfo('test-server', '1.0.0') @@ -616,6 +616,10 @@ static function (RequestContext $context): string { $builder->setRequestState($key ?? str_repeat('k', 32), binder: $binder); } + if (null !== $eventDispatcher) { + $builder->setEventDispatcher($eventDispatcher); + } + return $builder->buildStateless([ProtocolVersion::V2026_07_28]); } @@ -783,6 +787,28 @@ public function bind(Request $request, ?AccessToken $accessToken): string $this->assertSame([['books_flight', ['to' => 'BER'], 'alice']], $binder->calls); } + #[TestDox('a state binds to the request as sent, whatever a RequestEvent listener turns it into')] + public function testBindingIgnoresARequestEventListener(): void + { + $events = []; + $listener = static function (object $event): void { + if ($event instanceof RequestEvent && $event->getRequest() instanceof CallToolRequest) { + $request = $event->getRequest(); + $event->setRequest((new CallToolRequest('books_flight', ['to' => 'rewritten']))->withId($request->getId())->withMeta($request->getMeta())); + } + }; + + $protocol = self::twoAskProtocol(binder: new OriginatingRequestBinder(), eventDispatcher: $this->eventDispatcher($events, $listener)); + $state = $this->mintedState($protocol, self::alice(), ['to' => 'BER']); + $input = [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $state, + ]; + + $this->assertSame(400, self::bookAs($protocol, self::alice(), $input, ['to' => 'CDG'])['status']); + $this->assertSame('morning/aisle', self::bookAs($protocol, self::alice(), $input, ['to' => 'BER'])['body']['result']['content'][0]['text']); + } + #[TestDox('a binder that throws fails the request with an internal error instead of escaping to the transport')] public function testThrowingBinderFailsTheRequest(): void {