[Server] Simplify server entrypoint with run-based transport lifecycle (#103)

* feat: replace connect/listen flow with run lifecycle

* feat(transport): add phpstan generics for typed transport returns

* refactor: standardize variable naming for MCP server instances in transport handling

* test: remove unnecessary phpstan-ignore annotation in DocBlockTestFixture
This commit is contained in:
Kyrian Obikwelu 2025-10-10 18:52:06 +01:00 • committed by GitHub
commit 2dbd411194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 82 additions and 74 deletions

View file

@ -108,8 +108,8 @@ $server = Server::builder()
->build(); ->build();
$transport = new StdioTransport(); $transport = new StdioTransport();
$server->connect($transport);
$transport->listen(); $server->run($transport);
``` ```
### 3. Configure Your MCP Client ### 3. Configure Your MCP Client
@ -175,15 +175,13 @@ $server = Server::builder()
**STDIO Transport** (Command-line integration): **STDIO Transport** (Command-line integration):
```php ```php
$transport = new StdioTransport(); $transport = new StdioTransport();
$server->connect($transport); $server->run($transport);
$transport->listen();
``` ```
**HTTP Transport** (Web-based communication): **HTTP Transport** (Web-based communication):
```php ```php
$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory);
$server->connect($transport); $response = $server->run($transport);
$response = $transport->listen();
// Handle $response in your web application // Handle $response in your web application
``` ```

View file

@ -22,9 +22,7 @@ $server = Server::builder()
$transport = new SomeTransport(); $transport = new SomeTransport();
$server->connect($transport); $result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP
$transport->listen(); // For STDIO, or handle response for HTTP
``` ```
## STDIO Transport ## STDIO Transport
@ -70,9 +68,9 @@ $server = Server::builder()
$transport = new StdioTransport(); $transport = new StdioTransport();
$server->connect($transport); $status = $server->run($transport);
$transport->listen(); exit($status); // 0 on clean shutdown, non-zero if STDIN errored
``` ```
### Client Configuration ### Client Configuration
@ -138,24 +136,20 @@ use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator; use Nyholm\Psr7Server\ServerRequestCreator;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
// Create PSR-7 request from globals
$psr17Factory = new Psr17Factory(); $psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
$request = $creator->fromGlobals(); $request = $creator->fromGlobals();
// Build server
$server = Server::builder() $server = Server::builder()
->setServerInfo('HTTP Server', '1.0.0') ->setServerInfo('HTTP Server', '1.0.0')
->setDiscovery(__DIR__, ['.']) ->setDiscovery(__DIR__, ['.'])
->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions
->build(); ->build();
// Process request and get response
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport);
$response = $transport->listen();
// Emit response $response = $server->run($transport);
(new SapiEmitter())->emit($response); (new SapiEmitter())->emit($response);
``` ```
@ -187,7 +181,7 @@ use Mcp\Server\Transport\StreamableHttpTransport;
class McpController class McpController
{ {
#[Route('/mcp', name: 'mcp_endpoint'] #[Route('/mcp', name: 'mcp_endpoint']
public function handle(Request $request, Server $mcpServer): Response public function handle(Request $request, Server $server): Response
{ {
// Create PSR-7 factories // Create PSR-7 factories
$psr17Factory = new Psr17Factory(); $psr17Factory = new Psr17Factory();
@ -199,8 +193,7 @@ class McpController
// Process with MCP // Process with MCP
$transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory);
$mcpServer->connect($transport); $psrResponse = $server->run($transport);
$psrResponse = $transport->listen();
// Convert PSR-7 response back to Symfony // Convert PSR-7 response back to Symfony
return $httpFoundationFactory->createResponse($psrResponse); return $httpFoundationFactory->createResponse($psrResponse);
@ -230,17 +223,16 @@ use Nyholm\Psr7\Factory\Psr17Factory;
class McpController class McpController
{ {
public function handle(ServerRequestInterface $request, Server $mcpServer): ResponseInterface public function handle(ServerRequestInterface $request, Server $server): ResponseInterface
{ {
$psr17Factory = new Psr17Factory(); $psr17Factory = new Psr17Factory();
// Create and connect the MCP HTTP transport // Create the MCP HTTP transport
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$mcpServer->connect($transport);
// Process MCP request and return PSR-7 response // Process MCP request and return PSR-7 response
// Laravel automatically handles PSR-7 responses // Laravel automatically handles PSR-7 responses
return $transport->listen(); return $server->run($transport);
} }
} }
@ -255,7 +247,6 @@ Slim Framework works natively with PSR-7.
Create a route handler using Slim's built-in factories and container: Create a route handler using Slim's built-in factories and container:
```php ```php
use Psr\Container\ContainerInterface;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\ResponseFactory; use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\StreamFactory; use Slim\Psr7\Factory\StreamFactory;
@ -263,25 +254,19 @@ use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport; use Mcp\Server\Transport\StreamableHttpTransport;
$app = AppFactory::create(); $app = AppFactory::create();
$container = $app->getContainer();
$container->set('mcpServer', function (ContainerInterface $container) { $app->any('/mcp', function ($request, $response) {
return Server::builder() $server = Server::builder()
->setServerInfo('My MCP Server', '1.0.0') ->setServerInfo('My MCP Server', '1.0.0')
->setDiscovery(__DIR__, ['.']) ->setDiscovery(__DIR__, ['.'])
->build(); ->build();
});
$app->any('/mcp', function ($request, $response) {
$mcpServer = $this->get('mcpServer');
$responseFactory = new ResponseFactory(); $responseFactory = new ResponseFactory();
$streamFactory = new StreamFactory(); $streamFactory = new StreamFactory();
$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory);
$mcpServer->connect($transport);
return $transport->listen(); return $server->run($transport);
}); });
``` ```

View file

@ -137,8 +137,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -42,8 +42,6 @@ $server = Server::builder()
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport); $response = $server->run($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response); (new SapiEmitter())->emit($response);

View file

@ -35,8 +35,6 @@ $server = Server::builder()
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport); $response = $server->run($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response); (new SapiEmitter())->emit($response);

View file

@ -77,8 +77,6 @@ $server = Server::builder()
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport); $response = $server->run($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response); (new SapiEmitter())->emit($response);

View file

@ -35,8 +35,6 @@ $server = Server::builder()
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
$server->connect($transport); $response = $server->run($transport);
$response = $transport->listen();
(new SapiEmitter())->emit($response); (new SapiEmitter())->emit($response);

View file

@ -31,8 +31,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -39,8 +39,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -28,8 +28,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -57,8 +57,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -31,8 +31,8 @@ $server = Server::builder()
$transport = new StdioTransport(logger: logger()); $transport = new StdioTransport(logger: logger());
$server->connect($transport); $result = $server->run($transport);
$transport->listen(); logger()->info('Server listener stopped gracefully.', ['result' => $result]);
logger()->info('Server listener stopped gracefully.'); exit($result);

View file

@ -35,7 +35,14 @@ final class Server
return new Builder(); return new Builder();
} }
public function connect(TransportInterface $transport): void /**
* @template TResult
*
* @param TransportInterface<TResult> $transport
*
* @return TResult
*/
public function run(TransportInterface $transport): mixed
{ {
$transport->initialize(); $transport->initialize();
@ -56,5 +63,11 @@ final class Server
$transport->onSessionEnd(function (Uuid $sessionId) { $transport->onSessionEnd(function (Uuid $sessionId) {
$this->jsonRpcHandler->destroySession($sessionId); $this->jsonRpcHandler->destroySession($sessionId);
}); });
try {
return $transport->listen();
} finally {
$transport->close();
}
} }
} }

View file

@ -14,6 +14,8 @@ namespace Mcp\Server\Transport;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* @implements TransportInterface<null>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com> * @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/ */
class InMemoryTransport implements TransportInterface class InMemoryTransport implements TransportInterface
@ -50,6 +52,9 @@ class InMemoryTransport implements TransportInterface
} }
} }
/**
* @return null
*/
public function listen(): mixed public function listen(): mixed
{ {
foreach ($this->messages as $message) { foreach ($this->messages as $message) {
@ -60,6 +65,7 @@ class InMemoryTransport implements TransportInterface
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
\call_user_func($this->sessionDestroyListener, $this->sessionId); \call_user_func($this->sessionDestroyListener, $this->sessionId);
$this->sessionId = null;
} }
return null; return null;
@ -74,6 +80,7 @@ class InMemoryTransport implements TransportInterface
{ {
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
\call_user_func($this->sessionDestroyListener, $this->sessionId); \call_user_func($this->sessionDestroyListener, $this->sessionId);
$this->sessionId = null;
} }
} }
} }

View file

@ -16,6 +16,8 @@ use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* @implements TransportInterface<int>
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com> * @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/ */
class StdioTransport implements TransportInterface class StdioTransport implements TransportInterface
@ -59,13 +61,18 @@ class StdioTransport implements TransportInterface
fwrite($this->output, $data.\PHP_EOL); fwrite($this->output, $data.\PHP_EOL);
} }
public function listen(): mixed public function listen(): int
{ {
$this->logger->info('StdioTransport is listening for messages on STDIN...'); $this->logger->info('StdioTransport is listening for messages on STDIN...');
$status = 0;
while (!feof($this->input)) { while (!feof($this->input)) {
$line = fgets($this->input); $line = fgets($this->input);
if (false === $line) { if (false === $line) {
if (!feof($this->input)) {
$status = 1;
}
break; break;
} }
@ -82,9 +89,10 @@ class StdioTransport implements TransportInterface
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
\call_user_func($this->sessionEndListener, $this->sessionId); \call_user_func($this->sessionEndListener, $this->sessionId);
$this->sessionId = null;
} }
return null; return $status;
} }
public function onSessionEnd(callable $listener): void public function onSessionEnd(callable $listener): void
@ -96,6 +104,7 @@ class StdioTransport implements TransportInterface
{ {
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
\call_user_func($this->sessionEndListener, $this->sessionId); \call_user_func($this->sessionEndListener, $this->sessionId);
$this->sessionId = null;
} }
if (\is_resource($this->input)) { if (\is_resource($this->input)) {

View file

@ -21,6 +21,8 @@ use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* @implements TransportInterface<ResponseInterface>
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com> * @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/ */
class StreamableHttpTransport implements TransportInterface class StreamableHttpTransport implements TransportInterface
@ -78,7 +80,7 @@ class StreamableHttpTransport implements TransportInterface
]); ]);
} }
public function listen(): mixed public function listen(): ResponseInterface
{ {
return match ($this->request->getMethod()) { return match ($this->request->getMethod()) {
'OPTIONS' => $this->handleOptionsRequest(), 'OPTIONS' => $this->handleOptionsRequest(),

View file

@ -14,6 +14,8 @@ namespace Mcp\Server\Transport;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* @template TResult
*
* @author Christopher Hertel <mail@christopher-hertel.de> * @author Christopher Hertel <mail@christopher-hertel.de>
* @author Kyrian Obikwelu <koshnawaza@gmail.com> * @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/ */
@ -38,7 +40,7 @@ interface TransportInterface
* - For a single-request transport like HTTP, this will process the request * - For a single-request transport like HTTP, this will process the request
* and return a result (e.g., a PSR-7 Response) to be sent to the client. * and return a result (e.g., a PSR-7 Response) to be sent to the client.
* *
* @return mixed the result of the transport's execution, if any * @return TResult the result of the transport's execution, if any
*/ */
public function listen(): mixed; public function listen(): mixed;
@ -63,6 +65,7 @@ interface TransportInterface
* *
* This method should be called when the transport is no longer needed. * This method should be called when the transport is no longer needed.
* It should clean up any resources and close any connections. * It should clean up any resources and close any connections.
* `Server::run()` calls this automatically after `listen()` exits.
*/ */
public function close(): void; public function close(): void;
} }

View file

@ -75,7 +75,7 @@ class DocBlockTestFixture
* @deprecated use newMethod() instead * @deprecated use newMethod() instead
* @see DocBlockTestFixture::newMethod() * @see DocBlockTestFixture::newMethod()
*/ */
public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */ public function methodWithMultipleTags(float $value): bool
{ {
return true; return true;
} }

View file

@ -32,16 +32,15 @@ class ServerTest extends TestCase
$transport = $this->getMockBuilder(InMemoryTransport::class) $transport = $this->getMockBuilder(InMemoryTransport::class)
->setConstructorArgs([['foo', 'bar']]) ->setConstructorArgs([['foo', 'bar']])
->onlyMethods(['send']) ->onlyMethods(['send', 'close'])
->getMock(); ->getMock();
$transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls(
null, null,
null null
); );
$transport->expects($this->once())->method('close');
$server = new Server($handler); $server = new Server($handler);
$server->connect($transport); $server->run($transport);
$transport->listen();
} }
} }