diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..433c1fd --- /dev/null +++ b/src/Context.php @@ -0,0 +1,15 @@ +schemaValidator ??= new SchemaValidator($this->logger); } - public function handleRequest(Request $request, SessionInterface $session): Result + public function handleRequest(Request $request, Context $context): Result { switch ($request->method) { case 'initialize': $request = InitializeRequest::fromRequest($request); - return $this->handleInitialize($request, $session); + return $this->handleInitialize($request, $context->session); case 'ping': $request = PingRequest::fromRequest($request); return $this->handlePing($request); @@ -75,7 +75,7 @@ public function handleRequest(Request $request, SessionInterface $session): Resu return $this->handleToolList($request); case 'tools/call': $request = CallToolRequest::fromRequest($request); - return $this->handleToolCall($request); + return $this->handleToolCall($request, $context); case 'resources/list': $request = ListResourcesRequest::fromRequest($request); return $this->handleResourcesList($request); @@ -84,25 +84,25 @@ public function handleRequest(Request $request, SessionInterface $session): Resu return $this->handleResourceTemplateList($request); case 'resources/read': $request = ReadResourceRequest::fromRequest($request); - return $this->handleResourceRead($request); + return $this->handleResourceRead($request, $context); case 'resources/subscribe': $request = ResourceSubscribeRequest::fromRequest($request); - return $this->handleResourceSubscribe($request, $session); + return $this->handleResourceSubscribe($request, $context->session); case 'resources/unsubscribe': $request = ResourceUnsubscribeRequest::fromRequest($request); - return $this->handleResourceUnsubscribe($request, $session); + return $this->handleResourceUnsubscribe($request, $context->session); case 'prompts/list': $request = ListPromptsRequest::fromRequest($request); return $this->handlePromptsList($request); case 'prompts/get': $request = GetPromptRequest::fromRequest($request); - return $this->handlePromptGet($request); + return $this->handlePromptGet($request, $context); case 'logging/setLevel': $request = SetLogLevelRequest::fromRequest($request); - return $this->handleLoggingSetLevel($request, $session); + return $this->handleLoggingSetLevel($request, $context->session); case 'completion/complete': $request = CompletionCompleteRequest::fromRequest($request); - return $this->handleCompletionComplete($request, $session); + return $this->handleCompletionComplete($request, $context->session); default: throw McpServerException::methodNotFound("Method '{$request->method}' not found."); } @@ -151,7 +151,7 @@ public function handleToolList(ListToolsRequest $request): ListToolsResult return new ListToolsResult(array_values($pagedItems), $nextCursor); } - public function handleToolCall(CallToolRequest $request): CallToolResult + public function handleToolCall(CallToolRequest $request, Context $context): CallToolResult { $toolName = $request->name; $arguments = $request->arguments; @@ -184,7 +184,7 @@ public function handleToolCall(CallToolRequest $request): CallToolResult } try { - $result = $registeredTool->call($this->container, $arguments); + $result = $registeredTool->call($this->container, $arguments, $context); return new CallToolResult($result, false); } catch (JsonException $e) { @@ -222,7 +222,7 @@ public function handleResourceTemplateList(ListResourceTemplatesRequest $request return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); } - public function handleResourceRead(ReadResourceRequest $request): ReadResourceResult + public function handleResourceRead(ReadResourceRequest $request, Context $context): ReadResourceResult { $uri = $request->uri; @@ -233,7 +233,7 @@ public function handleResourceRead(ReadResourceRequest $request): ReadResourceRe } try { - $result = $registeredResource->read($this->container, $uri); + $result = $registeredResource->read($this->container, $uri, $context); return new ReadResourceResult($result); } catch (JsonException $e) { @@ -270,7 +270,7 @@ public function handlePromptsList(ListPromptsRequest $request): ListPromptsResul return new ListPromptsResult(array_values($pagedItems), $nextCursor); } - public function handlePromptGet(GetPromptRequest $request): GetPromptResult + public function handlePromptGet(GetPromptRequest $request, Context $context): GetPromptResult { $promptName = $request->name; $arguments = $request->arguments; @@ -289,7 +289,7 @@ public function handlePromptGet(GetPromptRequest $request): GetPromptResult } try { - $result = $registeredPrompt->get($this->container, $arguments); + $result = $registeredPrompt->get($this->container, $arguments, $context); return new GetPromptResult($result, $registeredPrompt->schema->description); } catch (JsonException $e) { diff --git a/src/Elements/RegisteredElement.php b/src/Elements/RegisteredElement.php index a409b49..73ac0c6 100644 --- a/src/Elements/RegisteredElement.php +++ b/src/Elements/RegisteredElement.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use JsonSerializable; +use PhpMcp\Server\Context; use PhpMcp\Server\Exception\McpServerException; use Psr\Container\ContainerInterface; use ReflectionException; @@ -30,33 +31,33 @@ public function __construct( $this->isManual = $isManual; } - public function handle(ContainerInterface $container, array $arguments): mixed + public function handle(ContainerInterface $container, array $arguments, Context $context): mixed { if (is_string($this->handler)) { if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) { $reflection = new \ReflectionMethod($this->handler, '__invoke'); - $arguments = $this->prepareArguments($reflection, $arguments); + $arguments = $this->prepareArguments($reflection, $arguments, $context); $instance = $container->get($this->handler); return call_user_func($instance, ...$arguments); } if (function_exists($this->handler)) { $reflection = new \ReflectionFunction($this->handler); - $arguments = $this->prepareArguments($reflection, $arguments); + $arguments = $this->prepareArguments($reflection, $arguments, $context); return call_user_func($this->handler, ...$arguments); } } if (is_callable($this->handler)) { $reflection = $this->getReflectionForCallable($this->handler); - $arguments = $this->prepareArguments($reflection, $arguments); + $arguments = $this->prepareArguments($reflection, $arguments, $context); return call_user_func($this->handler, ...$arguments); } if (is_array($this->handler)) { [$className, $methodName] = $this->handler; $reflection = new \ReflectionMethod($className, $methodName); - $arguments = $this->prepareArguments($reflection, $arguments); + $arguments = $this->prepareArguments($reflection, $arguments, $context); $instance = $container->get($className); return call_user_func([$instance, $methodName], ...$arguments); @@ -66,15 +67,22 @@ public function handle(ContainerInterface $container, array $arguments): mixed } - protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array + protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments, Context $context): array { $finalArgs = []; foreach ($reflection->getParameters() as $parameter) { // TODO: Handle variadic parameters. $paramName = $parameter->getName(); + $paramType = $parameter->getType(); $paramPosition = $parameter->getPosition(); + if ($paramType instanceof ReflectionNamedType && $paramType->getName() === Context::class) { + $finalArgs[$paramPosition] = $context; + + continue; + } + if (isset($arguments[$paramName])) { $argument = $arguments[$paramName]; try { diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php index 3ebcc0d..828d0a4 100644 --- a/src/Elements/RegisteredPrompt.php +++ b/src/Elements/RegisteredPrompt.php @@ -15,6 +15,7 @@ use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Enum\Role; use PhpMcp\Schema\Result\CompletionCompleteResult; +use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; @@ -43,9 +44,9 @@ public static function make(Prompt $schema, callable|array|string $handler, bool * @param array $arguments * @return PromptMessage[] */ - public function get(ContainerInterface $container, array $arguments): array + public function get(ContainerInterface $container, array $arguments, Context $context): array { - $result = $this->handle($container, $arguments); + $result = $this->handle($container, $arguments, $context); return $this->formatResult($result); } diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php index 489aa24..0a5e9fb 100644 --- a/src/Elements/RegisteredResource.php +++ b/src/Elements/RegisteredResource.php @@ -9,6 +9,7 @@ use PhpMcp\Schema\Content\ResourceContents; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Resource; +use PhpMcp\Server\Context; use Psr\Container\ContainerInterface; use Throwable; @@ -32,9 +33,9 @@ public static function make(Resource $schema, callable|array|string $handler, bo * * @return array Array of ResourceContents objects. */ - public function read(ContainerInterface $container, string $uri): array + public function read(ContainerInterface $container, string $uri, Context $context): array { - $result = $this->handle($container, ['uri' => $uri]); + $result = $this->handle($container, ['uri' => $uri], $context); return $this->formatResult($result, $uri, $this->schema->mimeType); } diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php index dc3e6a7..214c6fc 100644 --- a/src/Elements/RegisteredResourceTemplate.php +++ b/src/Elements/RegisteredResourceTemplate.php @@ -10,6 +10,7 @@ use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Result\CompletionCompleteResult; +use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; use Throwable; @@ -41,11 +42,11 @@ public static function make(ResourceTemplate $schema, callable|array|string $han * * @return array Array of ResourceContents objects. */ - public function read(ContainerInterface $container, string $uri): array + public function read(ContainerInterface $container, string $uri, Context $context): array { $arguments = array_merge($this->uriVariables, ['uri' => $uri]); - $result = $this->handle($container, $arguments); + $result = $this->handle($container, $arguments, $context); return $this->formatResult($result, $uri, $this->schema->mimeType); } diff --git a/src/Elements/RegisteredTool.php b/src/Elements/RegisteredTool.php index 9e35a0e..e848d1c 100644 --- a/src/Elements/RegisteredTool.php +++ b/src/Elements/RegisteredTool.php @@ -6,6 +6,7 @@ use PhpMcp\Schema\Content\Content; use PhpMcp\Schema\Content\TextContent; +use PhpMcp\Server\Context; use Psr\Container\ContainerInterface; use PhpMcp\Schema\Tool; use Throwable; @@ -30,9 +31,9 @@ public static function make(Tool $schema, callable|array|string $handler, bool $ * * @return Content[] The content items for CallToolResult. */ - public function call(ContainerInterface $container, array $arguments): array + public function call(ContainerInterface $container, array $arguments, Context $context): array { - $result = $this->handle($container, $arguments); + $result = $this->handle($container, $arguments, $context); return $this->formatResult($result); } diff --git a/src/Protocol.php b/src/Protocol.php index 4ca20f2..918885e 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -113,7 +113,7 @@ public function unbindTransport(): void * * Processes via Processor, sends Response/Error. */ - public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $context = []): void + public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $messageContext = []): void { $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]); @@ -121,31 +121,36 @@ public function processMessage(Request|Notification|BatchRequest $message, strin if ($session === null) { $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id); - $context['status_code'] = 404; + $messageContext['status_code'] = 404; - $this->transport->sendMessage($error, $sessionId, $context) - ->then(function () use ($sessionId, $error, $context) { - $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $context]); + $this->transport->sendMessage($error, $sessionId, $messageContext) + ->then(function () use ($sessionId, $error, $messageContext) { + $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $messageContext]); }) - ->catch(function (Throwable $e) use ($sessionId, $error, $context) { + ->catch(function (Throwable $e) use ($sessionId) { $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); }); return; } - if ($context['stateless'] ?? false) { + if ($messageContext['stateless'] ?? false) { $session->set('initialized', true); $session->set('protocol_version', self::LATEST_PROTOCOL_VERSION); $session->set('client_info', ['name' => 'stateless-client', 'version' => '1.0.0']); } + $context = new Context( + $session, + $messageContext['request'] ?? null, + ); + $response = null; if ($message instanceof BatchRequest) { - $response = $this->processBatchRequest($message, $session); + $response = $this->processBatchRequest($message, $session, $context); } elseif ($message instanceof Request) { - $response = $this->processRequest($message, $session); + $response = $this->processRequest($message, $session, $context); } elseif ($message instanceof Notification) { $this->processNotification($message, $session); } @@ -156,7 +161,7 @@ public function processMessage(Request|Notification|BatchRequest $message, strin return; } - $this->transport->sendMessage($response, $sessionId, $context) + $this->transport->sendMessage($response, $sessionId, $messageContext) ->then(function () use ($sessionId, $response) { $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $response]); }) @@ -168,7 +173,7 @@ public function processMessage(Request|Notification|BatchRequest $message, strin /** * Process a batch message */ - private function processBatchRequest(BatchRequest $batch, SessionInterface $session): ?BatchResponse + private function processBatchRequest(BatchRequest $batch, SessionInterface $session, Context $context): ?BatchResponse { $items = []; @@ -177,7 +182,7 @@ private function processBatchRequest(BatchRequest $batch, SessionInterface $sess } foreach ($batch->getRequests() as $request) { - $items[] = $this->processRequest($request, $session); + $items[] = $this->processRequest($request, $session, $context); } return empty($items) ? null : new BatchResponse($items); @@ -186,7 +191,7 @@ private function processBatchRequest(BatchRequest $batch, SessionInterface $sess /** * Process a request message */ - private function processRequest(Request $request, SessionInterface $session): Response|Error + private function processRequest(Request $request, SessionInterface $session, Context $context): Response|Error { try { if ($request->method !== 'initialize') { @@ -195,7 +200,7 @@ private function processRequest(Request $request, SessionInterface $session): Re $this->assertRequestCapability($request->method); - $result = $this->dispatcher->handleRequest($request, $session); + $result = $this->dispatcher->handleRequest($request, $context); return Response::make($request->id, $result); } catch (McpServerException $e) { diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index ae29267..a836e71 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -367,6 +367,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte } $context['stateless'] = $this->stateless; + $context['request'] = $request; $this->loop->futureTick(function () use ($message, $sessionId, $context) { $this->emit('message', [$message, $sessionId, $context]); diff --git a/src/Utils/SchemaGenerator.php b/src/Utils/SchemaGenerator.php index 16e4a2c..c896d9d 100644 --- a/src/Utils/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -4,6 +4,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Param; use PhpMcp\Server\Attributes\Schema; +use PhpMcp\Server\Context; use ReflectionEnum; use ReflectionIntersectionType; use ReflectionMethod; @@ -417,6 +418,11 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl $paramTag = $paramTags['$' . $paramName] ?? null; $reflectionType = $rp->getType(); + + if ($reflectionType instanceof ReflectionNamedType && $reflectionType?->getName() === Context::class) { + continue; + } + $typeString = $this->getParameterTypeString($rp, $paramTag); $description = $this->docBlockParser->getParamDescription($paramTag); $hasDefault = $rp->isDefaultValueAvailable(); diff --git a/tests/Fixtures/General/ToolHandlerFixture.php b/tests/Fixtures/General/ToolHandlerFixture.php index af3a9cc..c3dcee2 100644 --- a/tests/Fixtures/General/ToolHandlerFixture.php +++ b/tests/Fixtures/General/ToolHandlerFixture.php @@ -5,6 +5,7 @@ use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\ImageContent; use PhpMcp\Schema\Content\AudioContent; +use PhpMcp\Server\Context; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use Psr\Log\LoggerInterface; @@ -132,4 +133,13 @@ public function toolUnencodableResult() { return fopen('php://memory', 'r'); } + + public function toolReadsContext(Context $context): string + { + if (!$context->request) { + return "No request instance present"; + } + + return $context->request->getHeaderLine('X-Test-Header') ?: "No X-Test-Header"; + } } diff --git a/tests/Fixtures/General/VariousTypesHandler.php b/tests/Fixtures/General/VariousTypesHandler.php index 999dda9..a44d019 100644 --- a/tests/Fixtures/General/VariousTypesHandler.php +++ b/tests/Fixtures/General/VariousTypesHandler.php @@ -2,6 +2,7 @@ namespace PhpMcp\Server\Tests\Fixtures\General; +use PhpMcp\Server\Context; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; @@ -142,4 +143,11 @@ public function comprehensiveArgumentTest( public function methodCausesTypeError(int $mustBeInt): void { } + + public function contextArg(Context $context): array { + return [ + 'session' => $context->session->get('testKey'), + 'request' => $context->request->getHeaderLine('testHeader'), + ]; + } } diff --git a/tests/Fixtures/ServerScripts/StdioTestServer.php b/tests/Fixtures/ServerScripts/StdioTestServer.php index 7651a92..ec3111c 100755 --- a/tests/Fixtures/ServerScripts/StdioTestServer.php +++ b/tests/Fixtures/ServerScripts/StdioTestServer.php @@ -29,6 +29,7 @@ public function log($level, \Stringable|string $message, array $context = []): v ->withServerInfo('StdioIntegrationTestServer', '0.1.0') ->withLogger($logger) ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_stdio_tool') + ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing ->withResource([ResourceHandlerFixture::class, 'getStaticText'], 'test://stdio/static', 'static_stdio_resource') ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_stdio_prompt') ->build(); diff --git a/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php index 07fba5c..b7cdf6a 100755 --- a/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php +++ b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php @@ -40,6 +40,7 @@ public function log($level, \Stringable|string $message, array $context = []): v ->withLogger($logger) ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing + ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') ->build(); diff --git a/tests/Fixtures/Utils/SchemaGeneratorFixture.php b/tests/Fixtures/Utils/SchemaGeneratorFixture.php index 98b43b9..34a5605 100644 --- a/tests/Fixtures/Utils/SchemaGeneratorFixture.php +++ b/tests/Fixtures/Utils/SchemaGeneratorFixture.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Tests\Fixtures\Utils; use PhpMcp\Server\Attributes\Schema; +use PhpMcp\Server\Context; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; @@ -47,6 +48,10 @@ public function typeHintsWithDocBlock(string $email, int $score, bool $verified) { } + public function contextParameter(Context $context): void + { + } + // ===== METHOD-LEVEL SCHEMA SCENARIOS ===== /** diff --git a/tests/Integration/SchemaGenerationTest.php b/tests/Integration/SchemaGenerationTest.php index 1069c01..f40b936 100644 --- a/tests/Integration/SchemaGenerationTest.php +++ b/tests/Integration/SchemaGenerationTest.php @@ -58,6 +58,16 @@ expect($schema['required'])->toEqualCanonicalizing(['email', 'score', 'verified']); }); +it('ignores Context parameter for schema', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'contextParameter'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema)->toEqual([ + 'type' => 'object', + 'properties' => new stdClass() + ]); +}); + it('uses the complete schema definition provided by a method-level #[Schema(definition: ...)] attribute', function () { $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); $schema = $this->schemaGenerator->generate($method); diff --git a/tests/Integration/StdioServerTransportTest.php b/tests/Integration/StdioServerTransportTest.php index 4740ef4..b801fb6 100644 --- a/tests/Integration/StdioServerTransportTest.php +++ b/tests/Integration/StdioServerTransportTest.php @@ -241,6 +241,26 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo $this->process->stdin->end(); })->group('integration', 'stdio_transport'); +it('can passes an empty context', function () { + sendRequestToServer($this->process, 'init-context', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-context', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'tool-context-1', 'tools/call', [ + 'name' => 'tool_reads_context', + 'arguments' => [] + ]); + $toolResponse = await(readResponseFromServer($this->process, 'tool-context-1', $this->loop)); + + expect($toolResponse['id'])->toBe('tool-context-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('No request instance present'); + expect($toolResponse['result']['isError'])->toBeFalse(); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + it('can handle tool list request', function () { sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); @@ -252,8 +272,9 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo expect($toolListResponse['id'])->toBe('tool-list-1'); expect($toolListResponse)->not->toHaveKey('error'); - expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(1); + expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(2); expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool'); + expect($toolListResponse['result']['tools'][1]['name'])->toBe('tool_reads_context'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php index fd0f0c3..f7b7ebb 100644 --- a/tests/Integration/StreamableHttpServerTransportTest.php +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -217,9 +217,29 @@ expect($toolListResult['statusCode'])->toBe(200); expect($toolListResult['body']['id'])->toBe('tool-list-json-1'); expect($toolListResult['body']['result']['tools'])->toBeArray(); - expect(count($toolListResult['body']['result']['tools']))->toBe(2); + expect(count($toolListResult['body']['result']['tools']))->toBe(3); expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + expect($toolListResult['body']['result']['tools'][2]['name'])->toBe('tool_reads_context'); + })->group('integration', 'streamable_http_json'); + + it('passes request in Context', function () { + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-context')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $toolResult = await($this->jsonClient->sendRequest('tools/call', [ + 'name' => 'tool_reads_context', + 'arguments' => [] + ], 'tool-json-context-1', ['X-Test-Header' => 'TestValue'])); + + expect($toolResult['statusCode'])->toBe(200); + expect($toolResult['body']['id'])->toBe('tool-json-context-1'); + expect($toolResult['body'])->not->toHaveKey('error'); + expect($toolResult['body']['result']['content'][0]['text'])->toBe('TestValue'); })->group('integration', 'streamable_http_json'); it('can read a registered resource', function () { @@ -412,6 +432,25 @@ expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); })->group('integration', 'streamable_http_stream'); + it('passes request in Context', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-context')); + expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $toolResult = await($this->streamClient->sendRequest('tools/call', [ + 'name' => 'tool_reads_context', + 'arguments' => [] + ], 'tool-stream-context-1', ['X-Test-Header' => 'TestValue'])); + + expect($toolResult['id'])->toBe('tool-stream-context-1'); + expect($toolResult)->not->toHaveKey('error'); + expect($toolResult['result']['content'][0]['text'])->toBe('TestValue'); + })->group('integration', 'streamable_http_stream'); + it('can handle tool list request', function () { await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-tools')); await($this->streamClient->sendHttpNotification('notifications/initialized')); @@ -421,9 +460,10 @@ expect($toolListResponse['id'])->toBe('tool-list-stream-1'); expect($toolListResponse)->not->toHaveKey('error'); expect($toolListResponse['result']['tools'])->toBeArray(); - expect(count($toolListResponse['result']['tools']))->toBe(2); + expect(count($toolListResponse['result']['tools']))->toBe(3); expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); expect($toolListResponse['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + expect($toolListResponse['result']['tools'][2]['name'])->toBe('tool_reads_context'); })->group('integration', 'streamable_http_stream'); it('can read a registered resource', function () { @@ -604,6 +644,18 @@ expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); })->group('integration', 'streamable_http_stateless'); + it('passes request in Context', function () { + $toolResult = await($this->statelessClient->sendRequest('tools/call', [ + 'name' => 'tool_reads_context', + 'arguments' => [] + ], 'tool-stateless-context-1', ['X-Test-Header' => 'TestValue'])); + + expect($toolResult['statusCode'])->toBe(200); + expect($toolResult['body']['id'])->toBe('tool-stateless-context-1'); + expect($toolResult['body'])->not->toHaveKey('error'); + expect($toolResult['body']['result']['content'][0]['text'])->toBe('TestValue'); + })->group('integration', 'streamable_http_stateless'); + it('can handle tool list request', function () { $toolListResult = await($this->statelessClient->sendRequest('tools/list', [], 'tool-list-stateless-1')); @@ -611,9 +663,10 @@ expect($toolListResult['body']['id'])->toBe('tool-list-stateless-1'); expect($toolListResult['body'])->not->toHaveKey('error'); expect($toolListResult['body']['result']['tools'])->toBeArray(); - expect(count($toolListResult['body']['result']['tools']))->toBe(2); + expect(count($toolListResult['body']['result']['tools']))->toBe(3); expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + expect($toolListResult['body']['result']['tools'][2]['name'])->toBe('tool_reads_context'); })->group('integration', 'streamable_http_stateless'); it('can read a registered resource', function () { diff --git a/tests/Mocks/Clients/MockJsonHttpClient.php b/tests/Mocks/Clients/MockJsonHttpClient.php index 34bfbc2..364c90b 100644 --- a/tests/Mocks/Clients/MockJsonHttpClient.php +++ b/tests/Mocks/Clients/MockJsonHttpClient.php @@ -20,7 +20,7 @@ public function __construct(string $host, int $port, string $mcpPath, int $timeo $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; } - public function sendRequest(string $method, array $params = [], ?string $id = null): PromiseInterface + public function sendRequest(string $method, array $params = [], ?string $id = null, array $additionalHeaders = []): PromiseInterface { $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; if ($id !== null) { @@ -31,6 +31,7 @@ public function sendRequest(string $method, array $params = [], ?string $id = nu if ($this->sessionId && $method !== 'initialize') { $headers['Mcp-Session-Id'] = $this->sessionId; } + $headers += $additionalHeaders; $body = json_encode($payload); diff --git a/tests/Mocks/Clients/MockStreamHttpClient.php b/tests/Mocks/Clients/MockStreamHttpClient.php index 1d0f47d..51a64de 100644 --- a/tests/Mocks/Clients/MockStreamHttpClient.php +++ b/tests/Mocks/Clients/MockStreamHttpClient.php @@ -103,11 +103,14 @@ public function sendInitializeRequest(array $params, string $id = 'init-stream-1 }); } - public function sendRequest(string $method, array $params, string $id): PromiseInterface + public function sendRequest(string $method, array $params, string $id, array $additionalHeaders = []): PromiseInterface { $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id]; $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; - if ($this->sessionId) $headers['Mcp-Session-Id'] = $this->sessionId; + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + $headers += $additionalHeaders; $body = json_encode($payload); diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php index 79e7b15..cef2ffb 100644 --- a/tests/Unit/DispatcherTest.php +++ b/tests/Unit/DispatcherTest.php @@ -5,6 +5,7 @@ use Mockery; use Mockery\MockInterface; use PhpMcp\Schema\ClientCapabilities; +use PhpMcp\Server\Context; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; @@ -71,6 +72,7 @@ $this->session = Mockery::mock(SessionInterface::class); /** @var MockInterface&ContainerInterface $container */ $this->container = Mockery::mock(ContainerInterface::class); + $this->context = new Context($this->session); $configuration = new Configuration( serverInfo: Implementation::make('DispatcherTestServer', '1.0'), @@ -104,7 +106,7 @@ $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn($value) => $value['name'] === 'client' && $value['version'] === '1.0'))->once(); $this->session->shouldReceive('set')->with('protocol_version', Protocol::LATEST_PROTOCOL_VERSION)->once(); - $result = $this->dispatcher->handleRequest($request, $this->session); + $result = $this->dispatcher->handleRequest($request, $this->context); expect($result)->toBeInstanceOf(InitializeResult::class); expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION); expect($result->serverInfo->name)->toBe('DispatcherTestServer'); @@ -112,13 +114,13 @@ it('routes to handlePing for ping request', function () { $request = new JsonRpcRequest('2.0', 'id1', 'ping', []); - $result = $this->dispatcher->handleRequest($request, $this->session); + $result = $this->dispatcher->handleRequest($request, $this->context); expect($result)->toBeInstanceOf(EmptyResult::class); }); it('throws MethodNotFound for unknown request method', function () { $rawRequest = new JsonRpcRequest('2.0', 'id1', 'unknown/method', []); - $this->dispatcher->handleRequest($rawRequest, $this->session); + $this->dispatcher->handleRequest($rawRequest, $this->context); })->throws(McpServerException::class, "Method 'unknown/method' not found."); it('routes to handleNotificationInitialized for initialized notification', function () { @@ -203,10 +205,10 @@ $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn([]); // No validation errors - $registeredToolMock->shouldReceive('call')->with($this->container, $args)->andReturn([TextContent::make("Result: 15")]); + $registeredToolMock->shouldReceive('call')->with($this->container, $args, $this->context)->andReturn([TextContent::make("Result: 15")]); $request = CallToolRequest::make(1, $toolName, $args); - $result = $this->dispatcher->handleToolCall($request); + $result = $this->dispatcher->handleToolCall($request, $this->context); expect($result)->toBeInstanceOf(CallToolResult::class); expect($result->content[0]->text)->toBe("Result: 15"); @@ -216,7 +218,7 @@ it('can handle tool call request and throw exception if tool not found', function () { $this->registry->shouldReceive('getTool')->with('unknown-tool')->andReturn(null); $request = CallToolRequest::make(1, 'unknown-tool', []); - $this->dispatcher->handleToolCall($request); + $this->dispatcher->handleToolCall($request, $this->context); })->throws(McpServerException::class, "Tool 'unknown-tool' not found."); it('can handle tool call request and throw exception if argument validation fails', function () { @@ -231,7 +233,7 @@ $request = CallToolRequest::make(1, $toolName, $args); try { - $this->dispatcher->handleToolCall($request); + $this->dispatcher->handleToolCall($request, $this->context); } catch (McpServerException $e) { expect($e->getMessage())->toContain("Invalid parameters for tool 'strict-tool'"); expect($e->getData()['validation_errors'])->toBeArray(); @@ -248,7 +250,7 @@ $registeredToolMock->shouldReceive('call')->andThrow(new \RuntimeException("Tool crashed!")); $request = CallToolRequest::make(1, $toolName, []); - $result = $this->dispatcher->handleToolCall($request); + $result = $this->dispatcher->handleToolCall($request, $this->context); expect($result->isError)->toBeTrue(); expect($result->content[0]->text)->toBe("Tool execution failed: Tool crashed!"); @@ -265,13 +267,12 @@ $request = CallToolRequest::make(1, $toolName, []); - $result = $this->dispatcher->handleToolCall($request); + $result = $this->dispatcher->handleToolCall($request, $this->context); expect($result->isError)->toBeTrue(); expect($result->content[0]->text)->toBe("Failed to serialize tool result: Unencodable."); }); - it('can handle resources list request and return paginated resources', function () { $resourceSchemas = [ ResourceSchema::make('res://1', 'Resource1'), @@ -335,10 +336,10 @@ $resourceContents = [TextContent::make('File content')]; $this->registry->shouldReceive('getResource')->with($uri)->andReturn($registeredResourceMock); - $registeredResourceMock->shouldReceive('read')->with($this->container, $uri)->andReturn($resourceContents); + $registeredResourceMock->shouldReceive('read')->with($this->container, $uri, $this->context)->andReturn($resourceContents); $request = ReadResourceRequest::make(1, $uri); - $result = $this->dispatcher->handleResourceRead($request); + $result = $this->dispatcher->handleResourceRead($request, $this->context); expect($result)->toBeInstanceOf(ReadResourceResult::class); expect($result->contents)->toEqual($resourceContents); @@ -347,7 +348,7 @@ it('can handle resource read request and throw exception if resource not found', function () { $this->registry->shouldReceive('getResource')->with('unknown://uri')->andReturn(null); $request = ReadResourceRequest::make(1, 'unknown://uri'); - $this->dispatcher->handleResourceRead($request); + $this->dispatcher->handleResourceRead($request, $this->context); })->throws(McpServerException::class, "Resource URI 'unknown://uri' not found."); it('can handle resource subscribe request and call subscription manager', function () { @@ -393,10 +394,10 @@ $promptMessages = [PromptMessage::make(Role::User, TextContent::make("Summary for 2024-07-16"))]; $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); - $registeredPromptMock->shouldReceive('get')->with($this->container, $args)->andReturn($promptMessages); + $registeredPromptMock->shouldReceive('get')->with($this->container, $args, $this->context)->andReturn($promptMessages); $request = GetPromptRequest::make(1, $promptName, $args); - $result = $this->dispatcher->handlePromptGet($request, $this->session); + $result = $this->dispatcher->handlePromptGet($request, $this->context); expect($result)->toBeInstanceOf(GetPromptResult::class); expect($result->messages)->toEqual($promptMessages); @@ -410,7 +411,7 @@ $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); $request = GetPromptRequest::make(1, $promptName, ['other_arg' => 'value']); // 'topic' is missing - $this->dispatcher->handlePromptGet($request, $this->session); + $this->dispatcher->handlePromptGet($request, $this->context); })->throws(McpServerException::class, "Missing required argument 'topic' for prompt 'needs-topic'."); diff --git a/tests/Unit/Elements/RegisteredElementTest.php b/tests/Unit/Elements/RegisteredElementTest.php index b93c7c4..18d1d0a 100644 --- a/tests/Unit/Elements/RegisteredElementTest.php +++ b/tests/Unit/Elements/RegisteredElementTest.php @@ -3,6 +3,8 @@ namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; +use PhpMcp\Server\Context; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredElement; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; @@ -10,6 +12,7 @@ use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; use PhpMcp\Server\Tests\Fixtures\General\VariousTypesHandler; use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestInterface; use stdClass; // --- Test Fixtures for Handler Types --- @@ -39,6 +42,7 @@ function my_global_test_function(bool $flag): string beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler()); + $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('can be constructed as manual or discovered', function () { @@ -53,7 +57,7 @@ function my_global_test_function(bool $flag): string it('prepares arguments in correct order for simple required types', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; - $result = $element->handle($this->container, $args); + $result = $element->handle($this->container, $args, $this->context); $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true]; @@ -63,13 +67,13 @@ function my_global_test_function(bool $flag): string it('uses default values for missing optional arguments', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'optionalArgsWithDefaults']); - $result1 = $element->handle($this->container, ['pString' => 'override']); + $result1 = $element->handle($this->container, ['pString' => 'override'], $this->context); expect($result1['pString'])->toBe('override'); expect($result1['pInt'])->toBe(100); expect($result1['pNullableBool'])->toBeTrue(); expect($result1['pFloat'])->toBe(3.14); - $result2 = $element->handle($this->container, []); + $result2 = $element->handle($this->container, [], $this->context); expect($result2['pString'])->toBe('default_string'); expect($result2['pInt'])->toBe(100); expect($result2['pNullableBool'])->toBeTrue(); @@ -78,7 +82,7 @@ function my_global_test_function(bool $flag): string it('passes null for nullable arguments if not provided', function () { $elementNoDefaults = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); - $result2 = $elementNoDefaults->handle($this->container, []); + $result2 = $elementNoDefaults->handle($this->container, [], $this->context); expect($result2['pString'])->toBeNull(); expect($result2['pInt'])->toBeNull(); expect($result2['pArray'])->toBeNull(); @@ -86,7 +90,7 @@ function my_global_test_function(bool $flag): string it('passes null explicitly for nullable arguments', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); - $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null]); + $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null], $this->context); expect($result['pString'])->toBeNull(); expect($result['pInt'])->toBeNull(); expect($result['pArray'])->toBeNull(); @@ -104,14 +108,14 @@ function my_global_test_function(bool $flag): string $obj ]; foreach ($testValues as $value) { - $result = $element->handle($this->container, ['pMixed' => $value]); + $result = $element->handle($this->container, ['pMixed' => $value], $this->context); expect($result['pMixed'])->toBe($value); } }); it('throws McpServerException for missing required argument', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); - $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123]); + $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123], $this->context); })->throws(McpServerException::class, 'Missing required argument `pBool`'); dataset('valid_type_casts', [ @@ -168,7 +172,7 @@ function my_global_test_function(bool $flag): string ]; $testArgs = array_merge($allArgs, [$paramName => $inputValue]); - $result = $element->handle($this->container, $testArgs); + $result = $element->handle($this->container, $testArgs, $this->context); expect($result[$paramName])->toEqual($expectedValue); })->with('valid_type_casts'); @@ -212,7 +216,7 @@ function my_global_test_function(bool $flag): string $testArgs = array_merge($allArgs, [$paramName => $invalidValue]); try { - $element->handle($this->container, $testArgs); + $element->handle($this->container, $testArgs, $this->context); } catch (McpServerException $e) { expect($e->getMessage())->toMatch($expectedMsgRegex); } @@ -220,43 +224,57 @@ function my_global_test_function(bool $flag): string it('casts to BackedStringEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); - $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1]); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1], $this->context); expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); }); it('throws for invalid BackedStringEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); - $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1]); + $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1], $this->context); })->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); it('casts to BackedIntEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); - $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2]); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2], $this->context); expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); }); it('throws for invalid BackedIntEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); - $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999]); + $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999], $this->context); })->throws(McpServerException::class, "Invalid value '999' for backed enum"); it('casts to UnitEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); - $result = $element->handle($this->container, ['pUnitEnum' => 'Yes']); + $result = $element->handle($this->container, ['pUnitEnum' => 'Yes'], $this->context); expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); }); it('throws for invalid UnitEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); - $element->handle($this->container, ['pUnitEnum' => 'Invalid']); + $element->handle($this->container, ['pUnitEnum' => 'Invalid'], $this->context); })->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); it('throws ReflectionException if handler method does not exist', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'nonExistentMethod']); - $element->handle($this->container, []); + $element->handle($this->container, [], $this->context); })->throws(\ReflectionException::class, "VariousTypesHandler::nonExistentMethod() does not exist"); +it('passes Context object', function() { + $sessionMock = Mockery::mock(SessionInterface::class); + $sessionMock->expects('get')->with('testKey')->andReturn('testValue'); + $requestMock = Mockery::mock(ServerRequestInterface::class); + $requestMock->expects('getHeaderLine')->with('testHeader')->andReturn('testHeaderValue'); + + $context = new Context($sessionMock, $requestMock); + $element = new RegisteredElement([VariousTypesHandler::class, 'contextArg']); + $result = $element->handle($this->container, [], $context); + expect($result)->toBe([ + 'session' => 'testValue', + 'request' => 'testHeaderValue' + ]); +}); describe('Handler Types', function () { it('handles invokable class handler', function () { @@ -265,7 +283,7 @@ function my_global_test_function(bool $flag): string ->andReturn(new MyInvokableTestHandler()); $element = new RegisteredElement(MyInvokableTestHandler::class); - $result = $element->handle($this->container, ['name' => 'World']); + $result = $element->handle($this->container, ['name' => 'World'], $this->context); expect($result)->toBe('Hello, World!'); }); @@ -275,21 +293,21 @@ function my_global_test_function(bool $flag): string return $a . $b; }; $element = new RegisteredElement($closure); - $result = $element->handle($this->container, ['a' => 'foo', 'b' => 'bar']); + $result = $element->handle($this->container, ['a' => 'foo', 'b' => 'bar'], $this->context); expect($result)->toBe('foobar'); }); it('handles static method handler', function () { $handler = [MyStaticMethodTestHandler::class, 'myStaticMethod']; $element = new RegisteredElement($handler); - $result = $element->handle($this->container, ['a' => 5, 'b' => 10]); + $result = $element->handle($this->container, ['a' => 5, 'b' => 10], $this->context); expect($result)->toBe(15); }); it('handles global function name handler', function () { $handler = 'PhpMcp\Server\Tests\Unit\Elements\my_global_test_function'; $element = new RegisteredElement($handler); - $result = $element->handle($this->container, ['flag' => true]); + $result = $element->handle($this->container, ['flag' => true], $this->context); expect($result)->toBe('on'); }); }); diff --git a/tests/Unit/Elements/RegisteredPromptTest.php b/tests/Unit/Elements/RegisteredPromptTest.php index e3ff637..0f9e664 100644 --- a/tests/Unit/Elements/RegisteredPromptTest.php +++ b/tests/Unit/Elements/RegisteredPromptTest.php @@ -5,6 +5,8 @@ use Mockery; use PhpMcp\Schema\Prompt as PromptSchema; use PhpMcp\Schema\PromptArgument; +use PhpMcp\Server\Context; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Schema\Content\PromptMessage; use PhpMcp\Schema\Enum\Role; @@ -30,6 +32,8 @@ 'Generates a greeting.', [PromptArgument::make('name', 'The name to greet.', true)] ); + + $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly with schema, handler, and completion providers', function () { @@ -63,14 +67,14 @@ $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting']); - $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm']); + $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm'], $this->context); expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); }); it('formats single PromptMessage object from handler', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSinglePromptMessageObject']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toHaveCount(1); expect($messages[0])->toBeInstanceOf(PromptMessage::class); expect($messages[0]->content->text)->toBe("Single PromptMessage object."); @@ -78,7 +82,7 @@ it('formats array of PromptMessage objects from handler as is', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toHaveCount(2); expect($messages[0]->content->text)->toBe("First message object."); expect($messages[1]->content)->toBeInstanceOf(ImageContent::class); @@ -86,13 +90,13 @@ it('formats empty array from handler as empty array', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnEmptyArrayForPrompt']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toBeEmpty(); }); it('formats simple user/assistant map from handler', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSimpleUserAssistantMap']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(2); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content->text)->toBe("This is the user's turn."); @@ -102,7 +106,7 @@ it('formats user/assistant map with Content objects', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); expect($messages[1]->role)->toBe(Role::Assistant); @@ -111,7 +115,7 @@ it('formats user/assistant map with mixed content (string and Content object)', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); expect($messages[1]->role)->toBe(Role::Assistant); @@ -120,7 +124,7 @@ it('formats user/assistant map with array content', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); expect($messages[1]->role)->toBe(Role::Assistant); @@ -129,7 +133,7 @@ it('formats list of raw message arrays with various content types', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArrays']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(6); expect($messages[0]->content->text)->toBe("First raw message string."); expect($messages[1]->content)->toBeInstanceOf(TextContent::class); @@ -143,7 +147,7 @@ it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(5); expect($messages[0]->content->text)->toBe("123"); expect($messages[1]->content->text)->toBe("true"); @@ -154,7 +158,7 @@ it('formats mixed array of PromptMessage objects and raw message arrays', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw']); - $messages = $prompt->get($this->container, []); + $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(4); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array."); @@ -182,7 +186,7 @@ } try { - $prompt->get($this->container, []); + $prompt->get($this->container, [], $this->context); } catch (\RuntimeException $e) { expect($e->getMessage())->toMatch($expectedErrorPattern); } @@ -193,7 +197,7 @@ it('propagates exceptions from handler during get()', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'promptHandlerThrows']); - $prompt->get($this->container, []); + $prompt->get($this->container, [], $this->context); })->throws(\LogicException::class, "Prompt generation failed inside handler."); diff --git a/tests/Unit/Elements/RegisteredResourceTemplateTest.php b/tests/Unit/Elements/RegisteredResourceTemplateTest.php index ce3dd5e..d2f09d7 100644 --- a/tests/Unit/Elements/RegisteredResourceTemplateTest.php +++ b/tests/Unit/Elements/RegisteredResourceTemplateTest.php @@ -4,6 +4,8 @@ use Mockery; use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Server\Context; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredResourceTemplate; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; @@ -32,6 +34,8 @@ 'user-doc-template', mimeType: 'application/json' ); + + $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly with schema, handler, and completion providers', function () { @@ -122,7 +126,7 @@ expect($template->matches($uri))->toBeTrue(); - $resultContents = $template->read($this->container, $uri); + $resultContents = $template->read($this->container, $uri, $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); @@ -145,7 +149,7 @@ $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']); expect($template->matches($uri))->toBeTrue(); - $resultContents = $template->read($this->container, $uri); + $resultContents = $template->read($this->container, $uri, $this->context); expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml'); }); @@ -159,7 +163,7 @@ $mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler'); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler); - $resultContents = $template->read($this->container, $uri); + $resultContents = $template->read($this->container, $uri, $this->context); expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class) ->and($resultContents[0]->text)->toBe('Simple content from template handler') ->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema @@ -170,7 +174,7 @@ $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'handlerThrowsException']); expect($template->matches($uri))->toBeTrue(); - $template->read($this->container, $uri); + $template->read($this->container, $uri, $this->context); })->throws(\DomainException::class, "Cannot read resource"); it('can be serialized to array and deserialized', function () { diff --git a/tests/Unit/Elements/RegisteredResourceTest.php b/tests/Unit/Elements/RegisteredResourceTest.php index 85976c5..69a8979 100644 --- a/tests/Unit/Elements/RegisteredResourceTest.php +++ b/tests/Unit/Elements/RegisteredResourceTest.php @@ -4,8 +4,9 @@ use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PhpMcp\Schema\Annotations; use PhpMcp\Schema\Resource as ResourceSchema; +use PhpMcp\Server\Context; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredResource; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Content\BlobResourceContents; @@ -29,6 +30,7 @@ $this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'] ); + $this->context = new Context(Mockery::mock(SessionInterface::class)); }); afterEach(function () { @@ -62,7 +64,7 @@ ->andReturn("Confirmed URI: {$this->testUri}"); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); - $result = $resource->read($this->container, $this->testUri); + $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}"); }); @@ -75,7 +77,7 @@ $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI"); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); - $result = $resource->read($this->container, $this->testUri); + $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0]->text)->toBe("Success no URI"); }); @@ -98,7 +100,7 @@ $schema = ResourceSchema::make($this->testUri, 'format-test'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]); - $resultContents = $resource->read($this->container, $this->testUri); + $resultContents = $resource->read($this->container, $this->testUri, $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); $content = $resultContents[0]; @@ -119,7 +121,7 @@ it('formats SplFileInfo based on schema MIME type (text)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); - $result = $resource->read($this->container, $this->testUri); + $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0])->toBeInstanceOf(TextResourceContents::class); expect($result[0]->mimeType)->toBe('text/markdown'); @@ -129,7 +131,7 @@ it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); - $result = $resource->read($this->container, $this->testUri); + $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0])->toBeInstanceOf(BlobResourceContents::class); expect($result[0]->mimeType)->toBe('image/png'); @@ -138,7 +140,7 @@ it('formats array of ResourceContents as is', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']); - $results = $resource->read($this->container, $this->testUri); + $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC'); expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata')); @@ -146,7 +148,7 @@ it('formats array of EmbeddedResources by extracting their inner resource', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']); - $results = $resource->read($this->container, $this->testUri); + $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe(''); expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata')); @@ -154,7 +156,7 @@ it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']); - $results = $resource->read($this->container, $this->testUri); + $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toBeArray()->toHaveCount(4); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece"); @@ -175,17 +177,17 @@ $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error")); }) ); - $resource->read($this->container, $this->testUri); + $resource->read($this->container, $this->testUri, $this->context); })->throws(McpServerException::class, "Test error"); it('propagates other exceptions from handler during read', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']); - $resource->read($this->container, $this->testUri); + $resource->read($this->container, $this->testUri, $this->context); })->throws(\DomainException::class, "Cannot read resource"); it('throws RuntimeException for unformattable handler result', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']); - $resource->read($this->container, $this->testUri); + $resource->read($this->container, $this->testUri, $this->context); })->throws(\RuntimeException::class, "Cannot format resource read result for URI"); diff --git a/tests/Unit/Elements/RegisteredToolTest.php b/tests/Unit/Elements/RegisteredToolTest.php index f132c3e..6f8f648 100644 --- a/tests/Unit/Elements/RegisteredToolTest.php +++ b/tests/Unit/Elements/RegisteredToolTest.php @@ -6,6 +6,8 @@ use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PhpMcp\Schema\Tool; +use PhpMcp\Server\Context; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\ImageContent; @@ -31,6 +33,7 @@ $this->toolSchema, [ToolHandlerFixture::class, 'greet'] ); + $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly and exposes schema', function () { @@ -53,7 +56,7 @@ $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); - $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10']); // '10' will be cast to int by prepareArguments + $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10'], $this->context); // '10' will be cast to int by prepareArguments expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15'); @@ -68,7 +71,7 @@ $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); }); @@ -90,7 +93,7 @@ [ToolHandlerFixture::class, $handlerMethod] ); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText); @@ -101,7 +104,7 @@ Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnTextContent'] ); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent."); @@ -112,7 +115,7 @@ Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnArrayOfContent'] ); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(2); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1"); @@ -124,7 +127,7 @@ Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnMixedArray'] ); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(8); @@ -143,7 +146,7 @@ Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnEmptyArray'] ); - $resultContents = $tool->call($this->container, []); + $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]'); @@ -154,7 +157,7 @@ Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'toolUnencodableResult'] ); - $tool->call($this->container, []); + $tool->call($this->container, [], $this->context); })->throws(JsonException::class); it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () { @@ -165,5 +168,5 @@ $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture()); - $tool->call($this->container, []); + $tool->call($this->container, [], $this->context); })->throws(InvalidArgumentException::class, "Something went wrong in the tool."); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 138b37c..4bc0b15 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -5,6 +5,7 @@ use Mockery; use Mockery\MockInterface; use PhpMcp\Schema\Implementation; +use PhpMcp\Server\Context; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Dispatcher; @@ -171,7 +172,10 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $expectedResponse = Response::make($request->id, $result); $this->dispatcher->shouldReceive('handleRequest')->once() - ->with(Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'), $this->session) + ->with( + Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'), + Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session), + ) ->andReturn($result); $this->transport->shouldReceive('sendMessage')->once() @@ -204,9 +208,23 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $result1 = new EmptyResult(); $result2 = new EmptyResult(); - $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'), $this->session)->andReturn($result1); - $this->dispatcher->shouldReceive('handleNotification')->once()->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session); - $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'), $this->session)->andReturn($result2); + $this->dispatcher->shouldReceive('handleRequest') + ->once() + ->with( + Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'), + Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session), + ) + ->andReturn($result1); + $this->dispatcher->shouldReceive('handleNotification') + ->once() + ->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session); + $this->dispatcher->shouldReceive('handleRequest') + ->once() + ->with( + Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'), + Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session) + ) + ->andReturn($result2); $this->transport->shouldReceive('sendMessage')->once() @@ -492,13 +510,15 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $this->transport->shouldNotReceive('sendMessage'); })->with(['tools', 'resources', 'prompts',]); - it('allows initialize request when session not initialized', function () { $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION_PROTO]); $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); $this->dispatcher->shouldReceive('handleRequest')->once() - ->with(Mockery::type(Request::class), $this->session) + ->with( + Mockery::type(Request::class), + Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session) + ) ->andReturn(new EmptyResult()); $this->transport->shouldReceive('sendMessage')->once()