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.