setServerInfo('Stateless Lifecycle Demo', '1.0.0', title: 'Stateless Lifecycle Demo') ->setLogger(logger()) ->setCapabilities(new ServerCapabilities(tools: true, toolsListChanged: true, resources: true, logging: true)) // Only the handshake leg has anything to keep here: its clients open a // session and come back to it. The modern leg never mints one, so under // `php -S` — where nothing survives between requests — this is what lets // both eras reach the same tools. ->setSession(new FileSessionStore(__DIR__.'/sessions')) // How long an answer stays fresh. Lists are the same for every caller here, // so they are public and long-lived; anything user-shaped stays private. ->setCachePolicy( CachePolicy::default(30_000) ->withMethod('tools/list', 3_600_000, CacheScope::Public) ->withMethod('server/discover', 3_600_000, CacheScope::Public), ) // Signs the `requestState` a multi round-trip answer carries. The same key // must reach every process that might serve the retry — a per-process // random value only works for a single-process deployment. ->setRequestState(getenv('MCP_REQUEST_STATE_KEY') ?: str_repeat('example-development-key-', 2)) // Carries change notifications to open `subscriptions/listen` streams. // In-memory suits `php -S` and worker runtimes; under PHP-FPM use // Psr16NotificationBus, since the publisher and the stream are different // processes there. ->setNotificationBus($bus) ->setSubscriptionLifetime(20.0) // An ordinary tool: nothing about it is lifecycle-specific. ->addTool( static fn (string $city = 'Berlin'): string => sprintf('It is 17°C and cloudy in %s.', $city), name: 'get_weather', description: 'Reports the weather for a city', ) // Progress and logging both travel on this request's own response stream. // The client opts into each: progress by sending `_meta.progressToken`, // logging by sending `_meta["io.modelcontextprotocol/logLevel"]`. Without // those the server must stay silent, and does. ->addTool( static function (RequestContext $context, int $steps = 3): string { $client = $context->getClientGateway(); for ($step = 1; $step <= $steps; ++$step) { $client->log(LoggingLevel::Info, sprintf('Reindexing shard %d of %d', $step, $steps)); $client->progress($step, $steps, sprintf('Shard %d of %d', $step, $steps)); } return sprintf('Reindexed %d shards.', $steps); }, name: 'reindex', description: 'Reindexes shards, reporting progress as it goes', ) // A tool that needs something from the user. There are no server-initiated // requests in this revision: instead of asking the client and waiting, the // server *returns* the ask and the client retries the whole call with the // answer. Nothing is kept between the two rounds — what the server needs to // remember it seals into `requestState`, which comes back verified. // // No fork for the handshake era, and none needed: there the SDK fulfils the // same ask over that connection's own channel and re-enters this closure // with the answer. Which is why it re-derives where it is from what came // back rather than keeping anything of its own. ->addTool( static function (RequestContext $context): CallToolResult|InputRequiredResult { $answer = $context->getInputContext()?->elicitResult('who'); if (null === $answer) { return new InputRequiredResult( ['who' => new ElicitRequest( 'What name should the greeting use?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), )], requestState: $context->mintRequestState(['asked' => 'who']), ); } return new CallToolResult([new TextContent(sprintf('Hello, %s!', $answer->content['name'] ?? 'friend'))]); }, name: 'greet', description: 'Greets you by name, asking for it first if it has to', ) ->build(); shutdown($server->run(transport()));