The complete example is in php/ of the examples repository. PHP 8.2 or later.

Files in the repository

Every snippet on this page comes from these files; open them to see the whole thing.

Install

composer require mcp/sdk symfony/finder nyholm/psr7 nyholm/psr7-server laminas/laminas-httphandlerrunner php-http/discovery

Three of these are easy to miss: symfony/finder is required for attribute discovery but is not pulled in by mcp/sdk itself (without it the server starts and lists zero tools, silently); a PSR-17 factory such as nyholm/psr7 builds the request and response; laminas/laminas-httphandlerrunner emits the response.

If you resolve dependencies on a newer PHP than you deploy to, pin the platform in composer.json so the lock file stays compatible:

"config": { "platform": { "php": "8.2.0" } }

Tools are attributed methods

#[McpTool] on a method declares a tool; its parameters become the input schema, and #[Schema] on a parameter adds a description and constraints. Return an array and it becomes the result.

use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\ToolAnnotations;

final class ShopTools
{
    #[McpTool(
        name: 'search_products',
        title: 'Search products',
        description: 'Search the shop catalogue by phrase and optional category. Returns one page of up to `limit` products and a `nextCursor`; when nextCursor is not null, more results exist and you must say so rather than claiming the list is complete. Use get_product for full details of one item. This tool does not place orders.',
        annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
    )]
    public function searchProducts(
        #[Schema(description: 'Words to match against product names and descriptions.', maxLength: 100)]
        string $query = '',
        #[Schema(description: 'Restrict results to one category.', enum: ['lighting', 'furniture', 'kitchen'])]
        ?string $category = null,
        #[Schema(description: 'Page size.', minimum: 1, maximum: 20)]
        int $limit = 5,
        #[Schema(description: "Opaque token from a previous page's nextCursor. Never invent one.")]
        ?string $cursor = null,
    ): array {
        try {
            $page = $this->shop->searchProducts(['q' => $query, 'category' => $category, 'limit' => $limit, 'cursor' => $cursor]);
        } catch (UpstreamException $e) {
            throw self::explain($e);
        }
        return ['items' => array_map(self::summary(...), $page['items']), 'nextCursor' => $page['nextCursor']];
    }
}

Throwing ToolCallException returns an isError result with the message as its text; explain() maps the four upstream kinds to the four instructions, and the guarded cancel_order throws one with the order details until it is called with $confirm = true.

One behavioural difference from the other SDKs: this one validates arguments against the schema before calling your method and answers an out-of-range value with a JSON-RPC invalid-params error rather than a tool result. Both are protocol-correct; the conformance test accepts either.

The entry point: one request, one server

PHP handles each HTTP request in a fresh process, so the entry point builds the server, lets the transport handle one message, emits the response and exits. Sessions persist between requests through a file store.

use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\StreamableHttpTransport;

require __DIR__ . '/../vendor/autoload.php';

$factory = new Psr17Factory();
$request = $factory->createServerRequestFromGlobals();

$server = Server::builder()
    ->setServerInfo('sample-shop', '1.0.0')
    ->setInstructions('...')
    ->setDiscovery(dirname(__DIR__), ['src'])
    ->setSession(new FileSessionStore(sys_get_temp_dir() . '/sample-shop-mcp-sessions'))
    ->build();

$response = $server->run(new StreamableHttpTransport($request));
(new SapiEmitter())->emit($response);

setDiscovery scans src/ for attributed classes on each request. For production, pass a PSR-16 cache as its fourth argument so the scan happens once.

Run and test

node sample-api/server.mjs &
cd php && composer install && php -S 127.0.0.1:3004 public/index.php
# in another shell
cd tests && npm ci && MCP_URL=http://127.0.0.1:3004/mcp npm test

Behind nginx or Apache the entry point is public/index.php as usual; the health route in the example is a plain early return for /health.

Next: connect it to Claude or ChatGPT, or see what production needs.