phpnl/mcp

A framework-agnostic PHP SDK for the Model Context Protocol (MCP). Connect your PHP application to AI models like Claude in minutes.

Maintainers

Package info

github.com/Php-nl/mcp

Homepage

pkg:composer/phpnl/mcp

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.1 2026-04-17 14:23 UTC

This package is auto-updated.

Last update: 2026-04-17 14:26:39 UTC


README

A framework-agnostic PHP SDK for the Model Context Protocol (MCP). Connect your PHP application to AI models like Claude in minutes — zero runtime dependencies.

composer require phpnl/mcp

Table of Contents

What is MCP?

The Model Context Protocol is an open standard that lets AI models interact with external tools, data sources, and services. An MCP server exposes:

  • Tools — functions the AI can call (e.g. query a database, send an email)
  • Resources — read-only data the AI can reference (e.g. config files, documentation)
  • Prompts — reusable prompt templates the AI can retrieve and use

This library implements the MCP server side in PHP.

Quick Start

Create server.php:

<?php

declare(strict_types=1);

require 'vendor/autoload.php';

use Phpnl\Mcp\McpServer;

McpServer::make()
    ->tool(
        name: 'get_user',
        description: 'Fetch a user record from the database',
        handler: function (int $id): string {
            $user = fetchUserById($id); // your own code
            return json_encode($user);
        },
    )
    ->serve();

Add the server to your Claude Desktop claude_desktop_config.json:

{
  "mcpServers": {
    "my-app": {
      "command": "php",
      "args": ["/absolute/path/to/server.php"]
    }
  }
}

Restart Claude Desktop — your tools are available immediately.

Tools

Registering a tool

McpServer::make()
    ->tool(
        name: 'send_email',
        description: 'Send an email to a recipient',
        handler: function (string $to, string $subject, string $body): string {
            mail($to, $subject, $body);
            return "Email sent to {$to}";
        },
    )
    ->serve();

The handler is a Closure. Its parameter names and type hints are automatically reflected into the tool's JSON Schema that is advertised to the AI.

Type-safe parameters

PHP types map directly to JSON Schema types:

PHP type JSON Schema type
string "string"
int "integer"
float "number"
bool "boolean"
array "array"
?T / T|null ["T", "null"]

Any other type hint falls back to "string".

Parameter descriptions

Use the #[Description] attribute to add human-readable descriptions to parameters. These are included in the JSON Schema so the AI understands what each argument is for.

use Phpnl\Mcp\Tool\Description;

McpServer::make()
    ->tool(
        name: 'search_products',
        description: 'Search the product catalogue',
        handler: function (
            #[Description('Search term, e.g. "blue jeans"')] string $query,
            #[Description('Maximum number of results to return (1–100)')] int $limit = 10,
        ): string {
            return json_encode(searchProducts($query, $limit));
        },
    )
    ->serve();

Optional parameters

Parameters with default values are optional — the AI may omit them, and the default is used automatically.

handler: function (string $name, string $greeting = 'Hello'): string {
    return "{$greeting}, {$name}!";
}

Optional parameters are excluded from inputSchema.required.

Input validation

Arguments are automatically validated against the generated JSON Schema before your handler is called. If a required argument is missing or has the wrong type, a InvalidParams error is returned to the client — your handler is never invoked.

// If the AI omits 'to', the server responds with:
// {"error": {"code": -32602, "message": "Invalid params", "data": "Missing required argument: to"}}

Rich tool results

By default a handler returns a plain string, which is sent as a single text content item. Return a ToolResult for richer responses.

use Phpnl\Mcp\Tool\ToolResult;

// Single text item
return ToolResult::text('Here is the summary: ...');

// Image (base64-encoded)
return ToolResult::image(base64_encode($pngBytes), 'image/png');

// Embedded resource
return ToolResult::resource('file://report.json', $json, 'application/json');

// Multiple items chained together
return ToolResult::text('Monthly revenue: €12,400')
    ->withImage(base64_encode($chartPng), 'image/png')
    ->withText('Data as of 2024-01-01');

ToolResult is immutable. Every with*() call appends an item and returns a new instance.

Factory / method Content type Purpose
ToolResult::text(string $text) text Plain or markdown text
ToolResult::image(string $data, string $mimeType) image Base64-encoded image
ToolResult::resource(string $uri, string $text, string $mimeType) resource Embedded resource
->withText(string $text) text Append text item
->withImage(string $data, string $mimeType) image Append image item
->withResource(string $uri, string $text, string $mimeType) resource Append resource item

Progress notifications

For long-running tools, inject a ProgressReporter parameter. The SDK injects it automatically and it does not appear in the tool's JSON Schema.

use Phpnl\Mcp\Tool\ProgressReporter;

McpServer::make()
    ->tool(
        name: 'import_csv',
        description: 'Import a CSV file into the database',
        handler: function (
            string $path,
            ProgressReporter $progress,
        ): string {
            $rows = array_map('str_getcsv', file($path));
            $total = count($rows);

            foreach ($rows as $i => $row) {
                insertRow($row);
                $progress->report($i + 1, $total); // (current, total)
            }

            return "Imported {$total} rows from {$path}.";
        },
    )
    ->serve();

The client sends a progressToken in _meta to opt in to progress updates:

{
  "method": "tools/call",
  "params": {
    "name": "import_csv",
    "arguments": {"path": "/tmp/data.csv"},
    "_meta": {"progressToken": "import-42"}
  }
}

While the handler runs, the server sends out-of-band notifications:

{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "import-42", "progress": 150, "total": 1000}}

When the client sends no progressToken, all $progress->report() calls are silently ignored — the same handler works for both streaming and non-streaming clients without any code changes.

Resources

Resources expose read-only data that the AI can retrieve and reason about.

McpServer::make()
    ->resource(
        uri: 'file://app-config',
        name: 'Application Config',
        mimeType: 'application/json',
        handler: function (): string {
            return file_get_contents(__DIR__ . '/config.json');
        },
    )
    ->resource(
        uri: 'db://schema',
        name: 'Database Schema',
        mimeType: 'text/plain',
        handler: function (): string {
            return generateSchemaDescription(); // your own code
        },
    )
    ->serve();

Resources appear in resources/list and are fetched with resources/read. The capabilities.resources key is only advertised in the initialize response when at least one resource is registered.

Prompts

Prompts are reusable templates the AI can retrieve and populate.

McpServer::make()
    ->prompt(
        name: 'code_review',
        description: 'Review a piece of code for bugs and style issues',
        handler: function (array $args): string {
            $language = $args['language'] ?? 'PHP';
            $code = $args['code'] ?? '';
            return "Please review the following {$language} code for correctness, "
                 . "potential bugs, and style:\n\n```{$language}\n{$code}\n```";
        },
    )
    ->serve();

Prompts appear in prompts/list and are fetched with prompts/get. Like resources, capabilities.prompts is only advertised when at least one prompt is registered.

Middleware

Register middleware to wrap every tool invocation — for logging, authentication, rate limiting, caching, and more.

McpServer::make()
    ->middleware(function (string $name, array $args, callable $next): mixed {
        // Before: log the call
        $start = microtime(true);
        error_log("→ tool:{$name} " . json_encode($args));

        $result = $next($name, $args);

        // After: log the duration
        $ms = round((microtime(true) - $start) * 1000);
        error_log("← tool:{$name} {$ms}ms");

        return $result;
    })
    ->tool('ping', 'Returns pong', fn (): string => 'pong')
    ->serve();

Signature: function(string $name, array $arguments, callable $next): mixed

Multiple middleware are executed in registration order — the first registered is the outermost wrapper. Each middleware must either call $next($name, $args) to continue the chain, or return a value directly to short-circuit the remaining middleware and the handler.

// Authentication middleware
->middleware(function (string $name, array $args, callable $next): mixed {
    if (! isAuthenticated()) {
        throw new \RuntimeException('Unauthorized');
    }
    return $next($name, $args);
})

// Rate-limiting middleware
->middleware(function (string $name, array $args, callable $next): mixed {
    if (isRateLimited($name)) {
        throw new \RuntimeException('Rate limit exceeded');
    }
    return $next($name, $args);
})

Exception handling

All MCP-specific errors are thrown as typed exceptions that extend McpException (which itself extends \RuntimeException). The JSON-RPC error code is available via getErrorCode().

Exception class Thrown when Error code
ToolNotFoundException Tool name not registered ToolNotFound (−32601)
InvalidToolArgumentsException Required argument missing or wrong type InvalidParams (−32602)
ResourceNotFoundException Resource URI not registered ResourceNotFound (−32002)
PromptNotFoundException Prompt name not registered PromptNotFound (−32003)

Catching exceptions from the registries directly (e.g. in tests or CLI tooling):

use Phpnl\Mcp\Exception\McpException;
use Phpnl\Mcp\Exception\ToolNotFoundException;

try {
    $result = $toolRegistry->call('missing', []);
} catch (ToolNotFoundException $e) {
    echo "No such tool: " . $e->getMessage();
} catch (McpException $e) {
    // Any other MCP error
    echo "MCP error " . $e->getErrorCode()->value . ": " . $e->getMessage();
}

The JsonRpcHandler catches all McpException subclasses automatically and converts them into well-formed JSON-RPC error responses. Unexpected \Throwable exceptions are caught and returned as InternalError (−32603).

Transports

STDIN/STDOUT (default)

The default transport reads JSON-RPC messages from STDIN and writes responses to STDOUT. This is the standard for Claude Desktop and other local CLI-based MCP clients.

McpServer::make()     // StdioTransport is used automatically
    ->tool(...)
    ->serve();

HTTP + SSE

Use HttpSseTransport for web-based clients, browser integrations, or remote deployments.

use Phpnl\Mcp\Transport\HttpSseTransport;

$transport = new HttpSseTransport(port: 8080);

McpServer::make($transport)
    ->tool('hello', 'Returns a greeting', fn (): string => 'Hello from PHP!')
    ->serve();
php server.php
# MCP server now listening on http://localhost:8080

The transport exposes two endpoints:

Endpoint Method Purpose
/sse GET Client connects and receives a Server-Sent Events stream. The first event is an endpoint event with the message URL.
/message POST Client sends JSON-RPC requests here. Responses arrive via the SSE stream.

Configuration

new HttpSseTransport(
    host: '0.0.0.0',           // Bind address (default: 0.0.0.0)
    port: 8080,                 // TCP port (default: 8080)
    ssePath: '/sse',            // SSE endpoint path (default: /sse)
    messagePath: '/message',    // POST endpoint path (default: /message)
    baseUrl: null,              // Public URL override — set when behind a reverse proxy
                                // e.g. 'https://api.example.com'
);

CORS

All responses include Access-Control-Allow-Origin: *. CORS preflight (OPTIONS) requests are handled automatically so browser-based clients work without additional configuration.

Reverse proxy

When running behind nginx or another proxy, set baseUrl so clients receive the correct public endpoint URL:

$transport = new HttpSseTransport(
    host: '127.0.0.1',
    port: 9000,
    baseUrl: 'https://mcp.example.com',
);

Developer CLI

The phpnl binary provides tooling for inspecting and debugging MCP servers during development.

# List all registered tools and their schemas
./vendor/bin/phpnl inspect server.php

# Stream full JSON-RPC traffic in real time (tools, resources, prompts)
./vendor/bin/phpnl debug server.php

# Call a specific tool and print the result
./vendor/bin/phpnl call server.php get_user --id=42
./vendor/bin/phpnl call server.php send_email --to=alice@example.com --subject=Hello --body=Hi

# Read a resource
./vendor/bin/phpnl read server.php file://app-config

# Retrieve a prompt
./vendor/bin/phpnl prompt server.php code_review --language=PHP --code="echo 'hi';"

Argument type casting follows the tool's JSON Schema:

PHP type hint CLI argument form Example
string --name=value --to=alice@example.com
int --name=N --id=42
float --name=N.N --price=9.99
bool --name=true|false --active=true

API reference

McpServer

Method Description
McpServer::make(?TransportInterface $transport) Create a new server (static factory)
->tool(string $name, string $description, Closure $handler): self Register a tool
->resource(string $uri, string $name, string $mimeType, Closure $handler): self Register a resource
->prompt(string $name, string $description, Closure $handler): self Register a prompt
->middleware(Closure $fn): self Add a tool middleware
->serve(): void Start the server loop

ToolResult

Method Description
ToolResult::text(string $text) Create result with a single text item
ToolResult::image(string $data, string $mimeType) Create result with a single image item
ToolResult::resource(string $uri, string $text, string $mimeType) Create result with a single resource item
->withText(string $text) Append text item, return new instance
->withImage(string $data, string $mimeType) Append image item, return new instance
->withResource(string $uri, string $text, string $mimeType) Append resource item, return new instance
->toContent() Return the raw MCP content array

ProgressReporter

Method Description
->report(int|float $progress, int|float|null $total = null) Send a progress notification. No-op when no progressToken was provided by the client.

#[Description]

Apply to tool handler parameters to add a human-readable description to the JSON Schema:

function (#[Description('The user ID')] int $id): string { ... }

Supported MCP methods

Method Supported
initialize
notifications/initialized
ping
tools/list
tools/call
resources/list
resources/read
prompts/list
prompts/get
notifications/progress ✅ (server → client)

Requirements

  • PHP 8.3 or higher
  • Zero runtime dependencies

Testing

composer install
composer test    # PHPUnit (unit + integration)
composer stan    # PHPStan level 8
composer lint    # PHP CS Fixer (dry-run)

To auto-fix code style:

vendor/bin/php-cs-fixer fix

License

MIT — made with ❤️ by php.nl