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 |
```