soukicz/mcp

PHP implementation of Model Context Protocol (MCP) server with PSR-7 support

dev-main 2025-06-18 10:29 UTC

This package is auto-updated.

Last update: 2025-06-18 10:29:40 UTC


README

A simple PHP implementation of the Model Context Protocol (MCP) server that supports HTTP communication and tool calls using PSR-7 interfaces.

Features

  • ✅ HTTP-only communication (no Server-Sent Events)
  • ✅ PSR-7 request/response interfaces for easy integration
  • ✅ JSON-RPC 2.0 compliant messaging
  • ✅ Simple tool registration and execution
  • ✅ Comprehensive error handling
  • ✅ Request/response only (no streaming)
  • ✅ Tools only (no resources or prompts)

Installation

composer require soukicz/mcp

Quick Start

<?php

require_once 'vendor/autoload.php';

use Soukicz\Mcp\McpServer;
use GuzzleHttp\Psr7\ServerRequest;

// Create server instance
$server = new McpServer([
    'name' => 'my-mcp-server',
    'version' => '1.0.0'
]);

// Register a tool
$server->registerTool(
    'echo',
    'Echo back the input message',
    [
        'type' => 'object',
        'properties' => [
            'message' => ['type' => 'string']
        ],
        'required' => ['message']
    ],
    function (array $args): string {
        return $args['message'] ?? '';
    }
);

// Handle PSR-7 request
$request = ServerRequest::fromGlobals();
$response = $server->handleRequest($request);

// Send response
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
    foreach ($values as $value) {
        header("$name: $value", false);
    }
}
echo $response->getBody();

API Reference

McpServer

Constructor

public function __construct(array $serverInfo = [], ?SessionManagerInterface $sessionManager = null)

Creates a new MCP server instance with optional server information and session manager. If no session manager is provided, uses ArraySessionManager with default settings.

registerTool

public function registerTool(string $name, string $description, array $inputSchema, callable $handler): void

Registers a new tool with the server.

  • $name - Tool name
  • $description - Tool description
  • $inputSchema - JSON schema for tool input validation
  • $handler - Callable that executes the tool logic

handleRequest

public function handleRequest(RequestInterface $request): ResponseInterface

Handles a PSR-7 HTTP request and returns a PSR-7 response.

unregisterTool

public function unregisterTool(string $name): bool

Removes a tool from the server. Returns true if the tool was removed, false if it didn't exist.

hasTool

public function hasTool(string $name): bool

Checks if a tool is registered with the server.

getRegisteredTools

public function getRegisteredTools(): array

Returns an array of all registered tool names.

Supported Methods

initialize

Initializes the MCP connection.

Request:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "clientInfo": {
      "name": "client-name",
      "version": "1.0.0"
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": []
    },
    "serverInfo": {
      "name": "php-mcp-server",
      "version": "1.0.0"
    }
  }
}

tools/list

Lists all available tools.

Request:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list"
}

Response:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "echo",
        "description": "Echo back the input message",
        "inputSchema": {
          "type": "object",
          "properties": {
            "message": {"type": "string"}
          },
          "required": ["message"]
        }
      }
    ]
  }
}

tools/call

Executes a tool.

Request:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "echo",
    "arguments": {
      "message": "Hello World"
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Hello World"
      }
    ]
  }
}

Error Handling

The server returns JSON-RPC 2.0 compliant error responses:

{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32600,
    "message": "Invalid Request",
    "data": "Only POST method is supported"
  }
}

Error Codes

  • -32700 - Parse error (Invalid JSON)
  • -32600 - Invalid Request (malformed request, session not initialized, etc.)
  • -32601 - Method not found (unsupported MCP method)
  • -32602 - Invalid params (missing required parameters, tool not found)
  • -32603 - Internal error (tool execution failure, server errors)

Exception Classes

The library provides specific exception classes for better error handling:

use Soukicz\Mcp\Exception\McpException;
use Soukicz\Mcp\Exception\InvalidRequestException;
use Soukicz\Mcp\Exception\MethodNotFoundException;
use Soukicz\Mcp\Exception\InvalidParamsException;

try {
    $server->registerTool('', 'Empty name tool', [], function() {});
} catch (InvalidParamsException $e) {
    // Handle invalid tool registration
}

Integration Examples

With Slim Framework

use Slim\App;
use Slim\Psr7\Request;
use Slim\Psr7\Response;

$app = new App();

$app->post('/mcp', function (Request $request, Response $response) use ($server) {
    $mcpResponse = $server->handleRequest($request);
    
    $response = $response->withStatus($mcpResponse->getStatusCode());
    foreach ($mcpResponse->getHeaders() as $name => $values) {
        $response = $response->withHeader($name, $values);
    }
    
    $response->getBody()->write((string) $mcpResponse->getBody());
    return $response;
});

With Laravel

use Illuminate\Http\Request;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

Route::post('/mcp', function (Request $request) use ($server) {
    $psr7Factory = new PsrHttpFactory(/* ... */);
    $psrRequest = $psr7Factory->createRequest($request);
    
    $response = $server->handleRequest($psrRequest);
    
    return response((string) $response->getBody())
        ->header('Content-Type', 'application/json');
});

With Symfony and OAuth Authentication

For production applications, you'll typically want to handle authentication at the framework level before the MCP server processes requests.

1. Install Required Dependencies

composer require symfony/psr-http-message-bridge
composer require guzzlehttp/psr7

2. Service Configuration

# config/services.yaml
services:
    Soukicz\Mcp\McpServer:
        arguments:
            $serverInfo:
                name: 'symfony-mcp-server'
                version: '1.0.0'
        calls:
            - [registerTool, ['user_info', 'Get current user information', { type: 'object', properties: {} }, '@App\Mcp\Tools\UserInfoTool']]

3. Controller with OAuth Integration

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Soukicz\Mcp\McpServer;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;

class McpController extends AbstractController
{
    public function __construct(
        private McpServer $mcpServer,
        private HttpMessageFactoryInterface $psrFactory,
        private HttpFoundationFactoryInterface $httpFoundationFactory
    ) {}

    #[Route('/api/mcp', methods: ['POST'])]
    #[IsGranted('ROLE_API')] // Ensure user has API access
    public function handleMcp(Request $request): Response
    {
        // OAuth/authentication is handled by Symfony Security component
        // User is available via $this->getUser() if needed
        
        // Convert Symfony Request to PSR-7
        $psrRequest = $this->psrFactory->createRequest($request);
        
        // Handle MCP request
        $psrResponse = $this->mcpServer->handleRequest($psrRequest);
        
        // Convert PSR-7 Response back to Symfony Response
        return $this->httpFoundationFactory->createResponse($psrResponse);
    }
}

4. Security Configuration

# config/packages/security.yaml
security:
    providers:
        oauth_provider:
            # Configure your OAuth user provider here
    
    firewalls:
        api:
            pattern: ^/api/mcp
            stateless: true
            # Configure OAuth authentication (e.g., using KnpUOAuth2ClientBundle)
            oauth: true
            
    access_control:
        - { path: ^/api/mcp, roles: ROLE_API }

5. Example Tool with User Context

<?php

namespace App\Mcp\Tools;

use Symfony\Component\Security\Core\Security;

class UserInfoTool
{
    public function __construct(private Security $security) {}
    
    public function __invoke(array $args): array
    {
        $user = $this->security->getUser();
        
        return [
            'id' => $user?->getId(),
            'username' => $user?->getUserIdentifier(),
            'roles' => $user?->getRoles() ?? []
        ];
    }
}

Benefits of Framework-Level Authentication

  • ✅ Leverage Symfony's robust security component
  • ✅ Easy integration with existing OAuth providers
  • ✅ Access to authenticated user context in MCP tools
  • ✅ Consistent authentication across your entire API
  • ✅ Role-based access control
  • ✅ Integration with Symfony's security events and listeners
  • ✅ Separation of concerns (HTTP auth vs MCP protocol)

Session Management

The MCP server provides flexible session management through a pluggable interface system.

Default Array Session Manager

By default, the server uses ArraySessionManager which stores sessions in memory:

use Soukicz\Mcp\McpServer;

// Uses ArraySessionManager with default 1-hour TTL
$server = new McpServer();

// Custom TTL (30 minutes)
$server = new McpServer([], new ArraySessionManager(1800));

Custom Session Manager Interface

Implement SessionManagerInterface for custom session storage:

interface SessionManagerInterface
{
    public function createSession(string $sessionId, array $clientInfo): void;
    public function markSessionInitialized(string $sessionId): void;
    public function isSessionInitialized(string $sessionId): bool;
    public function getSessionInfo(string $sessionId): ?array;
    public function terminateSession(string $sessionId): void;
    public function isRequestIdUsed(string $sessionId, string $requestId): bool;
    public function markRequestIdUsed(string $sessionId, string $requestId): void;
    public function cleanupExpiredSessions(): int;
}

Session Cleanup

For long-running applications, regularly cleanup expired sessions:

// In a scheduled command or cron job
$server = new McpServer();
$cleanedCount = $server->cleanupExpiredSessions();
echo "Cleaned up {$cleanedCount} expired sessions\n";

Session Management Benefits

  • Scalability: Use Redis, database, or other persistent storage
  • Security: Implement custom session validation and security policies
  • Monitoring: Track session metrics and usage patterns
  • Integration: Leverage existing authentication and session systems
  • Performance: Optimize session storage for your specific needs

Security Considerations

Input Validation

The library includes built-in validation for:

  • Server configuration (non-empty names)
  • Tool registration (non-empty names and descriptions)
  • Session management (valid session IDs)
  • JSON-RPC message format

Session Security

  • Session IDs are generated using cryptographically secure random bytes
  • Request ID tracking prevents replay attacks within sessions
  • Session TTL prevents indefinite session persistence
  • Input validation on all session operations

Recommended Security Practices

// 1. Use HTTPS in production
// 2. Implement proper authentication (OAuth, JWT, etc.) at framework level
// 3. Validate tool input parameters
$server->registerTool('file_read', 'Read file contents', [
    'type' => 'object',
    'properties' => [
        'path' => ['type' => 'string', 'pattern' => '^[a-zA-Z0-9/_.-]+$']
    ],
    'required' => ['path']
], function (array $args): string {
    $path = $args['path'];
    
    // Validate path is within allowed directory
    $realPath = realpath($path);
    if (!$realPath || !str_starts_with($realPath, '/allowed/directory/')) {
        throw new \InvalidArgumentException('Access denied');
    }
    
    return file_get_contents($realPath);
});

// 4. Set appropriate session TTL
$sessionManager = new ArraySessionManager(900); // 15 minutes

// 5. Regular cleanup of expired sessions
$server->cleanupExpiredSessions();

Development

Running Tests

docker run --rm -i -v $PWD:/usr/src/app thecodingmachine/php:8.1-v4-cli ./vendor/bin/phpunit tests/

Static Analysis

docker run --rm -i -v $PWD:/usr/src/app thecodingmachine/php:8.1-v4-cli ./vendor/bin/phpstan analyse

Requirements

  • PHP 8.1+
  • PSR-7 HTTP message implementation (guzzlehttp/psr7)
  • JSON extension

License

BSD-3-Clause

Contributing

Contributions are welcome! Please ensure tests pass and follow PSR-12 coding standards.