◀ Back to blog
PHP / Symfony

Building an MCP server in PHP

Published on 22 Sep 2026· 9 min read
#PHP#MCP#IA#Symfony

MCP: giving an AI assistant a pair of hands

The Model Context Protocol (MCP) is an open protocol that standardizes how an AI application (Claude, an IDE, an agent) connects to external data sources and actions. Instead of writing a dedicated integration for every assistant, you write one MCP server, and every compatible client can use it.

An MCP server exposes three kinds of capabilities:

  • Tools: actions the model can call (check a service, create a ticket, run a read-only SQL query…)
  • Resources: data the client can read (configuration, documentation, server state)
  • Prompts: reusable message templates, parameterized by the user

Client and server exchange JSON-RPC 2.0 messages, either over standard input/output (stdio, ideal locally) or over HTTP (Streamable HTTP, for a remote server).

The official PHP SDK

Since 2025, PHP has an official SDK: mcp/sdk, built jointly by the PHP Foundation and the Symfony project, on top of the work done in PHP-MCP and Symfony AI. It is framework-agnostic and follows Symfony's backward compatibility promise. It is still flagged experimental until 1.0: pin the version in your composer.json.

Requirements: PHP 8.1 or later. Installation:

composer require mcp/sdk symfony/finder

Pitfall #1: symfony/finder is only a suggested dependency, yet it is required for attribute-based discovery. Without it, the server fails at startup… and since the error goes to standard error, the client simply sees a server with no tools at all.

A first server: a DevOps assistant

Let's build a server that is useful day to day: it checks that a URL responds, checks disk space, exposes system information and offers an incident report prompt. Start by declaring the autoloading of your classes:

{
    "require": {
        "mcp/sdk": "^0.8",
        "symfony/finder": "^8.1"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    }
}

Capabilities are plain annotated PHP methods. The SDK generates the JSON schema of the parameters from the PHP types, and the description from the docblock:

<?php

namespace App;

use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;

final class DevOpsTools
{
    /**
     * Checks that a URL responds and returns its HTTP status and response time.
     */
    #[McpTool(name: 'check_url')]
    public function checkUrl(
        #[Schema(format: 'uri', description: 'Full URL, e.g. https://benmacha.tn')]
        string $url,
    ): array {
        if (!preg_match('#^https?://#', $url)) {
            throw new ToolCallException('Only http(s) URLs are accepted.');
        }

        $start = microtime(true);
        $context = stream_context_create(['http' => ['method' => 'HEAD', 'timeout' => 5, 'ignore_errors' => true]]);
        $headers = @get_headers($url, true, $context);

        if ($headers === false) {
            return ['url' => $url, 'up' => false, 'error' => 'Host unreachable'];
        }

        preg_match('#\s(\d{3})\s#', $headers[0], $m);
        $status = (int) ($m[1] ?? 0);

        return [
            'url' => $url,
            'up' => $status > 0 && $status < 400,
            'status' => $status,
            'time_ms' => (int) round((microtime(true) - $start) * 1000),
        ];
    }

    /**
     * Returns the used and available disk space for a path.
     */
    #[McpTool(name: 'disk_usage')]
    public function diskUsage(
        #[Schema(description: 'Path to inspect')]
        string $path = '/',
    ): array {
        $total = @disk_total_space($path);
        $free = @disk_free_space($path);

        if ($total === false || $free === false) {
            throw new ToolCallException(sprintf('Unreadable path: %s', $path));
        }

        return [
            'path' => $path,
            'total_gb' => round($total / 1e9, 1),
            'free_gb' => round($free / 1e9, 1),
            'used_percent' => round(100 * ($total - $free) / $total, 1),
        ];
    }

    #[McpResource(uri: 'server://info', name: 'server_info', mimeType: 'application/json')]
    public function serverInfo(): array
    {
        return ['hostname' => gethostname(), 'os' => PHP_OS_FAMILY, 'php' => PHP_VERSION];
    }

    /**
     * Prepares an incident report from a service and a symptom.
     */
    #[McpPrompt(name: 'incident_report')]
    public function incidentReport(string $service, string $symptom): array
    {
        return [[
            'role' => 'user',
            'content' => "The \"$service\" service shows this symptom: $symptom. "
                . "Use check_url and disk_usage to diagnose it, then write a short "
                . "incident report: impact, probable cause, immediate actions.",
        ]];
    }
}

The entry point is only a few lines long: declare the server, tell it to scan the src directory, and run it on the stdio transport.

#!/usr/bin/env php
<?php

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

use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;

exit(Server::builder()
    ->setServerInfo('DevOps Assistant', '1.0.0')
    ->setDiscovery(__DIR__, ['src'])
    ->build()
    ->run(new StdioTransport()));

What the client sees

From the checkUrl(string $url) signature, the docblock and the #[Schema] attribute, the SDK publishes this tool definition:

{
  "name": "check_url",
  "description": "Checks that a URL responds and returns its HTTP status and response time.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "url": { "type": "string", "format": "uri", "description": "Full URL, e.g. https://benmacha.tn" }
    },
    "required": ["url"]
  }
}

For disk_usage, the '/' default value becomes a "default" and the parameter is optional. When a tool returns an array, the SDK sends it both as JSON text and as structuredContent, ready for the client to use.

Handling errors properly

Pitfall #2: not every exception will do. A generic exception (InvalidArgumentException, RuntimeException…) is turned into a generic JSON-RPC error, "Error while executing tool": the model has no idea what went wrong. Throw a Mcp\Exception\ToolCallException instead: its message is returned in a result flagged isError: true, which the model can read to fix its call (for instance, retry with an https URL).

Testing without an assistant: the PHP client and the Inspector

The SDK also ships a client, perfect for automated tests:

use Mcp\Client;
use Mcp\Client\Transport\StdioTransport;

$client = Client::builder()->setClientInfo('Tests', '1.0.0')->build();
$client->connect(new StdioTransport(command: 'php', args: [__DIR__.'/server.php']));

foreach ($client->listTools()->tools as $tool) {
    echo $tool->name, ': ', $tool->description, PHP_EOL;
}

$result = $client->callTool('check_url', ['url' => 'https://benmacha.tn']);
var_dump($result->structuredContent); // ['url' => ..., 'up' => true, 'status' => 200, 'time_ms' => ...]

$client->disconnect();

To explore the server visually, the official MCP Inspector starts the server and lists its tools, resources and prompts:

npx @modelcontextprotocol/inspector php server.php

Plugging the server into Claude

With Claude Code, a single command is enough:

claude mcp add devops -- php /absolute/path/to/server.php

With Claude Desktop, add the server to claude_desktop_config.json:

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

Then ask: "Is benmacha.tn responding correctly, and is there any disk space left?". The assistant calls check_url, then disk_usage, and summarizes the results.

Golden rules for production

  • Never write to stdout in stdio mode: standard output is reserved for the protocol. A forgotten echo or var_dump corrupts the exchange. Log to stderr or to a file (the builder accepts a PSR-3 logger).
  • Narrow tools rather than a "run a command" tool: exposing a shell or free-form SQL means handing the keys of the server to the model.
  • Validate every input: the JSON schema guides the model, but it does not replace server-side validation (allow-lists of paths, hosts, tables).
  • Least privilege: run the server under a dedicated system user, with a read-only database account whenever possible.
  • Carefully written descriptions: they are the only documentation the model reads to choose the right tool and the right parameter.

Going further

The SDK also provides an HTTP transport (Streamable HTTP) to host a remote server shared by a team, with session and authorization handling, and it supports both generations of the protocol, including the stateless 2026-07-28 revision. On the framework side, symfony/mcp-bundle integrates the SDK into Symfony (your services become MCP tools, with dependency injection), and api-platform/mcp exposes your API Platform resources directly.

This is the approach I use to connect AI assistants to business data: a few well-scoped, read-only tools with precise descriptions, and the AI can answer questions that used to require an SQL query or a manual export.