How to Build an MCP Server in PHP: Tools, Transports, and the Production Details

How to build a Model Context Protocol server in PHP with the official SDK: define tools, choose a transport, and harden it for production.

MCP Server Illustration

The Model Context Protocol (MCP) is how an AI assistant stops guessing about your software and starts calling into it. Instead of pasting database rows into a chat window and hoping the model infers the schema, you expose a small set of typed operations, and the assistant invokes them the same way it would any other tool. The protocol is transport-agnostic and language-agnostic, so the question is no longer “can I do this in PHP”, it is “what is the smallest, safest surface I should expose”.

For a long time the honest answer for PHP teams was “use the TypeScript or Python SDK and shell out”. That changed when the official PHP SDK shipped. You can now host an MCP server inside the same Symfony application that owns your domain, reusing the repositories, value objects, and authorisation you already trust. This is a walk through building one properly, from the first tool to the parts that bite in production.

1. Know what you are actually building

An MCP server is a small RPC endpoint that exposes three kinds of capability, and they are not interchangeable:

  • Tools are actions the assistant can take. They have side effects: create an order, deactivate a user, trigger a deployment. This is the part people mean when they say “MCP”.
  • Resources are read-only data the assistant can pull into context, addressed by a URI. Think of them as GET endpoints: a config file, a user record, the current release notes.
  • Prompts are reusable templates a user can invoke, parameterised by arguments.

The mistake I see first is teams modelling everything as a tool, because a tool is the most obvious primitive. A read with no side effect is a resource. Keeping that line clean matters, because tools are the things you have to defend, and the smaller that set is, the easier the defending gets.

2. Install the SDK and write the first tool

The SDK targets modern PHP and installs like anything else:

Bash
composer require mcp/sdk

A tool is a public method on a plain class, marked with an attribute. Because the class is resolved through your container, the constructor gets your real dependencies, the same repositories your controllers use:

PHP
namespace App\Mcp;

use App\Repository\OrderRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;

final readonly class OrderTools
{
    public function __construct(
        private OrderRepositoryInterface $orders,
    ) {
    }

    #[McpTool(
        name: 'count_open_orders',
        description: 'Returns the number of orders that have not yet shipped.',
    )]
    public function countOpenOrders(): int
    {
        return $this->orders->countByStatus('open');
    }
}

The server itself is a builder. For a first run, point discovery at the directory holding your tool classes and let it find the attributes:

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

declare(strict_types=1);

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

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

$server = Server::builder()
    ->setServerInfo('Acme Orders', '1.0.0')
    ->setDiscovery(__DIR__, ['src/Mcp'])
    ->build();

exit($server->run(new StdioTransport()));

That is a working MCP server. The interesting decisions all come after it.

3. Let the attributes carry the contract

The assistant never sees your code. It sees the name, the description, and the JSON Schema derived from your parameter types. Those three things are the entire contract, so treat them as API design, not annotation noise.

The description is read by the model to decide whether and how to call the tool. A vague description gets a tool called at the wrong moment with the wrong arguments. Write it for a competent stranger: what it does, what it returns, and any limit that matters.

Native types are inferred into schema automatically. When a parameter needs more than a type, for example a format, an enum, or a bound, declare it with the Schema attribute on the parameter:

PHP
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;

#[McpTool(
    name: 'search_orders',
    description: 'Find orders by customer email. Returns at most 20 matches.',
)]
public function searchOrders(
    #[Schema(['type' => 'string', 'format' => 'email'])]
    string $email,
): array {
    return $this->orders->findByEmail($email, limit: 20);
}

If you prefer prose, the SDK will fall back to the method DocBlock for the description. I lean on the attribute instead, because it keeps the contract explicit and survives refactoring tools that rewrite comments.

4. Resources and prompts, not just tools

When the assistant needs to read something rather than do something, expose a resource. It is the same idea, a different attribute, and no side effect:

PHP
use Mcp\Capability\Attribute\McpResource;

#[McpResource(
    uri: 'config://shipping/rates',
    description: 'The current shipping rate card.',
)]
public function shippingRates(): array
{
    return $this->rates->all();
}

Prompts are reusable instructions a user can pick, with the arguments filled in for them:

PHP
use Mcp\Capability\Attribute\McpPrompt;

#[McpPrompt(name: 'triage_incident')]
public function triageIncident(string $service): string
{
    return \sprintf('Investigate the latest errors for the %s service and propose a likely cause.', $service);
}

Reaching for the right primitive is the cheapest quality decision you make here. Every read you model as a resource is one fewer tool in the set you have to audit.

5. Pick a transport for how it will be reached

The transport is how a client talks to your server, and there are two that matter.

Stdio runs your server as a subprocess of the client. The client launches php server.php, talks over standard input and output, and shuts it down when it is done. It is perfect for a server that runs on one developer’s machine against one assistant. The client config is just a command:

JSON
{
    "mcpServers": {
        "acme-orders": {
            "command": "php",
            "args": ["/absolute/path/to/bin/mcp-server.php"]
        }
    }
}

Streamable HTTP runs your server as a normal web endpoint that many clients can reach over the network, with sessions and Server-Sent Events for streaming. This is the one you deploy. It speaks PSR-7, which is exactly why the next step is easy.

The trap is shipping the stdio script to production because it was the one you built first. Stdio is a local development transport. Anything multi-user or remote wants HTTP.

6. Host it inside Symfony, not beside it

The reason to write your MCP server in PHP at all is to reuse the application you already have. The Streamable HTTP transport speaks PSR-7, and Symfony ships a bridge, so the server becomes one ordinary controller:

Bash
composer require symfony/psr-http-message-bridge nyholm/psr7
PHP
namespace App\Controller;

use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/mcp', name: 'mcp_endpoint', methods: ['GET', 'POST'])]
final readonly class McpController
{
    public function __construct(
        private Server $server,
        private HttpMessageFactoryInterface $psrFactory,
        private HttpFoundationFactoryInterface $httpFoundationFactory,
    ) {
    }

    public function __invoke(Request $request): Response
    {
        $psrRequest = $this->psrFactory->createRequest($request);

        $psrResponse = $this->server->run(new StreamableHttpTransport($psrRequest));

        return $this->httpFoundationFactory->createResponse($psrResponse);
    }
}

Build the Server once as a service through a small factory so the container can inject it, the same way you would wire any other configured object. From here your tools are just services. They get your repositories, your message bus, your authorisation checker, by constructor injection, and you stop maintaining a second bootstrap that drifts from the real one.

7. Discovery is convenient and slow

setDiscovery() scans the filesystem for attributes every time the server boots. For stdio that cost is paid once per session and nobody notices. For an HTTP endpoint under load, scanning on every cold start is wasted work. Hand it a PSR-16 cache and it scans once, then reads the cache:

PHP
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;

$server = Server::builder()
    ->setServerInfo('Acme Orders', '1.0.0')
    ->setDiscovery(
        basePath: __DIR__,
        scanDirs: ['src/Mcp'],
        cache: new Psr16Cache(new FilesystemAdapter('mcp')),
    )
    ->build();

If you would rather not scan at all, register tools explicitly with addTool() on the builder. Explicit registration is more typing and zero magic, which on a server that is also a security boundary is a feature, not a cost.

8. Treat every tool as a security boundary

This is the section people skip and regret. An MCP tool is a remote procedure call whose caller is a language model steered by whoever is typing into it, and sometimes by text the model read somewhere else. The model is an untrusted caller. Authorisation is your job, not the protocol’s.

The rules I hold teams to:

  • Never expose a raw query tool. A run_sql or eval tool is a remote code execution endpoint with a friendly description. Expose specific operations, not general-purpose escape hatches.
  • Validate at the boundary. You already have value objects and webmozart/assert. A tool argument is external input, so it gets the same treatment as a request payload: parse it into a value object, reject what does not fit.
  • Scope by the authenticated principal, not by trusting the model. The session carries who is calling. A tool must check what that user is allowed to do, exactly as a controller would. “The model would not ask for that” is not an access control policy.
  • Assume prompt injection. A tool that takes free text and acts on it can be driven by content the user never wrote, for example instructions hidden in a document the assistant was asked to summarise. Keep destructive tools narrow, and require a deliberate confirmation step for anything you cannot undo.

The smaller your tool set, the more of this you can actually reason about. That is the real reason section 1 insisted on the tool-versus-resource line.

9. Errors and logging the client can use

When a tool cannot do its job, throw. The SDK turns the exception into a structured error the assistant receives and can react to, so the message you throw is part of your interface:

PHP
#[McpTool(name: 'cancel_order')]
public function cancelOrder(string $orderId): string
{
    $order = $this->orders->get(OrderId::fromString($orderId));

    if (!$order->isCancellable()) {
        throw new \RuntimeException('Order has already shipped and cannot be cancelled.');
    }

    $order->cancel();
    $this->orders->save($order);

    return 'Order cancelled.';
}

Write those messages for a reader: state what went wrong and what would make it work, and leak nothing about internals, because the model and often the user will see them. For progress and diagnostics, ask for a RequestContext and use the client logger, which sends structured messages back to the caller instead of into a log file they cannot read:

PHP
use Mcp\Server\RequestContext;

#[McpTool(name: 'rebuild_search_index')]
public function rebuildSearchIndex(RequestContext $context): string
{
    $context->getClientLogger()->info('Rebuilding search index');

    // ... work ...

    return 'Search index rebuilt.';
}

Keep your own server-side logging separate and as detailed as any other production service. The client log is for the assistant, your application log is for you.

10. The checklist before you connect a real assistant

The punch list I run before an MCP server points at anything that matters:

  • Every read with no side effect is a resource, not a tool.
  • Every tool has a description written for a stranger, and parameter schemas that constrain the input.
  • No tool is a general-purpose query or eval escape hatch.
  • Tool arguments are parsed into value objects and validated, not trusted.
  • Authorisation is checked per call against the authenticated principal.
  • Anything destructive is narrow and requires confirmation.
  • Production runs over Streamable HTTP, not the stdio dev script.
  • Discovery is cached, or tools are registered explicitly.
  • Thrown exceptions carry messages that are safe for the model and the user to read.
  • Server-side logging is as good as any other production endpoint.

Most of those are an hour of work each. Together they are the difference between a demo that impresses a standup and a server you are willing to leave connected to a system that can lose money.


If you are weighing where AI assistants should be allowed to act inside your business, that is the conversation our business alignment engagement is built for. We map which operations are safe to expose as MCP tools, where the authorisation boundary belongs, and how to keep the surface small enough that you can still reason about what an assistant is able to do.

References

  • Model Context Protocol : the protocol specification, including the tool, resource, and prompt primitives and the transport definitions.
  • MCP PHP SDK : the official PHP SDK, with the attributes, server builder, and transports used throughout this article.
  • Symfony PSR-7 bridge : the bridge that converts between Symfony and PSR-7 messages, which is what makes the HTTP transport a one-controller job.
  • PSR-16 simple cache : the caching interface the server builder accepts to avoid filesystem discovery on every boot.

Ready to Fix Your Architecture?

Book a free 30-minute call with Silas. No sales pitch, just a direct conversation about your challenges.

Typically responds within 24 hours.

Book a Free Call