This is page 2 of 7. Use http://codebase.md/php-mcp/server?lines=true&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 -------------------------------------------------------------------------------- /src/Session/CacheSessionHandler.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Session; 6 | 7 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 8 | use PhpMcp\Server\Defaults\SystemClock; 9 | use Psr\SimpleCache\CacheInterface; 10 | use Psr\Clock\ClockInterface; 11 | 12 | class CacheSessionHandler implements SessionHandlerInterface 13 | { 14 | private const SESSION_INDEX_KEY = 'mcp_session_index'; 15 | private array $sessionIndex = []; 16 | private ClockInterface $clock; 17 | 18 | public function __construct( 19 | public readonly CacheInterface $cache, 20 | public readonly int $ttl = 3600, 21 | ?ClockInterface $clock = null 22 | ) { 23 | $this->sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); 24 | $this->clock = $clock ?? new SystemClock(); 25 | } 26 | 27 | public function read(string $sessionId): string|false 28 | { 29 | $session = $this->cache->get($sessionId, false); 30 | if ($session === false) { 31 | if (isset($this->sessionIndex[$sessionId])) { 32 | unset($this->sessionIndex[$sessionId]); 33 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 34 | } 35 | return false; 36 | } 37 | 38 | if (!isset($this->sessionIndex[$sessionId])) { 39 | $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); 40 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 41 | return $session; 42 | } 43 | 44 | if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) { 45 | $this->cache->delete($sessionId); 46 | return false; 47 | } 48 | 49 | return $session; 50 | } 51 | 52 | public function write(string $sessionId, string $data): bool 53 | { 54 | $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); 55 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 56 | return $this->cache->set($sessionId, $data); 57 | } 58 | 59 | public function destroy(string $sessionId): bool 60 | { 61 | unset($this->sessionIndex[$sessionId]); 62 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 63 | return $this->cache->delete($sessionId); 64 | } 65 | 66 | public function gc(int $maxLifetime): array 67 | { 68 | $currentTime = $this->clock->now()->getTimestamp(); 69 | $deletedSessions = []; 70 | 71 | foreach ($this->sessionIndex as $sessionId => $timestamp) { 72 | if ($currentTime - $timestamp > $maxLifetime) { 73 | $this->cache->delete($sessionId); 74 | unset($this->sessionIndex[$sessionId]); 75 | $deletedSessions[] = $sessionId; 76 | } 77 | } 78 | 79 | $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); 80 | 81 | return $deletedSessions; 82 | } 83 | } 84 | ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/McpTaskHandlers.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace Mcp\DependenciesStdioExample; 4 | 5 | use Mcp\DependenciesStdioExample\Services\StatsServiceInterface; 6 | use Mcp\DependenciesStdioExample\Services\TaskRepositoryInterface; 7 | use PhpMcp\Server\Attributes\McpResource; 8 | use PhpMcp\Server\Attributes\McpTool; 9 | use Psr\Log\LoggerInterface; 10 | 11 | class McpTaskHandlers 12 | { 13 | private TaskRepositoryInterface $taskRepo; 14 | 15 | private StatsServiceInterface $statsService; 16 | 17 | private LoggerInterface $logger; 18 | 19 | // Dependencies injected by the DI container 20 | public function __construct( 21 | TaskRepositoryInterface $taskRepo, 22 | StatsServiceInterface $statsService, 23 | LoggerInterface $logger 24 | ) { 25 | $this->taskRepo = $taskRepo; 26 | $this->statsService = $statsService; 27 | $this->logger = $logger; 28 | $this->logger->info('McpTaskHandlers instantiated with dependencies.'); 29 | } 30 | 31 | /** 32 | * Adds a new task for a given user. 33 | * 34 | * @param string $userId The ID of the user. 35 | * @param string $description The task description. 36 | * @return array The created task details. 37 | */ 38 | #[McpTool(name: 'add_task')] 39 | public function addTask(string $userId, string $description): array 40 | { 41 | $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]); 42 | 43 | return $this->taskRepo->addTask($userId, $description); 44 | } 45 | 46 | /** 47 | * Lists pending tasks for a specific user. 48 | * 49 | * @param string $userId The ID of the user. 50 | * @return array A list of tasks. 51 | */ 52 | #[McpTool(name: 'list_user_tasks')] 53 | public function listUserTasks(string $userId): array 54 | { 55 | $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]); 56 | 57 | return $this->taskRepo->getTasksForUser($userId); 58 | } 59 | 60 | /** 61 | * Marks a task as complete. 62 | * 63 | * @param int $taskId The ID of the task to complete. 64 | * @return array Status of the operation. 65 | */ 66 | #[McpTool(name: 'complete_task')] 67 | public function completeTask(int $taskId): array 68 | { 69 | $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]); 70 | $success = $this->taskRepo->completeTask($taskId); 71 | 72 | return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."]; 73 | } 74 | 75 | /** 76 | * Provides current system statistics. 77 | * 78 | * @return array System statistics. 79 | */ 80 | #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] 81 | public function getSystemStatistics(): array 82 | { 83 | $this->logger->info("Resource 'stats://system/overview' invoked"); 84 | 85 | return $this->statsService->getSystemStats(); 86 | } 87 | } 88 | ``` -------------------------------------------------------------------------------- /src/Session/SubscriptionManager.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Session; 4 | 5 | use Psr\Log\LoggerInterface; 6 | 7 | class SubscriptionManager 8 | { 9 | /** @var array<string, array<string, true>> Key: URI, Value: array of session IDs */ 10 | private array $resourceSubscribers = []; 11 | 12 | /** @var array<string, array<string, true>> Key: Session ID, Value: array of URIs */ 13 | private array $sessionSubscriptions = []; 14 | 15 | public function __construct( 16 | private readonly LoggerInterface $logger 17 | ) { 18 | } 19 | 20 | /** 21 | * Subscribe a session to a resource 22 | */ 23 | public function subscribe(string $sessionId, string $uri): void 24 | { 25 | // Add to both mappings for efficient lookup 26 | $this->resourceSubscribers[$uri][$sessionId] = true; 27 | $this->sessionSubscriptions[$sessionId][$uri] = true; 28 | 29 | $this->logger->debug('Session subscribed to resource', [ 30 | 'sessionId' => $sessionId, 31 | 'uri' => $uri 32 | ]); 33 | } 34 | 35 | /** 36 | * Unsubscribe a session from a resource 37 | */ 38 | public function unsubscribe(string $sessionId, string $uri): void 39 | { 40 | unset($this->resourceSubscribers[$uri][$sessionId]); 41 | unset($this->sessionSubscriptions[$sessionId][$uri]); 42 | 43 | // Clean up empty arrays 44 | if (empty($this->resourceSubscribers[$uri])) { 45 | unset($this->resourceSubscribers[$uri]); 46 | } 47 | 48 | $this->logger->debug('Session unsubscribed from resource', [ 49 | 'sessionId' => $sessionId, 50 | 'uri' => $uri 51 | ]); 52 | } 53 | 54 | /** 55 | * Get all sessions subscribed to a resource 56 | */ 57 | public function getSubscribers(string $uri): array 58 | { 59 | return array_keys($this->resourceSubscribers[$uri] ?? []); 60 | } 61 | 62 | /** 63 | * Check if a session is subscribed to a resource 64 | */ 65 | public function isSubscribed(string $sessionId, string $uri): bool 66 | { 67 | return isset($this->sessionSubscriptions[$sessionId][$uri]); 68 | } 69 | 70 | /** 71 | * Clean up all subscriptions for a session 72 | */ 73 | public function cleanupSession(string $sessionId): void 74 | { 75 | if (!isset($this->sessionSubscriptions[$sessionId])) { 76 | return; 77 | } 78 | 79 | $uris = array_keys($this->sessionSubscriptions[$sessionId]); 80 | foreach ($uris as $uri) { 81 | unset($this->resourceSubscribers[$uri][$sessionId]); 82 | 83 | // Clean up empty arrays 84 | if (empty($this->resourceSubscribers[$uri])) { 85 | unset($this->resourceSubscribers[$uri]); 86 | } 87 | } 88 | 89 | unset($this->sessionSubscriptions[$sessionId]); 90 | 91 | $this->logger->debug('Cleaned up all subscriptions for session', [ 92 | 'sessionId' => $sessionId, 93 | 'count' => count($uris) 94 | ]); 95 | } 96 | } 97 | ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/Services.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace Mcp\DependenciesStdioExample\Services; 4 | 5 | use Psr\Log\LoggerInterface; 6 | 7 | // --- Mock Services --- 8 | 9 | interface TaskRepositoryInterface 10 | { 11 | public function addTask(string $userId, string $description): array; 12 | 13 | public function getTasksForUser(string $userId): array; 14 | 15 | public function getAllTasks(): array; 16 | 17 | public function completeTask(int $taskId): bool; 18 | } 19 | 20 | class InMemoryTaskRepository implements TaskRepositoryInterface 21 | { 22 | private array $tasks = []; 23 | 24 | private int $nextTaskId = 1; 25 | 26 | private LoggerInterface $logger; 27 | 28 | public function __construct(LoggerInterface $logger) 29 | { 30 | $this->logger = $logger; 31 | // Add some initial tasks 32 | $this->addTask('user1', 'Buy groceries'); 33 | $this->addTask('user1', 'Write MCP example'); 34 | $this->addTask('user2', 'Review PR'); 35 | } 36 | 37 | public function addTask(string $userId, string $description): array 38 | { 39 | $task = [ 40 | 'id' => $this->nextTaskId++, 41 | 'userId' => $userId, 42 | 'description' => $description, 43 | 'completed' => false, 44 | 'createdAt' => date('c'), 45 | ]; 46 | $this->tasks[$task['id']] = $task; 47 | $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]); 48 | 49 | return $task; 50 | } 51 | 52 | public function getTasksForUser(string $userId): array 53 | { 54 | return array_values(array_filter($this->tasks, fn ($task) => $task['userId'] === $userId && ! $task['completed'])); 55 | } 56 | 57 | public function getAllTasks(): array 58 | { 59 | return array_values($this->tasks); 60 | } 61 | 62 | public function completeTask(int $taskId): bool 63 | { 64 | if (isset($this->tasks[$taskId])) { 65 | $this->tasks[$taskId]['completed'] = true; 66 | $this->logger->info('Task completed', ['id' => $taskId]); 67 | 68 | return true; 69 | } 70 | 71 | return false; 72 | } 73 | } 74 | 75 | interface StatsServiceInterface 76 | { 77 | public function getSystemStats(): array; 78 | } 79 | 80 | class SystemStatsService implements StatsServiceInterface 81 | { 82 | private TaskRepositoryInterface $taskRepository; 83 | 84 | public function __construct(TaskRepositoryInterface $taskRepository) 85 | { 86 | $this->taskRepository = $taskRepository; 87 | } 88 | 89 | public function getSystemStats(): array 90 | { 91 | $allTasks = $this->taskRepository->getAllTasks(); 92 | $completed = count(array_filter($allTasks, fn ($task) => $task['completed'])); 93 | $pending = count($allTasks) - $completed; 94 | 95 | return [ 96 | 'total_tasks' => count($allTasks), 97 | 'completed_tasks' => $completed, 98 | 'pending_tasks' => $pending, 99 | 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script 100 | ]; 101 | } 102 | } 103 | ``` -------------------------------------------------------------------------------- /examples/03-manual-registration-stdio/server.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | MCP Stdio Server (Manual Element Registration) 7 | |-------------------------------------------------------------------------- 8 | | 9 | | This server demonstrates how to manually register all MCP elements 10 | | (Tools, Resources, Prompts, ResourceTemplates) using the ServerBuilder's 11 | | fluent `withTool()`, `withResource()`, etc., methods. 12 | | It does NOT use attribute discovery. Handlers are in 'SimpleHandlers.php'. 13 | | It runs via the STDIO transport. 14 | | 15 | | To Use: 16 | | 1. Configure your MCP Client (e.g., Cursor) for this server: 17 | | 18 | | { 19 | | "mcpServers": { 20 | | "php-stdio-manual": { 21 | | "command": "php", 22 | | "args": ["/full/path/to/examples/03-manual-registration-stdio/server.php"] 23 | | } 24 | | } 25 | | } 26 | | 27 | | All elements are explicitly defined during the ServerBuilder chain. 28 | | The $server->discover() method is NOT called. 29 | | 30 | */ 31 | 32 | declare(strict_types=1); 33 | 34 | chdir(__DIR__); 35 | require_once '../../vendor/autoload.php'; 36 | require_once './SimpleHandlers.php'; 37 | 38 | use Mcp\ManualStdioExample\SimpleHandlers; 39 | use PhpMcp\Server\Defaults\BasicContainer; 40 | use PhpMcp\Server\Server; 41 | use PhpMcp\Server\Transports\StdioServerTransport; 42 | use Psr\Log\AbstractLogger; 43 | use Psr\Log\LoggerInterface; 44 | 45 | class StderrLogger extends AbstractLogger 46 | { 47 | public function log($level, \Stringable|string $message, array $context = []): void 48 | { 49 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 50 | } 51 | } 52 | 53 | try { 54 | $logger = new StderrLogger(); 55 | $logger->info('Starting MCP Manual Registration (Stdio) Server...'); 56 | 57 | $container = new BasicContainer(); 58 | $container->set(LoggerInterface::class, $logger); 59 | 60 | $server = Server::make() 61 | ->withServerInfo('Manual Reg Server', '1.0.0') 62 | ->withLogger($logger) 63 | ->withContainer($container) 64 | ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') 65 | ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') 66 | ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') 67 | ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') 68 | ->build(); 69 | 70 | $transport = new StdioServerTransport(); 71 | $server->listen($transport); 72 | 73 | $logger->info('Server listener stopped gracefully.'); 74 | exit(0); 75 | 76 | } catch (\Throwable $e) { 77 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 78 | exit(1); 79 | } 80 | ``` -------------------------------------------------------------------------------- /tests/Unit/ConfigurationTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit; 4 | 5 | use Mockery; 6 | use PhpMcp\Schema\Implementation; 7 | use PhpMcp\Schema\ServerCapabilities; 8 | use PhpMcp\Server\Configuration; 9 | use Psr\Container\ContainerInterface; 10 | use Psr\Log\LoggerInterface; 11 | use Psr\SimpleCache\CacheInterface; 12 | use React\EventLoop\LoopInterface; 13 | 14 | beforeEach(function () { 15 | $this->serverInfo = Implementation::make('TestServer', '1.1.0'); 16 | $this->logger = Mockery::mock(LoggerInterface::class); 17 | $this->loop = Mockery::mock(LoopInterface::class); 18 | $this->cache = Mockery::mock(CacheInterface::class); 19 | $this->container = Mockery::mock(ContainerInterface::class); 20 | $this->capabilities = ServerCapabilities::make(); 21 | }); 22 | 23 | afterEach(function () { 24 | Mockery::close(); 25 | }); 26 | 27 | it('constructs configuration object with all properties', function () { 28 | $paginationLimit = 100; 29 | $config = new Configuration( 30 | serverInfo: $this->serverInfo, 31 | capabilities: $this->capabilities, 32 | logger: $this->logger, 33 | loop: $this->loop, 34 | cache: $this->cache, 35 | container: $this->container, 36 | paginationLimit: $paginationLimit 37 | ); 38 | 39 | expect($config->serverInfo)->toBe($this->serverInfo); 40 | expect($config->capabilities)->toBe($this->capabilities); 41 | expect($config->logger)->toBe($this->logger); 42 | expect($config->loop)->toBe($this->loop); 43 | expect($config->cache)->toBe($this->cache); 44 | expect($config->container)->toBe($this->container); 45 | expect($config->paginationLimit)->toBe($paginationLimit); 46 | }); 47 | 48 | it('constructs configuration object with default pagination limit', function () { 49 | $config = new Configuration( 50 | serverInfo: $this->serverInfo, 51 | capabilities: $this->capabilities, 52 | logger: $this->logger, 53 | loop: $this->loop, 54 | cache: $this->cache, 55 | container: $this->container 56 | ); 57 | 58 | expect($config->paginationLimit)->toBe(50); // Default value 59 | }); 60 | 61 | it('constructs configuration object with null cache', function () { 62 | $config = new Configuration( 63 | serverInfo: $this->serverInfo, 64 | capabilities: $this->capabilities, 65 | logger: $this->logger, 66 | loop: $this->loop, 67 | cache: null, 68 | container: $this->container 69 | ); 70 | 71 | expect($config->cache)->toBeNull(); 72 | }); 73 | 74 | it('constructs configuration object with specific capabilities', function () { 75 | $customCaps = ServerCapabilities::make( 76 | resourcesSubscribe: true, 77 | logging: true, 78 | ); 79 | 80 | $config = new Configuration( 81 | serverInfo: $this->serverInfo, 82 | capabilities: $customCaps, 83 | logger: $this->logger, 84 | loop: $this->loop, 85 | cache: null, 86 | container: $this->container 87 | ); 88 | 89 | expect($config->capabilities)->toBe($customCaps); 90 | expect($config->capabilities->resourcesSubscribe)->toBeTrue(); 91 | expect($config->capabilities->logging)->toBeTrue(); 92 | }); 93 | ``` -------------------------------------------------------------------------------- /examples/04-combined-registration-http/server.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | MCP HTTP Server (Combined Manual & Discovered Elements) 7 | |-------------------------------------------------------------------------- 8 | | 9 | | This server demonstrates a combination of manual element registration 10 | | via the ServerBuilder and attribute-based discovery. 11 | | - Manually registered elements are defined in 'ManualHandlers.php'. 12 | | - Discoverable elements are in 'DiscoveredElements.php'. 13 | | 14 | | It runs via the HTTP transport. 15 | | 16 | | This example also shows precedence: if a manually registered element 17 | | has the same identifier (e.g., URI for a resource, or name for a tool) 18 | | as a discovered one, the manual registration takes priority. 19 | | 20 | | To Use: 21 | | 1. Run this script from your CLI: `php server.php` 22 | | The server will listen on http://127.0.0.1:8081 by default. 23 | | 2. Configure your MCP Client (e.g., Cursor): 24 | | 25 | | { 26 | | "mcpServers": { 27 | | "php-http-combined": { 28 | | "url": "http://127.0.0.1:8081/mcp_combined/sse" // Note the prefix 29 | | } 30 | | } 31 | | } 32 | | 33 | | Manual elements are registered during ServerBuilder->build(). 34 | | Then, $server->discover() scans for attributed elements. 35 | | 36 | */ 37 | 38 | declare(strict_types=1); 39 | 40 | chdir(__DIR__); 41 | require_once '../../vendor/autoload.php'; 42 | require_once './DiscoveredElements.php'; 43 | require_once './ManualHandlers.php'; 44 | 45 | use Mcp\CombinedHttpExample\Manual\ManualHandlers; 46 | use PhpMcp\Server\Defaults\BasicContainer; 47 | use PhpMcp\Server\Server; 48 | use PhpMcp\Server\Transports\HttpServerTransport; 49 | use Psr\Log\AbstractLogger; 50 | use Psr\Log\LoggerInterface; 51 | 52 | class StderrLogger extends AbstractLogger 53 | { 54 | public function log($level, \Stringable|string $message, array $context = []): void 55 | { 56 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 57 | } 58 | } 59 | 60 | try { 61 | $logger = new StderrLogger(); 62 | $logger->info('Starting MCP Combined Registration (HTTP) Server...'); 63 | 64 | $container = new BasicContainer(); 65 | $container->set(LoggerInterface::class, $logger); // ManualHandlers needs LoggerInterface 66 | 67 | $server = Server::make() 68 | ->withServerInfo('Combined HTTP Server', '1.0.0') 69 | ->withLogger($logger) 70 | ->withContainer($container) 71 | ->withTool([ManualHandlers::class, 'manualGreeter']) 72 | ->withResource( 73 | [ManualHandlers::class, 'getPriorityConfigManual'], 74 | 'config://priority', 75 | 'priority_config_manual', 76 | ) 77 | ->build(); 78 | 79 | // Now, run discovery. Discovered elements will be added. 80 | // If 'config://priority' was discovered, the manual one takes precedence. 81 | $server->discover(__DIR__, scanDirs: ['.']); 82 | 83 | $transport = new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined'); 84 | 85 | $server->listen($transport); 86 | 87 | $logger->info('Server listener stopped gracefully.'); 88 | exit(0); 89 | 90 | } catch (\Throwable $e) { 91 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 92 | exit(1); 93 | } 94 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/ToolHandlerFixture.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\General; 4 | 5 | use PhpMcp\Schema\Content\TextContent; 6 | use PhpMcp\Schema\Content\ImageContent; 7 | use PhpMcp\Schema\Content\AudioContent; 8 | use PhpMcp\Server\Context; 9 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; 10 | use Psr\Log\LoggerInterface; 11 | 12 | class ToolHandlerFixture 13 | { 14 | public function __construct() 15 | { 16 | } 17 | 18 | public function greet(string $name): string 19 | { 20 | return "Hello, {$name}!"; 21 | } 22 | 23 | public function sum(int $a, int $b): int 24 | { 25 | return $a + $b; 26 | } 27 | 28 | public function optionalParamsTool(string $required, ?string $optional = "default_val"): string 29 | { 30 | return "{$required} and {$optional}"; 31 | } 32 | 33 | public function noParamsTool(): array 34 | { 35 | return ['status' => 'ok', 'timestamp' => time()]; 36 | } 37 | 38 | public function processBackedEnum(BackedStringEnum $status): string 39 | { 40 | return "Status processed: " . $status->value; 41 | } 42 | 43 | public function returnString(): string 44 | { 45 | return "This is a string result."; 46 | } 47 | 48 | public function returnInteger(): int 49 | { 50 | return 12345; 51 | } 52 | 53 | public function returnFloat(): float 54 | { 55 | return 67.89; 56 | } 57 | 58 | public function returnBooleanTrue(): bool 59 | { 60 | return true; 61 | } 62 | 63 | public function returnBooleanFalse(): bool 64 | { 65 | return false; 66 | } 67 | 68 | public function returnNull(): ?string 69 | { 70 | return null; 71 | } 72 | 73 | public function returnArray(): array 74 | { 75 | return ['message' => 'Array result', 'data' => [1, 2, 3]]; 76 | } 77 | 78 | public function returnStdClass(): \stdClass 79 | { 80 | $obj = new \stdClass(); 81 | $obj->property = "value"; 82 | return $obj; 83 | } 84 | 85 | public function returnTextContent(): TextContent 86 | { 87 | return TextContent::make("Pre-formatted TextContent."); 88 | } 89 | 90 | public function returnImageContent(): ImageContent 91 | { 92 | return ImageContent::make("base64data==", "image/png"); 93 | } 94 | 95 | public function returnAudioContent(): AudioContent 96 | { 97 | return AudioContent::make("base64audio==", "audio/mp3"); 98 | } 99 | 100 | public function returnArrayOfContent(): array 101 | { 102 | return [ 103 | TextContent::make("Part 1"), 104 | ImageContent::make("imgdata", "image/jpeg") 105 | ]; 106 | } 107 | 108 | public function returnMixedArray(): array 109 | { 110 | return [ 111 | "A raw string", 112 | TextContent::make("A TextContent object"), 113 | 123, 114 | true, 115 | null, 116 | ['nested_key' => 'nested_value', 'sub_array' => [4, 5]], 117 | ImageContent::make("img_data_mixed", "image/gif"), 118 | (object)['obj_prop' => 'obj_val'] 119 | ]; 120 | } 121 | 122 | public function returnEmptyArray(): array 123 | { 124 | return []; 125 | } 126 | 127 | public function toolThatThrows(): void 128 | { 129 | throw new \InvalidArgumentException("Something went wrong in the tool."); 130 | } 131 | 132 | public function toolUnencodableResult() 133 | { 134 | return fopen('php://memory', 'r'); 135 | } 136 | 137 | public function toolReadsContext(Context $context): string 138 | { 139 | if (!$context->request) { 140 | return "No request instance present"; 141 | } 142 | 143 | return $context->request->getHeaderLine('X-Test-Header') ?: "No X-Test-Header"; 144 | } 145 | } 146 | ``` -------------------------------------------------------------------------------- /examples/07-complex-tool-schema-http/server.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | MCP HTTP Server with Complex Tool Schema (Event Scheduler) 7 | |-------------------------------------------------------------------------- 8 | | 9 | | This example demonstrates how to define an MCP Tool with a more complex 10 | | input schema, utilizing various PHP types, optional parameters, default 11 | | values, and backed Enums. The server automatically generates the 12 | | corresponding JSON Schema for the tool's input. 13 | | 14 | | Scenario: 15 | | An "Event Scheduler" tool that allows scheduling events with details like 16 | | title, date, time (optional), type (enum), priority (enum with default), 17 | | attendees (optional list), and invite preferences (boolean with default). 18 | | 19 | | Key Points: 20 | | - The `schedule_event` tool in `McpEventScheduler.php` showcases: 21 | | - Required string parameters (`title`, `date`). 22 | | - A required backed string enum parameter (`EventType $type`). 23 | | - Optional nullable string (`?string $time = null`). 24 | | - Optional backed integer enum with a default value (`EventPriority $priority = EventPriority::Normal`). 25 | | - Optional nullable array of strings (`?array $attendees = null`). 26 | | - Optional boolean with a default value (`bool $sendInvites = true`). 27 | | - PHP type hints and default values are used by `SchemaGenerator` (internal) 28 | | to create the `inputSchema` for the tool. 29 | | - This example uses attribute-based discovery and the HTTP transport. 30 | | 31 | | To Use: 32 | | 1. Run this script: `php server.php` (from this directory) 33 | | The server will listen on http://127.0.0.1:8082 by default. 34 | | 2. Configure your MCP Client (e.g., Cursor) for this server: 35 | | 36 | | { 37 | | "mcpServers": { 38 | | "php-http-complex-scheduler": { 39 | | "url": "http://127.0.0.1:8082/mcp_scheduler/sse" // Note the prefix 40 | | } 41 | | } 42 | | } 43 | | 44 | | Connect your client, list tools, and inspect the 'inputSchema' for the 45 | | 'schedule_event' tool. Prompt your LLM with question to test the tool. 46 | | 47 | */ 48 | 49 | declare(strict_types=1); 50 | 51 | chdir(__DIR__); 52 | require_once '../../vendor/autoload.php'; 53 | require_once './EventTypes.php'; // Include enums 54 | require_once './McpEventScheduler.php'; 55 | 56 | use PhpMcp\Server\Defaults\BasicContainer; 57 | use PhpMcp\Server\Server; 58 | use PhpMcp\Server\Transports\HttpServerTransport; 59 | use Psr\Log\AbstractLogger; 60 | use Psr\Log\LoggerInterface; 61 | 62 | class StderrLogger extends AbstractLogger 63 | { 64 | public function log($level, \Stringable|string $message, array $context = []): void 65 | { 66 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 67 | } 68 | } 69 | 70 | try { 71 | $logger = new StderrLogger(); 72 | $logger->info('Starting MCP Complex Schema HTTP Server...'); 73 | 74 | $container = new BasicContainer(); 75 | $container->set(LoggerInterface::class, $logger); 76 | 77 | $server = Server::make() 78 | ->withServerInfo('Event Scheduler Server', '1.0.0') 79 | ->withLogger($logger) 80 | ->withContainer($container) 81 | ->build(); 82 | 83 | $server->discover(__DIR__, ['.']); 84 | 85 | $transport = new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler'); 86 | $server->listen($transport); 87 | 88 | $logger->info('Server listener stopped gracefully.'); 89 | exit(0); 90 | 91 | } catch (\Throwable $e) { 92 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); 93 | exit(1); 94 | } 95 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/ServerScripts/StreamableHttpTestServer.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | declare(strict_types=1); 5 | 6 | require_once __DIR__ . '/../../../vendor/autoload.php'; 7 | 8 | use PhpMcp\Server\Server; 9 | use PhpMcp\Server\Transports\StreamableHttpServerTransport; 10 | use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture; 11 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; 12 | use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; 13 | use PhpMcp\Server\Tests\Fixtures\General\RequestAttributeChecker; 14 | use PhpMcp\Server\Tests\Fixtures\Middlewares\HeaderMiddleware; 15 | use PhpMcp\Server\Tests\Fixtures\Middlewares\RequestAttributeMiddleware; 16 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ShortCircuitMiddleware; 17 | use PhpMcp\Server\Tests\Fixtures\Middlewares\FirstMiddleware; 18 | use PhpMcp\Server\Tests\Fixtures\Middlewares\SecondMiddleware; 19 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ThirdMiddleware; 20 | use PhpMcp\Server\Tests\Fixtures\Middlewares\ErrorMiddleware; 21 | use PhpMcp\Server\Defaults\InMemoryEventStore; 22 | use Psr\Log\AbstractLogger; 23 | use Psr\Log\NullLogger; 24 | 25 | class StdErrLogger extends AbstractLogger 26 | { 27 | public function log($level, \Stringable|string $message, array $context = []): void 28 | { 29 | fwrite(STDERR, sprintf("[%s] SERVER_LOG: %s %s\n", strtoupper((string)$level), $message, empty($context) ? '' : json_encode($context))); 30 | } 31 | } 32 | 33 | $host = $argv[1] ?? '127.0.0.1'; 34 | $port = (int)($argv[2] ?? 8992); 35 | $mcpPath = $argv[3] ?? 'mcp_streamable_test'; 36 | $enableJsonResponse = filter_var($argv[4] ?? 'true', FILTER_VALIDATE_BOOLEAN); 37 | $useEventStore = filter_var($argv[5] ?? 'false', FILTER_VALIDATE_BOOLEAN); 38 | $stateless = filter_var($argv[6] ?? 'false', FILTER_VALIDATE_BOOLEAN); 39 | 40 | try { 41 | $logger = new NullLogger(); 42 | $logger->info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF') . ", Stateless: " . ($stateless ? 'ON' : 'OFF')); 43 | 44 | $eventStore = $useEventStore ? new InMemoryEventStore() : null; 45 | 46 | $server = Server::make() 47 | ->withServerInfo('StreamableHttpIntegrationServer', '0.1.0') 48 | ->withLogger($logger) 49 | ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') 50 | ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing 51 | ->withTool([ToolHandlerFixture::class, 'toolReadsContext'], 'tool_reads_context') // for Context testing 52 | ->withTool([RequestAttributeChecker::class, 'checkAttribute'], 'check_request_attribute_tool') 53 | ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') 54 | ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') 55 | ->build(); 56 | 57 | $middlewares = [ 58 | new HeaderMiddleware(), 59 | new RequestAttributeMiddleware(), 60 | new ShortCircuitMiddleware(), 61 | new FirstMiddleware(), 62 | new SecondMiddleware(), 63 | new ThirdMiddleware(), 64 | new ErrorMiddleware() 65 | ]; 66 | 67 | $transport = new StreamableHttpServerTransport( 68 | host: $host, 69 | port: $port, 70 | mcpPath: $mcpPath, 71 | enableJsonResponse: $enableJsonResponse, 72 | stateless: $stateless, 73 | eventStore: $eventStore, 74 | middlewares: $middlewares 75 | ); 76 | 77 | $server->listen($transport); 78 | 79 | $logger->info("StreamableHttpTestServer listener stopped on {$host}:{$port}."); 80 | exit(0); 81 | } catch (\Throwable $e) { 82 | fwrite(STDERR, "[STREAMABLE_HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPath}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); 83 | exit(1); 84 | } 85 | ``` -------------------------------------------------------------------------------- /src/Utils/HandlerResolver.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Utils; 6 | 7 | use InvalidArgumentException; 8 | use ReflectionMethod; 9 | use ReflectionException; 10 | 11 | /** 12 | * Utility class to validate and resolve MCP element handlers. 13 | */ 14 | class HandlerResolver 15 | { 16 | /** 17 | * Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance. 18 | * 19 | * A handler can be: 20 | * - A Closure: function() { ... } 21 | * - An array: [ClassName::class, 'methodName'] (instance method) 22 | * - An array: [ClassName::class, 'staticMethod'] (static method, if callable) 23 | * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) 24 | * 25 | * @param \Closure|array|string $handler The handler to resolve. 26 | * @return \ReflectionMethod|\ReflectionFunction 27 | * 28 | * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, 29 | * or the method is unsuitable (e.g., private, abstract). 30 | */ 31 | public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction 32 | { 33 | // Handle Closures 34 | if ($handler instanceof \Closure) { 35 | return new \ReflectionFunction($handler); 36 | } 37 | 38 | $className = null; 39 | $methodName = null; 40 | 41 | if (is_array($handler)) { 42 | if (count($handler) !== 2 || !isset($handler[0]) || !isset($handler[1]) || !is_string($handler[0]) || !is_string($handler[1])) { 43 | throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); 44 | } 45 | [$className, $methodName] = $handler; 46 | if (!class_exists($className)) { 47 | throw new InvalidArgumentException("Handler class '{$className}' not found for array handler."); 48 | } 49 | if (!method_exists($className, $methodName)) { 50 | throw new InvalidArgumentException("Handler method '{$methodName}' not found in class '{$className}' for array handler."); 51 | } 52 | } elseif (is_string($handler) && class_exists($handler)) { 53 | $className = $handler; 54 | $methodName = '__invoke'; 55 | if (!method_exists($className, $methodName)) { 56 | throw new InvalidArgumentException("Invokable handler class '{$className}' must have a public '__invoke' method."); 57 | } 58 | } else { 59 | throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); 60 | } 61 | 62 | try { 63 | $reflectionMethod = new ReflectionMethod($className, $methodName); 64 | 65 | // For discovered elements (non-manual), still reject static methods 66 | // For manual elements, we'll allow static methods since they're callable 67 | if (!$reflectionMethod->isPublic()) { 68 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); 69 | } 70 | if ($reflectionMethod->isAbstract()) { 71 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); 72 | } 73 | if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { 74 | throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); 75 | } 76 | 77 | return $reflectionMethod; 78 | } catch (ReflectionException $e) { 79 | // This typically occurs if class_exists passed but ReflectionMethod still fails (rare) 80 | throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); 81 | } 82 | } 83 | } 84 | ``` -------------------------------------------------------------------------------- /examples/06-custom-dependencies-stdio/server.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | MCP Stdio Server with Custom Dependencies (Task Manager) 7 | |-------------------------------------------------------------------------- 8 | | 9 | | This example demonstrates how to use a PSR-11 Dependency Injection (DI) 10 | | container (PhpMcp\Server\Defaults\BasicContainer in this case) to inject 11 | | custom services (like a TaskRepositoryInterface or StatsServiceInterface) 12 | | into your MCP element handler classes. 13 | | 14 | | Scenario: 15 | | A simple Task Management system where: 16 | | - Tools allow adding tasks, listing tasks for a user, and completing tasks. 17 | | - A Resource provides system statistics (total tasks, pending, etc.). 18 | | - Handlers in 'McpTaskHandlers.php' depend on service interfaces. 19 | | - Concrete service implementations are in 'Services.php'. 20 | | 21 | | Key Points: 22 | | - The `ServerBuilder` is configured with `->withContainer($container)`. 23 | | - The DI container is set up with bindings for service interfaces to 24 | | their concrete implementations (e.g., TaskRepositoryInterface -> InMemoryTaskRepository). 25 | | - The `McpTaskHandlers` class receives its dependencies (TaskRepositoryInterface, 26 | | StatsServiceInterface, LoggerInterface) via constructor injection, resolved by 27 | | the DI container when the Processor needs an instance of McpTaskHandlers. 28 | | - This example uses attribute-based discovery via `$server->discover()`. 29 | | - It runs using the STDIO transport. 30 | | 31 | | To Use: 32 | | 1. Run this script: `php server.php` (from this directory) 33 | | 2. Configure your MCP Client (e.g., Cursor) for this server: 34 | | 35 | | { 36 | | "mcpServers": { 37 | | "php-stdio-deps-taskmgr": { 38 | | "command": "php", 39 | | "args": ["/full/path/to/examples/06-custom-dependencies-stdio/server.php"] 40 | | } 41 | | } 42 | | } 43 | | 44 | | Interact with tools like 'add_task', 'list_user_tasks', 'complete_task' 45 | | and read the resource 'stats://system/overview'. 46 | | 47 | */ 48 | 49 | declare(strict_types=1); 50 | 51 | chdir(__DIR__); 52 | require_once '../../vendor/autoload.php'; 53 | require_once './Services.php'; 54 | require_once './McpTaskHandlers.php'; 55 | 56 | use Mcp\DependenciesStdioExample\Services; 57 | use PhpMcp\Server\Defaults\BasicContainer; 58 | use PhpMcp\Server\Server; 59 | use PhpMcp\Server\Transports\StdioServerTransport; 60 | use Psr\Log\AbstractLogger; 61 | use Psr\Log\LoggerInterface; 62 | 63 | class StderrLogger extends AbstractLogger 64 | { 65 | public function log($level, \Stringable|string $message, array $context = []): void 66 | { 67 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 68 | } 69 | } 70 | 71 | try { 72 | $logger = new StderrLogger(); 73 | $logger->info('Starting MCP Custom Dependencies (Stdio) Server...'); 74 | 75 | $container = new BasicContainer(); 76 | $container->set(LoggerInterface::class, $logger); 77 | 78 | $taskRepo = new Services\InMemoryTaskRepository($logger); 79 | $container->set(Services\TaskRepositoryInterface::class, $taskRepo); 80 | 81 | $statsService = new Services\SystemStatsService($taskRepo); 82 | $container->set(Services\StatsServiceInterface::class, $statsService); 83 | 84 | $server = Server::make() 85 | ->withServerInfo('Task Manager Server', '1.0.0') 86 | ->withLogger($logger) 87 | ->withContainer($container) 88 | ->build(); 89 | 90 | $server->discover(__DIR__, ['.']); 91 | 92 | $transport = new StdioServerTransport(); 93 | $server->listen($transport); 94 | 95 | $logger->info('Server listener stopped gracefully.'); 96 | exit(0); 97 | } catch (\Throwable $e) { 98 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 99 | fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); 100 | fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); 101 | fwrite(STDERR, $e->getTraceAsString() . "\n"); 102 | exit(1); 103 | } 104 | ``` -------------------------------------------------------------------------------- /src/Session/SessionManager.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Session; 6 | 7 | use Evenement\EventEmitterInterface; 8 | use Evenement\EventEmitterTrait; 9 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 10 | use PhpMcp\Server\Contracts\SessionInterface; 11 | use Psr\Log\LoggerInterface; 12 | use React\EventLoop\Loop; 13 | use React\EventLoop\LoopInterface; 14 | use React\EventLoop\TimerInterface; 15 | 16 | class SessionManager implements EventEmitterInterface 17 | { 18 | use EventEmitterTrait; 19 | 20 | protected ?TimerInterface $gcTimer = null; 21 | 22 | public function __construct( 23 | protected SessionHandlerInterface $handler, 24 | protected LoggerInterface $logger, 25 | protected ?LoopInterface $loop = null, 26 | protected int $ttl = 3600, 27 | protected int|float $gcInterval = 300 28 | ) { 29 | $this->loop ??= Loop::get(); 30 | } 31 | 32 | /** 33 | * Start the garbage collection timer 34 | */ 35 | public function startGcTimer(): void 36 | { 37 | if ($this->gcTimer !== null) { 38 | return; 39 | } 40 | 41 | $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, [$this, 'gc']); 42 | } 43 | 44 | public function gc(): array 45 | { 46 | $deletedSessions = $this->handler->gc($this->ttl); 47 | 48 | foreach ($deletedSessions as $sessionId) { 49 | $this->emit('session_deleted', [$sessionId]); 50 | } 51 | 52 | if (count($deletedSessions) > 0) { 53 | $this->logger->debug('Session garbage collection complete', [ 54 | 'purged_sessions' => count($deletedSessions), 55 | ]); 56 | } 57 | 58 | return $deletedSessions; 59 | } 60 | 61 | /** 62 | * Stop the garbage collection timer 63 | */ 64 | public function stopGcTimer(): void 65 | { 66 | if ($this->gcTimer !== null) { 67 | $this->loop->cancelTimer($this->gcTimer); 68 | $this->gcTimer = null; 69 | } 70 | } 71 | 72 | /** 73 | * Create a new session 74 | */ 75 | public function createSession(string $sessionId): SessionInterface 76 | { 77 | $session = new Session($this->handler, $sessionId); 78 | 79 | $session->hydrate([ 80 | 'initialized' => false, 81 | 'client_info' => null, 82 | 'protocol_version' => null, 83 | 'subscriptions' => [], // [uri => true] 84 | 'message_queue' => [], // string[] (raw JSON-RPC frames) 85 | 'log_level' => null, 86 | ]); 87 | 88 | $session->save(); 89 | 90 | $this->logger->info('Session created', ['sessionId' => $sessionId]); 91 | $this->emit('session_created', [$sessionId, $session]); 92 | 93 | return $session; 94 | } 95 | 96 | /** 97 | * Get an existing session 98 | */ 99 | public function getSession(string $sessionId): ?SessionInterface 100 | { 101 | return Session::retrieve($sessionId, $this->handler); 102 | } 103 | 104 | public function hasSession(string $sessionId): bool 105 | { 106 | return $this->getSession($sessionId) !== null; 107 | } 108 | 109 | /** 110 | * Delete a session completely 111 | */ 112 | public function deleteSession(string $sessionId): bool 113 | { 114 | $success = $this->handler->destroy($sessionId); 115 | 116 | if ($success) { 117 | $this->emit('session_deleted', [$sessionId]); 118 | $this->logger->info('Session deleted', ['sessionId' => $sessionId]); 119 | } else { 120 | $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]); 121 | } 122 | 123 | return $success; 124 | } 125 | 126 | public function queueMessage(string $sessionId, string $message): void 127 | { 128 | $session = $this->getSession($sessionId); 129 | if ($session === null) { 130 | return; 131 | } 132 | 133 | $session->queueMessage($message); 134 | $session->save(); 135 | } 136 | 137 | public function dequeueMessages(string $sessionId): array 138 | { 139 | $session = $this->getSession($sessionId); 140 | if ($session === null) { 141 | return []; 142 | } 143 | 144 | $messages = $session->dequeueMessages(); 145 | $session->save(); 146 | 147 | return $messages; 148 | } 149 | 150 | public function hasQueuedMessages(string $sessionId): bool 151 | { 152 | $session = $this->getSession($sessionId, true); 153 | if ($session === null) { 154 | return false; 155 | } 156 | 157 | return $session->hasQueuedMessages(); 158 | } 159 | } 160 | ``` -------------------------------------------------------------------------------- /src/Exception/McpServerException.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Exception; 6 | 7 | use Exception; 8 | use PhpMcp\Schema\Constants; 9 | use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; 10 | use Throwable; 11 | 12 | /** 13 | * Base exception for all MCP Server library errors. 14 | */ 15 | class McpServerException extends Exception 16 | { 17 | // MCP reserved range: -32000 to -32099 (Server error) 18 | // Add specific server-side codes if needed later, e.g.: 19 | // public const CODE_RESOURCE_ACTION_FAILED = -32000; 20 | // public const CODE_TOOL_EXECUTION_FAILED = -32001; 21 | 22 | /** 23 | * Additional data associated with the error, suitable for JSON-RPC 'data' field. 24 | * 25 | * @var mixed|null 26 | */ 27 | protected mixed $data = null; 28 | 29 | /** 30 | * @param string $message Error message. 31 | * @param int $code Error code (use constants or appropriate HTTP status codes if applicable). 32 | * @param mixed|null $data Additional data. 33 | * @param ?Throwable $previous Previous exception. 34 | */ 35 | public function __construct( 36 | string $message = '', 37 | int $code = 0, 38 | mixed $data = null, 39 | ?Throwable $previous = null 40 | ) { 41 | parent::__construct($message, $code, $previous); 42 | $this->data = $data; 43 | } 44 | 45 | /** 46 | * Get additional error data. 47 | * 48 | * @return mixed|null 49 | */ 50 | public function getData(): mixed 51 | { 52 | return $this->data; 53 | } 54 | 55 | /** 56 | * Formats the exception into a JSON-RPC 2.0 error object structure. 57 | * Specific exceptions should override this or provide factories with correct codes. 58 | */ 59 | public function toJsonRpcError(string|int $id): JsonRpcError 60 | { 61 | $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR; 62 | 63 | return new JsonRpcError( 64 | jsonrpc: '2.0', 65 | id: $id, 66 | code: $code, 67 | message: $this->getMessage(), 68 | data: $this->getData() 69 | ); 70 | } 71 | 72 | public static function parseError(string $details, ?Throwable $previous = null): self 73 | { 74 | return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous); 75 | } 76 | 77 | public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self 78 | { 79 | return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous); 80 | } 81 | 82 | public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self 83 | { 84 | return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous); 85 | } 86 | 87 | public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self 88 | { 89 | // Pass data (e.g., validation errors) through 90 | return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous); 91 | } 92 | 93 | public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self 94 | { 95 | $message = 'Internal error'; 96 | if ($details && is_string($details)) { 97 | $message .= ': ' . $details; 98 | } elseif ($previous && $details === null) { 99 | $message .= ' (See server logs)'; 100 | } 101 | 102 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 103 | } 104 | 105 | public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self 106 | { 107 | $message = "Execution failed for tool '{$toolName}'"; 108 | if ($previous) { 109 | $message .= ': ' . $previous->getMessage(); 110 | } 111 | 112 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 113 | } 114 | 115 | public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self 116 | { 117 | $message = "Failed to read resource '{$uri}'"; 118 | if ($previous) { 119 | $message .= ': ' . $previous->getMessage(); 120 | } 121 | 122 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 123 | } 124 | 125 | public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self 126 | { 127 | $message = "Failed to generate prompt '{$promptName}'"; 128 | if ($previous) { 129 | $message .= ': ' . $previous->getMessage(); 130 | } 131 | 132 | return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); 133 | } 134 | } 135 | ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredTool.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Elements; 6 | 7 | use PhpMcp\Schema\Content\Content; 8 | use PhpMcp\Schema\Content\TextContent; 9 | use PhpMcp\Server\Context; 10 | use Psr\Container\ContainerInterface; 11 | use PhpMcp\Schema\Tool; 12 | use Throwable; 13 | 14 | class RegisteredTool extends RegisteredElement 15 | { 16 | public function __construct( 17 | public readonly Tool $schema, 18 | callable|array|string $handler, 19 | bool $isManual = false, 20 | ) { 21 | parent::__construct($handler, $isManual); 22 | } 23 | 24 | public static function make(Tool $schema, callable|array|string $handler, bool $isManual = false): self 25 | { 26 | return new self($schema, $handler, $isManual); 27 | } 28 | 29 | /** 30 | * Calls the underlying handler for this tool. 31 | * 32 | * @return Content[] The content items for CallToolResult. 33 | */ 34 | public function call(ContainerInterface $container, array $arguments, Context $context): array 35 | { 36 | $result = $this->handle($container, $arguments, $context); 37 | 38 | return $this->formatResult($result); 39 | } 40 | 41 | /** 42 | * Formats the result of a tool execution into an array of MCP Content items. 43 | * 44 | * - If the result is already a Content object, it's wrapped in an array. 45 | * - If the result is an array: 46 | * - If all elements are Content objects, the array is returned as is. 47 | * - If it's a mixed array (Content and non-Content items), non-Content items are 48 | * individually formatted (scalars to TextContent, others to JSON TextContent). 49 | * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. 50 | * - Scalars (string, int, float, bool) are wrapped in TextContent. 51 | * - null is represented as TextContent('(null)'). 52 | * - Other objects are JSON-encoded and wrapped in TextContent. 53 | * 54 | * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. 55 | * @return Content[] The content items for CallToolResult. 56 | * @throws JsonException if JSON encoding fails for non-Content array/object results. 57 | */ 58 | protected function formatResult(mixed $toolExecutionResult): array 59 | { 60 | if ($toolExecutionResult instanceof Content) { 61 | return [$toolExecutionResult]; 62 | } 63 | 64 | if (is_array($toolExecutionResult)) { 65 | if (empty($toolExecutionResult)) { 66 | return [TextContent::make('[]')]; 67 | } 68 | 69 | $allAreContent = true; 70 | $hasContent = false; 71 | 72 | foreach ($toolExecutionResult as $item) { 73 | if ($item instanceof Content) { 74 | $hasContent = true; 75 | } else { 76 | $allAreContent = false; 77 | } 78 | } 79 | 80 | if ($allAreContent && $hasContent) { 81 | return $toolExecutionResult; 82 | } 83 | 84 | if ($hasContent) { 85 | $result = []; 86 | foreach ($toolExecutionResult as $item) { 87 | if ($item instanceof Content) { 88 | $result[] = $item; 89 | } else { 90 | $result = array_merge($result, $this->formatResult($item)); 91 | } 92 | } 93 | return $result; 94 | } 95 | } 96 | 97 | if ($toolExecutionResult === null) { 98 | return [TextContent::make('(null)')]; 99 | } 100 | 101 | if (is_bool($toolExecutionResult)) { 102 | return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; 103 | } 104 | 105 | if (is_scalar($toolExecutionResult)) { 106 | return [TextContent::make($toolExecutionResult)]; 107 | } 108 | 109 | $jsonResult = json_encode( 110 | $toolExecutionResult, 111 | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE 112 | ); 113 | 114 | return [TextContent::make($jsonResult)]; 115 | } 116 | 117 | public function toArray(): array 118 | { 119 | return [ 120 | 'schema' => $this->schema->toArray(), 121 | ...parent::toArray(), 122 | ]; 123 | } 124 | 125 | public static function fromArray(array $data): self|false 126 | { 127 | try { 128 | if (! isset($data['schema']) || ! isset($data['handler'])) { 129 | return false; 130 | } 131 | 132 | return new self( 133 | Tool::fromArray($data['schema']), 134 | $data['handler'], 135 | $data['isManual'] ?? false, 136 | ); 137 | } catch (Throwable $e) { 138 | return false; 139 | } 140 | } 141 | } 142 | ``` -------------------------------------------------------------------------------- /src/Utils/DocBlockParser.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Utils; 4 | 5 | use phpDocumentor\Reflection\DocBlock; 6 | use phpDocumentor\Reflection\DocBlock\Tags\Param; 7 | use phpDocumentor\Reflection\DocBlock\Tags\Return_; 8 | use phpDocumentor\Reflection\DocBlockFactory; 9 | use Psr\Log\LoggerInterface; 10 | use Psr\Log\NullLogger; 11 | use Throwable; 12 | 13 | /** 14 | * Parses DocBlocks using phpdocumentor/reflection-docblock. 15 | */ 16 | class DocBlockParser 17 | { 18 | private DocBlockFactory $docBlockFactory; 19 | private LoggerInterface $logger; 20 | 21 | public function __construct(?LoggerInterface $logger = null) 22 | { 23 | $this->docBlockFactory = DocBlockFactory::createInstance(); 24 | $this->logger = $logger ?? new NullLogger(); 25 | } 26 | 27 | /** 28 | * Safely parses a DocComment string into a DocBlock object. 29 | */ 30 | public function parseDocBlock(string|null|false $docComment): ?DocBlock 31 | { 32 | if ($docComment === false || $docComment === null || empty($docComment)) { 33 | return null; 34 | } 35 | try { 36 | return $this->docBlockFactory->create($docComment); 37 | } catch (Throwable $e) { 38 | // Log error or handle gracefully if invalid DocBlock syntax is encountered 39 | $this->logger->warning('Failed to parse DocBlock', [ 40 | 'error' => $e->getMessage(), 41 | 'exception_trace' => $e->getTraceAsString(), 42 | ]); 43 | 44 | return null; 45 | } 46 | } 47 | 48 | /** 49 | * Gets the summary line from a DocBlock. 50 | */ 51 | public function getSummary(?DocBlock $docBlock): ?string 52 | { 53 | if (! $docBlock) { 54 | return null; 55 | } 56 | $summary = trim($docBlock->getSummary()); 57 | 58 | return $summary ?: null; // Return null if empty after trimming 59 | } 60 | 61 | /** 62 | * Gets the description from a DocBlock (summary + description body). 63 | */ 64 | public function getDescription(?DocBlock $docBlock): ?string 65 | { 66 | if (! $docBlock) { 67 | return null; 68 | } 69 | $summary = trim($docBlock->getSummary()); 70 | $descriptionBody = trim((string) $docBlock->getDescription()); 71 | 72 | if ($summary && $descriptionBody) { 73 | return $summary . "\n\n" . $descriptionBody; 74 | } 75 | if ($summary) { 76 | return $summary; 77 | } 78 | if ($descriptionBody) { 79 | return $descriptionBody; 80 | } 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * Extracts @param tag information from a DocBlock, keyed by variable name (e.g., '$paramName'). 87 | * 88 | * @return array<string, Param> 89 | */ 90 | public function getParamTags(?DocBlock $docBlock): array 91 | { 92 | if (! $docBlock) { 93 | return []; 94 | } 95 | 96 | /** @var array<string, Param> $paramTags */ 97 | $paramTags = []; 98 | foreach ($docBlock->getTagsByName('param') as $tag) { 99 | if ($tag instanceof Param && $tag->getVariableName()) { 100 | $paramTags['$' . $tag->getVariableName()] = $tag; 101 | } 102 | } 103 | 104 | return $paramTags; 105 | } 106 | 107 | /** 108 | * Gets the @return tag information from a DocBlock. 109 | */ 110 | public function getReturnTag(?DocBlock $docBlock): ?Return_ 111 | { 112 | if (! $docBlock) { 113 | return null; 114 | } 115 | /** @var Return_|null $returnTag */ 116 | $returnTag = $docBlock->getTagsByName('return')[0] ?? null; 117 | 118 | return $returnTag; 119 | } 120 | 121 | /** 122 | * Gets the description string from a Param tag. 123 | */ 124 | public function getParamDescription(?Param $paramTag): ?string 125 | { 126 | return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null; 127 | } 128 | 129 | /** 130 | * Gets the type string from a Param tag. 131 | */ 132 | public function getParamTypeString(?Param $paramTag): ?string 133 | { 134 | if ($paramTag && $paramTag->getType()) { 135 | $typeFromTag = trim((string) $paramTag->getType()); 136 | if (! empty($typeFromTag)) { 137 | return ltrim($typeFromTag, '\\'); 138 | } 139 | } 140 | 141 | return null; 142 | } 143 | 144 | /** 145 | * Gets the description string from a Return_ tag. 146 | */ 147 | public function getReturnDescription(?Return_ $returnTag): ?string 148 | { 149 | return $returnTag ? (trim((string) $returnTag->getDescription()) ?: null) : null; 150 | } 151 | 152 | /** 153 | * Gets the type string from a Return_ tag. 154 | */ 155 | public function getReturnTypeString(?Return_ $returnTag): ?string 156 | { 157 | if ($returnTag && $returnTag->getType()) { 158 | $typeFromTag = trim((string) $returnTag->getType()); 159 | if (! empty($typeFromTag)) { 160 | return ltrim($typeFromTag, '\\'); 161 | } 162 | } 163 | 164 | return null; 165 | } 166 | } 167 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/Schema/SchemaGenerationTarget.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\Schema; 4 | 5 | use PhpMcp\Server\Attributes\Schema; 6 | use PhpMcp\Server\Attributes\Schema\Format; 7 | use PhpMcp\Server\Attributes\Schema\ArrayItems; 8 | use PhpMcp\Server\Attributes\Schema\Property; 9 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; 10 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; 11 | use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; 12 | use stdClass; 13 | 14 | class SchemaGenerationTarget 15 | { 16 | public function noParamsMethod(): void 17 | { 18 | } 19 | 20 | /** 21 | * Method with simple required types. 22 | * @param string $pString String param 23 | * @param int $pInt Int param 24 | * @param bool $pBool Bool param 25 | * @param float $pFloat Float param 26 | * @param array $pArray Array param 27 | * @param stdClass $pObject Object param 28 | */ 29 | public function simpleRequiredTypes(string $pString, int $pInt, bool $pBool, float $pFloat, array $pArray, stdClass $pObject): void 30 | { 31 | } 32 | 33 | /** 34 | * Method with simple optional types with default values. 35 | * @param string $pStringOpt String param with default 36 | * @param int $pIntOpt Int param with default 37 | * @param bool $pBoolOpt Bool param with default 38 | * @param ?float $pFloatOptNullable Float param with default, also nullable 39 | * @param array $pArrayOpt Array param with default 40 | * @param ?stdClass $pObjectOptNullable Object param with default null 41 | */ 42 | public function optionalTypesWithDefaults( 43 | string $pStringOpt = "hello", 44 | int $pIntOpt = 123, 45 | bool $pBoolOpt = true, 46 | ?float $pFloatOptNullable = 1.23, 47 | array $pArrayOpt = ['a', 'b'], 48 | ?stdClass $pObjectOptNullable = null 49 | ): void { 50 | } 51 | 52 | /** 53 | * Nullable types without explicit defaults. 54 | * @param ?string $pNullableString Nullable string 55 | * @param int|null $pUnionNullableInt Union nullable int 56 | */ 57 | public function nullableTypes(?string $pNullableString, ?int $pUnionNullableInt, ?BackedStringEnum $pNullableEnum): void 58 | { 59 | } 60 | 61 | /** 62 | * Union types. 63 | * @param string|int $pStringOrInt String or Int 64 | * @param bool|float|null $pBoolOrFloatOrNull Bool, Float or Null 65 | */ 66 | public function unionTypes(string|int $pStringOrInt, $pBoolOrFloatOrNull): void 67 | { 68 | } // PHP 7.x style union in docblock usually 69 | 70 | /** 71 | * Various array type hints. 72 | * @param string[] $pStringArray Array of strings (docblock style) 73 | * @param array<int> $pIntArrayGeneric Array of integers (generic style) 74 | * @param array<string, mixed> $pAssocArray Associative array 75 | * @param BackedIntEnum[] $pEnumArray Array of enums 76 | * @param array{name: string, age: int} $pShapeArray Typed array shape 77 | * @param array<array{id:int, value:string}> $pArrayOfShapes Array of shapes 78 | */ 79 | public function arrayTypes( 80 | array $pStringArray, 81 | array $pIntArrayGeneric, 82 | array $pAssocArray, 83 | array $pEnumArray, 84 | array $pShapeArray, 85 | array $pArrayOfShapes 86 | ): void { 87 | } 88 | 89 | /** 90 | * Enum types. 91 | * @param BackedStringEnum $pBackedStringEnum Backed string enum 92 | * @param BackedIntEnum $pBackedIntEnum Backed int enum 93 | * @param UnitEnum $pUnitEnum Unit enum 94 | */ 95 | public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void 96 | { 97 | } 98 | 99 | /** 100 | * Variadic parameters. 101 | * @param string ...$pVariadicStrings Variadic strings 102 | */ 103 | public function variadicParams(string ...$pVariadicStrings): void 104 | { 105 | } 106 | 107 | /** 108 | * Mixed type. 109 | * @param mixed $pMixed Mixed type 110 | */ 111 | public function mixedType(mixed $pMixed): void 112 | { 113 | } 114 | 115 | /** 116 | * With #[Schema] attributes for enhanced validation. 117 | * @param string $email With email format. 118 | * @param int $quantity With numeric constraints. 119 | * @param string[] $tags With array constraints. 120 | * @param array $userProfile With object property constraints. 121 | */ 122 | public function withSchemaAttributes( 123 | #[Schema(format: Format::EMAIL)] 124 | string $email, 125 | #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] 126 | int $quantity, 127 | #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] 128 | array $tags, 129 | #[Schema( 130 | properties: [ 131 | new Property(name: 'id', minimum: 1), 132 | new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'), 133 | ], 134 | required: ['id', 'username'], 135 | additionalProperties: false 136 | )] 137 | array $userProfile 138 | ): void { 139 | } 140 | } 141 | ``` -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/McpElements.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace Mcp\HttpUserProfileExample; 4 | 5 | use PhpMcp\Server\Attributes\CompletionProvider; 6 | use PhpMcp\Server\Attributes\McpPrompt; 7 | use PhpMcp\Server\Attributes\McpResource; 8 | use PhpMcp\Server\Attributes\McpResourceTemplate; 9 | use PhpMcp\Server\Attributes\McpTool; 10 | use PhpMcp\Server\Exception\McpServerException; 11 | use Psr\Log\LoggerInterface; 12 | 13 | class McpElements 14 | { 15 | // Simulate a simple user database 16 | private array $users = [ 17 | '101' => ['name' => 'Alice', 'email' => '[email protected]', 'role' => 'admin'], 18 | '102' => ['name' => 'Bob', 'email' => '[email protected]', 'role' => 'user'], 19 | '103' => ['name' => 'Charlie', 'email' => '[email protected]', 'role' => 'user'], 20 | ]; 21 | 22 | private LoggerInterface $logger; 23 | 24 | public function __construct(LoggerInterface $logger) 25 | { 26 | $this->logger = $logger; 27 | $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); 28 | } 29 | 30 | /** 31 | * Retrieves the profile data for a specific user. 32 | * 33 | * @param string $userId The ID of the user (from URI). 34 | * @return array User profile data. 35 | * 36 | * @throws McpServerException If the user is not found. 37 | */ 38 | #[McpResourceTemplate( 39 | uriTemplate: 'user://{userId}/profile', 40 | name: 'user_profile', 41 | description: 'Get profile information for a specific user ID.', 42 | mimeType: 'application/json' 43 | )] 44 | 45 | public function getUserProfile( 46 | #[CompletionProvider(values: ['101', '102', '103'])] 47 | string $userId 48 | ): array { 49 | $this->logger->info('Reading resource: user profile', ['userId' => $userId]); 50 | if (! isset($this->users[$userId])) { 51 | // Throwing an exception that Processor can turn into an error response 52 | throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); 53 | } 54 | 55 | return $this->users[$userId]; 56 | } 57 | 58 | /** 59 | * Retrieves a list of all known user IDs. 60 | * 61 | * @return array List of user IDs. 62 | */ 63 | #[McpResource( 64 | uri: 'user://list/ids', 65 | name: 'user_id_list', 66 | description: 'Provides a list of all available user IDs.', 67 | mimeType: 'application/json' 68 | )] 69 | public function listUserIds(): array 70 | { 71 | $this->logger->info('Reading resource: user ID list'); 72 | 73 | return array_keys($this->users); 74 | } 75 | 76 | /** 77 | * Sends a welcome message to a user. 78 | * (This is a placeholder - in a real app, it might queue an email) 79 | * 80 | * @param string $userId The ID of the user to message. 81 | * @param string|null $customMessage An optional custom message part. 82 | * @return array Status of the operation. 83 | */ 84 | #[McpTool(name: 'send_welcome')] 85 | public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array 86 | { 87 | $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]); 88 | if (! isset($this->users[$userId])) { 89 | return ['success' => false, 'error' => "User ID {$userId} not found."]; 90 | } 91 | $user = $this->users[$userId]; 92 | $message = "Welcome, {$user['name']}!"; 93 | if ($customMessage) { 94 | $message .= ' ' . $customMessage; 95 | } 96 | // Simulate sending 97 | $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); 98 | 99 | return ['success' => true, 'message_sent' => $message]; 100 | } 101 | 102 | #[McpTool(name: 'test_tool_without_params')] 103 | public function testToolWithoutParams() 104 | { 105 | return ['success' => true, 'message' => 'Test tool without params']; 106 | } 107 | 108 | /** 109 | * Generates a prompt to write a bio for a user. 110 | * 111 | * @param string $userId The user ID to generate the bio for. 112 | * @param string $tone Desired tone (e.g., 'formal', 'casual'). 113 | * @return array Prompt messages. 114 | * 115 | * @throws McpServerException If user not found. 116 | */ 117 | #[McpPrompt(name: 'generate_bio_prompt')] 118 | public function generateBio( 119 | #[CompletionProvider(provider: UserIdCompletionProvider::class)] 120 | string $userId, 121 | string $tone = 'professional' 122 | ): array { 123 | $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); 124 | if (! isset($this->users[$userId])) { 125 | throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); 126 | } 127 | $user = $this->users[$userId]; 128 | 129 | return [ 130 | ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."], 131 | ]; 132 | } 133 | } 134 | ``` -------------------------------------------------------------------------------- /examples/01-discovery-stdio-calculator/McpElements.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace Mcp\StdioCalculatorExample; 4 | 5 | use PhpMcp\Server\Attributes\McpResource; 6 | use PhpMcp\Server\Attributes\McpTool; 7 | 8 | class McpElements 9 | { 10 | private array $config = [ 11 | 'precision' => 2, 12 | 'allow_negative' => true, 13 | ]; 14 | 15 | /** 16 | * Performs a calculation based on the operation. 17 | * 18 | * Supports 'add', 'subtract', 'multiply', 'divide'. 19 | * Obeys the 'precision' and 'allow_negative' settings from the config resource. 20 | * 21 | * @param float $a The first operand. 22 | * @param float $b The second operand. 23 | * @param string $operation The operation ('add', 'subtract', 'multiply', 'divide'). 24 | * @return float|string The result of the calculation, or an error message string. 25 | */ 26 | #[McpTool(name: 'calculate')] 27 | public function calculate(float $a, float $b, string $operation): float|string 28 | { 29 | // Use STDERR for logs 30 | fwrite(STDERR, "Calculate tool called: a=$a, b=$b, op=$operation\n"); 31 | 32 | $op = strtolower($operation); 33 | $result = null; 34 | 35 | switch ($op) { 36 | case 'add': 37 | $result = $a + $b; 38 | break; 39 | case 'subtract': 40 | $result = $a - $b; 41 | break; 42 | case 'multiply': 43 | $result = $a * $b; 44 | break; 45 | case 'divide': 46 | if ($b == 0) { 47 | return 'Error: Division by zero.'; 48 | } 49 | $result = $a / $b; 50 | break; 51 | default: 52 | return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; 53 | } 54 | 55 | if (! $this->config['allow_negative'] && $result < 0) { 56 | return 'Error: Negative results are disabled.'; 57 | } 58 | 59 | return round($result, $this->config['precision']); 60 | } 61 | 62 | /** 63 | * Provides the current calculator configuration. 64 | * Can be read by clients to understand precision etc. 65 | * 66 | * @return array The configuration array. 67 | */ 68 | #[McpResource( 69 | uri: 'config://calculator/settings', 70 | name: 'calculator_config', 71 | description: 'Current settings for the calculator tool (precision, allow_negative).', 72 | mimeType: 'application/json' // Return as JSON 73 | )] 74 | public function getConfiguration(): array 75 | { 76 | fwrite(STDERR, "Resource config://calculator/settings read.\n"); 77 | 78 | return $this->config; 79 | } 80 | 81 | /** 82 | * Updates a specific configuration setting. 83 | * Note: This requires more robust validation in a real app. 84 | * 85 | * @param string $setting The setting key ('precision' or 'allow_negative'). 86 | * @param mixed $value The new value (int for precision, bool for allow_negative). 87 | * @return array Success message or error. 88 | */ 89 | #[McpTool(name: 'update_setting')] 90 | public function updateSetting(string $setting, mixed $value): array 91 | { 92 | fwrite(STDERR, "Update Setting tool called: setting=$setting, value=".var_export($value, true)."\n"); 93 | if (! array_key_exists($setting, $this->config)) { 94 | return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; 95 | } 96 | 97 | if ($setting === 'precision') { 98 | if (! is_int($value) || $value < 0 || $value > 10) { 99 | return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; 100 | } 101 | $this->config['precision'] = $value; 102 | 103 | // In real app, notify subscribers of config://calculator/settings change 104 | // $registry->notifyResourceChanged('config://calculator/settings'); 105 | return ['success' => true, 'message' => "Precision updated to {$value}."]; 106 | } 107 | 108 | if ($setting === 'allow_negative') { 109 | if (! is_bool($value)) { 110 | // Attempt basic cast for flexibility 111 | if (in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) { 112 | $value = true; 113 | } elseif (in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { 114 | $value = false; 115 | } else { 116 | return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; 117 | } 118 | } 119 | $this->config['allow_negative'] = $value; 120 | 121 | // $registry->notifyResourceChanged('config://calculator/settings'); 122 | return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; 123 | } 124 | 125 | return ['success' => false, 'error' => 'Internal error handling setting.']; // Should not happen 126 | } 127 | } 128 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/ResourceHandlerFixture.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\General; 4 | 5 | use PhpMcp\Schema\Content\EmbeddedResource; 6 | use PhpMcp\Schema\Content\TextResourceContents; 7 | use PhpMcp\Schema\Content\BlobResourceContents; 8 | use Psr\Log\LoggerInterface; 9 | use SplFileInfo; 10 | 11 | class ResourceHandlerFixture 12 | { 13 | public static string $staticTextContent = "Default static text content."; 14 | public array $dynamicContentStore = []; 15 | public static ?string $unlinkableSplFile = null; 16 | 17 | public function __construct() 18 | { 19 | $this->dynamicContentStore['dynamic://data/item1'] = "Content for item 1"; 20 | } 21 | 22 | public function returnStringText(string $uri): string 23 | { 24 | return "Plain string content for {$uri}"; 25 | } 26 | 27 | public function returnStringJson(string $uri): string 28 | { 29 | return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']); 30 | } 31 | 32 | public function returnStringHtml(string $uri): string 33 | { 34 | return "<html><title>{$uri}</title><body>Content</body></html>"; 35 | } 36 | 37 | public function returnArrayJson(string $uri): array 38 | { 39 | return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()]; 40 | } 41 | 42 | public function returnEmptyArray(string $uri): array 43 | { 44 | return []; 45 | } 46 | 47 | public function returnStream(string $uri) // Returns a stream resource 48 | { 49 | $stream = fopen('php://memory', 'r+'); 50 | fwrite($stream, "Streamed content for {$uri}"); 51 | rewind($stream); 52 | return $stream; 53 | } 54 | 55 | public function returnSplFileInfo(string $uri): SplFileInfo 56 | { 57 | self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_'); 58 | file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}"); 59 | return new SplFileInfo(self::$unlinkableSplFile); 60 | } 61 | 62 | public function returnEmbeddedResource(string $uri): EmbeddedResource 63 | { 64 | return EmbeddedResource::make( 65 | TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content') 66 | ); 67 | } 68 | 69 | public function returnTextResourceContents(string $uri): TextResourceContents 70 | { 71 | return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents'); 72 | } 73 | 74 | public function returnBlobResourceContents(string $uri): BlobResourceContents 75 | { 76 | return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents')); 77 | } 78 | 79 | public function returnArrayForBlobSchema(string $uri): array 80 | { 81 | return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array']; 82 | } 83 | 84 | public function returnArrayForTextSchema(string $uri): array 85 | { 86 | return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text']; 87 | } 88 | 89 | public function returnArrayOfResourceContents(string $uri): array 90 | { 91 | return [ 92 | TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'), 93 | BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')), 94 | ]; 95 | } 96 | 97 | public function returnArrayOfEmbeddedResources(string $uri): array 98 | { 99 | return [ 100 | EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '<doc1/>')), 101 | EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))), 102 | ]; 103 | } 104 | 105 | public function returnMixedArrayWithResourceTypes(string $uri): array 106 | { 107 | return [ 108 | "A raw string piece", // Will be formatted 109 | TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is 110 | ['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON) 111 | EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted 112 | ]; 113 | } 114 | 115 | public function handlerThrowsException(string $uri): void 116 | { 117 | throw new \DomainException("Cannot read resource {$uri} - handler error."); 118 | } 119 | 120 | public function returnUnformattableType(string $uri) 121 | { 122 | return new \DateTimeImmutable(); 123 | } 124 | 125 | public function resourceHandlerNeedsUri(string $uri): string 126 | { 127 | return "Handler received URI: " . $uri; 128 | } 129 | 130 | public function resourceHandlerDoesNotNeedUri(): string 131 | { 132 | return "Handler did not need or receive URI parameter."; 133 | } 134 | 135 | public function getTemplatedContent( 136 | string $category, 137 | string $itemId, 138 | string $format, 139 | ): array { 140 | return [ 141 | 'message' => "Content for item {$itemId} in category {$category}, format {$format}.", 142 | 'category_received' => $category, 143 | 'itemId_received' => $itemId, 144 | 'format_received' => $format, 145 | ]; 146 | } 147 | 148 | public function getStaticText(): string 149 | { 150 | return self::$staticTextContent; 151 | } 152 | } 153 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/VariousTypesHandler.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\General; 4 | 5 | use PhpMcp\Server\Context; 6 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; 7 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; 8 | use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; 9 | use stdClass; 10 | 11 | class VariousTypesHandler 12 | { 13 | public function noArgsMethod(): array 14 | { 15 | return compact([]); 16 | } 17 | 18 | public function simpleRequiredArgs(string $pString, int $pInt, bool $pBool): array 19 | { 20 | return compact('pString', 'pInt', 'pBool'); 21 | } 22 | 23 | public function optionalArgsWithDefaults( 24 | string $pString = 'default_string', 25 | int $pInt = 100, 26 | ?bool $pNullableBool = true, 27 | float $pFloat = 3.14 28 | ): array { 29 | return compact('pString', 'pInt', 'pNullableBool', 'pFloat'); 30 | } 31 | 32 | public function nullableArgsWithoutDefaults(?string $pString, ?int $pInt, ?array $pArray): array 33 | { 34 | return compact('pString', 'pInt', 'pArray'); 35 | } 36 | 37 | public function mixedTypeArg(mixed $pMixed): array 38 | { 39 | return compact('pMixed'); 40 | } 41 | 42 | public function backedEnumArgs( 43 | BackedStringEnum $pBackedString, 44 | BackedIntEnum $pBackedInt, 45 | ?BackedStringEnum $pNullableBackedString = null, 46 | BackedIntEnum $pOptionalBackedInt = BackedIntEnum::First 47 | ): array { 48 | return compact('pBackedString', 'pBackedInt', 'pNullableBackedString', 'pOptionalBackedInt'); 49 | } 50 | 51 | public function unitEnumArg(UnitEnum $pUnitEnum): array 52 | { 53 | return compact('pUnitEnum'); 54 | } 55 | 56 | public function arrayArg(array $pArray): array 57 | { 58 | return compact('pArray'); 59 | } 60 | 61 | public function objectArg(stdClass $pObject): array 62 | { 63 | return compact('pObject'); 64 | } 65 | 66 | public function variadicArgs(string ...$items): array 67 | { 68 | return compact('items'); 69 | } 70 | 71 | /** 72 | * A comprehensive method for testing various argument types and casting. 73 | * @param string $strParam A string. 74 | * @param int $intParam An integer. 75 | * @param bool $boolProp A boolean. 76 | * @param float $floatParam A float. 77 | * @param array $arrayParam An array. 78 | * @param BackedStringEnum $backedStringEnumParam A backed string enum. 79 | * @param BackedIntEnum $backedIntEnumParam A backed int enum. 80 | * @param UnitEnum $unitEnumParam A unit enum (passed as instance). 81 | * @param string|null $nullableStringParam A nullable string. 82 | * @param int $optionalIntWithDefaultParam An optional int with default. 83 | * @param mixed $mixedParam A mixed type. 84 | * @param stdClass $objectParam An object. 85 | * @param string $stringForIntCast String that should be cast to int. 86 | * @param string $stringForFloatCast String that should be cast to float. 87 | * @param string $stringForBoolTrueCast String that should be cast to bool true. 88 | * @param string $stringForBoolFalseCast String that should be cast to bool false. 89 | * @param int $intForStringCast Int that should be cast to string. 90 | * @param int $intForFloatCast Int that should be cast to float. 91 | * @param bool $boolForStringCast Bool that should be cast to string. 92 | * @param string $valueForBackedStringEnum String value for backed string enum. 93 | * @param int $valueForBackedIntEnum Int value for backed int enum. 94 | */ 95 | public function comprehensiveArgumentTest( 96 | string $strParam, 97 | int $intParam, 98 | bool $boolProp, 99 | float $floatParam, 100 | array $arrayParam, 101 | BackedStringEnum $backedStringEnumParam, 102 | BackedIntEnum $backedIntEnumParam, 103 | UnitEnum $unitEnumParam, 104 | ?string $nullableStringParam, 105 | mixed $mixedParam, 106 | stdClass $objectParam, 107 | string $stringForIntCast, 108 | string $stringForFloatCast, 109 | string $stringForBoolTrueCast, 110 | string $stringForBoolFalseCast, 111 | int $intForStringCast, 112 | int $intForFloatCast, 113 | bool $boolForStringCast, 114 | string $valueForBackedStringEnum, 115 | int $valueForBackedIntEnum, 116 | int $optionalIntWithDefaultParam = 999, 117 | ): array { 118 | return compact( 119 | 'strParam', 120 | 'intParam', 121 | 'boolProp', 122 | 'floatParam', 123 | 'arrayParam', 124 | 'backedStringEnumParam', 125 | 'backedIntEnumParam', 126 | 'unitEnumParam', 127 | 'nullableStringParam', 128 | 'optionalIntWithDefaultParam', 129 | 'mixedParam', 130 | 'objectParam', 131 | 'stringForIntCast', 132 | 'stringForFloatCast', 133 | 'stringForBoolTrueCast', 134 | 'stringForBoolFalseCast', 135 | 'intForStringCast', 136 | 'intForFloatCast', 137 | 'boolForStringCast', 138 | 'valueForBackedStringEnum', 139 | 'valueForBackedIntEnum' 140 | ); 141 | } 142 | 143 | public function methodCausesTypeError(int $mustBeInt): void 144 | { 145 | } 146 | 147 | public function contextArg(Context $context): array { 148 | return [ 149 | 'session' => $context->session->get('testKey'), 150 | 'request' => $context->request->getHeaderLine('testHeader'), 151 | ]; 152 | } 153 | } 154 | ``` -------------------------------------------------------------------------------- /examples/02-discovery-http-userprofile/server.php: -------------------------------------------------------------------------------- ```php 1 | #!/usr/bin/env php 2 | <?php 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | MCP HTTP User Profile Server (Attribute Discovery) 7 | |-------------------------------------------------------------------------- 8 | | 9 | | This server demonstrates attribute-based discovery for MCP elements 10 | | (ResourceTemplates, Resources, Tools, Prompts) defined in 'McpElements.php'. 11 | | It runs via the HTTP transport, listening for SSE and POST requests. 12 | | 13 | | To Use: 14 | | 1. Ensure 'McpElements.php' defines classes with MCP attributes. 15 | | 2. Run this script from your CLI: `php server.php` 16 | | The server will listen on http://127.0.0.1:8080 by default. 17 | | 3. Configure your MCP Client (e.g., Cursor) for this server: 18 | | 19 | | { 20 | | "mcpServers": { 21 | | "php-http-userprofile": { 22 | | "url": "http://127.0.0.1:8080/mcp/sse" // Use the SSE endpoint 23 | | // Ensure your client can reach this address 24 | | } 25 | | } 26 | | } 27 | | 28 | | The ServerBuilder builds the server, $server->discover() scans for elements, 29 | | and then $server->listen() starts the ReactPHP HTTP server. 30 | | 31 | | If you provided a `CacheInterface` implementation to the ServerBuilder, 32 | | the discovery process will be cached, so you can comment out the 33 | | discovery call after the first run to speed up subsequent runs. 34 | | 35 | */ 36 | 37 | declare(strict_types=1); 38 | 39 | chdir(__DIR__); 40 | require_once '../../vendor/autoload.php'; 41 | require_once 'McpElements.php'; 42 | require_once 'UserIdCompletionProvider.php'; 43 | 44 | use PhpMcp\Schema\ServerCapabilities; 45 | use PhpMcp\Server\Defaults\BasicContainer; 46 | use PhpMcp\Server\Server; 47 | use PhpMcp\Server\Transports\HttpServerTransport; 48 | use PhpMcp\Server\Transports\StreamableHttpServerTransport; 49 | use Psr\Log\AbstractLogger; 50 | use Psr\Log\LoggerInterface; 51 | 52 | class StderrLogger extends AbstractLogger 53 | { 54 | public function log($level, \Stringable|string $message, array $context = []): void 55 | { 56 | fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); 57 | } 58 | } 59 | 60 | try { 61 | $logger = new StderrLogger(); 62 | $logger->info('Starting MCP HTTP User Profile Server...'); 63 | 64 | // --- Setup DI Container for DI in McpElements class --- 65 | $container = new BasicContainer(); 66 | $container->set(LoggerInterface::class, $logger); 67 | 68 | $server = Server::make() 69 | ->withServerInfo('HTTP User Profiles', '1.0.0') 70 | ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) 71 | ->withLogger($logger) 72 | ->withContainer($container) 73 | ->withTool( 74 | function (float $a, float $b, string $operation = 'add'): array { 75 | $result = match ($operation) { 76 | 'add' => $a + $b, 77 | 'subtract' => $a - $b, 78 | 'multiply' => $a * $b, 79 | 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'), 80 | default => throw new \InvalidArgumentException("Unknown operation: {$operation}") 81 | }; 82 | 83 | return [ 84 | 'operation' => $operation, 85 | 'operands' => [$a, $b], 86 | 'result' => $result 87 | ]; 88 | }, 89 | name: 'calculator', 90 | description: 'Perform basic math operations (add, subtract, multiply, divide)' 91 | ) 92 | ->withResource( 93 | function (): array { 94 | $memoryUsage = memory_get_usage(true); 95 | $memoryPeak = memory_get_peak_usage(true); 96 | $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); 97 | $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; 98 | 99 | return [ 100 | 'server_time' => date('Y-m-d H:i:s'), 101 | 'uptime_seconds' => $uptime, 102 | 'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2), 103 | 'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2), 104 | 'php_version' => PHP_VERSION, 105 | 'server_software' => $serverSoftware, 106 | 'operating_system' => PHP_OS_FAMILY, 107 | 'status' => 'healthy' 108 | ]; 109 | }, 110 | uri: 'system://status', 111 | name: 'system_status', 112 | description: 'Current system status and runtime information', 113 | mimeType: 'application/json' 114 | ) 115 | ->build(); 116 | 117 | $server->discover(__DIR__, ['.']); 118 | 119 | // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); 120 | $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); 121 | 122 | $server->listen($transport); 123 | 124 | $logger->info('Server listener stopped gracefully.'); 125 | exit(0); 126 | } catch (\Throwable $e) { 127 | fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); 128 | fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); 129 | fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); 130 | fwrite(STDERR, $e->getTraceAsString() . "\n"); 131 | exit(1); 132 | } 133 | ``` -------------------------------------------------------------------------------- /src/Session/Session.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Session; 6 | 7 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 8 | use PhpMcp\Server\Contracts\SessionInterface; 9 | 10 | class Session implements SessionInterface 11 | { 12 | /** 13 | * @var array<string, mixed> Stores all session data. 14 | * Keys are snake_case by convention for MCP-specific data. 15 | * 16 | * Official keys are: 17 | * - initialized: bool 18 | * - client_info: array|null 19 | * - protocol_version: string|null 20 | * - subscriptions: array<string, bool> 21 | * - message_queue: array<string> 22 | * - log_level: string|null 23 | */ 24 | protected array $data = []; 25 | 26 | public function __construct( 27 | protected SessionHandlerInterface $handler, 28 | protected string $id = '', 29 | ?array $data = null 30 | ) { 31 | if (empty($this->id)) { 32 | $this->id = $this->generateId(); 33 | } 34 | 35 | if ($data !== null) { 36 | $this->hydrate($data); 37 | } elseif ($sessionData = $this->handler->read($this->id)) { 38 | $this->data = json_decode($sessionData, true) ?? []; 39 | } 40 | } 41 | 42 | /** 43 | * Retrieve an existing session instance from handler or return null if session doesn't exist 44 | */ 45 | public static function retrieve(string $id, SessionHandlerInterface $handler): ?SessionInterface 46 | { 47 | $sessionData = $handler->read($id); 48 | 49 | if (!$sessionData) { 50 | return null; 51 | } 52 | 53 | $data = json_decode($sessionData, true); 54 | if ($data === null) { 55 | return null; 56 | } 57 | 58 | return new static($handler, $id, $data); 59 | } 60 | 61 | public function getId(): string 62 | { 63 | return $this->id; 64 | } 65 | 66 | public function getHandler(): SessionHandlerInterface 67 | { 68 | return $this->handler; 69 | } 70 | 71 | public function generateId(): string 72 | { 73 | return bin2hex(random_bytes(16)); 74 | } 75 | 76 | public function save(): void 77 | { 78 | $this->handler->write($this->id, json_encode($this->data)); 79 | } 80 | 81 | public function get(string $key, mixed $default = null): mixed 82 | { 83 | $key = explode('.', $key); 84 | $data = $this->data; 85 | 86 | foreach ($key as $segment) { 87 | if (is_array($data) && array_key_exists($segment, $data)) { 88 | $data = $data[$segment]; 89 | } else { 90 | return $default; 91 | } 92 | } 93 | 94 | return $data; 95 | } 96 | 97 | public function set(string $key, mixed $value, bool $overwrite = true): void 98 | { 99 | $segments = explode('.', $key); 100 | $data = &$this->data; 101 | 102 | while (count($segments) > 1) { 103 | $segment = array_shift($segments); 104 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 105 | $data[$segment] = []; 106 | } 107 | $data = &$data[$segment]; 108 | } 109 | 110 | $lastKey = array_shift($segments); 111 | if ($overwrite || !isset($data[$lastKey])) { 112 | $data[$lastKey] = $value; 113 | } 114 | } 115 | 116 | public function has(string $key): bool 117 | { 118 | $key = explode('.', $key); 119 | $data = $this->data; 120 | 121 | foreach ($key as $segment) { 122 | if (is_array($data) && array_key_exists($segment, $data)) { 123 | $data = $data[$segment]; 124 | } elseif (is_object($data) && isset($data->{$segment})) { 125 | $data = $data->{$segment}; 126 | } else { 127 | return false; 128 | } 129 | } 130 | 131 | return true; 132 | } 133 | 134 | public function forget(string $key): void 135 | { 136 | $segments = explode('.', $key); 137 | $data = &$this->data; 138 | 139 | while (count($segments) > 1) { 140 | $segment = array_shift($segments); 141 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 142 | $data[$segment] = []; 143 | } 144 | $data = &$data[$segment]; 145 | } 146 | 147 | $lastKey = array_shift($segments); 148 | if (isset($data[$lastKey])) { 149 | unset($data[$lastKey]); 150 | } 151 | } 152 | 153 | public function clear(): void 154 | { 155 | $this->data = []; 156 | } 157 | 158 | public function pull(string $key, mixed $default = null): mixed 159 | { 160 | $value = $this->get($key, $default); 161 | $this->forget($key); 162 | return $value; 163 | } 164 | 165 | public function all(): array 166 | { 167 | return $this->data; 168 | } 169 | 170 | public function hydrate(array $attributes): void 171 | { 172 | $this->data = array_merge( 173 | [ 174 | 'initialized' => false, 175 | 'client_info' => null, 176 | 'protocol_version' => null, 177 | 'message_queue' => [], 178 | 'log_level' => null, 179 | ], 180 | $attributes 181 | ); 182 | unset($this->data['id']); 183 | } 184 | 185 | public function queueMessage(string $rawFramedMessage): void 186 | { 187 | $this->data['message_queue'][] = $rawFramedMessage; 188 | } 189 | 190 | public function dequeueMessages(): array 191 | { 192 | $messages = $this->data['message_queue'] ?? []; 193 | $this->data['message_queue'] = []; 194 | return $messages; 195 | } 196 | 197 | public function hasQueuedMessages(): bool 198 | { 199 | return !empty($this->data['message_queue']); 200 | } 201 | 202 | public function jsonSerialize(): array 203 | { 204 | return $this->all(); 205 | } 206 | } 207 | ``` -------------------------------------------------------------------------------- /tests/Mocks/Clients/MockJsonHttpClient.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Tests\Mocks\Clients; 6 | 7 | use Psr\Http\Message\ResponseInterface; 8 | use React\Http\Browser; 9 | use React\Promise\PromiseInterface; 10 | 11 | class MockJsonHttpClient 12 | { 13 | public Browser $browser; 14 | public string $baseUrl; 15 | public ?string $sessionId = null; 16 | public array $lastResponseHeaders = []; // Store last response headers for testing 17 | 18 | public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2) 19 | { 20 | $this->browser = (new Browser())->withTimeout($timeout); 21 | $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; 22 | } 23 | 24 | public function sendRequest(string $method, array $params = [], ?string $id = null, array $additionalHeaders = []): PromiseInterface 25 | { 26 | $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; 27 | if ($id !== null) { 28 | $payload['id'] = $id; 29 | } 30 | 31 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; 32 | if ($this->sessionId && $method !== 'initialize') { 33 | $headers['Mcp-Session-Id'] = $this->sessionId; 34 | } 35 | $headers += $additionalHeaders; 36 | 37 | $body = json_encode($payload); 38 | 39 | return $this->browser->post($this->baseUrl, $headers, $body) 40 | ->then(function (ResponseInterface $response) use ($method) { 41 | // Store response headers for testing 42 | $this->lastResponseHeaders = []; 43 | foreach ($response->getHeaders() as $name => $values) { 44 | foreach ($values as $value) { 45 | $this->lastResponseHeaders[] = "{$name}: {$value}"; 46 | } 47 | } 48 | 49 | $bodyContent = (string) $response->getBody()->getContents(); 50 | $statusCode = $response->getStatusCode(); 51 | 52 | if ($method === 'initialize' && $statusCode === 200) { 53 | $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); 54 | } 55 | 56 | if ($statusCode === 202) { 57 | if ($bodyContent !== '') { 58 | throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); 59 | } 60 | return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; 61 | } 62 | 63 | try { 64 | $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); 65 | return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; 66 | } catch (\JsonException $e) { 67 | throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); 68 | } 69 | }); 70 | } 71 | 72 | public function sendBatchRequest(array $batchRequestObjects): PromiseInterface 73 | { 74 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; 75 | if ($this->sessionId) { 76 | $headers['Mcp-Session-Id'] = $this->sessionId; 77 | } 78 | $body = json_encode($batchRequestObjects); 79 | 80 | return $this->browser->post($this->baseUrl, $headers, $body) 81 | ->then(function (ResponseInterface $response) { 82 | $bodyContent = (string) $response->getBody()->getContents(); 83 | $statusCode = $response->getStatusCode(); 84 | if ($statusCode === 202) { 85 | if ($bodyContent !== '') { 86 | throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); 87 | } 88 | return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; 89 | } 90 | 91 | try { 92 | $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); 93 | return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; 94 | } catch (\JsonException $e) { 95 | throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); 96 | } 97 | }); 98 | } 99 | 100 | public function sendDeleteRequest(): PromiseInterface 101 | { 102 | $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; 103 | if ($this->sessionId) { 104 | $headers['Mcp-Session-Id'] = $this->sessionId; 105 | } 106 | 107 | return $this->browser->delete($this->baseUrl, $headers) 108 | ->then(function (ResponseInterface $response) { 109 | $bodyContent = (string) $response->getBody()->getContents(); 110 | $statusCode = $response->getStatusCode(); 111 | return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()]; 112 | }); 113 | } 114 | 115 | public function sendNotification(string $method, array $params = []): PromiseInterface 116 | { 117 | return $this->sendRequest($method, $params, null); 118 | } 119 | 120 | public function connectSseForNotifications(): PromiseInterface 121 | { 122 | return resolve(null); 123 | } 124 | } 125 | ``` -------------------------------------------------------------------------------- /tests/Fixtures/General/PromptHandlerFixture.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\General; 4 | 5 | use PhpMcp\Schema\Content\PromptMessage; 6 | use PhpMcp\Schema\Enum\Role; 7 | use PhpMcp\Schema\Content\TextContent; 8 | use PhpMcp\Schema\Content\ImageContent; 9 | use PhpMcp\Schema\Content\AudioContent; 10 | use PhpMcp\Server\Attributes\CompletionProvider; 11 | use Psr\Log\LoggerInterface; 12 | 13 | class PromptHandlerFixture 14 | { 15 | public function generateSimpleGreeting(string $name, string $style = "friendly"): array 16 | { 17 | return [ 18 | ['role' => 'user', 'content' => "Craft a {$style} greeting for {$name}."] 19 | ]; 20 | } 21 | 22 | public function returnSinglePromptMessageObject(): PromptMessage 23 | { 24 | return PromptMessage::make(Role::User, TextContent::make("Single PromptMessage object.")); 25 | } 26 | 27 | public function returnArrayOfPromptMessageObjects(): array 28 | { 29 | return [ 30 | PromptMessage::make(Role::User, TextContent::make("First message object.")), 31 | PromptMessage::make(Role::Assistant, ImageContent::make("img_data", "image/png")), 32 | ]; 33 | } 34 | 35 | public function returnEmptyArrayForPrompt(): array 36 | { 37 | return []; 38 | } 39 | 40 | public function returnSimpleUserAssistantMap(): array 41 | { 42 | return [ 43 | 'user' => "This is the user's turn.", 44 | 'assistant' => "And this is the assistant's reply." 45 | ]; 46 | } 47 | 48 | public function returnUserAssistantMapWithContentObjects(): array 49 | { 50 | return [ 51 | 'user' => TextContent::make("User text content object."), 52 | 'assistant' => ImageContent::make("asst_img_data", "image/gif"), 53 | ]; 54 | } 55 | 56 | public function returnUserAssistantMapWithMixedContent(): array 57 | { 58 | return [ 59 | 'user' => "Plain user string.", 60 | 'assistant' => AudioContent::make("aud_data", "audio/mp3"), 61 | ]; 62 | } 63 | 64 | public function returnUserAssistantMapWithArrayContent(): array 65 | { 66 | return [ 67 | 'user' => ['type' => 'text', 'text' => 'User array content'], 68 | 'assistant' => ['type' => 'image', 'data' => 'asst_arr_img_data', 'mimeType' => 'image/jpeg'], 69 | ]; 70 | } 71 | 72 | public function returnListOfRawMessageArrays(): array 73 | { 74 | return [ 75 | ['role' => 'user', 'content' => "First raw message string."], 76 | ['role' => 'assistant', 'content' => TextContent::make("Second raw message with Content obj.")], 77 | ['role' => 'user', 'content' => ['type' => 'image', 'data' => 'raw_img_data', 'mimeType' => 'image/webp']], 78 | ['role' => 'assistant', 'content' => ['type' => 'audio', 'data' => 'raw_aud_data', 'mimeType' => 'audio/ogg']], 79 | ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'file://doc.pdf', 'blob' => base64_encode('pdf-data'), 'mimeType' => 'application/pdf']]], 80 | ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'config://settings.json', 'text' => '{"theme":"dark"}']]], 81 | ]; 82 | } 83 | 84 | public function returnListOfRawMessageArraysWithScalars(): array 85 | { 86 | return [ 87 | ['role' => 'user', 'content' => 123], // int 88 | ['role' => 'assistant', 'content' => true], // bool 89 | ['role' => 'user', 'content' => null], // null 90 | ['role' => 'assistant', 'content' => 3.14], // float 91 | ['role' => 'user', 'content' => ['key' => 'value']], // array that becomes JSON 92 | ]; 93 | } 94 | 95 | public function returnMixedArrayOfPromptMessagesAndRaw(): array 96 | { 97 | return [ 98 | PromptMessage::make(Role::User, TextContent::make("This is a PromptMessage object.")), 99 | ['role' => 'assistant', 'content' => "This is a raw message array."], 100 | PromptMessage::make(Role::User, ImageContent::make("pm_img", "image/bmp")), 101 | ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Raw message with typed content.']], 102 | ]; 103 | } 104 | 105 | public function promptWithArgumentCompletion( 106 | #[CompletionProvider(provider: CompletionProviderFixture::class)] 107 | string $entityName, 108 | string $action = "describe" 109 | ): array { 110 | return [ 111 | ['role' => 'user', 'content' => "Please {$action} the entity: {$entityName}."] 112 | ]; 113 | } 114 | 115 | public function promptReturnsNonArray(): string 116 | { 117 | return "This is not a valid prompt return type."; 118 | } 119 | 120 | public function promptReturnsArrayWithInvalidRole(): array 121 | { 122 | return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; 123 | } 124 | 125 | public function promptReturnsInvalidRole(): array 126 | { 127 | return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; 128 | } 129 | 130 | public function promptReturnsArrayWithInvalidContentStructure(): array 131 | { 132 | return [['role' => 'user', 'content' => ['text_only_no_type' => 'invalid']]]; 133 | } 134 | 135 | public function promptReturnsArrayWithInvalidTypedContent(): array 136 | { 137 | return [['role' => 'user', 'content' => ['type' => 'image', 'source' => 'url.jpg']]]; // 'image' needs 'data' and 'mimeType' 138 | } 139 | 140 | public function promptReturnsArrayWithInvalidResourceContent(): array 141 | { 142 | return [ 143 | [ 144 | 'role' => 'user', 145 | 'content' => ['type' => 'resource', 'resource' => ['uri' => 'uri://uri']] 146 | ] 147 | ]; 148 | } 149 | 150 | public function promptHandlerThrows(): void 151 | { 152 | throw new \LogicException("Prompt generation failed inside handler."); 153 | } 154 | } 155 | ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/HandlerResolverTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Utils; 4 | 5 | use PhpMcp\Server\Utils\HandlerResolver; 6 | use ReflectionMethod; 7 | use ReflectionFunction; 8 | use InvalidArgumentException; 9 | 10 | class ValidHandlerClass 11 | { 12 | public function publicMethod() {} 13 | protected function protectedMethod() {} 14 | private function privateMethod() {} 15 | public static function staticMethod() {} 16 | public function __construct() {} 17 | public function __destruct() {} 18 | } 19 | 20 | class ValidInvokableClass 21 | { 22 | public function __invoke() {} 23 | } 24 | 25 | class NonInvokableClass {} 26 | 27 | abstract class AbstractHandlerClass 28 | { 29 | abstract public function abstractMethod(); 30 | } 31 | 32 | // Test closure support 33 | it('resolves closures to ReflectionFunction', function () { 34 | $closure = function (string $input): string { 35 | return "processed: $input"; 36 | }; 37 | 38 | $resolved = HandlerResolver::resolve($closure); 39 | 40 | expect($resolved)->toBeInstanceOf(ReflectionFunction::class); 41 | expect($resolved->getNumberOfParameters())->toBe(1); 42 | expect($resolved->getReturnType()->getName())->toBe('string'); 43 | }); 44 | 45 | it('resolves valid array handler', function () { 46 | $handler = [ValidHandlerClass::class, 'publicMethod']; 47 | $resolved = HandlerResolver::resolve($handler); 48 | 49 | expect($resolved)->toBeInstanceOf(ReflectionMethod::class); 50 | expect($resolved->getName())->toBe('publicMethod'); 51 | expect($resolved->getDeclaringClass()->getName())->toBe(ValidHandlerClass::class); 52 | }); 53 | 54 | it('resolves valid invokable class string handler', function () { 55 | $handler = ValidInvokableClass::class; 56 | $resolved = HandlerResolver::resolve($handler); 57 | 58 | expect($resolved)->toBeInstanceOf(ReflectionMethod::class); 59 | expect($resolved->getName())->toBe('__invoke'); 60 | expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class); 61 | }); 62 | 63 | it('resolves static methods for manual registration', function () { 64 | $handler = [ValidHandlerClass::class, 'staticMethod']; 65 | $resolved = HandlerResolver::resolve($handler); 66 | 67 | expect($resolved)->toBeInstanceOf(ReflectionMethod::class); 68 | expect($resolved->getName())->toBe('staticMethod'); 69 | expect($resolved->isStatic())->toBeTrue(); 70 | }); 71 | 72 | it('throws for invalid array handler format (count)', function () { 73 | HandlerResolver::resolve([ValidHandlerClass::class]); 74 | })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); 75 | 76 | it('throws for invalid array handler format (types)', function () { 77 | HandlerResolver::resolve([ValidHandlerClass::class, 123]); 78 | })->throws(InvalidArgumentException::class, 'Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); 79 | 80 | it('throws for non-existent class in array handler', function () { 81 | HandlerResolver::resolve(['NonExistentClass', 'method']); 82 | })->throws(InvalidArgumentException::class, "Handler class 'NonExistentClass' not found"); 83 | 84 | it('throws for non-existent method in array handler', function () { 85 | HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']); 86 | })->throws(InvalidArgumentException::class, "Handler method 'nonExistentMethod' not found in class"); 87 | 88 | it('throws for non-existent class in string handler', function () { 89 | HandlerResolver::resolve('NonExistentInvokableClass'); 90 | })->throws(InvalidArgumentException::class, 'Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); 91 | 92 | it('throws for non-invokable class string handler', function () { 93 | HandlerResolver::resolve(NonInvokableClass::class); 94 | })->throws(InvalidArgumentException::class, "Invokable handler class '" . NonInvokableClass::class . "' must have a public '__invoke' method."); 95 | 96 | it('throws for protected method handler', function () { 97 | HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']); 98 | })->throws(InvalidArgumentException::class, 'must be public'); 99 | 100 | it('throws for private method handler', function () { 101 | HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']); 102 | })->throws(InvalidArgumentException::class, 'must be public'); 103 | 104 | it('throws for constructor as handler', function () { 105 | HandlerResolver::resolve([ValidHandlerClass::class, '__construct']); 106 | })->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor'); 107 | 108 | it('throws for destructor as handler', function () { 109 | HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']); 110 | })->throws(InvalidArgumentException::class, 'cannot be a constructor or destructor'); 111 | 112 | it('throws for abstract method handler', function () { 113 | HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); 114 | })->throws(InvalidArgumentException::class, 'cannot be abstract'); 115 | 116 | // Test different closure types 117 | it('resolves closures with different signatures', function () { 118 | $noParams = function () { 119 | return 'test'; 120 | }; 121 | $withParams = function (int $a, string $b = 'default') { 122 | return $a . $b; 123 | }; 124 | $variadic = function (...$args) { 125 | return $args; 126 | }; 127 | 128 | expect(HandlerResolver::resolve($noParams))->toBeInstanceOf(ReflectionFunction::class); 129 | expect(HandlerResolver::resolve($withParams))->toBeInstanceOf(ReflectionFunction::class); 130 | expect(HandlerResolver::resolve($variadic))->toBeInstanceOf(ReflectionFunction::class); 131 | 132 | expect(HandlerResolver::resolve($noParams)->getNumberOfParameters())->toBe(0); 133 | expect(HandlerResolver::resolve($withParams)->getNumberOfParameters())->toBe(2); 134 | expect(HandlerResolver::resolve($variadic)->isVariadic())->toBeTrue(); 135 | }); 136 | 137 | // Test that we can distinguish between closures and callable arrays 138 | it('distinguishes between closures and callable arrays', function () { 139 | $closure = function () { 140 | return 'closure'; 141 | }; 142 | $array = [ValidHandlerClass::class, 'publicMethod']; 143 | $string = ValidInvokableClass::class; 144 | 145 | expect(HandlerResolver::resolve($closure))->toBeInstanceOf(ReflectionFunction::class); 146 | expect(HandlerResolver::resolve($array))->toBeInstanceOf(ReflectionMethod::class); 147 | expect(HandlerResolver::resolve($string))->toBeInstanceOf(ReflectionMethod::class); 148 | }); 149 | ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/DocBlockParserTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Utils; 4 | 5 | use phpDocumentor\Reflection\DocBlock; 6 | use phpDocumentor\Reflection\DocBlock\Tags\Deprecated; 7 | use phpDocumentor\Reflection\DocBlock\Tags\Param; 8 | use phpDocumentor\Reflection\DocBlock\Tags\Return_; 9 | use phpDocumentor\Reflection\DocBlock\Tags\See; 10 | use phpDocumentor\Reflection\DocBlock\Tags\Throws; 11 | use PhpMcp\Server\Utils\DocBlockParser; 12 | use PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture; 13 | use ReflectionMethod; 14 | 15 | beforeEach(function () { 16 | $this->parser = new DocBlockParser(); 17 | }); 18 | 19 | test('getSummary returns correct summary', function () { 20 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); 21 | $docComment = $method->getDocComment() ?: null; 22 | $docBlock = $this->parser->parseDocBlock($docComment); 23 | expect($this->parser->getSummary($docBlock))->toBe('Simple summary line.'); 24 | 25 | $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); 26 | $docComment2 = $method2->getDocComment() ?: null; 27 | $docBlock2 = $this->parser->parseDocBlock($docComment2); 28 | expect($this->parser->getSummary($docBlock2))->toBe('Summary line here.'); 29 | }); 30 | 31 | test('getDescription returns correct description', function () { 32 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); 33 | $docComment = $method->getDocComment() ?: null; 34 | $docBlock = $this->parser->parseDocBlock($docComment); 35 | $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; 36 | expect($this->parser->getDescription($docBlock))->toBe($expectedDesc); 37 | 38 | $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); 39 | $docComment2 = $method2->getDocComment() ?: null; 40 | $docBlock2 = $this->parser->parseDocBlock($docComment2); 41 | expect($this->parser->getDescription($docBlock2))->toBe('Simple summary line.'); 42 | }); 43 | 44 | test('getParamTags returns structured param info', function () { 45 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); 46 | $docComment = $method->getDocComment() ?: null; 47 | $docBlock = $this->parser->parseDocBlock($docComment); 48 | $params = $this->parser->getParamTags($docBlock); 49 | 50 | expect($params)->toBeArray()->toHaveCount(6); 51 | expect($params)->toHaveKeys(['$param1', '$param2', '$param3', '$param4', '$param5', '$param6']); 52 | 53 | expect($params['$param1'])->toBeInstanceOf(Param::class); 54 | expect($params['$param1']->getVariableName())->toBe('param1'); 55 | expect($this->parser->getParamTypeString($params['$param1']))->toBe('string'); 56 | expect($this->parser->getParamDescription($params['$param1']))->toBe('Description for string param.'); 57 | 58 | expect($params['$param2'])->toBeInstanceOf(Param::class); 59 | expect($params['$param2']->getVariableName())->toBe('param2'); 60 | expect($this->parser->getParamTypeString($params['$param2']))->toBe('int|null'); 61 | expect($this->parser->getParamDescription($params['$param2']))->toBe('Description for nullable int param.'); 62 | 63 | expect($params['$param3'])->toBeInstanceOf(Param::class); 64 | expect($params['$param3']->getVariableName())->toBe('param3'); 65 | expect($this->parser->getParamTypeString($params['$param3']))->toBe('bool'); 66 | expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); 67 | 68 | expect($params['$param4'])->toBeInstanceOf(Param::class); 69 | expect($params['$param4']->getVariableName())->toBe('param4'); 70 | expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); 71 | expect($this->parser->getParamDescription($params['$param4']))->toBe('Missing type.'); 72 | 73 | expect($params['$param5'])->toBeInstanceOf(Param::class); 74 | expect($params['$param5']->getVariableName())->toBe('param5'); 75 | expect($this->parser->getParamTypeString($params['$param5']))->toBe('array<string,mixed>'); 76 | expect($this->parser->getParamDescription($params['$param5']))->toBe('Array description.'); 77 | 78 | expect($params['$param6'])->toBeInstanceOf(Param::class); 79 | expect($params['$param6']->getVariableName())->toBe('param6'); 80 | expect($this->parser->getParamTypeString($params['$param6']))->toBe('stdClass'); 81 | expect($this->parser->getParamDescription($params['$param6']))->toBe('Object param.'); 82 | }); 83 | 84 | test('getReturnTag returns structured return info', function () { 85 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithReturn'); 86 | $docComment = $method->getDocComment() ?: null; 87 | $docBlock = $this->parser->parseDocBlock($docComment); 88 | $returnTag = $this->parser->getReturnTag($docBlock); 89 | 90 | expect($returnTag)->toBeInstanceOf(Return_::class); 91 | expect($this->parser->getReturnTypeString($returnTag))->toBe('string'); 92 | expect($this->parser->getReturnDescription($returnTag))->toBe('The result of the operation.'); 93 | 94 | $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); 95 | $docComment2 = $method2->getDocComment() ?: null; 96 | $docBlock2 = $this->parser->parseDocBlock($docComment2); 97 | expect($this->parser->getReturnTag($docBlock2))->toBeNull(); 98 | }); 99 | 100 | test('getTagsByName returns specific tags', function () { 101 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); 102 | $docComment = $method->getDocComment() ?: null; 103 | $docBlock = $this->parser->parseDocBlock($docComment); 104 | 105 | expect($docBlock)->toBeInstanceOf(DocBlock::class); 106 | 107 | $throwsTags = $docBlock->getTagsByName('throws'); 108 | expect($throwsTags)->toBeArray()->toHaveCount(1); 109 | expect($throwsTags[0])->toBeInstanceOf(Throws::class); 110 | expect((string) $throwsTags[0]->getType())->toBe('\\RuntimeException'); 111 | expect($throwsTags[0]->getDescription()->render())->toBe('If processing fails.'); 112 | 113 | $deprecatedTags = $docBlock->getTagsByName('deprecated'); 114 | expect($deprecatedTags)->toBeArray()->toHaveCount(1); 115 | expect($deprecatedTags[0])->toBeInstanceOf(Deprecated::class); 116 | expect($deprecatedTags[0]->getDescription()->render())->toBe('Use newMethod() instead.'); 117 | 118 | $seeTags = $docBlock->getTagsByName('see'); 119 | expect($seeTags)->toBeArray()->toHaveCount(1); 120 | expect($seeTags[0])->toBeInstanceOf(See::class); 121 | expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestFixture::newMethod()'); 122 | 123 | $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); 124 | expect($nonExistentTags)->toBeArray()->toBeEmpty(); 125 | }); 126 | 127 | test('handles method with no docblock gracefully', function () { 128 | $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); 129 | $docComment = $method->getDocComment() ?: null; 130 | $docBlock = $this->parser->parseDocBlock($docComment); 131 | 132 | expect($docBlock)->toBeNull(); 133 | 134 | expect($this->parser->getSummary($docBlock))->toBeNull(); 135 | expect($this->parser->getDescription($docBlock))->toBeNull(); 136 | expect($this->parser->getParamTags($docBlock))->toBeArray()->toBeEmpty(); 137 | expect($this->parser->getReturnTag($docBlock))->toBeNull(); 138 | }); 139 | ``` -------------------------------------------------------------------------------- /src/Attributes/Schema.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Attributes; 6 | 7 | use Attribute; 8 | 9 | /** 10 | * Defines a JSON Schema for a method's input or an individual parameter. 11 | * 12 | * When used at the method level, it describes an object schema where properties 13 | * correspond to the method's parameters. 14 | * 15 | * When used at the parameter level, it describes the schema for that specific parameter. 16 | * If 'type' is omitted at the parameter level, it will be inferred. 17 | */ 18 | #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] 19 | class Schema 20 | { 21 | /** 22 | * The complete JSON schema array. 23 | * If provided, it takes precedence over individual properties like $type, $properties, etc. 24 | */ 25 | public ?array $definition = null; 26 | 27 | /** 28 | * Alternatively, provide individual top-level schema keywords. 29 | * These are used if $definition is null. 30 | */ 31 | public ?string $type = null; 32 | public ?string $description = null; 33 | public mixed $default = null; 34 | public ?array $enum = null; // list of allowed values 35 | public ?string $format = null; // e.g., 'email', 'date-time' 36 | 37 | // Constraints for string 38 | public ?int $minLength = null; 39 | public ?int $maxLength = null; 40 | public ?string $pattern = null; 41 | 42 | // Constraints for number/integer 43 | public int|float|null $minimum = null; 44 | public int|float|null $maximum = null; 45 | public ?bool $exclusiveMinimum = null; 46 | public ?bool $exclusiveMaximum = null; 47 | public int|float|null $multipleOf = null; 48 | 49 | // Constraints for array 50 | public ?array $items = null; // JSON schema for array items 51 | public ?int $minItems = null; 52 | public ?int $maxItems = null; 53 | public ?bool $uniqueItems = null; 54 | 55 | // Constraints for object (primarily used when Schema is on a method or an object-typed parameter) 56 | public ?array $properties = null; // [propertyName => [schema array], ...] 57 | public ?array $required = null; // [propertyName, ...] 58 | public bool|array|null $additionalProperties = null; // true, false, or a schema array 59 | 60 | /** 61 | * @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored. 62 | * @param Type|null $type The JSON schema type. 63 | * @param string|null $description Description of the element. 64 | * @param array|null $enum Allowed enum values. 65 | * @param string|null $format String format (e.g., 'date-time', 'email'). 66 | * @param int|null $minLength Minimum length for strings. 67 | * @param int|null $maxLength Maximum length for strings. 68 | * @param string|null $pattern Regex pattern for strings. 69 | * @param int|float|null $minimum Minimum value for numbers/integers. 70 | * @param int|float|null $maximum Maximum value for numbers/integers. 71 | * @param bool|null $exclusiveMinimum Exclusive minimum. 72 | * @param bool|null $exclusiveMaximum Exclusive maximum. 73 | * @param int|float|null $multipleOf Must be a multiple of this value. 74 | * @param array|null $items JSON Schema for items if type is 'array'. 75 | * @param int|null $minItems Minimum items for an array. 76 | * @param int|null $maxItems Maximum items for an array. 77 | * @param bool|null $uniqueItems Whether array items must be unique. 78 | * @param array|null $properties Property definitions if type is 'object'. [name => schema_array]. 79 | * @param array|null $required List of required properties for an object. 80 | * @param bool|array|null $additionalProperties Policy for additional properties in an object. 81 | */ 82 | public function __construct( 83 | ?array $definition = null, 84 | ?string $type = null, 85 | ?string $description = null, 86 | ?array $enum = null, 87 | ?string $format = null, 88 | ?int $minLength = null, 89 | ?int $maxLength = null, 90 | ?string $pattern = null, 91 | int|float|null $minimum = null, 92 | int|float|null $maximum = null, 93 | ?bool $exclusiveMinimum = null, 94 | ?bool $exclusiveMaximum = null, 95 | int|float|null $multipleOf = null, 96 | ?array $items = null, 97 | ?int $minItems = null, 98 | ?int $maxItems = null, 99 | ?bool $uniqueItems = null, 100 | ?array $properties = null, 101 | ?array $required = null, 102 | bool|array|null $additionalProperties = null 103 | ) { 104 | if ($definition !== null) { 105 | $this->definition = $definition; 106 | } else { 107 | $this->type = $type; 108 | $this->description = $description; 109 | $this->enum = $enum; 110 | $this->format = $format; 111 | $this->minLength = $minLength; 112 | $this->maxLength = $maxLength; 113 | $this->pattern = $pattern; 114 | $this->minimum = $minimum; 115 | $this->maximum = $maximum; 116 | $this->exclusiveMinimum = $exclusiveMinimum; 117 | $this->exclusiveMaximum = $exclusiveMaximum; 118 | $this->multipleOf = $multipleOf; 119 | $this->items = $items; 120 | $this->minItems = $minItems; 121 | $this->maxItems = $maxItems; 122 | $this->uniqueItems = $uniqueItems; 123 | $this->properties = $properties; 124 | $this->required = $required; 125 | $this->additionalProperties = $additionalProperties; 126 | } 127 | } 128 | 129 | /** 130 | * Converts the attribute's definition to a JSON schema array. 131 | */ 132 | public function toArray(): array 133 | { 134 | if ($this->definition !== null) { 135 | return [ 136 | 'definition' => $this->definition, 137 | ]; 138 | } 139 | 140 | $schema = []; 141 | if ($this->type !== null) { 142 | $schema['type'] = $this->type; 143 | } 144 | if ($this->description !== null) { 145 | $schema['description'] = $this->description; 146 | } 147 | if ($this->enum !== null) { 148 | $schema['enum'] = $this->enum; 149 | } 150 | if ($this->format !== null) { 151 | $schema['format'] = $this->format; 152 | } 153 | 154 | // String 155 | if ($this->minLength !== null) { 156 | $schema['minLength'] = $this->minLength; 157 | } 158 | if ($this->maxLength !== null) { 159 | $schema['maxLength'] = $this->maxLength; 160 | } 161 | if ($this->pattern !== null) { 162 | $schema['pattern'] = $this->pattern; 163 | } 164 | 165 | // Numeric 166 | if ($this->minimum !== null) { 167 | $schema['minimum'] = $this->minimum; 168 | } 169 | if ($this->maximum !== null) { 170 | $schema['maximum'] = $this->maximum; 171 | } 172 | if ($this->exclusiveMinimum !== null) { 173 | $schema['exclusiveMinimum'] = $this->exclusiveMinimum; 174 | } 175 | if ($this->exclusiveMaximum !== null) { 176 | $schema['exclusiveMaximum'] = $this->exclusiveMaximum; 177 | } 178 | if ($this->multipleOf !== null) { 179 | $schema['multipleOf'] = $this->multipleOf; 180 | } 181 | 182 | // Array 183 | if ($this->items !== null) { 184 | $schema['items'] = $this->items; 185 | } 186 | if ($this->minItems !== null) { 187 | $schema['minItems'] = $this->minItems; 188 | } 189 | if ($this->maxItems !== null) { 190 | $schema['maxItems'] = $this->maxItems; 191 | } 192 | if ($this->uniqueItems !== null) { 193 | $schema['uniqueItems'] = $this->uniqueItems; 194 | } 195 | 196 | // Object 197 | if ($this->properties !== null) { 198 | $schema['properties'] = $this->properties; 199 | } 200 | if ($this->required !== null) { 201 | $schema['required'] = $this->required; 202 | } 203 | if ($this->additionalProperties !== null) { 204 | $schema['additionalProperties'] = $this->additionalProperties; 205 | } 206 | 207 | return $schema; 208 | } 209 | } 210 | ``` -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server; 6 | 7 | use LogicException; 8 | use PhpMcp\Server\Contracts\LoggerAwareInterface; 9 | use PhpMcp\Server\Contracts\LoopAwareInterface; 10 | use PhpMcp\Server\Contracts\ServerTransportInterface; 11 | use PhpMcp\Server\Exception\ConfigurationException; 12 | use PhpMcp\Server\Exception\DiscoveryException; 13 | use PhpMcp\Server\Session\SessionManager; 14 | use PhpMcp\Server\Utils\Discoverer; 15 | use Throwable; 16 | 17 | /** 18 | * Core MCP Server instance. 19 | * 20 | * Holds the configured MCP logic (Configuration, Registry, Protocol) 21 | * but is transport-agnostic. It relies on a ServerTransportInterface implementation, 22 | * provided via the listen() method, to handle network communication. 23 | * 24 | * Instances should be created via the ServerBuilder. 25 | */ 26 | class Server 27 | { 28 | protected bool $discoveryRan = false; 29 | 30 | protected bool $isListening = false; 31 | 32 | /** 33 | * @internal Use ServerBuilder::make()->...->build(). 34 | * 35 | * @param Configuration $configuration Core configuration and dependencies. 36 | * @param Registry $registry Holds registered MCP element definitions. 37 | * @param Protocol $protocol Handles MCP requests and responses. 38 | */ 39 | public function __construct( 40 | protected readonly Configuration $configuration, 41 | protected readonly Registry $registry, 42 | protected readonly Protocol $protocol, 43 | protected readonly SessionManager $sessionManager, 44 | ) { 45 | } 46 | 47 | public static function make(): ServerBuilder 48 | { 49 | return new ServerBuilder(); 50 | } 51 | 52 | /** 53 | * Runs the attribute discovery process based on the configuration 54 | * provided during build time. Caches results if cache is available. 55 | * Can be called explicitly, but is also called by ServerBuilder::build() 56 | * if discovery paths are configured. 57 | * 58 | * @param bool $force Re-run discovery even if already run. 59 | * @param bool $useCache Attempt to load from/save to cache. Defaults to true if cache is available. 60 | * 61 | * @throws DiscoveryException If discovery process encounters errors. 62 | * @throws ConfigurationException If discovery paths were not configured. 63 | */ 64 | public function discover( 65 | string $basePath, 66 | array $scanDirs = ['.', 'src'], 67 | array $excludeDirs = [], 68 | bool $force = false, 69 | bool $saveToCache = true, 70 | ?Discoverer $discoverer = null 71 | ): void { 72 | $realBasePath = realpath($basePath); 73 | if ($realBasePath === false || ! is_dir($realBasePath)) { 74 | throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}"); 75 | } 76 | 77 | $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']); 78 | 79 | if ($this->discoveryRan && ! $force) { 80 | $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.'); 81 | 82 | return; 83 | } 84 | 85 | $cacheAvailable = $this->configuration->cache !== null; 86 | $shouldSaveCache = $saveToCache && $cacheAvailable; 87 | 88 | $this->configuration->logger->info('Starting MCP element discovery...', [ 89 | 'basePath' => $realBasePath, 90 | 'force' => $force, 91 | 'saveToCache' => $shouldSaveCache, 92 | ]); 93 | 94 | $this->registry->clear(); 95 | 96 | try { 97 | $discoverer ??= new Discoverer($this->registry, $this->configuration->logger); 98 | 99 | $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); 100 | 101 | $this->discoveryRan = true; 102 | 103 | if ($shouldSaveCache) { 104 | $this->registry->save(); 105 | } 106 | } catch (Throwable $e) { 107 | $this->discoveryRan = false; 108 | $this->configuration->logger->critical('MCP element discovery failed.', ['exception' => $e]); 109 | throw new DiscoveryException("Element discovery failed: {$e->getMessage()}", $e->getCode(), $e); 110 | } 111 | } 112 | 113 | /** 114 | * Binds the server's MCP logic to the provided transport and starts the transport's listener, 115 | * then runs the event loop, making this a BLOCKING call suitable for standalone servers. 116 | * 117 | * For framework integration where the loop is managed externally, use `getProtocol()` 118 | * and bind it to your framework's transport mechanism manually. 119 | * 120 | * @param ServerTransportInterface $transport The transport to listen with. 121 | * 122 | * @throws LogicException If called after already listening. 123 | * @throws Throwable If transport->listen() fails immediately. 124 | */ 125 | public function listen(ServerTransportInterface $transport, bool $runLoop = true): void 126 | { 127 | if ($this->isListening) { 128 | throw new LogicException('Server is already listening via a transport.'); 129 | } 130 | 131 | $this->warnIfNoElements(); 132 | 133 | if ($transport instanceof LoggerAwareInterface) { 134 | $transport->setLogger($this->configuration->logger); 135 | } 136 | if ($transport instanceof LoopAwareInterface) { 137 | $transport->setLoop($this->configuration->loop); 138 | } 139 | 140 | $protocol = $this->getProtocol(); 141 | 142 | $closeHandlerCallback = function (?string $reason = null) use ($protocol) { 143 | $this->isListening = false; 144 | $this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']); 145 | $protocol->unbindTransport(); 146 | $this->configuration->loop->stop(); 147 | }; 148 | 149 | $transport->once('close', $closeHandlerCallback); 150 | 151 | $protocol->bindTransport($transport); 152 | 153 | try { 154 | $transport->listen(); 155 | 156 | $this->isListening = true; 157 | 158 | if ($runLoop) { 159 | $this->sessionManager->startGcTimer(); 160 | 161 | $this->configuration->loop->run(); 162 | 163 | $this->endListen($transport); 164 | } 165 | } catch (Throwable $e) { 166 | $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]); 167 | $this->endListen($transport); 168 | throw $e; 169 | } 170 | } 171 | 172 | public function endListen(ServerTransportInterface $transport): void 173 | { 174 | $protocol = $this->getProtocol(); 175 | 176 | $protocol->unbindTransport(); 177 | 178 | $this->sessionManager->stopGcTimer(); 179 | 180 | $transport->removeAllListeners('close'); 181 | $transport->close(); 182 | 183 | $this->isListening = false; 184 | $this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down."); 185 | } 186 | 187 | /** 188 | * Warns if no MCP elements are registered and discovery has not been run. 189 | */ 190 | protected function warnIfNoElements(): void 191 | { 192 | if (! $this->registry->hasElements() && ! $this->discoveryRan) { 193 | $this->configuration->logger->warning( 194 | 'Starting listener, but no MCP elements are registered and discovery has not been run. ' . 195 | 'Call $server->discover(...) at least once to find and cache elements before listen().' 196 | ); 197 | } elseif (! $this->registry->hasElements() && $this->discoveryRan) { 198 | $this->configuration->logger->warning( 199 | 'Starting listener, but no MCP elements were found after discovery/cache load.' 200 | ); 201 | } 202 | } 203 | 204 | /** 205 | * Gets the Configuration instance associated with this server. 206 | */ 207 | public function getConfiguration(): Configuration 208 | { 209 | return $this->configuration; 210 | } 211 | 212 | /** 213 | * Gets the Registry instance associated with this server. 214 | */ 215 | public function getRegistry(): Registry 216 | { 217 | return $this->registry; 218 | } 219 | 220 | /** 221 | * Gets the Protocol instance associated with this server. 222 | */ 223 | public function getProtocol(): Protocol 224 | { 225 | return $this->protocol; 226 | } 227 | 228 | public function getSessionManager(): SessionManager 229 | { 230 | return $this->sessionManager; 231 | } 232 | } 233 | ``` -------------------------------------------------------------------------------- /tests/Unit/Session/ArraySessionHandlerTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Session; 4 | 5 | use PhpMcp\Server\Session\ArraySessionHandler; 6 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 7 | use PhpMcp\Server\Defaults\SystemClock; 8 | use PhpMcp\Server\Tests\Mocks\Clock\FixedClock; 9 | 10 | const SESSION_ID_ARRAY_1 = 'array-session-id-1'; 11 | const SESSION_ID_ARRAY_2 = 'array-session-id-2'; 12 | const SESSION_ID_ARRAY_3 = 'array-session-id-3'; 13 | const SESSION_DATA_1 = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":2},{"id":"prod_B","qty":1}],"total":150.75},"theme":"dark"}'; 14 | const SESSION_DATA_2 = '{"user_id":102,"preferences":{"notifications":true,"language":"en"},"last_login":"2024-07-15T10:00:00Z"}'; 15 | const SESSION_DATA_3 = '{"guest":true,"viewed_products":["prod_C","prod_D"]}'; 16 | const DEFAULT_TTL_ARRAY = 3600; 17 | 18 | beforeEach(function () { 19 | $this->fixedClock = new FixedClock(); 20 | $this->handler = new ArraySessionHandler(DEFAULT_TTL_ARRAY, $this->fixedClock); 21 | }); 22 | 23 | it('implements SessionHandlerInterface', function () { 24 | expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); 25 | }); 26 | 27 | it('constructs with a default TTL and SystemClock if no clock provided', function () { 28 | $handler = new ArraySessionHandler(); 29 | expect($handler->ttl)->toBe(DEFAULT_TTL_ARRAY); 30 | $reflection = new \ReflectionClass($handler); 31 | $clockProp = $reflection->getProperty('clock'); 32 | $clockProp->setAccessible(true); 33 | expect($clockProp->getValue($handler))->toBeInstanceOf(SystemClock::class); 34 | }); 35 | 36 | it('constructs with a custom TTL and injected clock', function () { 37 | $customTtl = 1800; 38 | $clock = new FixedClock(); 39 | $handler = new ArraySessionHandler($customTtl, $clock); 40 | expect($handler->ttl)->toBe($customTtl); 41 | $reflection = new \ReflectionClass($handler); 42 | $clockProp = $reflection->getProperty('clock'); 43 | $clockProp->setAccessible(true); 44 | expect($clockProp->getValue($handler))->toBe($clock); 45 | }); 46 | 47 | it('writes session data and reads it back correctly', function () { 48 | $writeResult = $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 49 | expect($writeResult)->toBeTrue(); 50 | 51 | $readData = $this->handler->read(SESSION_ID_ARRAY_1); 52 | expect($readData)->toBe(SESSION_DATA_1); 53 | }); 54 | 55 | it('returns false when reading a non-existent session', function () { 56 | $readData = $this->handler->read('non-existent-session-id'); 57 | expect($readData)->toBeFalse(); 58 | }); 59 | 60 | it('overwrites existing session data on subsequent write', function () { 61 | $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 62 | $updatedData = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":3}],"total":175.25},"theme":"light"}'; 63 | $this->handler->write(SESSION_ID_ARRAY_1, $updatedData); 64 | 65 | $readData = $this->handler->read(SESSION_ID_ARRAY_1); 66 | expect($readData)->toBe($updatedData); 67 | }); 68 | 69 | it('returns false and removes data when reading an expired session due to handler TTL', function () { 70 | $ttl = 60; 71 | $fixedClock = new FixedClock(); 72 | $handler = new ArraySessionHandler($ttl, $fixedClock); 73 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 74 | 75 | $fixedClock->addSeconds($ttl + 1); 76 | 77 | $readData = $handler->read(SESSION_ID_ARRAY_1); 78 | expect($readData)->toBeFalse(); 79 | 80 | $reflection = new \ReflectionClass($handler); 81 | $storeProp = $reflection->getProperty('store'); 82 | $storeProp->setAccessible(true); 83 | $internalStore = $storeProp->getValue($handler); 84 | expect($internalStore)->not->toHaveKey(SESSION_ID_ARRAY_1); 85 | }); 86 | 87 | it('does not return data if read exactly at TTL expiration time', function () { 88 | $shortTtl = 60; 89 | $fixedClock = new FixedClock(); 90 | $handler = new ArraySessionHandler($shortTtl, $fixedClock); 91 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 92 | 93 | $fixedClock->addSeconds($shortTtl); 94 | 95 | $readData = $handler->read(SESSION_ID_ARRAY_1); 96 | expect($readData)->toBe(SESSION_DATA_1); 97 | 98 | $fixedClock->addSecond(); 99 | 100 | $readDataExpired = $handler->read(SESSION_ID_ARRAY_1); 101 | expect($readDataExpired)->toBeFalse(); 102 | }); 103 | 104 | 105 | it('updates timestamp on write, effectively extending session life', function () { 106 | $veryShortTtl = 5; 107 | $fixedClock = new FixedClock(); 108 | $handler = new ArraySessionHandler($veryShortTtl, $fixedClock); 109 | 110 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 111 | 112 | $fixedClock->addSeconds(3); 113 | 114 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_2); 115 | 116 | $fixedClock->addSeconds(3); 117 | 118 | $readData = $handler->read(SESSION_ID_ARRAY_1); 119 | expect($readData)->toBe(SESSION_DATA_2); 120 | }); 121 | 122 | it('destroys an existing session and it cannot be read', function () { 123 | $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 124 | expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); 125 | 126 | $destroyResult = $this->handler->destroy(SESSION_ID_ARRAY_1); 127 | expect($destroyResult)->toBeTrue(); 128 | expect($this->handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); 129 | 130 | $reflection = new \ReflectionClass($this->handler); 131 | $storeProp = $reflection->getProperty('store'); 132 | $storeProp->setAccessible(true); 133 | expect($storeProp->getValue($this->handler))->not->toHaveKey(SESSION_ID_ARRAY_1); 134 | }); 135 | 136 | it('destroy returns true and does nothing for a non-existent session', function () { 137 | $destroyResult = $this->handler->destroy('non-existent-id'); 138 | expect($destroyResult)->toBeTrue(); 139 | }); 140 | 141 | it('garbage collects only sessions older than maxLifetime', function () { 142 | $gcMaxLifetime = 100; 143 | $handlerTtl = 300; 144 | $fixedClock = new FixedClock(); 145 | $handler = new ArraySessionHandler($handlerTtl, $fixedClock); 146 | 147 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 148 | 149 | $fixedClock->addSeconds(50); 150 | $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); 151 | 152 | $fixedClock->addSeconds(80); 153 | 154 | $deletedSessions = $handler->gc($gcMaxLifetime); 155 | 156 | expect($deletedSessions)->toBeArray()->toEqual([SESSION_ID_ARRAY_1]); 157 | expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); 158 | expect($handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); 159 | }); 160 | 161 | it('garbage collection respects maxLifetime precisely', function () { 162 | $maxLifetime = 60; 163 | $fixedClock = new FixedClock(); 164 | $handler = new ArraySessionHandler(300, $fixedClock); 165 | 166 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 167 | 168 | $fixedClock->addSeconds($maxLifetime); 169 | $deleted = $handler->gc($maxLifetime); 170 | expect($deleted)->toBeEmpty(); 171 | expect($handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); 172 | 173 | $fixedClock->addSecond(); 174 | $deleted2 = $handler->gc($maxLifetime); 175 | expect($deleted2)->toEqual([SESSION_ID_ARRAY_1]); 176 | expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); 177 | }); 178 | 179 | it('garbage collection returns empty array if no sessions meet criteria', function () { 180 | $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 181 | $this->handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); 182 | 183 | $this->fixedClock->addSeconds(DEFAULT_TTL_ARRAY / 2); 184 | 185 | $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); 186 | expect($deletedSessions)->toBeArray()->toBeEmpty(); 187 | expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); 188 | expect($this->handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); 189 | }); 190 | 191 | it('garbage collection correctly handles an empty store', function () { 192 | $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); 193 | expect($deletedSessions)->toBeArray()->toBeEmpty(); 194 | }); 195 | 196 | it('garbage collection removes multiple expired sessions', function () { 197 | $maxLifetime = 30; 198 | $fixedClock = new FixedClock(); 199 | $handler = new ArraySessionHandler(300, $fixedClock); 200 | 201 | $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); 202 | 203 | $fixedClock->addSeconds(20); 204 | $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); 205 | 206 | $fixedClock->addSeconds(20); 207 | $handler->write(SESSION_ID_ARRAY_3, SESSION_DATA_3); 208 | 209 | $fixedClock->addSeconds(20); 210 | 211 | $deleted = $handler->gc($maxLifetime); 212 | expect($deleted)->toHaveCount(2)->toContain(SESSION_ID_ARRAY_1)->toContain(SESSION_ID_ARRAY_2); 213 | expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); 214 | expect($handler->read(SESSION_ID_ARRAY_2))->toBeFalse(); 215 | expect($handler->read(SESSION_ID_ARRAY_3))->toBe(SESSION_DATA_3); 216 | }); 217 | ``` -------------------------------------------------------------------------------- /src/Defaults/BasicContainer.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); // Added missing strict_types 4 | 5 | namespace PhpMcp\Server\Defaults; 6 | 7 | use Psr\Container\ContainerExceptionInterface; 8 | use Psr\Container\ContainerInterface; 9 | use Psr\Container\NotFoundExceptionInterface; 10 | use ReflectionClass; 11 | use ReflectionException; 12 | use ReflectionNamedType; 13 | use ReflectionParameter; 14 | use Throwable; // Changed from \Throwable to Throwable 15 | 16 | /** 17 | * A basic PSR-11 container implementation with simple constructor auto-wiring. 18 | * 19 | * Supports instantiating classes with parameterless constructors or constructors 20 | * where all parameters are type-hinted classes/interfaces known to the container, 21 | * or have default values. Does NOT support scalar/built-in type injection without defaults. 22 | */ 23 | class BasicContainer implements ContainerInterface 24 | { 25 | /** @var array<string, object> Cache for already created instances (shared singletons) */ 26 | private array $instances = []; 27 | 28 | /** @var array<string, bool> Track classes currently being resolved to detect circular dependencies */ 29 | private array $resolving = []; 30 | 31 | /** 32 | * Finds an entry of the container by its identifier and returns it. 33 | * 34 | * @param string $id Identifier of the entry to look for (usually a FQCN). 35 | * @return mixed Entry. 36 | * 37 | * @throws NotFoundExceptionInterface No entry was found for **this** identifier. 38 | * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency). 39 | */ 40 | public function get(string $id): mixed 41 | { 42 | // 1. Check instance cache 43 | if (isset($this->instances[$id])) { 44 | return $this->instances[$id]; 45 | } 46 | 47 | // 2. Check if class exists 48 | if (! class_exists($id) && ! interface_exists($id)) { // Also check interface for bindings 49 | throw new NotFoundException("Class, interface, or entry '{$id}' not found."); 50 | } 51 | 52 | // 7. Circular Dependency Check 53 | if (isset($this->resolving[$id])) { 54 | throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}"); 55 | } 56 | 57 | $this->resolving[$id] = true; // Mark as currently resolving 58 | 59 | try { 60 | // 3. Reflect on the class 61 | $reflector = new ReflectionClass($id); 62 | 63 | // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated) 64 | if (! $reflector->isInstantiable()) { 65 | // We might have an interface bound to a concrete class via set() 66 | // This check is slightly redundant due to class_exists but good practice 67 | throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding)."); 68 | } 69 | 70 | // 4. Get the constructor 71 | $constructor = $reflector->getConstructor(); 72 | 73 | // 5. If no constructor or constructor has no parameters, instantiate directly 74 | if ($constructor === null || $constructor->getNumberOfParameters() === 0) { 75 | $instance = $reflector->newInstance(); 76 | } else { 77 | // 6. Constructor has parameters, attempt to resolve them 78 | $parameters = $constructor->getParameters(); 79 | $resolvedArgs = []; 80 | 81 | foreach ($parameters as $parameter) { 82 | $resolvedArgs[] = $this->resolveParameter($parameter, $id); 83 | } 84 | 85 | // Instantiate with resolved arguments 86 | $instance = $reflector->newInstanceArgs($resolvedArgs); 87 | } 88 | 89 | // Cache the instance 90 | $this->instances[$id] = $instance; 91 | 92 | return $instance; 93 | 94 | } catch (ReflectionException $e) { 95 | throw new ContainerException("Reflection failed for '{$id}'.", 0, $e); 96 | } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly 97 | throw $e; 98 | } catch (Throwable $e) { // Catch other instantiation errors 99 | throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e); 100 | } finally { 101 | // 7. Remove from resolving stack once done (success or failure) 102 | unset($this->resolving[$id]); 103 | } 104 | } 105 | 106 | /** 107 | * Attempts to resolve a single constructor parameter. 108 | * 109 | * @throws ContainerExceptionInterface If a required dependency cannot be resolved. 110 | */ 111 | private function resolveParameter(ReflectionParameter $parameter, string $consumerClassId): mixed 112 | { 113 | // Check for type hint 114 | $type = $parameter->getType(); 115 | 116 | if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { 117 | // Type hint is a class or interface name 118 | $typeName = $type->getName(); 119 | try { 120 | // Recursively get the dependency 121 | return $this->get($typeName); 122 | } catch (NotFoundExceptionInterface $e) { 123 | // Dependency class not found, fail ONLY if required 124 | if (! $parameter->isOptional() && ! $parameter->allowsNull()) { 125 | throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e); 126 | } 127 | // If optional or nullable, proceed (will check allowsNull/Default below) 128 | } catch (ContainerExceptionInterface $e) { 129 | // Dependency itself failed to resolve (e.g., its own deps, circular) 130 | throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e); 131 | } 132 | } 133 | 134 | // Check if parameter has a default value 135 | if ($parameter->isDefaultValueAvailable()) { 136 | return $parameter->getDefaultValue(); 137 | } 138 | 139 | // Check if parameter allows null (and wasn't resolved above) 140 | if ($parameter->allowsNull()) { 141 | return null; 142 | } 143 | 144 | // Check if it was a built-in type without a default (unresolvable by this basic container) 145 | if ($type instanceof ReflectionNamedType && $type->isBuiltin()) { 146 | 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."); 147 | } 148 | 149 | // Check if it was a union/intersection type without a default (also unresolvable) 150 | if ($type !== null && ! $type instanceof ReflectionNamedType) { 151 | 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."); 152 | } 153 | 154 | // If we reach here, it's an untyped, required parameter without a default. 155 | // Or potentially an unresolvable optional class dependency where null is not allowed (edge case). 156 | throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type)."); 157 | } 158 | 159 | /** 160 | * Returns true if the container can return an entry for the given identifier. 161 | * Checks explicitly set instances and if the class/interface exists. 162 | * Does not guarantee `get()` will succeed if auto-wiring fails. 163 | */ 164 | public function has(string $id): bool 165 | { 166 | return isset($this->instances[$id]) || class_exists($id) || interface_exists($id); 167 | } 168 | 169 | /** 170 | * Adds a pre-built instance or a factory/binding to the container. 171 | * This basic version only supports pre-built instances (singletons). 172 | */ 173 | public function set(string $id, object $instance): void 174 | { 175 | // Could add support for closures/factories later if needed 176 | $this->instances[$id] = $instance; 177 | } 178 | } 179 | 180 | // Keep custom exception classes as they are PSR-11 compliant placeholders 181 | class ContainerException extends \Exception implements ContainerExceptionInterface 182 | { 183 | } 184 | class NotFoundException extends \Exception implements NotFoundExceptionInterface 185 | { 186 | } 187 | ```