A PSR-15 middleware and handler library for integrating Model Context Protocol (MCP) servers into any PSR-15 compatible framework.
- Framework Agnostic: Works with any PSR-15 compatible framework (Mezzio, Slim, Laravel, Symfony, etc.)
- Flexible Routing: Support for multiple endpoint patterns and route parameters
- Streamable HTTP Transport: Full support for Server-Sent Events (SSE) and JSON-RPC over HTTP
- Bring Your Own Middleware: Use your existing auth, CORS, rate limiting, and other middleware
- Auto-Discovery: Automatic MCP element discovery with caching
- Type Safe: Full PHP 8.1+ type hints and modern syntax
- Standards Compliant: Built on PSR-7, PSR-11, PSR-15, and PSR-16
- Production Ready: Comprehensive testing, error handling, and monitoring support
composer require php-mcp/psr-15
- PHP 8.1+
- PSR-15 compatible framework
php-mcp/server
^3.3
use PhpMcp\Psr15\McpPsr15Builder;
use PhpMcp\Server\Server;
use Laminas\Diactoros\ResponseFactory;
use Laminas\Diactoros\StreamFactory;
// Create your MCP server
$server = Server::make()
->withServerInfo('My MCP Server', '1.0.0')
->withTool(fn(int $a, int $b) => $a + $b, 'add')
->build();
// Build PSR-15 components
$mcpSetup = McpPsr15Builder::create(
$server,
new ResponseFactory(),
new StreamFactory()
)
->withAutoDiscover(scanDirs: ['src/Mcp'])
->build();
// Use with your PSR-15 framework
$handler = $mcpSetup['handler'];
$middleware = $mcpSetup['middleware'];
// In your route configuration
$app->route('/mcp', [
...$mcpSetup['middleware'],
$mcpSetup['handler'],
], ['GET', 'POST', 'OPTIONS'], 'mcp');
$app->any('/mcp[/{clientId}]', $mcpSetup['handler'])
->add($mcpSetup['middleware'][2] ?? null) // Rate limit
->add($mcpSetup['middleware'][1] ?? null) // Auth
->add($mcpSetup['middleware'][0] ?? null); // CORS
// Convert PSR-15 to Laravel-compatible
Route::match(['GET', 'POST', 'OPTIONS'], '/mcp/{clientId?}',
function (Request $request, string $clientId = null) use ($mcpSetup) {
// Convert Laravel request to PSR-7 and process
// See examples/laravel/ for full implementation
}
);
The PSR-15 integration supports flexible routing patterns:
/mcp
- Basic MCP endpoint/mcp/{clientId}
- Client-specific endpoint/mcp/{clientId}/{sessionId}
- Session-specific endpoint/mcp/status
- Status/health check endpoint
- GET: Server-Sent Events (SSE) connections and server info
- POST: JSON-RPC message handling
- OPTIONS: CORS preflight requests
The handler automatically extracts client IDs from:
- Route parameters (
{clientId}
) - Query parameters (
?clientId=abc
) - Headers (
X-Client-ID: abc
)
->withCors([
'allowed_origins' => ['http://localhost:3000', 'https://app.example.com'],
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Client-ID'],
'exposed_headers' => ['X-RateLimit-Remaining'],
'max_age' => 86400,
'credentials' => false,
])
->withAuth(function(string $token, ServerRequestInterface $request): bool {
// Simple token validation
return $token === $_ENV['MCP_API_TOKEN'];
// Or JWT validation
try {
$payload = JWT::decode($token, $key, ['HS256']);
return isset($payload->sub);
} catch (Exception $e) {
return false;
}
})
->withRateLimit([
'requests_per_minute' => 60,
'burst_limit' => 10,
'storage' => 'memory', // memory, cache, redis
])
->withAutoDiscover(
basePath: getcwd(),
scanDirs: ['src/Mcp', 'app/Mcp'],
excludeDirs: ['tests', 'vendor'],
saveToCache: true,
cacheTtl: 3600
)
const eventSource = new EventSource('/mcp?clientId=my-client', {
headers: {
'Authorization': 'Bearer your-token-here'
}
});
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('MCP message:', data);
};
eventSource.onerror = function(event) {
console.error('SSE error:', event);
};
// List available tools
fetch('/mcp/my-client', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-token-here'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list'
})
})
.then(response => response.json())
.then(data => console.log('Tools:', data.result));
// Call a tool
fetch('/mcp/my-client', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-token-here'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'add',
arguments: { a: 5, b: 3 }
}
})
})
.then(response => response.json())
.then(data => console.log('Result:', data.result));
curl -X GET "http://localhost:8080/mcp/status"
Run the test suite:
composer test
Run with coverage:
composer test:coverage
# Status check
curl -X GET "http://localhost:8080/mcp/status"
# CORS preflight
curl -X OPTIONS "http://localhost:8080/mcp" \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST"
# JSON-RPC request
curl -X POST "http://localhost:8080/mcp" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
- McpHandler: PSR-15 RequestHandlerInterface that processes HTTP requests
- McpPsr15Builder: Fluent builder for configuration
- Psr7Transport: Bridge between PSR-7 and MCP server
- Middleware: CORS, Authentication, Rate Limiting components
- RouteHelper: Framework-agnostic routing utilities
- HTTP Request → Framework Router → PSR-15 Middleware Stack
- Middleware Stack → CORS → Auth → Rate Limit → Custom Middleware
- McpHandler → Parse HTTP request → Extract MCP message
- Psr7Transport → Convert to MCP transport format
- MCP Server → Process via Protocol and Dispatcher
- Response Path → MCP Response → PSR-7 Response → HTTP Response
See the examples/
directory for complete framework integration examples:
- Mezzio: Clean middleware pipeline with container integration
- Slim: Optional route parameters with fluent middleware API
- Laravel: PSR-7 bridge integration with Sanctum authentication
- Use middleware composition for clean separation of concerns
- Implement proper CORS for web client access
- Add authentication for production deployments
- Enable rate limiting to prevent abuse
- Provide status endpoints for monitoring
- Document your API with the
/docs
endpoint - Use route parameters for client/session identification
- Handle errors gracefully with proper HTTP status codes
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
MIT License. See LICENSE for details.
- php-mcp/server: Core MCP server implementation
- php-mcp/laravel: Laravel-specific integration
- php-mcp/schema: MCP protocol schemas
php-mcp/server
^3.3
<?php
use PhpMcp\Psr15\McpPsr15Builder;
use PhpMcp\Server\Server;
// Create your MCP server
$server = Server::make()
->withServerInfo('My MCP Server', '1.0.0')
->build();
// Build PSR-15 components
$mcpSetup = McpPsr15Builder::create($server, $responseFactory, $streamFactory)
->withAutoDiscover(scanDirs: ['src/Mcp'])
->withCors()
->build();
// Use $mcpSetup['handler'] and $mcpSetup['middleware'] in your framework
composer require laminas/laminas-diactoros
composer require php-mcp/psr-15
Create src/Mcp/Factory/McpHandlerFactory.php
:
<?php
namespace App\Mcp\Factory;
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Server\Server;
use Psr\Container\ContainerInterface;
use Laminas\Diactoros\ResponseFactory;
use Laminas\Diactoros\StreamFactory;
class McpHandlerFactory
{
public function __invoke(ContainerInterface $container): McpHandler
{
$server = $container->get(Server::class);
$config = $container->get('config')['mcp'] ?? [];
return new McpHandler(
server: $server,
responseFactory: new ResponseFactory(),
streamFactory: new StreamFactory(),
options: $config['psr15'] ?? []
);
}
}
Create src/Mcp/Factory/ServerFactory.php
:
<?php
namespace App\Mcp\Factory;
use PhpMcp\Server\Server;
use Psr\Container\ContainerInterface;
class ServerFactory
{
public function __invoke(ContainerInterface $container): Server
{
$config = $container->get('config')['mcp'] ?? [];
$server = Server::make()
->withServerInfo(
name: $config['server']['name'] ?? 'Mezzio MCP Server',
version: $config['server']['version'] ?? '1.0.0'
)
->withLogger($container->get('logger'))
->withContainer($container)
->build();
// Auto-discover MCP elements
if ($config['auto_discover'] ?? true) {
$server->discover(
basePath: getcwd(),
scanDirs: $config['scan_dirs'] ?? ['src/Mcp'],
excludeDirs: $config['exclude_dirs'] ?? []
);
}
return $server;
}
}
In config/dependencies.php
:
<?php
use App\Mcp\Factory\McpHandlerFactory;
use App\Mcp\Factory\ServerFactory;
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Psr15\Middleware\CorsMiddleware;
use PhpMcp\Server\Server;
return [
'dependencies' => [
'factories' => [
Server::class => ServerFactory::class,
McpHandler::class => McpHandlerFactory::class,
CorsMiddleware::class => function() {
return new CorsMiddleware([
'allowed_origins' => ['*'],
'allowed_methods' => ['GET', 'POST', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Last-Event-ID', 'Authorization'],
]);
},
],
],
];
In config/routes.php
:
<?php
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Psr15\Middleware\CorsMiddleware;
return function (
\Mezzio\Application $app,
\Mezzio\MiddlewareFactory $factory,
\Psr\Container\ContainerInterface $container
): void {
// MCP endpoint with middleware stack
$app->route('/mcp', [
CorsMiddleware::class,
McpHandler::class,
], ['GET', 'POST', 'DELETE', 'OPTIONS'], 'mcp.endpoint');
};
Create config/autoload/mcp.global.php
:
<?php
return [
'mcp' => [
'server' => [
'name' => 'Mezzio MCP Server',
'version' => '1.0.0',
],
'auto_discover' => true,
'scan_dirs' => ['src/Mcp'],
'exclude_dirs' => ['src/Mcp/Tests'],
'psr15' => [
'auto_discover' => true,
'base_path' => getcwd(),
'save_to_cache' => true,
],
],
];
Create src/Mcp/UserService.php
:
<?php
namespace App\Mcp;
use PhpMcp\Server\Attributes\McpTool;
use PhpMcp\Server\Attributes\McpResource;
class UserService
{
#[McpTool(name: 'get_user_info')]
public function getUserInfo(int $userId): array
{
return [
'id' => $userId,
'name' => 'John Doe',
'email' => 'john@example.com',
];
}
#[McpResource(uri: 'users://stats', mimeType: 'application/json')]
public function getUserStats(): array
{
return [
'total_users' => 1250,
'active_users' => 890,
'new_today' => 15,
];
}
}
composer require slim/slim:"4.*"
composer require slim/psr7
composer require php-di/php-di
composer require php-mcp/psr-15
Create public/index.php
:
<?php
use DI\Container;
use PhpMcp\Psr15\McpPsr15Builder;
use PhpMcp\Server\Server;
use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\StreamFactory;
require __DIR__ . '/../vendor/autoload.php';
// Create DI container
$container = new Container();
// Configure MCP Server
$container->set(Server::class, function() use ($container) {
return Server::make()
->withServerInfo('Slim MCP Server', '1.0.0')
->withContainer($container)
->build();
});
// Create Slim app
AppFactory::setContainer($container);
$app = AppFactory::create();
// Configure MCP with builder
$server = $container->get(Server::class);
$mcpSetup = McpPsr15Builder::create($server, new ResponseFactory(), new StreamFactory())
->withAutoDiscover(
basePath: __DIR__ . '/..',
scanDirs: ['src/Mcp'],
saveToCache: true
)
->withCors([
'allowed_origins' => ['http://localhost:3000', 'https://app.example.com'],
'allowed_methods' => ['GET', 'POST', 'DELETE', 'OPTIONS'],
])
->withAuth(function(string $token): bool {
// Simple token validation - replace with your auth logic
return $token === $_ENV['MCP_API_TOKEN'] ?? 'secret-key';
})
->build();
// Register MCP routes
$mcpGroup = $app->group('/mcp', function ($group) use ($mcpSetup) {
$group->any('', $mcpSetup['handler']);
});
// Apply middleware in reverse order (Slim applies them in LIFO order)
foreach (array_reverse($mcpSetup['middleware']) as $middleware) {
$mcpGroup->add($middleware);
}
// Add other routes
$app->get('/', function ($request, $response) {
$response->getBody()->write('MCP Server is running!');
return $response;
});
$app->run();
Create src/Mcp/CalculatorService.php
:
<?php
namespace App\Mcp;
use PhpMcp\Server\Attributes\McpTool;
use PhpMcp\Server\Attributes\Schema;
class CalculatorService
{
#[McpTool(name: 'add_numbers', description: 'Add two numbers together')]
#[Schema([
'type' => 'object',
'properties' => [
'a' => ['type' => 'number', 'description' => 'First number'],
'b' => ['type' => 'number', 'description' => 'Second number'],
],
'required' => ['a', 'b'],
])]
public function add(float $a, float $b): float
{
return $a + $b;
}
#[McpTool(name: 'multiply_numbers')]
public function multiply(
#[Schema(['type' => 'number', 'minimum' => 0])] float $x,
#[Schema(['type' => 'number', 'minimum' => 0])] float $y
): float {
return $x * $y;
}
}
Create .env
:
MCP_API_TOKEN=your-secret-api-token-here
MCP_AUTO_DISCOVER=true
MCP_CACHE_TTL=3600
composer require spiral/framework
composer require nyholm/psr7
composer require php-mcp/psr-15
Create app/src/Bootloader/McpBootloader.php
:
<?php
namespace App\Bootloader;
use App\Mcp\Factory\McpHandlerFactory;
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Psr15\Middleware\CorsMiddleware;
use PhpMcp\Server\Server;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Boot\DirectoriesInterface;
use Spiral\Core\Container;
class McpBootloader extends Bootloader
{
protected const SINGLETONS = [
Server::class => [self::class, 'createServer'],
McpHandler::class => McpHandlerFactory::class,
];
public function boot(Container $container): void
{
$container->bindSingleton(CorsMiddleware::class, function() {
return new CorsMiddleware([
'allowed_origins' => ['*'],
'allowed_methods' => ['GET', 'POST', 'DELETE', 'OPTIONS'],
]);
});
}
private function createServer(
Container $container,
DirectoriesInterface $dirs
): Server {
$server = Server::make()
->withServerInfo('Spiral MCP Server', '1.0.0')
->withContainer($container)
->build();
// Auto-discover
$server->discover(
basePath: $dirs->get('root'),
scanDirs: ['app/src/Mcp']
);
return $server;
}
}
In app/config/http.php
:
<?php
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Psr15\Middleware\CorsMiddleware;
use Spiral\Router\Route;
return [
'routes' => [
Route::methods(['GET', 'POST', 'DELETE', 'OPTIONS'], '/mcp', [
CorsMiddleware::class,
McpHandler::class,
])->withName('mcp.endpoint'),
],
];
$mcpSetup = McpPsr15Builder::create($server, $responseFactory, $streamFactory)
->withAuth(function(string $token, ServerRequestInterface $request): bool {
// JWT validation
try {
$payload = JWT::decode($token, $secretKey, ['HS256']);
return $payload->role === 'mcp-client';
} catch (Exception $e) {
return false;
}
})
->build();
use PhpMcp\Psr15\Middleware\RateLimitMiddleware;
$mcpSetup = McpPsr15Builder::create($server, $responseFactory, $streamFactory)
->withMiddleware(new RateLimitMiddleware([
'requests_per_minute' => 60,
'burst_limit' => 10,
]))
->build();
$mcpSetup = McpPsr15Builder::create($server, $responseFactory, $streamFactory)
->withCors([
'allowed_origins' => ['https://app.example.com', 'https://admin.example.com'],
'allowed_methods' => ['GET', 'POST', 'DELETE'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-API-Key'],
'exposed_headers' => ['X-RateLimit-Remaining'],
'max_age' => 86400,
'credentials' => true,
])
->build();
<?php
use PhpMcp\Psr15\Handler\McpHandler;
use PhpMcp\Server\Server;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
class McpHandlerTest extends TestCase
{
public function testHandlesGetRequest(): void
{
$server = Server::make()->build();
$handler = new McpHandler($server, $responseFactory, $streamFactory);
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn('GET');
$response = $handler->handle($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('text/event-stream', $response->getHeaderLine('Content-Type'));
}
}
<?php
use PhpMcp\Psr15\McpPsr15Builder;
use PhpMcp\Server\Server;
class McpIntegrationTest extends TestCase
{
public function testFullMcpStack(): void
{
$server = Server::make()
->withTool(fn($x, $y) => $x + $y, 'add')
->build();
$mcpSetup = McpPsr15Builder::create($server, $responseFactory, $streamFactory)
->withCors()
->build();
// Test that handler and middleware are properly configured
$this->assertInstanceOf(McpHandler::class, $mcpSetup['handler']);
$this->assertCount(1, $mcpSetup['middleware']); // CORS middleware
}
}
FROM php:8.1-fpm-alpine
# Install extensions
RUN docker-php-ext-install pdo_mysql opcache
# Copy application
COPY . /var/www/html
WORKDIR /var/www/html
# Install dependencies
RUN composer install --no-dev --optimize-autoloader
# Configure PHP for production
COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini
EXPOSE 9000
CMD ["php-fpm"]
server {
listen 80;
server_name mcp.example.com;
root /var/www/html/public;
index index.php;
location /mcp {
try_files $uri $uri/ /index.php$is_args$args;
# Enable Server-Sent Events
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
MIT License. See LICENSE file for details.