This is page 2 of 5. Use http://codebase.md/php-mcp/server?page={x} to view the full context. # Directory Structure ``` ├── .github │ └── workflows │ ├── changelog.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── composer.json ├── CONTRIBUTING.md ├── examples │ ├── 01-discovery-stdio-calculator │ │ ├── McpElements.php │ │ └── server.php │ ├── 02-discovery-http-userprofile │ │ ├── McpElements.php │ │ ├── server.php │ │ └── UserIdCompletionProvider.php │ ├── 03-manual-registration-stdio │ │ ├── server.php │ │ └── SimpleHandlers.php │ ├── 04-combined-registration-http │ │ ├── DiscoveredElements.php │ │ ├── ManualHandlers.php │ │ └── server.php │ ├── 05-stdio-env-variables │ │ ├── EnvToolHandler.php │ │ └── server.php │ ├── 06-custom-dependencies-stdio │ │ ├── McpTaskHandlers.php │ │ ├── server.php │ │ └── Services.php │ ├── 07-complex-tool-schema-http │ │ ├── EventTypes.php │ │ ├── McpEventScheduler.php │ │ └── server.php │ └── 08-schema-showcase-streamable │ ├── SchemaShowcaseElements.php │ └── server.php ├── LICENSE ├── phpunit.xml ├── README.md ├── src │ ├── Attributes │ │ ├── CompletionProvider.php │ │ ├── McpPrompt.php │ │ ├── McpResource.php │ │ ├── McpResourceTemplate.php │ │ ├── McpTool.php │ │ └── Schema.php │ ├── Configuration.php │ ├── Context.php │ ├── Contracts │ │ ├── CompletionProviderInterface.php │ │ ├── EventStoreInterface.php │ │ ├── LoggerAwareInterface.php │ │ ├── LoopAwareInterface.php │ │ ├── ServerTransportInterface.php │ │ ├── SessionHandlerInterface.php │ │ └── SessionInterface.php │ ├── Defaults │ │ ├── ArrayCache.php │ │ ├── BasicContainer.php │ │ ├── DefaultUuidSessionIdGenerator.php │ │ ├── EnumCompletionProvider.php │ │ ├── FileCache.php │ │ ├── InMemoryEventStore.php │ │ ├── ListCompletionProvider.php │ │ └── SystemClock.php │ ├── Dispatcher.php │ ├── Elements │ │ ├── RegisteredElement.php │ │ ├── RegisteredPrompt.php │ │ ├── RegisteredResource.php │ │ ├── RegisteredResourceTemplate.php │ │ └── RegisteredTool.php │ ├── Exception │ │ ├── ConfigurationException.php │ │ ├── DiscoveryException.php │ │ ├── McpServerException.php │ │ ├── ProtocolException.php │ │ └── TransportException.php │ ├── Protocol.php │ ├── Registry.php │ ├── Server.php │ ├── ServerBuilder.php │ ├── Session │ │ ├── ArraySessionHandler.php │ │ ├── CacheSessionHandler.php │ │ ├── Session.php │ │ ├── SessionManager.php │ │ └── SubscriptionManager.php │ ├── Transports │ │ ├── HttpServerTransport.php │ │ ├── StdioServerTransport.php │ │ └── StreamableHttpServerTransport.php │ └── Utils │ ├── Discoverer.php │ ├── DocBlockParser.php │ ├── HandlerResolver.php │ ├── SchemaGenerator.php │ └── SchemaValidator.php └── tests ├── Fixtures │ ├── Discovery │ │ ├── DiscoverablePromptHandler.php │ │ ├── DiscoverableResourceHandler.php │ │ ├── DiscoverableTemplateHandler.php │ │ ├── DiscoverableToolHandler.php │ │ ├── EnhancedCompletionHandler.php │ │ ├── InvocablePromptFixture.php │ │ ├── InvocableResourceFixture.php │ │ ├── InvocableResourceTemplateFixture.php │ │ ├── InvocableToolFixture.php │ │ ├── NonDiscoverableClass.php │ │ └── SubDir │ │ └── HiddenTool.php │ ├── Enums │ │ ├── BackedIntEnum.php │ │ ├── BackedStringEnum.php │ │ ├── PriorityEnum.php │ │ ├── StatusEnum.php │ │ └── UnitEnum.php │ ├── General │ │ ├── CompletionProviderFixture.php │ │ ├── DocBlockTestFixture.php │ │ ├── InvokableHandlerFixture.php │ │ ├── PromptHandlerFixture.php │ │ ├── RequestAttributeChecker.php │ │ ├── ResourceHandlerFixture.php │ │ ├── ToolHandlerFixture.php │ │ └── VariousTypesHandler.php │ ├── Middlewares │ │ ├── ErrorMiddleware.php │ │ ├── FirstMiddleware.php │ │ ├── HeaderMiddleware.php │ │ ├── RequestAttributeMiddleware.php │ │ ├── SecondMiddleware.php │ │ ├── ShortCircuitMiddleware.php │ │ └── ThirdMiddleware.php │ ├── Schema │ │ └── SchemaGenerationTarget.php │ ├── ServerScripts │ │ ├── HttpTestServer.php │ │ ├── StdioTestServer.php │ │ └── StreamableHttpTestServer.php │ └── Utils │ ├── AttributeFixtures.php │ ├── DockBlockParserFixture.php │ └── SchemaGeneratorFixture.php ├── Integration │ ├── DiscoveryTest.php │ ├── HttpServerTransportTest.php │ ├── SchemaGenerationTest.php │ ├── StdioServerTransportTest.php │ └── StreamableHttpServerTransportTest.php ├── Mocks │ ├── Clients │ │ ├── MockJsonHttpClient.php │ │ ├── MockSseClient.php │ │ └── MockStreamHttpClient.php │ └── Clock │ └── FixedClock.php ├── Pest.php ├── TestCase.php └── Unit ├── Attributes │ ├── CompletionProviderTest.php │ ├── McpPromptTest.php │ ├── McpResourceTemplateTest.php │ ├── McpResourceTest.php │ └── McpToolTest.php ├── ConfigurationTest.php ├── Defaults │ ├── EnumCompletionProviderTest.php │ └── ListCompletionProviderTest.php ├── DispatcherTest.php ├── Elements │ ├── RegisteredElementTest.php │ ├── RegisteredPromptTest.php │ ├── RegisteredResourceTemplateTest.php │ ├── RegisteredResourceTest.php │ └── RegisteredToolTest.php ├── ProtocolTest.php ├── RegistryTest.php ├── ServerBuilderTest.php ├── ServerTest.php ├── Session │ ├── ArraySessionHandlerTest.php │ ├── CacheSessionHandlerTest.php │ ├── SessionManagerTest.php │ └── SessionTest.php └── Utils ├── DocBlockParserTest.php ├── HandlerResolverTest.php └── SchemaValidatorTest.php ``` # Files -------------------------------------------------------------------------------- /tests/Fixtures/Schema/SchemaGenerationTarget.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Schema; use PhpMcp\Server\Attributes\Schema; use PhpMcp\Server\Attributes\Schema\Format; use PhpMcp\Server\Attributes\Schema\ArrayItems; use PhpMcp\Server\Attributes\Schema\Property; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; use stdClass; class SchemaGenerationTarget { public function noParamsMethod(): void { } /** * Method with simple required types. * @param string $pString String param * @param int $pInt Int param * @param bool $pBool Bool param * @param float $pFloat Float param * @param array $pArray Array param * @param stdClass $pObject Object param */ public function simpleRequiredTypes(string $pString, int $pInt, bool $pBool, float $pFloat, array $pArray, stdClass $pObject): void { } /** * Method with simple optional types with default values. * @param string $pStringOpt String param with default * @param int $pIntOpt Int param with default * @param bool $pBoolOpt Bool param with default * @param ?float $pFloatOptNullable Float param with default, also nullable * @param array $pArrayOpt Array param with default * @param ?stdClass $pObjectOptNullable Object param with default null */ public function optionalTypesWithDefaults( string $pStringOpt = "hello", int $pIntOpt = 123, bool $pBoolOpt = true, ?float $pFloatOptNullable = 1.23, array $pArrayOpt = ['a', 'b'], ?stdClass $pObjectOptNullable = null ): void { } /** * Nullable types without explicit defaults. * @param ?string $pNullableString Nullable string * @param int|null $pUnionNullableInt Union nullable int */ public function nullableTypes(?string $pNullableString, ?int $pUnionNullableInt, ?BackedStringEnum $pNullableEnum): void { } /** * Union types. * @param string|int $pStringOrInt String or Int * @param bool|float|null $pBoolOrFloatOrNull Bool, Float or Null */ public function unionTypes(string|int $pStringOrInt, $pBoolOrFloatOrNull): void { } // PHP 7.x style union in docblock usually /** * Various array type hints. * @param string[] $pStringArray Array of strings (docblock style) * @param array<int> $pIntArrayGeneric Array of integers (generic style) * @param array<string, mixed> $pAssocArray Associative array * @param BackedIntEnum[] $pEnumArray Array of enums * @param array{name: string, age: int} $pShapeArray Typed array shape * @param array<array{id:int, value:string}> $pArrayOfShapes Array of shapes */ public function arrayTypes( array $pStringArray, array $pIntArrayGeneric, array $pAssocArray, array $pEnumArray, array $pShapeArray, array $pArrayOfShapes ): void { } /** * Enum types. * @param BackedStringEnum $pBackedStringEnum Backed string enum * @param BackedIntEnum $pBackedIntEnum Backed int enum * @param UnitEnum $pUnitEnum Unit enum */ public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void { } /** * Variadic parameters. * @param string ...$pVariadicStrings Variadic strings */ public function variadicParams(string ...$pVariadicStrings): void { } /** * Mixed type. * @param mixed $pMixed Mixed type */ public function mixedType(mixed $pMixed): void { } /** * With #[Schema] attributes for enhanced validation. * @param string $email With email format. * @param int $quantity With numeric constraints. * @param string[] $tags With array constraints. * @param array $userProfile With object property constraints. */ public function withSchemaAttributes( #[Schema(format: Format::EMAIL)] string $email, #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] int $quantity, #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] array $tags, #[Schema( properties: [ new Property(name: 'id', minimum: 1), new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'), ], required: ['id', 'username'], additionalProperties: false )] array $userProfile ): void { } } ``` -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/McpElements.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\HttpUserProfileExample; use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Exception\McpServerException; use Psr\Log\LoggerInterface; class McpElements { // Simulate a simple user database private array $users = [ '101' => ['name' => 'Alice', 'email' => '[email protected]', 'role' => 'admin'], '102' => ['name' => 'Bob', 'email' => '[email protected]', 'role' => 'user'], '103' => ['name' => 'Charlie', 'email' => '[email protected]', 'role' => 'user'], ]; private LoggerInterface $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); } /** * Retrieves the profile data for a specific user. * * @param string $userId The ID of the user (from URI). * @return array User profile data. * * @throws McpServerException If the user is not found. */ #[McpResourceTemplate( uriTemplate: 'user://{userId}/profile', name: 'user_profile', description: 'Get profile information for a specific user ID.', mimeType: 'application/json' )] public function getUserProfile( #[CompletionProvider(values: ['101', '102', '103'])] string $userId ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); if (! isset($this->users[$userId])) { // Throwing an exception that Processor can turn into an error response throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); } return $this->users[$userId]; } /** * Retrieves a list of all known user IDs. * * @return array List of user IDs. */ #[McpResource( uri: 'user://list/ids', name: 'user_id_list', description: 'Provides a list of all available user IDs.', mimeType: 'application/json' )] public function listUserIds(): array { $this->logger->info('Reading resource: user ID list'); return array_keys($this->users); } /** * Sends a welcome message to a user. * (This is a placeholder - in a real app, it might queue an email) * * @param string $userId The ID of the user to message. * @param string|null $customMessage An optional custom message part. * @return array Status of the operation. */ #[McpTool(name: 'send_welcome')] public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array { $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]); if (! isset($this->users[$userId])) { return ['success' => false, 'error' => "User ID {$userId} not found."]; } $user = $this->users[$userId]; $message = "Welcome, {$user['name']}!"; if ($customMessage) { $message .= ' ' . $customMessage; } // Simulate sending $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); return ['success' => true, 'message_sent' => $message]; } #[McpTool(name: 'test_tool_without_params')] public function testToolWithoutParams() { return ['success' => true, 'message' => 'Test tool without params']; } /** * Generates a prompt to write a bio for a user. * * @param string $userId The user ID to generate the bio for. * @param string $tone Desired tone (e.g., 'formal', 'casual'). * @return array Prompt messages. * * @throws McpServerException If user not found. */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( #[CompletionProvider(provider: UserIdCompletionProvider::class)] string $userId, string $tone = 'professional' ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (! isset($this->users[$userId])) { throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); } $user = $this->users[$userId]; return [ ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."], ]; } } ``` -------------------------------------------------------------------------------- /examples/01-discovery-stdio-calculator/McpElements.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\StdioCalculatorExample; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpTool; class McpElements { private array $config = [ 'precision' => 2, 'allow_negative' => true, ]; /** * Performs a calculation based on the operation. * * Supports 'add', 'subtract', 'multiply', 'divide'. * Obeys the 'precision' and 'allow_negative' settings from the config resource. * * @param float $a The first operand. * @param float $b The second operand. * @param string $operation The operation ('add', 'subtract', 'multiply', 'divide'). * @return float|string The result of the calculation, or an error message string. */ #[McpTool(name: 'calculate')] public function calculate(float $a, float $b, string $operation): float|string { // Use STDERR for logs fwrite(STDERR, "Calculate tool called: a=$a, b=$b, op=$operation\n"); $op = strtolower($operation); $result = null; switch ($op) { case 'add': $result = $a + $b; break; case 'subtract': $result = $a - $b; break; case 'multiply': $result = $a * $b; break; case 'divide': if ($b == 0) { return 'Error: Division by zero.'; } $result = $a / $b; break; default: return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; } if (! $this->config['allow_negative'] && $result < 0) { return 'Error: Negative results are disabled.'; } return round($result, $this->config['precision']); } /** * Provides the current calculator configuration. * Can be read by clients to understand precision etc. * * @return array The configuration array. */ #[McpResource( uri: 'config://calculator/settings', name: 'calculator_config', description: 'Current settings for the calculator tool (precision, allow_negative).', mimeType: 'application/json' // Return as JSON )] public function getConfiguration(): array { fwrite(STDERR, "Resource config://calculator/settings read.\n"); return $this->config; } /** * Updates a specific configuration setting. * Note: This requires more robust validation in a real app. * * @param string $setting The setting key ('precision' or 'allow_negative'). * @param mixed $value The new value (int for precision, bool for allow_negative). * @return array Success message or error. */ #[McpTool(name: 'update_setting')] public function updateSetting(string $setting, mixed $value): array { fwrite(STDERR, "Update Setting tool called: setting=$setting, value=".var_export($value, true)."\n"); if (! array_key_exists($setting, $this->config)) { return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; } if ($setting === 'precision') { if (! is_int($value) || $value < 0 || $value > 10) { return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; } $this->config['precision'] = $value; // In real app, notify subscribers of config://calculator/settings change // $registry->notifyResourceChanged('config://calculator/settings'); return ['success' => true, 'message' => "Precision updated to {$value}."]; } if ($setting === 'allow_negative') { if (! is_bool($value)) { // Attempt basic cast for flexibility if (in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) { $value = true; } elseif (in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { $value = false; } else { return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; } } $this->config['allow_negative'] = $value; // $registry->notifyResourceChanged('config://calculator/settings'); return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; } return ['success' => false, 'error' => 'Internal error handling setting.']; // Should not happen } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/ResourceHandlerFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Content\BlobResourceContents; use Psr\Log\LoggerInterface; use SplFileInfo; class ResourceHandlerFixture { public static string $staticTextContent = "Default static text content."; public array $dynamicContentStore = []; public static ?string $unlinkableSplFile = null; public function __construct() { $this->dynamicContentStore['dynamic://data/item1'] = "Content for item 1"; } public function returnStringText(string $uri): string { return "Plain string content for {$uri}"; } public function returnStringJson(string $uri): string { return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']); } public function returnStringHtml(string $uri): string { return "<html><title>{$uri}</title><body>Content</body></html>"; } public function returnArrayJson(string $uri): array { return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()]; } public function returnEmptyArray(string $uri): array { return []; } public function returnStream(string $uri) // Returns a stream resource { $stream = fopen('php://memory', 'r+'); fwrite($stream, "Streamed content for {$uri}"); rewind($stream); return $stream; } public function returnSplFileInfo(string $uri): SplFileInfo { self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_'); file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}"); return new SplFileInfo(self::$unlinkableSplFile); } public function returnEmbeddedResource(string $uri): EmbeddedResource { return EmbeddedResource::make( TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content') ); } public function returnTextResourceContents(string $uri): TextResourceContents { return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents'); } public function returnBlobResourceContents(string $uri): BlobResourceContents { return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents')); } public function returnArrayForBlobSchema(string $uri): array { return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array']; } public function returnArrayForTextSchema(string $uri): array { return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text']; } public function returnArrayOfResourceContents(string $uri): array { return [ TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'), BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')), ]; } public function returnArrayOfEmbeddedResources(string $uri): array { return [ EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '<doc1/>')), EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))), ]; } public function returnMixedArrayWithResourceTypes(string $uri): array { return [ "A raw string piece", // Will be formatted TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is ['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON) EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted ]; } public function handlerThrowsException(string $uri): void { throw new \DomainException("Cannot read resource {$uri} - handler error."); } public function returnUnformattableType(string $uri) { return new \DateTimeImmutable(); } public function resourceHandlerNeedsUri(string $uri): string { return "Handler received URI: " . $uri; } public function resourceHandlerDoesNotNeedUri(): string { return "Handler did not need or receive URI parameter."; } public function getTemplatedContent( string $category, string $itemId, string $format, ): array { return [ 'message' => "Content for item {$itemId} in category {$category}, format {$format}.", 'category_received' => $category, 'itemId_received' => $itemId, 'format_received' => $format, ]; } public function getStaticText(): string { return self::$staticTextContent; } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/VariousTypesHandler.php: -------------------------------------------------------------------------------- ```php <?php 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; use stdClass; class VariousTypesHandler { public function noArgsMethod(): array { return compact([]); } public function simpleRequiredArgs(string $pString, int $pInt, bool $pBool): array { return compact('pString', 'pInt', 'pBool'); } public function optionalArgsWithDefaults( string $pString = 'default_string', int $pInt = 100, ?bool $pNullableBool = true, float $pFloat = 3.14 ): array { return compact('pString', 'pInt', 'pNullableBool', 'pFloat'); } public function nullableArgsWithoutDefaults(?string $pString, ?int $pInt, ?array $pArray): array { return compact('pString', 'pInt', 'pArray'); } public function mixedTypeArg(mixed $pMixed): array { return compact('pMixed'); } public function backedEnumArgs( BackedStringEnum $pBackedString, BackedIntEnum $pBackedInt, ?BackedStringEnum $pNullableBackedString = null, BackedIntEnum $pOptionalBackedInt = BackedIntEnum::First ): array { return compact('pBackedString', 'pBackedInt', 'pNullableBackedString', 'pOptionalBackedInt'); } public function unitEnumArg(UnitEnum $pUnitEnum): array { return compact('pUnitEnum'); } public function arrayArg(array $pArray): array { return compact('pArray'); } public function objectArg(stdClass $pObject): array { return compact('pObject'); } public function variadicArgs(string ...$items): array { return compact('items'); } /** * A comprehensive method for testing various argument types and casting. * @param string $strParam A string. * @param int $intParam An integer. * @param bool $boolProp A boolean. * @param float $floatParam A float. * @param array $arrayParam An array. * @param BackedStringEnum $backedStringEnumParam A backed string enum. * @param BackedIntEnum $backedIntEnumParam A backed int enum. * @param UnitEnum $unitEnumParam A unit enum (passed as instance). * @param string|null $nullableStringParam A nullable string. * @param int $optionalIntWithDefaultParam An optional int with default. * @param mixed $mixedParam A mixed type. * @param stdClass $objectParam An object. * @param string $stringForIntCast String that should be cast to int. * @param string $stringForFloatCast String that should be cast to float. * @param string $stringForBoolTrueCast String that should be cast to bool true. * @param string $stringForBoolFalseCast String that should be cast to bool false. * @param int $intForStringCast Int that should be cast to string. * @param int $intForFloatCast Int that should be cast to float. * @param bool $boolForStringCast Bool that should be cast to string. * @param string $valueForBackedStringEnum String value for backed string enum. * @param int $valueForBackedIntEnum Int value for backed int enum. */ public function comprehensiveArgumentTest( string $strParam, int $intParam, bool $boolProp, float $floatParam, array $arrayParam, BackedStringEnum $backedStringEnumParam, BackedIntEnum $backedIntEnumParam, UnitEnum $unitEnumParam, ?string $nullableStringParam, mixed $mixedParam, stdClass $objectParam, string $stringForIntCast, string $stringForFloatCast, string $stringForBoolTrueCast, string $stringForBoolFalseCast, int $intForStringCast, int $intForFloatCast, bool $boolForStringCast, string $valueForBackedStringEnum, int $valueForBackedIntEnum, int $optionalIntWithDefaultParam = 999, ): array { return compact( 'strParam', 'intParam', 'boolProp', 'floatParam', 'arrayParam', 'backedStringEnumParam', 'backedIntEnumParam', 'unitEnumParam', 'nullableStringParam', 'optionalIntWithDefaultParam', 'mixedParam', 'objectParam', 'stringForIntCast', 'stringForFloatCast', 'stringForBoolTrueCast', 'stringForBoolFalseCast', 'intForStringCast', 'intForFloatCast', 'boolForStringCast', 'valueForBackedStringEnum', 'valueForBackedIntEnum' ); } public function methodCausesTypeError(int $mustBeInt): void { } public function contextArg(Context $context): array { return [ 'session' => $context->session->get('testKey'), 'request' => $context->request->getHeaderLine('testHeader'), ]; } } ``` -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/server.php: -------------------------------------------------------------------------------- ```php #!/usr/bin/env php <?php /* |-------------------------------------------------------------------------- | MCP HTTP User Profile Server (Attribute Discovery) |-------------------------------------------------------------------------- | | This server demonstrates attribute-based discovery for MCP elements | (ResourceTemplates, Resources, Tools, Prompts) defined in 'McpElements.php'. | It runs via the HTTP transport, listening for SSE and POST requests. | | To Use: | 1. Ensure 'McpElements.php' defines classes with MCP attributes. | 2. Run this script from your CLI: `php server.php` | The server will listen on http://127.0.0.1:8080 by default. | 3. Configure your MCP Client (e.g., Cursor) for this server: | | { | "mcpServers": { | "php-http-userprofile": { | "url": "http://127.0.0.1:8080/mcp/sse" // Use the SSE endpoint | // Ensure your client can reach this address | } | } | } | | The ServerBuilder builds the server, $server->discover() scans for elements, | and then $server->listen() starts the ReactPHP HTTP server. | | If you provided a `CacheInterface` implementation to the ServerBuilder, | the discovery process will be cached, so you can comment out the | discovery call after the first run to speed up subsequent runs. | */ declare(strict_types=1); chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once 'McpElements.php'; require_once 'UserIdCompletionProvider.php'; use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; use PhpMcp\Server\Transports\StreamableHttpServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; class StderrLogger extends AbstractLogger { public function log($level, \Stringable|string $message, array $context = []): void { fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); } } try { $logger = new StderrLogger(); $logger->info('Starting MCP HTTP User Profile Server...'); // --- Setup DI Container for DI in McpElements class --- $container = new BasicContainer(); $container->set(LoggerInterface::class, $logger); $server = Server::make() ->withServerInfo('HTTP User Profiles', '1.0.0') ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) ->withLogger($logger) ->withContainer($container) ->withTool( function (float $a, float $b, string $operation = 'add'): array { $result = match ($operation) { 'add' => $a + $b, 'subtract' => $a - $b, 'multiply' => $a * $b, 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'), default => throw new \InvalidArgumentException("Unknown operation: {$operation}") }; return [ 'operation' => $operation, 'operands' => [$a, $b], 'result' => $result ]; }, name: 'calculator', description: 'Perform basic math operations (add, subtract, multiply, divide)' ) ->withResource( function (): array { $memoryUsage = memory_get_usage(true); $memoryPeak = memory_get_peak_usage(true); $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; return [ 'server_time' => date('Y-m-d H:i:s'), 'uptime_seconds' => $uptime, 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2), 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2), 'php_version' => PHP_VERSION, 'server_software' => $serverSoftware, 'operating_system' => PHP_OS_FAMILY, 'status' => 'healthy' ]; }, uri: 'system://status', name: 'system_status', description: 'Current system status and runtime information', mimeType: 'application/json' ) ->build(); $server->discover(__DIR__, ['.']); // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } ``` -------------------------------------------------------------------------------- /src/Session/Session.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Session; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; class Session implements SessionInterface { /** * @var array<string, mixed> Stores all session data. * Keys are snake_case by convention for MCP-specific data. * * Official keys are: * - initialized: bool * - client_info: array|null * - protocol_version: string|null * - subscriptions: array<string, bool> * - message_queue: array<string> * - log_level: string|null */ protected array $data = []; public function __construct( protected SessionHandlerInterface $handler, protected string $id = '', ?array $data = null ) { if (empty($this->id)) { $this->id = $this->generateId(); } if ($data !== null) { $this->hydrate($data); } elseif ($sessionData = $this->handler->read($this->id)) { $this->data = json_decode($sessionData, true) ?? []; } } /** * Retrieve an existing session instance from handler or return null if session doesn't exist */ public static function retrieve(string $id, SessionHandlerInterface $handler): ?SessionInterface { $sessionData = $handler->read($id); if (!$sessionData) { return null; } $data = json_decode($sessionData, true); if ($data === null) { return null; } return new static($handler, $id, $data); } public function getId(): string { return $this->id; } public function getHandler(): SessionHandlerInterface { return $this->handler; } public function generateId(): string { return bin2hex(random_bytes(16)); } public function save(): void { $this->handler->write($this->id, json_encode($this->data)); } public function get(string $key, mixed $default = null): mixed { $key = explode('.', $key); $data = $this->data; foreach ($key as $segment) { if (is_array($data) && array_key_exists($segment, $data)) { $data = $data[$segment]; } else { return $default; } } return $data; } public function set(string $key, mixed $value, bool $overwrite = true): void { $segments = explode('.', $key); $data = &$this->data; while (count($segments) > 1) { $segment = array_shift($segments); if (!isset($data[$segment]) || !is_array($data[$segment])) { $data[$segment] = []; } $data = &$data[$segment]; } $lastKey = array_shift($segments); if ($overwrite || !isset($data[$lastKey])) { $data[$lastKey] = $value; } } public function has(string $key): bool { $key = explode('.', $key); $data = $this->data; foreach ($key as $segment) { if (is_array($data) && array_key_exists($segment, $data)) { $data = $data[$segment]; } elseif (is_object($data) && isset($data->{$segment})) { $data = $data->{$segment}; } else { return false; } } return true; } public function forget(string $key): void { $segments = explode('.', $key); $data = &$this->data; while (count($segments) > 1) { $segment = array_shift($segments); if (!isset($data[$segment]) || !is_array($data[$segment])) { $data[$segment] = []; } $data = &$data[$segment]; } $lastKey = array_shift($segments); if (isset($data[$lastKey])) { unset($data[$lastKey]); } } public function clear(): void { $this->data = []; } public function pull(string $key, mixed $default = null): mixed { $value = $this->get($key, $default); $this->forget($key); return $value; } public function all(): array { return $this->data; } public function hydrate(array $attributes): void { $this->data = array_merge( [ 'initialized' => false, 'client_info' => null, 'protocol_version' => null, 'message_queue' => [], 'log_level' => null, ], $attributes ); unset($this->data['id']); } public function queueMessage(string $rawFramedMessage): void { $this->data['message_queue'][] = $rawFramedMessage; } public function dequeueMessages(): array { $messages = $this->data['message_queue'] ?? []; $this->data['message_queue'] = []; return $messages; } public function hasQueuedMessages(): bool { return !empty($this->data['message_queue']); } public function jsonSerialize(): array { return $this->all(); } } ``` -------------------------------------------------------------------------------- /tests/Mocks/Clients/MockJsonHttpClient.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Mocks\Clients; use Psr\Http\Message\ResponseInterface; use React\Http\Browser; use React\Promise\PromiseInterface; class MockJsonHttpClient { public Browser $browser; public string $baseUrl; public ?string $sessionId = null; public array $lastResponseHeaders = []; // Store last response headers for testing public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2) { $this->browser = (new Browser())->withTimeout($timeout); $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; } public function sendRequest(string $method, array $params = [], ?string $id = null, array $additionalHeaders = []): PromiseInterface { $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; if ($id !== null) { $payload['id'] = $id; } $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; if ($this->sessionId && $method !== 'initialize') { $headers['Mcp-Session-Id'] = $this->sessionId; } $headers += $additionalHeaders; $body = json_encode($payload); return $this->browser->post($this->baseUrl, $headers, $body) ->then(function (ResponseInterface $response) use ($method) { // Store response headers for testing $this->lastResponseHeaders = []; foreach ($response->getHeaders() as $name => $values) { foreach ($values as $value) { $this->lastResponseHeaders[] = "{$name}: {$value}"; } } $bodyContent = (string) $response->getBody()->getContents(); $statusCode = $response->getStatusCode(); if ($method === 'initialize' && $statusCode === 200) { $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); } if ($statusCode === 202) { if ($bodyContent !== '') { throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); } return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; } try { $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); } }); } public function sendBatchRequest(array $batchRequestObjects): PromiseInterface { $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; if ($this->sessionId) { $headers['Mcp-Session-Id'] = $this->sessionId; } $body = json_encode($batchRequestObjects); return $this->browser->post($this->baseUrl, $headers, $body) ->then(function (ResponseInterface $response) { $bodyContent = (string) $response->getBody()->getContents(); $statusCode = $response->getStatusCode(); if ($statusCode === 202) { if ($bodyContent !== '') { throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); } return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; } try { $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); } }); } public function sendDeleteRequest(): PromiseInterface { $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; if ($this->sessionId) { $headers['Mcp-Session-Id'] = $this->sessionId; } return $this->browser->delete($this->baseUrl, $headers) ->then(function (ResponseInterface $response) { $bodyContent = (string) $response->getBody()->getContents(); $statusCode = $response->getStatusCode(); return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()]; }); } public function sendNotification(string $method, array $params = []): PromiseInterface { return $this->sendRequest($method, $params, null); } public function connectSseForNotifications(): PromiseInterface { return resolve(null); } } ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/PromptHandlerFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\General; use PhpMcp\Schema\Content\PromptMessage; use PhpMcp\Schema\Enum\Role; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\ImageContent; use PhpMcp\Schema\Content\AudioContent; use PhpMcp\Server\Attributes\CompletionProvider; use Psr\Log\LoggerInterface; class PromptHandlerFixture { public function generateSimpleGreeting(string $name, string $style = "friendly"): array { return [ ['role' => 'user', 'content' => "Craft a {$style} greeting for {$name}."] ]; } public function returnSinglePromptMessageObject(): PromptMessage { return PromptMessage::make(Role::User, TextContent::make("Single PromptMessage object.")); } public function returnArrayOfPromptMessageObjects(): array { return [ PromptMessage::make(Role::User, TextContent::make("First message object.")), PromptMessage::make(Role::Assistant, ImageContent::make("img_data", "image/png")), ]; } public function returnEmptyArrayForPrompt(): array { return []; } public function returnSimpleUserAssistantMap(): array { return [ 'user' => "This is the user's turn.", 'assistant' => "And this is the assistant's reply." ]; } public function returnUserAssistantMapWithContentObjects(): array { return [ 'user' => TextContent::make("User text content object."), 'assistant' => ImageContent::make("asst_img_data", "image/gif"), ]; } public function returnUserAssistantMapWithMixedContent(): array { return [ 'user' => "Plain user string.", 'assistant' => AudioContent::make("aud_data", "audio/mp3"), ]; } public function returnUserAssistantMapWithArrayContent(): array { return [ 'user' => ['type' => 'text', 'text' => 'User array content'], 'assistant' => ['type' => 'image', 'data' => 'asst_arr_img_data', 'mimeType' => 'image/jpeg'], ]; } public function returnListOfRawMessageArrays(): array { return [ ['role' => 'user', 'content' => "First raw message string."], ['role' => 'assistant', 'content' => TextContent::make("Second raw message with Content obj.")], ['role' => 'user', 'content' => ['type' => 'image', 'data' => 'raw_img_data', 'mimeType' => 'image/webp']], ['role' => 'assistant', 'content' => ['type' => 'audio', 'data' => 'raw_aud_data', 'mimeType' => 'audio/ogg']], ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'file://doc.pdf', 'blob' => base64_encode('pdf-data'), 'mimeType' => 'application/pdf']]], ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'config://settings.json', 'text' => '{"theme":"dark"}']]], ]; } public function returnListOfRawMessageArraysWithScalars(): array { return [ ['role' => 'user', 'content' => 123], // int ['role' => 'assistant', 'content' => true], // bool ['role' => 'user', 'content' => null], // null ['role' => 'assistant', 'content' => 3.14], // float ['role' => 'user', 'content' => ['key' => 'value']], // array that becomes JSON ]; } public function returnMixedArrayOfPromptMessagesAndRaw(): array { return [ PromptMessage::make(Role::User, TextContent::make("This is a PromptMessage object.")), ['role' => 'assistant', 'content' => "This is a raw message array."], PromptMessage::make(Role::User, ImageContent::make("pm_img", "image/bmp")), ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Raw message with typed content.']], ]; } public function promptWithArgumentCompletion( #[CompletionProvider(provider: CompletionProviderFixture::class)] string $entityName, string $action = "describe" ): array { return [ ['role' => 'user', 'content' => "Please {$action} the entity: {$entityName}."] ]; } public function promptReturnsNonArray(): string { return "This is not a valid prompt return type."; } public function promptReturnsArrayWithInvalidRole(): array { return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; } public function promptReturnsInvalidRole(): array { return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; } public function promptReturnsArrayWithInvalidContentStructure(): array { return [['role' => 'user', 'content' => ['text_only_no_type' => 'invalid']]]; } public function promptReturnsArrayWithInvalidTypedContent(): array { return [['role' => 'user', 'content' => ['type' => 'image', 'source' => 'url.jpg']]]; // 'image' needs 'data' and 'mimeType' } public function promptReturnsArrayWithInvalidResourceContent(): array { return [ [ 'role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'uri://uri']] ] ]; } public function promptHandlerThrows(): void { throw new \LogicException("Prompt generation failed inside handler."); } } ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/HandlerResolverTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Utils; use PhpMcp\Server\Utils\HandlerResolver; use ReflectionMethod; use ReflectionFunction; use InvalidArgumentException; class ValidHandlerClass { public function publicMethod() {} protected function protectedMethod() {} private function privateMethod() {} public static function staticMethod() {} public function __construct() {} public function __destruct() {} } class ValidInvokableClass { public function __invoke() {} } class NonInvokableClass {} abstract class AbstractHandlerClass { abstract public function abstractMethod(); } // Test closure support it('resolves closures to ReflectionFunction', function () { $closure = function (string $input): string { return "processed: $input"; }; $resolved = HandlerResolver::resolve($closure); expect($resolved)->toBeInstanceOf(ReflectionFunction::class); expect($resolved->getNumberOfParameters())->toBe(1); expect($resolved->getReturnType()->getName())->toBe('string'); }); it('resolves valid array handler', function () { $handler = [ValidHandlerClass::class, 'publicMethod']; $resolved = HandlerResolver::resolve($handler); expect($resolved)->toBeInstanceOf(ReflectionMethod::class); expect($resolved->getName())->toBe('publicMethod'); expect($resolved->getDeclaringClass()->getName())->toBe(ValidHandlerClass::class); }); it('resolves valid invokable class string handler', function () { $handler = ValidInvokableClass::class; $resolved = HandlerResolver::resolve($handler); expect($resolved)->toBeInstanceOf(ReflectionMethod::class); expect($resolved->getName())->toBe('__invoke'); expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class); }); it('resolves static methods for manual registration', function () { $handler = [ValidHandlerClass::class, 'staticMethod']; $resolved = HandlerResolver::resolve($handler); expect($resolved)->toBeInstanceOf(ReflectionMethod::class); expect($resolved->getName())->toBe('staticMethod'); expect($resolved->isStatic())->toBeTrue(); }); it('throws for invalid array handler format (count)', function () { HandlerResolver::resolve([ValidHandlerClass::class]); })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); it('throws for invalid array handler format (types)', function () { HandlerResolver::resolve([ValidHandlerClass::class, 123]); })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); it('throws for non-existent class in array handler', function () { HandlerResolver::resolve(['NonExistentClass', 'method']); })->throws(InvalidArgumentException::class, "Handler class 'NonExistentClass' not found"); it('throws for non-existent method in array handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']); })->throws(InvalidArgumentException::class, "Handler method 'nonExistentMethod' not found in class"); it('throws for non-existent class in string handler', function () { HandlerResolver::resolve('NonExistentInvokableClass'); })->throws(InvalidArgumentException::class, 'Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); it('throws for non-invokable class string handler', function () { HandlerResolver::resolve(NonInvokableClass::class); })->throws(InvalidArgumentException::class, "Invokable handler class '" . NonInvokableClass::class . "' must have a public '__invoke' method."); it('throws for protected method handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); })->throws(InvalidArgumentException::class, 'must be public'); it('throws for private method handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']); })->throws(InvalidArgumentException::class, 'must be public'); it('throws for constructor as handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, '__construct']); })->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor'); it('throws for destructor as handler', function () { HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); })->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor'); it('throws for abstract method handler', function () { HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); })->throws(InvalidArgumentException::class, 'cannot be abstract'); // Test different closure types it('resolves closures with different signatures', function () { $noParams = function () { return 'test'; }; $withParams = function (int $a, string $b = 'default') { return $a . $b; }; $variadic = function (...$args) { return $args; }; expect(HandlerResolver::resolve($noParams))->toBeInstanceOf(ReflectionFunction::class); expect(HandlerResolver::resolve($withParams))->toBeInstanceOf(ReflectionFunction::class); expect(HandlerResolver::resolve($variadic))->toBeInstanceOf(ReflectionFunction::class); expect(HandlerResolver::resolve($noParams)->getNumberOfParameters())->toBe(0); expect(HandlerResolver::resolve($withParams)->getNumberOfParameters())->toBe(2); expect(HandlerResolver::resolve($variadic)->isVariadic())->toBeTrue(); }); // Test that we can distinguish between closures and callable arrays it('distinguishes between closures and callable arrays', function () { $closure = function () { return 'closure'; }; $array = [ValidHandlerClass::class, 'publicMethod']; $string = ValidInvokableClass::class; expect(HandlerResolver::resolve($closure))->toBeInstanceOf(ReflectionFunction::class); expect(HandlerResolver::resolve($array))->toBeInstanceOf(ReflectionMethod::class); expect(HandlerResolver::resolve($string))->toBeInstanceOf(ReflectionMethod::class); }); ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/DocBlockParserTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Utils; use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlock\Tags\See; use phpDocumentor\Reflection\DocBlock\Tags\Throws; use PhpMcp\Server\Utils\DocBlockParser; use PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture; use ReflectionMethod; beforeEach(function () { $this->parser = new DocBlockParser(); }); test('getSummary returns correct summary', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($this->parser->getSummary($docBlock))->toBe('Simple summary line.'); $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getSummary($docBlock2))->toBe('Summary line here.'); }); test('getDescription returns correct description', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; expect($this->parser->getDescription($docBlock))->toBe($expectedDesc); $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getDescription($docBlock2))->toBe('Simple summary line.'); }); test('getParamTags returns structured param info', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $params = $this->parser->getParamTags($docBlock); expect($params)->toBeArray()->toHaveCount(6); expect($params)->toHaveKeys(['$param1', '$param2', '$param3', '$param4', '$param5', '$param6']); expect($params['$param1'])->toBeInstanceOf(Param::class); expect($params['$param1']->getVariableName())->toBe('param1'); expect($this->parser->getParamTypeString($params['$param1']))->toBe('string'); expect($this->parser->getParamDescription($params['$param1']))->toBe('Description for string param.'); expect($params['$param2'])->toBeInstanceOf(Param::class); expect($params['$param2']->getVariableName())->toBe('param2'); expect($this->parser->getParamTypeString($params['$param2']))->toBe('int|null'); expect($this->parser->getParamDescription($params['$param2']))->toBe('Description for nullable int param.'); expect($params['$param3'])->toBeInstanceOf(Param::class); expect($params['$param3']->getVariableName())->toBe('param3'); expect($this->parser->getParamTypeString($params['$param3']))->toBe('bool'); expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); expect($params['$param4'])->toBeInstanceOf(Param::class); expect($params['$param4']->getVariableName())->toBe('param4'); expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); expect($this->parser->getParamDescription($params['$param4']))->toBe('Missing type.'); expect($params['$param5'])->toBeInstanceOf(Param::class); expect($params['$param5']->getVariableName())->toBe('param5'); expect($this->parser->getParamTypeString($params['$param5']))->toBe('array<string,mixed>'); expect($this->parser->getParamDescription($params['$param5']))->toBe('Array description.'); expect($params['$param6'])->toBeInstanceOf(Param::class); expect($params['$param6']->getVariableName())->toBe('param6'); expect($this->parser->getParamTypeString($params['$param6']))->toBe('stdClass'); expect($this->parser->getParamDescription($params['$param6']))->toBe('Object param.'); }); test('getReturnTag returns structured return info', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithReturn'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $returnTag = $this->parser->getReturnTag($docBlock); expect($returnTag)->toBeInstanceOf(Return_::class); expect($this->parser->getReturnTypeString($returnTag))->toBe('string'); expect($this->parser->getReturnDescription($returnTag))->toBe('The result of the operation.'); $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getReturnTag($docBlock2))->toBeNull(); }); test('getTagsByName returns specific tags', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($docBlock)->toBeInstanceOf(DocBlock::class); $throwsTags = $docBlock->getTagsByName('throws'); expect($throwsTags)->toBeArray()->toHaveCount(1); expect($throwsTags[0])->toBeInstanceOf(Throws::class); expect((string) $throwsTags[0]->getType())->toBe('\\RuntimeException'); expect($throwsTags[0]->getDescription()->render())->toBe('If processing fails.'); $deprecatedTags = $docBlock->getTagsByName('deprecated'); expect($deprecatedTags)->toBeArray()->toHaveCount(1); expect($deprecatedTags[0])->toBeInstanceOf(Deprecated::class); expect($deprecatedTags[0]->getDescription()->render())->toBe('Use newMethod() instead.'); $seeTags = $docBlock->getTagsByName('see'); expect($seeTags)->toBeArray()->toHaveCount(1); expect($seeTags[0])->toBeInstanceOf(See::class); expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestFixture::newMethod()'); $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); expect($nonExistentTags)->toBeArray()->toBeEmpty(); }); test('handles method with no docblock gracefully', function () { $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($docBlock)->toBeNull(); expect($this->parser->getSummary($docBlock))->toBeNull(); expect($this->parser->getDescription($docBlock))->toBeNull(); expect($this->parser->getParamTags($docBlock))->toBeArray()->toBeEmpty(); expect($this->parser->getReturnTag($docBlock))->toBeNull(); }); ``` -------------------------------------------------------------------------------- /src/Attributes/Schema.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Attributes; use Attribute; /** * Defines a JSON Schema for a method's input or an individual parameter. * * When used at the method level, it describes an object schema where properties * correspond to the method's parameters. * * When used at the parameter level, it describes the schema for that specific parameter. * If 'type' is omitted at the parameter level, it will be inferred. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] class Schema { /** * The complete JSON schema array. * If provided, it takes precedence over individual properties like $type, $properties, etc. */ public ?array $definition = null; /** * Alternatively, provide individual top-level schema keywords. * These are used if $definition is null. */ public ?string $type = null; public ?string $description = null; public mixed $default = null; public ?array $enum = null; // list of allowed values public ?string $format = null; // e.g., 'email', 'date-time' // Constraints for string public ?int $minLength = null; public ?int $maxLength = null; public ?string $pattern = null; // Constraints for number/integer public int|float|null $minimum = null; public int|float|null $maximum = null; public ?bool $exclusiveMinimum = null; public ?bool $exclusiveMaximum = null; public int|float|null $multipleOf = null; // Constraints for array public ?array $items = null; // JSON schema for array items public ?int $minItems = null; public ?int $maxItems = null; public ?bool $uniqueItems = null; // Constraints for object (primarily used when Schema is on a method or an object-typed parameter) public ?array $properties = null; // [propertyName => [schema array], ...] public ?array $required = null; // [propertyName, ...] public bool|array|null $additionalProperties = null; // true, false, or a schema array /** * @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored. * @param Type|null $type The JSON schema type. * @param string|null $description Description of the element. * @param array|null $enum Allowed enum values. * @param string|null $format String format (e.g., 'date-time', 'email'). * @param int|null $minLength Minimum length for strings. * @param int|null $maxLength Maximum length for strings. * @param string|null $pattern Regex pattern for strings. * @param int|float|null $minimum Minimum value for numbers/integers. * @param int|float|null $maximum Maximum value for numbers/integers. * @param bool|null $exclusiveMinimum Exclusive minimum. * @param bool|null $exclusiveMaximum Exclusive maximum. * @param int|float|null $multipleOf Must be a multiple of this value. * @param array|null $items JSON Schema for items if type is 'array'. * @param int|null $minItems Minimum items for an array. * @param int|null $maxItems Maximum items for an array. * @param bool|null $uniqueItems Whether array items must be unique. * @param array|null $properties Property definitions if type is 'object'. [name => schema_array]. * @param array|null $required List of required properties for an object. * @param bool|array|null $additionalProperties Policy for additional properties in an object. */ public function __construct( ?array $definition = null, ?string $type = null, ?string $description = null, ?array $enum = null, ?string $format = null, ?int $minLength = null, ?int $maxLength = null, ?string $pattern = null, int|float|null $minimum = null, int|float|null $maximum = null, ?bool $exclusiveMinimum = null, ?bool $exclusiveMaximum = null, int|float|null $multipleOf = null, ?array $items = null, ?int $minItems = null, ?int $maxItems = null, ?bool $uniqueItems = null, ?array $properties = null, ?array $required = null, bool|array|null $additionalProperties = null ) { if ($definition !== null) { $this->definition = $definition; } else { $this->type = $type; $this->description = $description; $this->enum = $enum; $this->format = $format; $this->minLength = $minLength; $this->maxLength = $maxLength; $this->pattern = $pattern; $this->minimum = $minimum; $this->maximum = $maximum; $this->exclusiveMinimum = $exclusiveMinimum; $this->exclusiveMaximum = $exclusiveMaximum; $this->multipleOf = $multipleOf; $this->items = $items; $this->minItems = $minItems; $this->maxItems = $maxItems; $this->uniqueItems = $uniqueItems; $this->properties = $properties; $this->required = $required; $this->additionalProperties = $additionalProperties; } } /** * Converts the attribute's definition to a JSON schema array. */ public function toArray(): array { if ($this->definition !== null) { return [ 'definition' => $this->definition, ]; } $schema = []; if ($this->type !== null) { $schema['type'] = $this->type; } if ($this->description !== null) { $schema['description'] = $this->description; } if ($this->enum !== null) { $schema['enum'] = $this->enum; } if ($this->format !== null) { $schema['format'] = $this->format; } // String if ($this->minLength !== null) { $schema['minLength'] = $this->minLength; } if ($this->maxLength !== null) { $schema['maxLength'] = $this->maxLength; } if ($this->pattern !== null) { $schema['pattern'] = $this->pattern; } // Numeric if ($this->minimum !== null) { $schema['minimum'] = $this->minimum; } if ($this->maximum !== null) { $schema['maximum'] = $this->maximum; } if ($this->exclusiveMinimum !== null) { $schema['exclusiveMinimum'] = $this->exclusiveMinimum; } if ($this->exclusiveMaximum !== null) { $schema['exclusiveMaximum'] = $this->exclusiveMaximum; } if ($this->multipleOf !== null) { $schema['multipleOf'] = $this->multipleOf; } // Array if ($this->items !== null) { $schema['items'] = $this->items; } if ($this->minItems !== null) { $schema['minItems'] = $this->minItems; } if ($this->maxItems !== null) { $schema['maxItems'] = $this->maxItems; } if ($this->uniqueItems !== null) { $schema['uniqueItems'] = $this->uniqueItems; } // Object if ($this->properties !== null) { $schema['properties'] = $this->properties; } if ($this->required !== null) { $schema['required'] = $this->required; } if ($this->additionalProperties !== null) { $schema['additionalProperties'] = $this->additionalProperties; } return $schema; } } ``` -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server; use LogicException; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DiscoveryException; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Utils\Discoverer; use Throwable; /** * Core MCP Server instance. * * Holds the configured MCP logic (Configuration, Registry, Protocol) * but is transport-agnostic. It relies on a ServerTransportInterface implementation, * provided via the listen() method, to handle network communication. * * Instances should be created via the ServerBuilder. */ class Server { protected bool $discoveryRan = false; protected bool $isListening = false; /** * @internal Use ServerBuilder::make()->...->build(). * * @param Configuration $configuration Core configuration and dependencies. * @param Registry $registry Holds registered MCP element definitions. * @param Protocol $protocol Handles MCP requests and responses. */ public function __construct( protected readonly Configuration $configuration, protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, ) { } public static function make(): ServerBuilder { return new ServerBuilder(); } /** * Runs the attribute discovery process based on the configuration * provided during build time. Caches results if cache is available. * Can be called explicitly, but is also called by ServerBuilder::build() * if discovery paths are configured. * * @param bool $force Re-run discovery even if already run. * @param bool $useCache Attempt to load from/save to cache. Defaults to true if cache is available. * * @throws DiscoveryException If discovery process encounters errors. * @throws ConfigurationException If discovery paths were not configured. */ public function discover( string $basePath, array $scanDirs = ['.', 'src'], array $excludeDirs = [], bool $force = false, bool $saveToCache = true, ?Discoverer $discoverer = null ): void { $realBasePath = realpath($basePath); if ($realBasePath === false || ! is_dir($realBasePath)) { throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}"); } $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']); if ($this->discoveryRan && ! $force) { $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.'); return; } $cacheAvailable = $this->configuration->cache !== null; $shouldSaveCache = $saveToCache && $cacheAvailable; $this->configuration->logger->info('Starting MCP element discovery...', [ 'basePath' => $realBasePath, 'force' => $force, 'saveToCache' => $shouldSaveCache, ]); $this->registry->clear(); try { $discoverer ??= new Discoverer($this->registry, $this->configuration->logger); $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); $this->discoveryRan = true; if ($shouldSaveCache) { $this->registry->save(); } } catch (Throwable $e) { $this->discoveryRan = false; $this->configuration->logger->critical('MCP element discovery failed.', ['exception' => $e]); throw new DiscoveryException("Element discovery failed: {$e->getMessage()}", $e->getCode(), $e); } } /** * Binds the server's MCP logic to the provided transport and starts the transport's listener, * then runs the event loop, making this a BLOCKING call suitable for standalone servers. * * For framework integration where the loop is managed externally, use `getProtocol()` * and bind it to your framework's transport mechanism manually. * * @param ServerTransportInterface $transport The transport to listen with. * * @throws LogicException If called after already listening. * @throws Throwable If transport->listen() fails immediately. */ public function listen(ServerTransportInterface $transport, bool $runLoop = true): void { if ($this->isListening) { throw new LogicException('Server is already listening via a transport.'); } $this->warnIfNoElements(); if ($transport instanceof LoggerAwareInterface) { $transport->setLogger($this->configuration->logger); } if ($transport instanceof LoopAwareInterface) { $transport->setLoop($this->configuration->loop); } $protocol = $this->getProtocol(); $closeHandlerCallback = function (?string $reason = null) use ($protocol) { $this->isListening = false; $this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']); $protocol->unbindTransport(); $this->configuration->loop->stop(); }; $transport->once('close', $closeHandlerCallback); $protocol->bindTransport($transport); try { $transport->listen(); $this->isListening = true; if ($runLoop) { $this->sessionManager->startGcTimer(); $this->configuration->loop->run(); $this->endListen($transport); } } catch (Throwable $e) { $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]); $this->endListen($transport); throw $e; } } public function endListen(ServerTransportInterface $transport): void { $protocol = $this->getProtocol(); $protocol->unbindTransport(); $this->sessionManager->stopGcTimer(); $transport->removeAllListeners('close'); $transport->close(); $this->isListening = false; $this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down."); } /** * Warns if no MCP elements are registered and discovery has not been run. */ protected function warnIfNoElements(): void { if (! $this->registry->hasElements() && ! $this->discoveryRan) { $this->configuration->logger->warning( 'Starting listener, but no MCP elements are registered and discovery has not been run. ' . 'Call $server->discover(...) at least once to find and cache elements before listen().' ); } elseif (! $this->registry->hasElements() && $this->discoveryRan) { $this->configuration->logger->warning( 'Starting listener, but no MCP elements were found after discovery/cache load.' ); } } /** * Gets the Configuration instance associated with this server. */ public function getConfiguration(): Configuration { return $this->configuration; } /** * Gets the Registry instance associated with this server. */ public function getRegistry(): Registry { return $this->registry; } /** * Gets the Protocol instance associated with this server. */ public function getProtocol(): Protocol { return $this->protocol; } public function getSessionManager(): SessionManager { return $this->sessionManager; } } ``` -------------------------------------------------------------------------------- /tests/Unit/Session/ArraySessionHandlerTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Session; use PhpMcp\Server\Session\ArraySessionHandler; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\SystemClock; use PhpMcp\Server\Tests\Mocks\Clock\FixedClock; const SESSION_ID_ARRAY_1 = 'array-session-id-1'; const SESSION_ID_ARRAY_2 = 'array-session-id-2'; const SESSION_ID_ARRAY_3 = 'array-session-id-3'; const SESSION_DATA_1 = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":2},{"id":"prod_B","qty":1}],"total":150.75},"theme":"dark"}'; const SESSION_DATA_2 = '{"user_id":102,"preferences":{"notifications":true,"language":"en"},"last_login":"2024-07-15T10:00:00Z"}'; const SESSION_DATA_3 = '{"guest":true,"viewed_products":["prod_C","prod_D"]}'; const DEFAULT_TTL_ARRAY = 3600; beforeEach(function () { $this->fixedClock = new FixedClock(); $this->handler = new ArraySessionHandler(DEFAULT_TTL_ARRAY, $this->fixedClock); }); it('implements SessionHandlerInterface', function () { expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); }); it('constructs with a default TTL and SystemClock if no clock provided', function () { $handler = new ArraySessionHandler(); expect($handler->ttl)->toBe(DEFAULT_TTL_ARRAY); $reflection = new \ReflectionClass($handler); $clockProp = $reflection->getProperty('clock'); $clockProp->setAccessible(true); expect($clockProp->getValue($handler))->toBeInstanceOf(SystemClock::class); }); it('constructs with a custom TTL and injected clock', function () { $customTtl = 1800; $clock = new FixedClock(); $handler = new ArraySessionHandler($customTtl, $clock); expect($handler->ttl)->toBe($customTtl); $reflection = new \ReflectionClass($handler); $clockProp = $reflection->getProperty('clock'); $clockProp->setAccessible(true); expect($clockProp->getValue($handler))->toBe($clock); }); it('writes session data and reads it back correctly', function () { $writeResult = $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); expect($writeResult)->toBeTrue(); $readData = $this->handler->read(SESSION_ID_ARRAY_1); expect($readData)->toBe(SESSION_DATA_1); }); it('returns false when reading a non-existent session', function () { $readData = $this->handler->read('non-existent-session-id'); expect($readData)->toBeFalse(); }); it('overwrites existing session data on subsequent write', function () { $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $updatedData = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":3}],"total":175.25},"theme":"light"}'; $this->handler->write(SESSION_ID_ARRAY_1, $updatedData); $readData = $this->handler->read(SESSION_ID_ARRAY_1); expect($readData)->toBe($updatedData); }); it('returns false and removes data when reading an expired session due to handler TTL', function () { $ttl = 60; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler($ttl, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds($ttl + 1); $readData = $handler->read(SESSION_ID_ARRAY_1); expect($readData)->toBeFalse(); $reflection = new \ReflectionClass($handler); $storeProp = $reflection->getProperty('store'); $storeProp->setAccessible(true); $internalStore = $storeProp->getValue($handler); expect($internalStore)->not->toHaveKey(SESSION_ID_ARRAY_1); }); it('does not return data if read exactly at TTL expiration time', function () { $shortTtl = 60; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler($shortTtl, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds($shortTtl); $readData = $handler->read(SESSION_ID_ARRAY_1); expect($readData)->toBe(SESSION_DATA_1); $fixedClock->addSecond(); $readDataExpired = $handler->read(SESSION_ID_ARRAY_1); expect($readDataExpired)->toBeFalse(); }); it('updates timestamp on write, effectively extending session life', function () { $veryShortTtl = 5; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler($veryShortTtl, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds(3); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_2); $fixedClock->addSeconds(3); $readData = $handler->read(SESSION_ID_ARRAY_1); expect($readData)->toBe(SESSION_DATA_2); }); it('destroys an existing session and it cannot be read', function () { $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); $destroyResult = $this->handler->destroy(SESSION_ID_ARRAY_1); expect($destroyResult)->toBeTrue(); expect($this->handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); $reflection = new \ReflectionClass($this->handler); $storeProp = $reflection->getProperty('store'); $storeProp->setAccessible(true); expect($storeProp->getValue($this->handler))->not->toHaveKey(SESSION_ID_ARRAY_1); }); it('destroy returns true and does nothing for a non-existent session', function () { $destroyResult = $this->handler->destroy('non-existent-id'); expect($destroyResult)->toBeTrue(); }); it('garbage collects only sessions older than maxLifetime', function () { $gcMaxLifetime = 100; $handlerTtl = 300; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler($handlerTtl, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds(50); $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); $fixedClock->addSeconds(80); $deletedSessions = $handler->gc($gcMaxLifetime); expect($deletedSessions)->toBeArray()->toEqual([SESSION_ID_ARRAY_1]); expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); expect($handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); }); it('garbage collection respects maxLifetime precisely', function () { $maxLifetime = 60; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler(300, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds($maxLifetime); $deleted = $handler->gc($maxLifetime); expect($deleted)->toBeEmpty(); expect($handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); $fixedClock->addSecond(); $deleted2 = $handler->gc($maxLifetime); expect($deleted2)->toEqual([SESSION_ID_ARRAY_1]); expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); }); it('garbage collection returns empty array if no sessions meet criteria', function () { $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $this->handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); $this->fixedClock->addSeconds(DEFAULT_TTL_ARRAY / 2); $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); expect($deletedSessions)->toBeArray()->toBeEmpty(); expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); expect($this->handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); }); it('garbage collection correctly handles an empty store', function () { $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); expect($deletedSessions)->toBeArray()->toBeEmpty(); }); it('garbage collection removes multiple expired sessions', function () { $maxLifetime = 30; $fixedClock = new FixedClock(); $handler = new ArraySessionHandler(300, $fixedClock); $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); $fixedClock->addSeconds(20); $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); $fixedClock->addSeconds(20); $handler->write(SESSION_ID_ARRAY_3, SESSION_DATA_3); $fixedClock->addSeconds(20); $deleted = $handler->gc($maxLifetime); expect($deleted)->toHaveCount(2)->toContain(SESSION_ID_ARRAY_1)->toContain(SESSION_ID_ARRAY_2); expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); expect($handler->read(SESSION_ID_ARRAY_2))->toBeFalse(); expect($handler->read(SESSION_ID_ARRAY_3))->toBe(SESSION_DATA_3); }); ``` -------------------------------------------------------------------------------- /src/Defaults/BasicContainer.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); // Added missing strict_types namespace PhpMcp\Server\Defaults; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use ReflectionClass; use ReflectionException; use ReflectionNamedType; use ReflectionParameter; use Throwable; // Changed from \Throwable to Throwable /** * A basic PSR-11 container implementation with simple constructor auto-wiring. * * Supports instantiating classes with parameterless constructors or constructors * where all parameters are type-hinted classes/interfaces known to the container, * or have default values. Does NOT support scalar/built-in type injection without defaults. */ class BasicContainer implements ContainerInterface { /** @var array<string, object> Cache for already created instances (shared singletons) */ private array $instances = []; /** @var array<string, bool> Track classes currently being resolved to detect circular dependencies */ private array $resolving = []; /** * Finds an entry of the container by its identifier and returns it. * * @param string $id Identifier of the entry to look for (usually a FQCN). * @return mixed Entry. * * @throws NotFoundExceptionInterface No entry was found for **this** identifier. * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency). */ public function get(string $id): mixed { // 1. Check instance cache if (isset($this->instances[$id])) { return $this->instances[$id]; } // 2. Check if class exists if (! class_exists($id) && ! interface_exists($id)) { // Also check interface for bindings throw new NotFoundException("Class, interface, or entry '{$id}' not found."); } // 7. Circular Dependency Check if (isset($this->resolving[$id])) { throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}"); } $this->resolving[$id] = true; // Mark as currently resolving try { // 3. Reflect on the class $reflector = new ReflectionClass($id); // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated) if (! $reflector->isInstantiable()) { // We might have an interface bound to a concrete class via set() // This check is slightly redundant due to class_exists but good practice throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding)."); } // 4. Get the constructor $constructor = $reflector->getConstructor(); // 5. If no constructor or constructor has no parameters, instantiate directly if ($constructor === null || $constructor->getNumberOfParameters() === 0) { $instance = $reflector->newInstance(); } else { // 6. Constructor has parameters, attempt to resolve them $parameters = $constructor->getParameters(); $resolvedArgs = []; foreach ($parameters as $parameter) { $resolvedArgs[] = $this->resolveParameter($parameter, $id); } // Instantiate with resolved arguments $instance = $reflector->newInstanceArgs($resolvedArgs); } // Cache the instance $this->instances[$id] = $instance; return $instance; } catch (ReflectionException $e) { throw new ContainerException("Reflection failed for '{$id}'.", 0, $e); } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly throw $e; } catch (Throwable $e) { // Catch other instantiation errors throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e); } finally { // 7. Remove from resolving stack once done (success or failure) unset($this->resolving[$id]); } } /** * Attempts to resolve a single constructor parameter. * * @throws ContainerExceptionInterface If a required dependency cannot be resolved. */ private function resolveParameter(ReflectionParameter $parameter, string $consumerClassId): mixed { // Check for type hint $type = $parameter->getType(); if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { // Type hint is a class or interface name $typeName = $type->getName(); try { // Recursively get the dependency return $this->get($typeName); } catch (NotFoundExceptionInterface $e) { // Dependency class not found, fail ONLY if required if (! $parameter->isOptional() && ! $parameter->allowsNull()) { throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e); } // If optional or nullable, proceed (will check allowsNull/Default below) } catch (ContainerExceptionInterface $e) { // Dependency itself failed to resolve (e.g., its own deps, circular) throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e); } } // Check if parameter has a default value if ($parameter->isDefaultValueAvailable()) { return $parameter->getDefaultValue(); } // Check if parameter allows null (and wasn't resolved above) if ($parameter->allowsNull()) { return null; } // Check if it was a built-in type without a default (unresolvable by this basic container) if ($type instanceof ReflectionNamedType && $type->isBuiltin()) { throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); } // Check if it was a union/intersection type without a default (also unresolvable) if ($type !== null && ! $type instanceof ReflectionNamedType) { throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); } // If we reach here, it's an untyped, required parameter without a default. // Or potentially an unresolvable optional class dependency where null is not allowed (edge case). throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type)."); } /** * Returns true if the container can return an entry for the given identifier. * Checks explicitly set instances and if the class/interface exists. * Does not guarantee `get()` will succeed if auto-wiring fails. */ public function has(string $id): bool { return isset($this->instances[$id]) || class_exists($id) || interface_exists($id); } /** * Adds a pre-built instance or a factory/binding to the container. * This basic version only supports pre-built instances (singletons). */ public function set(string $id, object $instance): void { // Could add support for closures/factories later if needed $this->instances[$id] = $instance; } } // Keep custom exception classes as they are PSR-11 compliant placeholders class ContainerException extends \Exception implements ContainerExceptionInterface { } class NotFoundException extends \Exception implements NotFoundExceptionInterface { } ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredToolTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Elements; use InvalidArgumentException; 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; use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture; use Psr\Container\ContainerInterface; use JsonException; use PhpMcp\Server\Exception\McpServerException; uses(MockeryPHPUnitIntegration::class); beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->handlerInstance = new ToolHandlerFixture(); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class) ->andReturn($this->handlerInstance)->byDefault(); $this->toolSchema = Tool::make( name: 'test-tool', inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]] ); $this->registeredTool = RegisteredTool::make( $this->toolSchema, [ToolHandlerFixture::class, 'greet'] ); $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly and exposes schema', function () { expect($this->registeredTool->schema)->toBe($this->toolSchema); expect($this->registeredTool->handler)->toBe([ToolHandlerFixture::class, 'greet']); expect($this->registeredTool->isManual)->toBeFalse(); }); it('can be made as a manual registration', function () { $manualTool = RegisteredTool::make($this->toolSchema, [ToolHandlerFixture::class, 'greet'], true); expect($manualTool->isManual)->toBeTrue(); }); it('calls the handler with prepared arguments', function () { $tool = RegisteredTool::make( Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]), [ToolHandlerFixture::class, 'sum'] ); $mockHandler = Mockery::mock(ToolHandlerFixture::class); $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'], $this->context); // '10' will be cast to int by prepareArguments expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15'); }); it('calls handler with no arguments if tool takes none and none provided', function () { $tool = RegisteredTool::make( Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'noParamsTool'] ); $mockHandler = Mockery::mock(ToolHandlerFixture::class); $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); $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)); }); dataset('tool_handler_return_values', [ 'string' => ['returnString', "This is a string result."], 'integer' => ['returnInteger', "12345"], 'float' => ['returnFloat', "67.89"], 'boolean_true' => ['returnBooleanTrue', "true"], 'boolean_false' => ['returnBooleanFalse', "false"], 'null' => ['returnNull', "(null)"], 'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], 'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], ]); it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) { $tool = RegisteredTool::make( Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, $handlerMethod] ); $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText); })->with('tool_handler_return_values'); it('returns single Content object from handler as array with one Content object', function () { $tool = RegisteredTool::make( Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnTextContent'] ); $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent."); }); it('returns array of Content objects from handler as is', function () { $tool = RegisteredTool::make( Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnArrayOfContent'] ); $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(2); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1"); expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata"); }); it('formats mixed array from handler into array of Content objects', function () { $tool = RegisteredTool::make( Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnMixedArray'] ); $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(8); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string"); expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123"); expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true"); expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)"); expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); }); it('formats empty array from handler into TextContent with "[]"', function () { $tool = RegisteredTool::make( Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'returnEmptyArray'] ); $resultContents = $tool->call($this->container, [], $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]'); }); it('throws JsonException during formatResult if handler returns unencodable value', function () { $tool = RegisteredTool::make( Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'toolUnencodableResult'] ); $tool->call($this->container, [], $this->context); })->throws(JsonException::class); it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () { $tool = RegisteredTool::make( Tool::make('exception-tool', ['type' => 'object', 'properties' => []]), [ToolHandlerFixture::class, 'toolThatThrows'] ); $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture()); $tool->call($this->container, [], $this->context); })->throws(InvalidArgumentException::class, "Something went wrong in the tool."); ``` -------------------------------------------------------------------------------- /src/Transports/StdioServerTransport.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Message; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Stream\ReadableResourceStream; use React\Stream\ReadableStreamInterface; use React\Stream\WritableResourceStream; use React\Stream\WritableStreamInterface; use Throwable; use function React\Promise\reject; /** * Implementation of the STDIO server transport using ReactPHP Process and Streams. * Listens on STDIN, writes to STDOUT, and emits events for the Protocol. */ class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; protected LoggerInterface $logger; protected LoopInterface $loop; protected ?Process $process = null; protected ?ReadableStreamInterface $stdin = null; protected ?WritableStreamInterface $stdout = null; protected string $buffer = ''; protected bool $closing = false; protected bool $listening = false; private const CLIENT_ID = 'stdio'; /** * Constructor takes optional stream resources. * Defaults to STDIN and STDOUT if not provided. * Dependencies like Logger and Loop are injected via setters. * * @param resource|null $inputStreamResource The readable resource (e.g., STDIN). * @param resource|null $outputStreamResource The writable resource (e.g., STDOUT). * * @throws TransportException If provided resources are invalid. */ public function __construct( protected $inputStreamResource = STDIN, protected $outputStreamResource = STDOUT ) { if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) { $message = 'STDIN and STDOUT are not supported as input and output stream resources' . 'on Windows due to PHP\'s limitations with non blocking pipes.' . 'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.'; throw new TransportException($message); } // if (str_contains(PHP_OS, 'WIN')) { // $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r')); // $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w')); // } if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') { throw new TransportException('Invalid input stream resource provided.'); } if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') { throw new TransportException('Invalid output stream resource provided.'); } $this->logger = new NullLogger(); $this->loop = Loop::get(); } public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; } public function setLoop(LoopInterface $loop): void { $this->loop = $loop; } /** * Starts listening on STDIN. * * @throws TransportException If already listening or streams cannot be opened. */ public function listen(): void { if ($this->listening) { throw new TransportException('Stdio transport is already listening.'); } if ($this->closing) { throw new TransportException('Cannot listen, transport is closing/closed.'); } try { $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop); $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop); } catch (Throwable $e) { $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]); throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e); } $this->stdin->on('data', function ($chunk) { $this->buffer .= $chunk; $this->processBuffer(); }); $this->stdin->on('error', function (Throwable $error) { $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); $this->stdin->on('close', function () { $this->logger->info('STDIN stream closed.'); $this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']); $this->close(); }); $this->stdout->on('error', function (Throwable $error) { $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); try { $signalHandler = function (int $signal) { $this->logger->info("Received signal {$signal}, shutting down."); $this->close(); }; $this->loop->addSignal(SIGTERM, $signalHandler); $this->loop->addSignal(SIGINT, $signalHandler); } catch (Throwable $e) { $this->logger->debug('Signal handling not supported by current event loop.'); } $this->logger->info('Server is up and listening on STDIN 🚀'); $this->listening = true; $this->closing = false; $this->emit('ready'); $this->emit('client_connected', [self::CLIENT_ID]); } /** Processes the internal buffer to find complete lines/frames. */ private function processBuffer(): void { while (str_contains($this->buffer, "\n")) { $pos = strpos($this->buffer, "\n"); $line = substr($this->buffer, 0, $pos); $this->buffer = substr($this->buffer, $pos + 1); $trimmedLine = trim($line); if (empty($trimmedLine)) { continue; } try { $message = Parser::parse($trimmedLine); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['exception' => $e]); $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); $this->sendMessage($error, self::CLIENT_ID); continue; } $this->emit('message', [$message, self::CLIENT_ID]); } } /** * Sends a raw, framed message to STDOUT. */ public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) { return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); } $deferred = new Deferred(); $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $written = $this->stdout->write($json . "\n"); if ($written) { $deferred->resolve(null); } else { $this->logger->debug('STDOUT buffer full, waiting for drain.'); $this->stdout->once('drain', function () use ($deferred) { $this->logger->debug('STDOUT drained.'); $deferred->resolve(null); }); } return $deferred->promise(); } /** * Stops listening and cleans up resources. */ public function close(): void { if ($this->closing) { return; } $this->closing = true; $this->listening = false; $this->logger->info('Closing Stdio transport...'); $this->stdin?->close(); $this->stdout?->close(); $this->stdin = null; $this->stdout = null; $this->emit('close', ['StdioTransport closed.']); $this->removeAllListeners(); } } ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredResource.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Elements; use PhpMcp\Schema\Content\BlobResourceContents; use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Schema\Content\ResourceContents; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Resource; use PhpMcp\Server\Context; use Psr\Container\ContainerInterface; use Throwable; class RegisteredResource extends RegisteredElement { public function __construct( public readonly Resource $schema, callable|array|string $handler, bool $isManual = false, ) { parent::__construct($handler, $isManual); } public static function make(Resource $schema, callable|array|string $handler, bool $isManual = false): self { return new self($schema, $handler, $isManual); } /** * Reads the resource content. * * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects. */ public function read(ContainerInterface $container, string $uri, Context $context): array { $result = $this->handle($container, ['uri' => $uri], $context); return $this->formatResult($result, $uri, $this->schema->mimeType); } /** * Formats the raw result of a resource read operation into MCP ResourceContent items. * * @param mixed $readResult The raw result from the resource handler method. * @param string $uri The URI of the resource that was read. * @param ?string $mimeType The MIME type from the ResourceDefinition. * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects. * * @throws \RuntimeException If the result cannot be formatted. * * Supported result types: * - ResourceContent: Used as-is * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type * - array with 'blob' key: Used as blob content * - array with 'text' key: Used as text content * - SplFileInfo: Read and converted to blob * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array { if ($readResult instanceof ResourceContents) { return [$readResult]; } if ($readResult instanceof EmbeddedResource) { return [$readResult->resource]; } if (is_array($readResult)) { if (empty($readResult)) { return [TextResourceContents::make($uri, 'application/json', '[]')]; } $allAreResourceContents = true; $hasResourceContents = false; $allAreEmbeddedResource = true; $hasEmbeddedResource = false; foreach ($readResult as $item) { if ($item instanceof ResourceContents) { $hasResourceContents = true; $allAreEmbeddedResource = false; } elseif ($item instanceof EmbeddedResource) { $hasEmbeddedResource = true; $allAreResourceContents = false; } else { $allAreResourceContents = false; $allAreEmbeddedResource = false; } } if ($allAreResourceContents && $hasResourceContents) { return $readResult; } if ($allAreEmbeddedResource && $hasEmbeddedResource) { return array_map(fn($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { $result = []; foreach ($readResult as $item) { if ($item instanceof ResourceContents) { $result[] = $item; } elseif ($item instanceof EmbeddedResource) { $result[] = $item->resource; } else { $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); } } return $result; } } if (is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); return [TextResourceContents::make($uri, $mimeType, $readResult)]; } if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { $result = BlobResourceContents::fromStream( $uri, $readResult, $mimeType ?? 'application/octet-stream' ); @fclose($readResult); return [$result]; } if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; } return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; } if (is_array($readResult)) { if ($mimeType && (str_contains(strtolower($mimeType), 'json') || $mimeType === 'application/json')) { try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } } try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } } throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); } /** Guesses MIME type from string content (very basic) */ private function guessMimeTypeFromString(string $content): string { $trimmed = ltrim($content); if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { if (str_contains($trimmed, '<html')) { return 'text/html'; } if (str_contains($trimmed, '<?xml')) { return 'application/xml'; } return 'text/plain'; } if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) { return 'application/json'; } if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) { return 'application/json'; } return 'text/plain'; } public function toArray(): array { return [ 'schema' => $this->schema->toArray(), ...parent::toArray(), ]; } public static function fromArray(array $data): self|false { try { if (! isset($data['schema']) || ! isset($data['handler'])) { return false; } return new self( Resource::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, ); } catch (Throwable $e) { return false; } } } ``` -------------------------------------------------------------------------------- /src/Defaults/FileCache.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Defaults; use DateInterval; use DateTimeImmutable; use InvalidArgumentException; use Psr\SimpleCache\CacheInterface; use Throwable; /** * Basic PSR-16 file cache implementation. * * Stores cache entries serialized in a JSON file. * Uses file locking for basic concurrency control during writes. * Not recommended for high-concurrency environments. */ class FileCache implements CacheInterface { /** * @param string $cacheFile Absolute path to the cache file. * The directory will be created if it doesn't exist. * @param int $filePermission Optional file mode (octal) for the cache file (default: 0664). * @param int $dirPermission Optional directory mode (octal) for the cache directory (default: 0775). */ public function __construct( private readonly string $cacheFile, private readonly int $filePermission = 0664, private readonly int $dirPermission = 0775 ) { $this->ensureDirectoryExists(dirname($this->cacheFile)); } // --------------------------------------------------------------------- // PSR-16 Methods // --------------------------------------------------------------------- public function get(string $key, mixed $default = null): mixed { $data = $this->readCacheFile(); $key = $this->sanitizeKey($key); if (! isset($data[$key])) { return $default; } if ($this->isExpired($data[$key]['expiry'])) { $this->delete($key); // Clean up expired entry return $default; } return $data[$key]['value'] ?? $default; } public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool { $data = $this->readCacheFile(); $key = $this->sanitizeKey($key); $data[$key] = [ 'value' => $value, 'expiry' => $this->calculateExpiry($ttl), ]; return $this->writeCacheFile($data); } public function delete(string $key): bool { $data = $this->readCacheFile(); $key = $this->sanitizeKey($key); if (isset($data[$key])) { unset($data[$key]); return $this->writeCacheFile($data); } return true; // Key didn't exist, considered successful delete } public function clear(): bool { // Write an empty array to the file return $this->writeCacheFile([]); } public function getMultiple(iterable $keys, mixed $default = null): iterable { $keys = $this->iterableToArray($keys); $this->validateKeys($keys); $data = $this->readCacheFile(); $results = []; $needsWrite = false; foreach ($keys as $key) { $sanitizedKey = $this->sanitizeKey($key); if (! isset($data[$sanitizedKey])) { $results[$key] = $default; continue; } if ($this->isExpired($data[$sanitizedKey]['expiry'])) { unset($data[$sanitizedKey]); // Clean up expired entry $needsWrite = true; $results[$key] = $default; continue; } $results[$key] = $data[$sanitizedKey]['value'] ?? $default; } if ($needsWrite) { $this->writeCacheFile($data); } return $results; } public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool { $values = $this->iterableToArray($values); $this->validateKeys(array_keys($values)); $data = $this->readCacheFile(); $expiry = $this->calculateExpiry($ttl); foreach ($values as $key => $value) { $sanitizedKey = $this->sanitizeKey((string) $key); $data[$sanitizedKey] = [ 'value' => $value, 'expiry' => $expiry, ]; } return $this->writeCacheFile($data); } public function deleteMultiple(iterable $keys): bool { $keys = $this->iterableToArray($keys); $this->validateKeys($keys); $data = $this->readCacheFile(); $deleted = false; foreach ($keys as $key) { $sanitizedKey = $this->sanitizeKey($key); if (isset($data[$sanitizedKey])) { unset($data[$sanitizedKey]); $deleted = true; } } if ($deleted) { return $this->writeCacheFile($data); } return true; // No keys existed or no changes made } public function has(string $key): bool { $data = $this->readCacheFile(); $key = $this->sanitizeKey($key); if (! isset($data[$key])) { return false; } if ($this->isExpired($data[$key]['expiry'])) { $this->delete($key); // Clean up expired return false; } return true; } // --------------------------------------------------------------------- // Internal Methods // --------------------------------------------------------------------- private function readCacheFile(): array { if (! file_exists($this->cacheFile) || filesize($this->cacheFile) === 0) { return []; } $handle = @fopen($this->cacheFile, 'rb'); if ($handle === false) { return []; } try { if (! flock($handle, LOCK_SH)) { return []; } $content = stream_get_contents($handle); flock($handle, LOCK_UN); if ($content === false || $content === '') { return []; } $data = unserialize($content); if ($data === false) { return []; } return $data; } finally { if (is_resource($handle)) { fclose($handle); } } } private function writeCacheFile(array $data): bool { $jsonData = serialize($data); if ($jsonData === false) { return false; } $handle = @fopen($this->cacheFile, 'cb'); if ($handle === false) { return false; } try { if (! flock($handle, LOCK_EX)) { return false; } if (! ftruncate($handle, 0)) { return false; } if (fwrite($handle, $jsonData) === false) { return false; } fflush($handle); flock($handle, LOCK_UN); @chmod($this->cacheFile, $this->filePermission); return true; } catch (Throwable $e) { flock($handle, LOCK_UN); // Ensure lock release on error return false; } finally { if (is_resource($handle)) { fclose($handle); } } } private function ensureDirectoryExists(string $directory): void { if (! is_dir($directory)) { if (! @mkdir($directory, $this->dirPermission, true)) { throw new InvalidArgumentException("Cache directory does not exist and could not be created: {$directory}"); } @chmod($directory, $this->dirPermission); } } private function calculateExpiry(DateInterval|int|null $ttl): ?int { if ($ttl === null) { return null; } $now = time(); if (is_int($ttl)) { return $ttl <= 0 ? $now - 1 : $now + $ttl; } if ($ttl instanceof DateInterval) { try { return (new DateTimeImmutable())->add($ttl)->getTimestamp(); } catch (Throwable $e) { return null; } } throw new InvalidArgumentException('Invalid TTL type provided. Must be null, int, or DateInterval.'); } private function isExpired(?int $expiry): bool { return $expiry !== null && time() >= $expiry; } private function sanitizeKey(string $key): string { if ($key === '') { throw new InvalidArgumentException('Cache key cannot be empty.'); } // PSR-16 validation (optional stricter check) // if (preg_match('/[{}()\/@:]/', $key)) { // throw new InvalidArgumentException("Cache key \"{$key}\" contains reserved characters."); // } return $key; } private function validateKeys(array $keys): void { foreach ($keys as $key) { if (! is_string($key)) { throw new InvalidArgumentException('Cache key must be a string, got ' . gettype($key)); } $this->sanitizeKey($key); } } private function iterableToArray(iterable $iterable): array { if (is_array($iterable)) { return $iterable; } return iterator_to_array($iterable); } } ``` -------------------------------------------------------------------------------- /tests/Integration/DiscoveryTest.php: -------------------------------------------------------------------------------- ```php <?php use PhpMcp\Server\Defaults\EnumCompletionProvider; use PhpMcp\Server\Defaults\ListCompletionProvider; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Server\Elements\RegisteredResource; use PhpMcp\Server\Elements\RegisteredResourceTemplate; use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Registry; use PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler; use PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture; use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture; use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture; use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture; use PhpMcp\Server\Utils\Discoverer; use PhpMcp\Server\Utils\DocBlockParser; use PhpMcp\Server\Utils\SchemaGenerator; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; use Psr\Log\NullLogger; beforeEach(function () { $logger = new NullLogger(); $this->registry = new Registry($logger); $docBlockParser = new DocBlockParser($logger); $schemaGenerator = new SchemaGenerator($docBlockParser); $this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator); $this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures'); }); it('discovers all element types correctly from fixture files', function () { $scanDir = 'Discovery'; $this->discoverer->discover($this->fixtureBasePath, [$scanDir]); $tools = $this->registry->getTools(); expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool $greetUserTool = $this->registry->getTool('greet_user'); expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class) ->and($greetUserTool->isManual)->toBeFalse() ->and($greetUserTool->schema->name)->toBe('greet_user') ->and($greetUserTool->schema->description)->toBe('Greets a user by name.') ->and($greetUserTool->handler)->toBe([DiscoverableToolHandler::class, 'greet']); expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name'); $repeatActionTool = $this->registry->getTool('repeatAction'); expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class) ->and($repeatActionTool->isManual)->toBeFalse() ->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.') ->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue(); expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']); $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class) ->and($invokableCalcTool->isManual)->toBeFalse() ->and($invokableCalcTool->handler)->toBe([InvocableToolFixture::class, '__invoke']); expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull(); expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull(); expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull(); $resources = $this->registry->getResources(); expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture $appVersionRes = $this->registry->getResource('app://info/version'); expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class) ->and($appVersionRes->isManual)->toBeFalse() ->and($appVersionRes->schema->name)->toBe('app_version') ->and($appVersionRes->schema->mimeType)->toBe('text/plain'); $invokableStatusRes = $this->registry->getResource('invokable://config/status'); expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class) ->and($invokableStatusRes->isManual)->toBeFalse() ->and($invokableStatusRes->handler)->toBe([InvocableResourceFixture::class, '__invoke']); $prompts = $this->registry->getPrompts(); expect($prompts)->toHaveCount(4); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture, content_creator $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class) ->and($storyPrompt->isManual)->toBeFalse() ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords ->and($storyPrompt->completionProviders['genre'])->toBe(CompletionProviderFixture::class); $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class) ->and($simplePrompt->isManual)->toBeFalse(); $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class) ->and($invokableGreeter->isManual)->toBeFalse() ->and($invokableGreeter->handler)->toBe([InvocablePromptFixture::class, '__invoke']); $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); expect($contentCreatorPrompt)->toBeInstanceOf(RegisteredPrompt::class) ->and($contentCreatorPrompt->isManual)->toBeFalse() ->and($contentCreatorPrompt->completionProviders)->toHaveCount(3); $templates = $this->registry->getResourceTemplates(); expect($templates)->toHaveCount(4); // product_details_template, getFileContent, InvocableResourceTemplateFixture, content_template $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) ->and($productTemplate->isManual)->toBeFalse() ->and($productTemplate->schema->name)->toBe('product_details_template') ->and($productTemplate->completionProviders['region'])->toBe(CompletionProviderFixture::class); expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']); $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) ->and($invokableUserTemplate->isManual)->toBeFalse() ->and($invokableUserTemplate->handler)->toBe([InvocableResourceTemplateFixture::class, '__invoke']); }); it('does not discover elements from excluded directories', function () { $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull(); $this->registry->clear(); $this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']); expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull(); }); it('handles empty directories or directories with no PHP files', function () { $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']); expect($this->registry->getTools())->toBeEmpty(); }); it('correctly infers names and descriptions from methods/classes if not set in attribute', function () { $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); $repeatActionTool = $this->registry->getTool('repeatAction'); expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt'); expect($simplePrompt->schema->description)->toBeNull(); $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); }); it('discovers enhanced completion providers with values and enum attributes', function () { $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); $contentPrompt = $this->registry->getPrompt('content_creator'); expect($contentPrompt)->toBeInstanceOf(RegisteredPrompt::class); expect($contentPrompt->completionProviders)->toHaveCount(3); $typeProvider = $contentPrompt->completionProviders['type']; expect($typeProvider)->toBeInstanceOf(ListCompletionProvider::class); $statusProvider = $contentPrompt->completionProviders['status']; expect($statusProvider)->toBeInstanceOf(EnumCompletionProvider::class); $priorityProvider = $contentPrompt->completionProviders['priority']; expect($priorityProvider)->toBeInstanceOf(EnumCompletionProvider::class); $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); expect($contentTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class); expect($contentTemplate->completionProviders)->toHaveCount(1); $categoryProvider = $contentTemplate->completionProviders['category']; expect($categoryProvider)->toBeInstanceOf(ListCompletionProvider::class); }); ``` -------------------------------------------------------------------------------- /tests/Unit/Session/SessionManagerTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Session; use Mockery; use Mockery\MockInterface; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Session\ArraySessionHandler; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Tests\Mocks\Clock\FixedClock; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; const SESSION_ID_MGR_1 = 'manager-session-1'; const SESSION_ID_MGR_2 = 'manager-session-2'; const DEFAULT_TTL_MGR = 3600; const GC_INTERVAL_MGR = 5; beforeEach(function () { /** @var MockInterface&SessionHandlerInterface $sessionHandler */ $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class); /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); $this->loop = Loop::get(); $this->sessionManager = new SessionManager( $this->sessionHandler, $this->logger, $this->loop, DEFAULT_TTL_MGR ); $this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault(); $this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault(); $this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault(); $this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault(); }); it('creates a new session with default hydrated values and saves it', function () { $this->sessionHandler->shouldReceive('write') ->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) { $data = json_decode($dataJson, true); expect($data['initialized'])->toBeFalse(); expect($data['client_info'])->toBeNull(); expect($data['protocol_version'])->toBeNull(); expect($data['subscriptions'])->toEqual([]); expect($data['message_queue'])->toEqual([]); expect($data['log_level'])->toBeNull(); return true; }))->once()->andReturn(true); $sessionCreatedEmitted = false; $emittedSessionId = null; $emittedSessionObj = null; $this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) { $sessionCreatedEmitted = true; $emittedSessionId = $id; $emittedSessionObj = $session; }); $session = $this->sessionManager->createSession(SESSION_ID_MGR_1); expect($session)->toBeInstanceOf(SessionInterface::class); expect($session->getId())->toBe(SESSION_ID_MGR_1); expect($session->get('initialized'))->toBeFalse(); $this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]); expect($sessionCreatedEmitted)->toBeTrue(); expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); expect($emittedSessionObj)->toBe($session); }); it('gets an existing session if handler read returns data', function () { $existingData = ['user_id' => 123, 'initialized' => true]; $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData)); $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); expect($session)->toBeInstanceOf(SessionInterface::class); expect($session->getId())->toBe(SESSION_ID_MGR_1); expect($session->get('user_id'))->toBe(123); }); it('returns null from getSession if session does not exist (handler read returns false)', function () { $this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false); $session = $this->sessionManager->getSession('non-existent'); expect($session)->toBeNull(); }); it('returns null from getSession if session data is empty after load', function () { $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(false); $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); expect($session)->toBeNull(); }); it('deletes a session successfully and emits event', function () { $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true); $sessionDeletedEmitted = false; $emittedSessionId = null; $this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) { $sessionDeletedEmitted = true; $emittedSessionId = $id; }); $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); expect($success)->toBeTrue(); $this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]); expect($sessionDeletedEmitted)->toBeTrue(); expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); }); it('logs warning and does not emit event if deleteSession fails', function () { $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false); $sessionDeletedEmitted = false; $this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) { $sessionDeletedEmitted = true; }); $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); expect($success)->toBeFalse(); $this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]); expect($sessionDeletedEmitted)->toBeFalse(); }); it('queues message for existing session', function () { $sessionData = ['message_queue' => []]; $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); $message = '{"id":1}'; $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) use ($message) { $data = json_decode($dataJson, true); expect($data['message_queue'])->toEqual([$message]); return true; }))->once()->andReturn(true); $this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message); }); it('does nothing on queueMessage if session does not exist', function () { $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); $this->sessionHandler->shouldNotReceive('write'); $this->sessionManager->queueMessage('no-such-session', '{"id":1}'); }); it('dequeues messages from existing session', function () { $messages = ['{"id":1}', '{"id":2}']; $sessionData = ['message_queue' => $messages]; $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) { $data = json_decode($dataJson, true); expect($data['message_queue'])->toEqual([]); return true; }))->once()->andReturn(true); $dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1); expect($dequeued)->toEqual($messages); }); it('returns empty array from dequeueMessages if session does not exist', function () { $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]); }); it('checks hasQueuedMessages for existing session', function () { $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']])); expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue(); $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []])); expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse(); }); it('returns false from hasQueuedMessages if session does not exist', function () { $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse(); }); it('can stop GC timer on stopGcTimer ', function () { $loop = Mockery::mock(LoopInterface::class); $loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class)); $loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once(); $manager = new SessionManager($this->sessionHandler, $this->logger, $loop); $manager->startGcTimer(); $manager->stopGcTimer(); }); it('GC timer callback deletes expired sessions', function () { $clock = new FixedClock(); $sessionHandler = new ArraySessionHandler(60, $clock); $sessionHandler->write('sess_expired', 'data'); // $clock->addSeconds(100); $manager = new SessionManager( $sessionHandler, $this->logger, ttl: 30, gcInterval: 0.01 ); $session = $manager->getSession('sess_expired'); expect($session)->toBeNull(); }); it('does not start GC timer if already started', function () { $this->loop = Mockery::mock(LoopInterface::class); $this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class)); $manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop); $manager->startGcTimer(); }); ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredElement.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Elements; use InvalidArgumentException; use JsonSerializable; use PhpMcp\Server\Context; use PhpMcp\Server\Exception\McpServerException; use Psr\Container\ContainerInterface; use ReflectionException; use ReflectionFunctionAbstract; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; use Throwable; use TypeError; class RegisteredElement implements JsonSerializable { /** @var callable|array|string */ public readonly mixed $handler; public readonly bool $isManual; public function __construct( callable|array|string $handler, bool $isManual = false, ) { $this->handler = $handler; $this->isManual = $isManual; } 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, $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, $context); return call_user_func($this->handler, ...$arguments); } } if (is_callable($this->handler)) { $reflection = $this->getReflectionForCallable($this->handler); $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, $context); $instance = $container->get($className); return call_user_func([$instance, $methodName], ...$arguments); } throw new \InvalidArgumentException('Invalid handler type'); } 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 { $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); } catch (InvalidArgumentException $e) { throw McpServerException::invalidParams($e->getMessage(), $e); } catch (Throwable $e) { throw McpServerException::internalError( "Error processing parameter `{$paramName}`: {$e->getMessage()}", $e ); } } elseif ($parameter->isDefaultValueAvailable()) { $finalArgs[$paramPosition] = $parameter->getDefaultValue(); } elseif ($parameter->allowsNull()) { $finalArgs[$paramPosition] = null; } elseif ($parameter->isOptional()) { continue; } else { $reflectionName = $reflection instanceof \ReflectionMethod ? $reflection->class . '::' . $reflection->name : 'Closure'; throw McpServerException::internalError( "Missing required argument `{$paramName}` for {$reflectionName}." ); } } return array_values($finalArgs); } /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction { if (is_string($handler)) { return new \ReflectionFunction($handler); } if ($handler instanceof \Closure) { return new \ReflectionFunction($handler); } if (is_array($handler) && count($handler) === 2) { [$class, $method] = $handler; return new \ReflectionMethod($class, $method); } throw new \InvalidArgumentException('Cannot create reflection for this callable type'); } /** * Attempts type casting based on ReflectionParameter type hints. * * @throws InvalidArgumentException If casting is impossible for the required type. * @throws TypeError If internal PHP casting fails unexpectedly. */ private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed { $type = $parameter->getType(); if ($argument === null) { if ($type && $type->allowsNull()) { return null; } } if (! $type instanceof ReflectionNamedType) { return $argument; } $typeName = $type->getName(); if (enum_exists($typeName)) { if (is_object($argument) && $argument instanceof $typeName) { return $argument; } if (is_subclass_of($typeName, \BackedEnum::class)) { $value = $typeName::tryFrom($argument); if ($value === null) { throw new InvalidArgumentException( "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.", ); } return $value; } else { if (is_string($argument)) { foreach ($typeName::cases() as $case) { if ($case->name === $argument) { return $case; } } $validNames = array_map(fn($c) => $c->name, $typeName::cases()); throw new InvalidArgumentException( "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "." ); } else { throw new InvalidArgumentException( "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name." ); } } } try { return match (strtolower($typeName)) { 'int', 'integer' => $this->castToInt($argument), 'string' => (string) $argument, 'bool', 'boolean' => $this->castToBoolean($argument), 'float', 'double' => $this->castToFloat($argument), 'array' => $this->castToArray($argument), default => $argument, }; } catch (TypeError $e) { throw new InvalidArgumentException( "Value cannot be cast to required type `{$typeName}`.", 0, $e ); } } /** Helper to cast strictly to boolean */ private function castToBoolean(mixed $argument): bool { if (is_bool($argument)) { return $argument; } if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') { return true; } if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') { return false; } throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); } /** Helper to cast strictly to integer */ private function castToInt(mixed $argument): int { if (is_int($argument)) { return $argument; } if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) { return (int) $argument; } if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) { return (int) $argument; } throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); } /** Helper to cast strictly to float */ private function castToFloat(mixed $argument): float { if (is_float($argument)) { return $argument; } if (is_int($argument)) { return (float) $argument; } if (is_numeric($argument)) { return (float) $argument; } throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); } /** Helper to cast strictly to array */ private function castToArray(mixed $argument): array { if (is_array($argument)) { return $argument; } throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); } public function toArray(): array { return [ 'handler' => $this->handler, 'isManual' => $this->isManual, ]; } public function jsonSerialize(): array { return $this->toArray(); } } ``` -------------------------------------------------------------------------------- /tests/Mocks/Clients/MockSseClient.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Mocks\Clients; use Psr\Http\Message\ResponseInterface; use React\EventLoop\Loop; use React\Http\Browser; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; use function React\Promise\reject; class MockSseClient { public Browser $browser; private ?ReadableStreamInterface $stream = null; private string $buffer = ''; private array $receivedMessages = []; // Stores decoded JSON-RPC messages private array $receivedSseEvents = []; // Stores raw SSE events (type, data, id) public ?string $endpointUrl = null; // The /message endpoint URL provided by server public ?string $clientId = null; // The clientId from the /message endpoint URL public ?ResponseInterface $lastConnectResponse = null; // Last connect response for header testing public function __construct(int $timeout = 2) { $this->browser = (new Browser())->withTimeout($timeout); } public function connect(string $sseBaseUrl): PromiseInterface { return $this->browser->requestStreaming('GET', $sseBaseUrl) ->then(function (ResponseInterface $response) { $this->lastConnectResponse = $response; // Store response for header testing if ($response->getStatusCode() !== 200) { $body = (string) $response->getBody(); throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}"); } $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream"); $this->stream = $stream; $this->stream->on('data', [$this, 'handleSseData']); $this->stream->on('close', function () { $this->stream = null; }); return $this; }); } public function handleSseData(string $chunk): void { $this->buffer .= $chunk; while (($eventPos = strpos($this->buffer, "\n\n")) !== false) { $eventBlock = substr($this->buffer, 0, $eventPos); $this->buffer = substr($this->buffer, $eventPos + 2); $lines = explode("\n", $eventBlock); $event = ['type' => 'message', 'data' => '', 'id' => null]; foreach ($lines as $line) { if (str_starts_with($line, "event:")) { $event['type'] = trim(substr($line, strlen("event:"))); } elseif (str_starts_with($line, "data:")) { $event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:"))); } elseif (str_starts_with($line, "id:")) { $event['id'] = trim(substr($line, strlen("id:"))); } } $this->receivedSseEvents[] = $event; if ($event['type'] === 'endpoint' && $event['data']) { $this->endpointUrl = $event['data']; $query = parse_url($this->endpointUrl, PHP_URL_QUERY); if ($query) { parse_str($query, $params); $this->clientId = $params['clientId'] ?? null; } } elseif ($event['type'] === 'message' && $event['data']) { try { $decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR); $this->receivedMessages[] = $decodedJson; } catch (\JsonException $e) { } } } } public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface { $deferred = new Deferred(); $startTime = microtime(true); $checkMessages = null; $checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) { foreach ($this->receivedMessages as $i => $msg) { if (isset($msg['id']) && $msg['id'] === $expectedRequestId) { unset($this->receivedMessages[$i]); // Consume message $this->receivedMessages = array_values($this->receivedMessages); $deferred->resolve($msg); return; } } if (microtime(true) - $startTime > $timeoutSecs) { $deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'")); return; } if ($this->stream) { Loop::addTimer(0.05, $checkMessages); } else { $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'")); } }; $checkMessages(); // Start checking return $deferred->promise(); } public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface { $deferred = new Deferred(); $startTime = microtime(true); $checkMessages = null; $checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) { foreach ($this->receivedMessages as $i => $msg) { if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) { $isLikelyBatchResponse = true; if (empty($msg) && $expectedItemCount === 0) { } elseif (empty($msg) && $expectedItemCount > 0) { $isLikelyBatchResponse = false; } else { foreach ($msg as $item) { if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) { $isLikelyBatchResponse = false; break; } } } if ($isLikelyBatchResponse) { unset($this->receivedMessages[$i]); $this->receivedMessages = array_values($this->receivedMessages); $deferred->resolve($msg); return; } } } if (microtime(true) - $startTime > $timeoutSecs) { $deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items.")); return; } if ($this->stream) { Loop::addTimer(0.05, $checkMessages); } else { $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response.")); } }; $checkMessages(); return $deferred->promise(); } public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface { if (!$this->endpointUrl || !$this->clientId) { return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); } $payload = [ 'jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method, 'params' => $params, ]; $body = json_encode($payload); return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) ->then(function (ResponseInterface $response) use ($requestId) { $bodyContent = (string) $response->getBody(); if ($response->getStatusCode() !== 202) { throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); } return $response; }); } public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface { if (!$this->endpointUrl || !$this->clientId) { return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); } $body = json_encode($batchRequestObjects); return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) ->then(function (ResponseInterface $response) { $bodyContent = (string) $response->getBody(); if ($response->getStatusCode() !== 202) { throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); } return $response; }); } public function sendHttpNotification(string $method, array $params = []): PromiseInterface { if (!$this->endpointUrl || !$this->clientId) { return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); } $payload = [ 'jsonrpc' => '2.0', 'method' => $method, 'params' => $params, ]; $body = json_encode($payload); return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) ->then(function (ResponseInterface $response) { $bodyContent = (string) $response->getBody(); if ($response->getStatusCode() !== 202) { throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}"); } return null; }); } public function close(): void { if ($this->stream) { $this->stream->close(); $this->stream = null; } } } ``` -------------------------------------------------------------------------------- /tests/Unit/Session/SessionTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Session; use Mockery; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Session\Session; const SESSION_ID_SESS = 'test-session-obj-id'; beforeEach(function () { $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class); $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault(); }); it('implements SessionInterface', function () { $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session)->toBeInstanceOf(SessionInterface::class); }); // --- Constructor and ID Generation --- it('uses provided ID if given', function () { $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->getId())->toBe(SESSION_ID_SESS); }); it('generates an ID if none is provided', function () { $this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false); $session = new Session($this->sessionHandler); expect($session->getId())->toBeString()->toHaveLength(32); }); it('loads data from handler on construction if session exists', function () { $initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]]; $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData)); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->all())->toEqual($initialData); expect($session->get('foo'))->toBe('bar'); }); it('initializes with empty data if handler read returns false', function () { $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->all())->toBeEmpty(); }); it('initializes with empty data if handler read returns invalid JSON', function () { $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json'); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->all())->toBeEmpty(); }); it('saves current data to handler', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->set('name', 'Alice'); $session->set('level', 10); $expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]); $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true); $session->save(); }); it('sets and gets a top-level attribute', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->set('name', 'Bob'); expect($session->get('name'))->toBe('Bob'); expect($session->has('name'))->toBeTrue(); }); it('gets default value if attribute does not exist', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->get('nonexistent', 'default_val'))->toBe('default_val'); expect($session->has('nonexistent'))->toBeFalse(); }); it('sets and gets nested attributes using dot notation', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->set('user.profile.email', '[email protected]'); $session->set('user.profile.active', true); $session->set('user.roles', ['admin', 'editor']); expect($session->get('user.profile'))->toEqual(['email' => '[email protected]', 'active' => true]); expect($session->get('user.roles'))->toEqual(['admin', 'editor']); expect($session->has('user.profile.email'))->toBeTrue(); expect($session->has('user.other_profile.settings'))->toBeFalse(); }); it('set does not overwrite if overwrite is false and key exists', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->set('counter', 10); $session->set('counter', 20, false); expect($session->get('counter'))->toBe(10); $session->set('user.id', 1); $session->set('user.id', 2, false); expect($session->get('user.id'))->toBe(1); }); it('set overwrites if overwrite is true (default)', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->set('counter', 10); $session->set('counter', 20); expect($session->get('counter'))->toBe(20); }); it('forgets a top-level attribute', function () { $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30])); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->forget('age'); expect($session->has('age'))->toBeFalse(); expect($session->has('name'))->toBeTrue(); expect($session->all())->toEqual(['name' => 'Alice']); }); it('forgets a nested attribute using dot notation', function () { $initialData = ['user' => ['profile' => ['email' => '[email protected]', 'status' => 'active'], 'id' => 1]]; $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->forget('user.profile.status'); expect($session->has('user.profile.status'))->toBeFalse(); expect($session->has('user.profile.email'))->toBeTrue(); expect($session->get('user.profile'))->toEqual(['email' => '[email protected]']); $session->forget('user.profile'); expect($session->has('user.profile'))->toBeFalse(); expect($session->get('user'))->toEqual(['id' => 1]); }); it('forget does nothing if key does not exist', function () { $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test'])); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->forget('nonexistent'); $session->forget('another_nonexistent'); expect($session->all())->toEqual(['name' => 'Test']); }); it('pulls an attribute (gets and forgets)', function () { $initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']]; $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $pulledItem = $session->pull('item', 'default'); expect($pulledItem)->toBe('important'); expect($session->has('item'))->toBeFalse(); $pulledToken = $session->pull('user.token'); expect($pulledToken)->toBe('abc123xyz'); expect($session->has('user.token'))->toBeFalse(); expect($session->has('user'))->toBeTrue(); $pulledNonExistent = $session->pull('nonexistent', 'fallback'); expect($pulledNonExistent)->toBe('fallback'); }); it('clears all session data', function () { $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2])); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $session->clear(); expect($session->all())->toBeEmpty(); }); it('returns all data with all()', function () { $data = ['a' => 1, 'b' => ['c' => 3]]; $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->all())->toEqual($data); }); it('hydrates session data, merging with defaults and removing id', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $newAttributes = [ 'client_info' => ['name' => 'TestClient', 'version' => '1.1'], 'protocol_version' => '2024-custom', 'user_custom_key' => 'my_value', 'id' => 'should_be_ignored_on_hydrate' ]; $session->hydrate($newAttributes); $allData = $session->all(); expect($allData['initialized'])->toBeFalse(); expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']); expect($allData['protocol_version'])->toBe('2024-custom'); expect($allData['message_queue'])->toEqual([]); expect($allData['log_level'])->toBeNull(); expect($allData['user_custom_key'])->toBe('my_value'); expect($allData)->not->toHaveKey('id'); }); it('queues messages correctly', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect($session->hasQueuedMessages())->toBeFalse(); $msg1 = '{"jsonrpc":"2.0","method":"n1"}'; $msg2 = '{"jsonrpc":"2.0","method":"n2"}'; $session->queueMessage($msg1); $session->queueMessage($msg2); expect($session->hasQueuedMessages())->toBeTrue(); expect($session->get('message_queue'))->toEqual([$msg1, $msg2]); }); it('dequeues messages and clears queue', function () { $this->sessionHandler->shouldReceive('read')->andReturn(false); $session = new Session($this->sessionHandler, SESSION_ID_SESS); $msg1 = '{"id":1}'; $msg2 = '{"id":2}'; $session->queueMessage($msg1); $session->queueMessage($msg2); $dequeued = $session->dequeueMessages(); expect($dequeued)->toEqual([$msg1, $msg2]); expect($session->hasQueuedMessages())->toBeFalse(); expect($session->get('message_queue', 'not_found'))->toEqual([]); expect($session->dequeueMessages())->toEqual([]); }); it('jsonSerializes to all session data', function () { $data = ['serialize' => 'me', 'nested' => ['ok' => true]]; $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); $session = new Session($this->sessionHandler, SESSION_ID_SESS); expect(json_encode($session))->toBe(json_encode($data)); }); ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredResourceTemplateTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Elements; 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; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; use Psr\Container\ContainerInterface; use PhpMcp\Schema\Annotations; beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->handlerInstance = new ResourceHandlerFixture(); $this->container->shouldReceive('get') ->with(ResourceHandlerFixture::class) ->andReturn($this->handlerInstance) ->byDefault(); $this->templateUri = 'item://{category}/{itemId}/details'; $this->resourceTemplateSchema = ResourceTemplate::make( $this->templateUri, 'item-details-template', mimeType: 'application/json' ); $this->defaultHandlerMethod = 'getUserDocument'; $this->matchingTemplateSchema = ResourceTemplate::make( 'user://{userId}/doc/{documentId}', 'user-doc-template', mimeType: 'application/json' ); $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly with schema, handler, and completion providers', function () { $completionProviders = [ 'userId' => CompletionProviderFixture::class, 'documentId' => 'Another\ProviderClass' ]; $schema = ResourceTemplate::make( 'user://{userId}/doc/{documentId}', 'user-doc-template', mimeType: 'application/json' ); $template = RegisteredResourceTemplate::make( schema: $schema, handler: [ResourceHandlerFixture::class, 'getUserDocument'], completionProviders: $completionProviders ); expect($template->schema)->toBe($schema); expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($template->isManual)->toBeFalse(); expect($template->completionProviders)->toEqual($completionProviders); expect($template->completionProviders['userId'])->toBe(CompletionProviderFixture::class); expect($template->completionProviders['documentId'])->toBe('Another\ProviderClass'); expect($template->completionProviders)->not->toHaveKey('nonExistentVar'); }); it('can be made as a manual registration', function () { $schema = ResourceTemplate::make( 'user://{userId}/doc/{documentId}', 'user-doc-template', mimeType: 'application/json' ); $manualTemplate = RegisteredResourceTemplate::make( schema: $schema, handler: [ResourceHandlerFixture::class, 'getUserDocument'], isManual: true ); expect($manualTemplate->isManual)->toBeTrue(); }); dataset('uri_template_matching_cases', [ 'simple_var' => ['user://{userId}', 'user://12345', ['userId' => '12345']], 'simple_var_alpha' => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']], 'no_match_missing_var_part' => ['user://{userId}', 'user://', null], 'no_match_prefix' => ['user://{userId}', 'users://12345', null], 'multi_var' => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']], 'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment 'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null], 'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']], 'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null], 'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']], 'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null], 'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null], 'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']], ]); it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) { $schema = ResourceTemplate::make($templateString, 'test-match'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']); if ($expectedVariables !== null) { expect($template->matches($uriToTest))->toBeTrue(); $reflection = new \ReflectionClass($template); $prop = $reflection->getProperty('uriVariables'); $prop->setAccessible(true); expect($prop->getValue($template))->toEqual($expectedVariables); } else { expect($template->matches($uriToTest))->toBeFalse(); } })->with('uri_template_matching_cases'); it('gets variable names from compiled template', function () { $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']); expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']); }); it('reads resource using handler with extracted URI variables', function () { $uriTemplate = 'item://{category}/{itemId}?format={format}'; $uri = 'item://electronics/tv-123?format=json_pretty'; $schema = ResourceTemplate::make($uriTemplate, 'item-details-template'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']); expect($template->matches($uri))->toBeTrue(); $resultContents = $template->read($this->container, $uri, $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); $content = $resultContents[0]; expect($content)->toBeInstanceOf(TextResourceContents::class); expect($content->uri)->toBe($uri); expect($content->mimeType)->toBe('application/json'); $decodedText = json_decode($content->text, true); expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty."); expect($decodedText['category_received'])->toBe('electronics'); expect($decodedText['itemId_received'])->toBe('tv-123'); expect($decodedText['format_received'])->toBe('json_pretty'); }); it('uses mimeType from schema if handler result does not specify one', function () { $uriTemplate = 'item://{category}/{itemId}?format={format}'; $uri = 'item://books/bestseller?format=json_pretty'; $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']); expect($template->matches($uri))->toBeTrue(); $resultContents = $template->read($this->container, $uri, $this->context); expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml'); }); it('formats a simple string result from handler correctly for template', function () { $uri = 'item://tools/hammer'; $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'returnStringText']); expect($template->matches($uri))->toBeTrue(); $mockHandler = Mockery::mock(ResourceHandlerFixture::class); $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, $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 }); it('propagates exceptions from handler during read', function () { $uri = 'item://tools/hammer'; $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, $this->context); })->throws(\DomainException::class, "Cannot read resource"); it('can be serialized to array and deserialized', function () { $schema = ResourceTemplate::make( 'obj://{type}/{id}', 'my-template', mimeType: 'application/template+json', annotations: Annotations::make(priority: 0.7) ); $providers = ['type' => CompletionProviderFixture::class]; $serializedProviders = ['type' => serialize(CompletionProviderFixture::class)]; $original = RegisteredResourceTemplate::make( $schema, [ResourceHandlerFixture::class, 'getUserDocument'], true, $providers ); $array = $original->toArray(); expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}'); expect($array['schema']['name'])->toBe('my-template'); expect($array['schema']['mimeType'])->toBe('application/template+json'); expect($array['schema']['annotations']['priority'])->toBe(0.7); expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']); expect($array['isManual'])->toBeTrue(); expect($array['completionProviders'])->toEqual($serializedProviders); $rehydrated = RegisteredResourceTemplate::fromArray($array); expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class); expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate); expect($rehydrated->schema->name)->toEqual($original->schema->name); expect($rehydrated->isManual)->toBeTrue(); expect($rehydrated->completionProviders)->toEqual($providers); }); it('fromArray returns false on failure', function () { $badData = ['schema' => ['uriTemplate' => 'fail']]; expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse(); }); ``` -------------------------------------------------------------------------------- /tests/Mocks/Clients/MockStreamHttpClient.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Tests\Mocks\Clients; use Psr\Http\Message\ResponseInterface; use React\EventLoop\Loop; use React\Http\Browser; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Stream\ReadableStreamInterface; use function React\Promise\reject; class MockStreamHttpClient { public Browser $browser; public string $baseMcpUrl; public ?string $sessionId = null; private ?ReadableStreamInterface $mainSseGetStream = null; private string $mainSseGetBuffer = ''; private array $mainSseReceivedNotifications = []; public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2) { $this->browser = (new Browser())->withTimeout($timeout); $this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}"; } public function connectMainSseStream(): PromiseInterface { if (!$this->sessionId) { return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first.")); } return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [ 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId ]) ->then(function (ResponseInterface $response) { if ($response->getStatusCode() !== 200) { $body = (string) $response->getBody(); throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}"); } $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface); $this->mainSseGetStream = $stream; $this->mainSseGetStream->on('data', function ($chunk) { $this->mainSseGetBuffer .= $chunk; $this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications); }); return $this; }); } private function processBufferForNotifications(string &$buffer, array &$targetArray): void { while (($eventPos = strpos($buffer, "\n\n")) !== false) { $eventBlock = substr($buffer, 0, $eventPos); $buffer = substr($buffer, $eventPos + 2); $lines = explode("\n", $eventBlock); $eventData = ''; foreach ($lines as $line) { if (str_starts_with($line, "data:")) { $eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:"))); } } if (!empty($eventData)) { try { $decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR); if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) { $targetArray[] = $decodedJson; } } catch (\JsonException $e) { /* ignore non-json data lines or log */ } } } } public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface { $payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id]; $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; $body = json_encode($payload); return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) ->then(function (ResponseInterface $response) use ($id) { $statusCode = $response->getStatusCode(); if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}"); } $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface); return $this->collectSingleSseResponse($stream, $id, "Initialize"); }); } 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; } $headers += $additionalHeaders; $body = json_encode($payload); return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) ->then(function (ResponseInterface $response) use ($id, $method) { $statusCode = $response->getStatusCode(); if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { $bodyContent = (string) $response->getBody(); throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}"); } $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface); return $this->collectSingleSseResponse($stream, $id, $method); }); } public function sendBatchRequest(array $batchPayload): PromiseInterface { if (!$this->sessionId) { return reject(new \LogicException("Session ID not set. Initialize first for batch request.")); } $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId]; $body = json_encode($batchPayload); return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) ->then(function (ResponseInterface $response) { $statusCode = $response->getStatusCode(); if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}"); } $stream = $response->getBody(); assert($stream instanceof ReadableStreamInterface); return $this->collectSingleSseResponse($stream, null, "Batch", true); }); } private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface { $deferred = new Deferred(); $buffer = ''; $streamClosed = false; $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) { if ($streamClosed) return; $buffer .= $chunk; if (str_contains($buffer, "event: message\n")) { if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) { $jsonData = trim($matches[1]); try { $decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR); $isValid = false; if ($expectBatchArray) { $isValid = is_array($decoded) && !isset($decoded['jsonrpc']); } else { $isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId; } if ($isValid) { $deferred->resolve($decoded); $stream->removeListener('data', $dataListener); $stream->close(); return; } } catch (\JsonException $e) { $deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e)); $stream->removeListener('data', $dataListener); $stream->close(); return; } } } }; $stream->on('data', $dataListener); $stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) { $streamClosed = true; $deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received.")); }); $stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) { $streamClosed = true; $deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null)); }); return timeout($deferred->promise(), 2, Loop::get()) ->finally(function () use ($stream, $dataListener) { if ($stream->isReadable()) { $stream->removeListener('data', $dataListener); } }); } public function sendHttpNotification(string $method, array $params = []): PromiseInterface { if (!$this->sessionId) { return reject(new \LogicException("Session ID not set for notification. Initialize first.")); } $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId]; $body = json_encode($payload); return $this->browser->post($this->baseMcpUrl, $headers, $body) ->then(function (ResponseInterface $response) { $statusCode = $response->getStatusCode(); if ($statusCode !== 202) { throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody()); } return ['statusCode' => $statusCode, 'body' => null]; }); } public function sendDeleteRequest(): PromiseInterface { if (!$this->sessionId) { return reject(new \LogicException("Session ID not set for DELETE request. Initialize first.")); } $headers = ['Mcp-Session-Id' => $this->sessionId]; return $this->browser->request('DELETE', $this->baseMcpUrl, $headers) ->then(function (ResponseInterface $response) { $statusCode = $response->getStatusCode(); return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()]; }); } public function closeMainSseStream(): void { if ($this->mainSseGetStream) { $this->mainSseGetStream->close(); $this->mainSseGetStream = null; } } } ``` -------------------------------------------------------------------------------- /tests/Unit/ServerTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit; use LogicException; use Mockery; use Mockery\MockInterface; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\DiscoveryException; use PhpMcp\Schema\Implementation; use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; use PhpMcp\Server\Session\ArraySessionHandler; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Utils\Discoverer; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; beforeEach(function () { /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); /** @var MockInterface&LoopInterface $loop */ $this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); /** @var MockInterface&CacheInterface $cache */ $this->cache = Mockery::mock(CacheInterface::class); /** @var MockInterface&ContainerInterface $container */ $this->container = Mockery::mock(ContainerInterface::class); $this->configuration = new Configuration( serverInfo: Implementation::make('TestServerInstance', '1.0'), capabilities: ServerCapabilities::make(), logger: $this->logger, loop: $this->loop, cache: $this->cache, container: $this->container ); /** @var MockInterface&Registry $registry */ $this->registry = Mockery::mock(Registry::class); /** @var MockInterface&Protocol $protocol */ $this->protocol = Mockery::mock(Protocol::class); /** @var MockInterface&Discoverer $discoverer */ $this->discoverer = Mockery::mock(Discoverer::class); $this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop); $this->server = new Server( $this->configuration, $this->registry, $this->protocol, $this->sessionManager ); $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault(); $this->registry->allows('clear')->withAnyArgs()->byDefault(); $this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault(); }); afterEach(function () { $this->sessionManager->stopGcTimer(); }); it('provides getters for core components', function () { expect($this->server->getConfiguration())->toBe($this->configuration); expect($this->server->getRegistry())->toBe($this->registry); expect($this->server->getProtocol())->toBe($this->protocol); expect($this->server->getSessionManager())->toBe($this->sessionManager); }); it('provides a static make method returning ServerBuilder', function () { expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class); }); it('skips discovery if already run and not forced', function () { $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); $prop->setValue($this->server, true); $this->registry->shouldNotReceive('clear'); $this->discoverer->shouldNotReceive('discover'); $this->registry->shouldNotReceive('save'); $this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer); $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.'); }); it('forces discovery even if already run, calling injected discoverer', function () { $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); $prop->setValue($this->server, true); $basePath = realpath(sys_get_temp_dir()); $scanDirs = ['.', 'src']; $this->registry->shouldReceive('clear')->once(); $this->discoverer->shouldReceive('discover') ->with($basePath, $scanDirs, Mockery::type('array')) ->once(); $this->registry->shouldReceive('save')->once()->andReturn(true); $this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer); expect($prop->getValue($this->server))->toBeTrue(); }); it('calls registry clear and discoverer, then saves to cache by default', function () { $basePath = realpath(sys_get_temp_dir()); $scanDirs = ['app', 'lib']; $userExcludeDirs = ['specific_exclude']; $finalExcludeDirs = array_unique(array_merge( ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'], $userExcludeDirs )); $this->registry->shouldReceive('clear')->once(); $this->discoverer->shouldReceive('discover') ->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) { expect($arg)->toBeArray(); expect($arg)->toEqualCanonicalizing($finalExcludeDirs); return true; })) ->once(); $this->registry->shouldReceive('save')->once()->andReturn(true); $this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer); $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); expect($prop->getValue($this->server))->toBeTrue(); }); it('does not save to cache if saveToCache is false', function () { $basePath = realpath(sys_get_temp_dir()); $this->registry->shouldReceive('clear')->once(); $this->discoverer->shouldReceive('discover')->once(); $this->registry->shouldNotReceive('save'); $this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer); }); it('throws InvalidArgumentException for bad base path in discover', function () { $this->discoverer->shouldNotReceive('discover'); $this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer); })->throws(\InvalidArgumentException::class, 'Invalid discovery base path'); it('throws DiscoveryException if Discoverer fails during discovery', function () { $basePath = realpath(sys_get_temp_dir()); $this->registry->shouldReceive('clear')->once(); $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error')); $this->registry->shouldNotReceive('save'); $this->server->discover($basePath, discoverer: $this->discoverer); })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error'); it('resets discoveryRan flag on Discoverer failure', function () { $basePath = realpath(sys_get_temp_dir()); $this->registry->shouldReceive('clear')->once(); $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure')); try { $this->server->discover($basePath, discoverer: $this->discoverer); } catch (DiscoveryException $e) { // Expected } $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); expect($prop->getValue($this->server))->toBeFalse(); }); // --- Listening Tests --- it('throws LogicException if listen is called when already listening', function () { $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->server->listen($transport, false); $this->server->listen($transport, false); })->throws(LogicException::class, 'Server is already listening'); it('warns if no elements and discovery not run when listen is called', function () { $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->registry->shouldReceive('hasElements')->andReturn(false); $this->logger->shouldReceive('warning') ->once() ->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/')); $this->server->listen($transport, false); }); it('injects logger and loop into aware transports during listen', function () { $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class); $transport->shouldReceive('setLogger')->with($this->logger)->once(); $transport->shouldReceive('setLoop')->with($this->loop)->once(); $transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs(); $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs(); $this->server->listen($transport); }); it('binds protocol, starts transport listener, and runs loop by default', function () { $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $transport->shouldReceive('listen')->once(); $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->loop->shouldReceive('run')->once(); $this->protocol->shouldReceive('unbindTransport')->once(); $this->server->listen($transport); expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); it('does not run loop if runLoop is false in listen', function () { $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->loop->shouldNotReceive('run'); $this->server->listen($transport, runLoop: false); expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue(); $this->protocol->shouldReceive('unbindTransport'); $transport->shouldReceive('removeAllListeners'); $transport->shouldReceive('close'); $this->server->endListen($transport); }); it('calls endListen if transport listen throws immediately', function () { $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use")); $this->protocol->shouldReceive('bindTransport')->once(); $this->protocol->shouldReceive('unbindTransport')->once(); $this->loop->shouldNotReceive('run'); try { $this->server->listen($transport); } catch (\RuntimeException $e) { expect($e->getMessage())->toBe("Port in use"); } expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); it('endListen unbinds protocol and closes transport if listening', function () { $transport = Mockery::mock(ServerTransportInterface::class); $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('isListening'); $prop->setAccessible(true); $prop->setValue($this->server, true); $this->protocol->shouldReceive('unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->with('close')->once(); $transport->shouldReceive('close')->once(); $this->server->endListen($transport); expect($prop->getValue($this->server))->toBeFalse(); }); ```