Should a Symfony app's MCP server be written in PHP or Node?

For most Symfony teams starting today, the answer depends on two things: which Symfony version you run and how good your API's permission checks are. If you are on Symfony 7.3 or newer and comfortable with a pre-1.0 dependency, the official PHP SDK with Symfony's MCP Bundle lets you turn existing services into MCP tools inside the app, behind your existing firewall. If you are on Symfony 6.4, need the most mature SDK, or want the MCP server to deploy separately, a small Node/TypeScript service that calls your API with the user's token works well. We run the second option in production for our own Symfony app.

This guide is narrow on purpose. For what MCP is and when a product needs it, read what is an MCP server. For tool design, OAuth, safe write tools and testing in general, read how to build an MCP server for a SaaS product. Here we cover the PHP-specific decision, with code and with what went wrong for us.

What is the state of the official PHP MCP SDK in October 2026?

The official PHP SDK exists, it works, and it is still young. We checked the repositories and Packagist on October 22, 2026:

Item Status as of October 2026
Package mcp/sdk, repository modelcontextprotocol/php-sdk
Latest release v0.9.0, October 18, 2026
PHP version 8.1 or newer
Official SDK tier Tier 3 (experimental); TypeScript, Python, C#, Go, Rust and Ruby are Tier 1
Conformance (official audit, August 2026) Server side 100% (67 of 67); client side 20%, mostly OAuth client scenarios
Protocol revisions 2025-11-25 (handshake era) and 2026-07-28 (stateless, the current spec) on the same endpoint
Transports StdioTransport, StreamableHttpTransport (a PSR-7 handler, not a web server)
Symfony integration symfony/mcp-bundle v0.14.1 (September 26, 2026), marked experimental, needs Symfony 7.3 or 8.0
Auth OAuth 2.1 resource server only: token validation, protected resource metadata, scope policy

Two details matter more than the tier label. First, the weak spot in the audit was the client side and OAuth client flows, which you do not use when you build a server. Second, the SDK is pre-1.0 and the bundle is outside Symfony's backward compatibility promise, so expect config and API changes between minor versions. The bundle changelog already shows one: version 0.13 moved from a single implicit server to a required servers: map with an explicit registry: list.

How do you expose a Symfony service as an MCP tool with the PHP SDK?

With the MCP Bundle, you put the #[McpTool] attribute on a method of a service, inject your existing domain service through the constructor, and list the class in the server's registry. The bundle reflects the attributes when the container compiles and generates the JSON input schema from your PHP types.

Install and route it:

composer require symfony/mcp-bundle
# config/routes.yaml
mcp:
    resource: .
    type: mcp
# config/packages/mcp.yaml
mcp:
    servers:
        default:
            name: 'acme-crm'
            transports:
                stdio: false
                http: true
            http:
                path: /mcp
            session:
                store: file          # FPM workers share no memory; see below
            registry:
                tools: ['App\Mcp\']  # expose only this namespace

Then the tool itself. InvoiceFinder stands for any service you already have, with its own Doctrine queries and voters:

<?php
// src/Mcp/SearchOverdueInvoicesTool.php
namespace App\Mcp;

use App\Invoice\InvoiceFinder;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\ToolAnnotations;
use Symfony\Bundle\SecurityBundle\Security;

final class SearchOverdueInvoicesTool
{
    public function __construct(
        private readonly InvoiceFinder $finder,
        private readonly Security $security,
    ) {}

    /**
     * Find overdue invoices for one client in the current user's company.
     * Returns at most 20 invoices with id, number, amount, due date and URL.
     * Call list_clients first if you only know the client's name.
     */
    #[McpTool(
        name: 'search_overdue_invoices',
        annotations: new ToolAnnotations(readOnlyHint: true),
    )]
    public function __invoke(
        #[Schema(description: 'Client id from list_clients')]
        string $clientId,
        #[Schema(description: 'Only invoices overdue by at least this many days', minimum: 0, maximum: 365)]
        int $minDaysOverdue = 1,
        #[Schema(minimum: 1, maximum: 20)]
        int $limit = 10,
    ): array {
        $user = $this->security->getUser()
            ?? throw new ToolCallException('Not signed in.');

        $invoices = $this->finder->overdueForClient($user, $clientId, $minDaysOverdue, $limit);
        if ($invoices === null) {
            throw new ToolCallException('Client not found. Call list_clients to get valid ids.');
        }

        return array_map(static fn ($i) => [
            'id' => $i->getId(),
            'number' => $i->getNumber(),
            'amount' => $i->getAmountFormatted(),
            'dueDate' => $i->getDueDate()->format('Y-m-d'),
            'url' => $i->getPublicUrl(),
        ], $invoices);
    }
}

Protect the route like any other endpoint:

# config/packages/security.yaml (excerpt)
security:
    firewalls:
        mcp:
            pattern: ^/mcp
            stateless: true
            access_token:
                token_handler: App\Security\McpTokenHandler
    access_control:
        - { path: ^/mcp, roles: ROLE_USER }

Run bin/console debug:mcp to see which tools each server exposes, and connect MCP Inspector to /mcp to check the generated schemas. The docblock becomes the tool description, so write it for a model that has never seen your product. Treat the snippet as a shape to copy, not as a pinned API: names in a 0.x SDK can change, so check the current docs for your installed version.

Why this is attractive: the tool calls InvoiceFinder directly, so the same voters, Doctrine filters and tenant rules that protect your web app protect the assistant. There is no second HTTP hop and no second copy of the permission rules.

Can you use the PHP SDK without the bundle, or on Symfony 6.4?

Yes. The bundle needs Symfony 7.3 or 8.0, but mcp/sdk itself only needs PHP 8.1. On Symfony 6.4 you build the server yourself and register tools explicitly, then call it from a controller (HTTP) or a console command (stdio):

<?php
// src/Mcp/ServerFactory.php - Symfony 6.4, no bundle
namespace App\Mcp;

use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Psr\Container\ContainerInterface;

final class ServerFactory
{
    public function __construct(
        private readonly ContainerInterface $mcpTools, // a service locator holding the tool services
        private readonly string $sessionDir,
    ) {}

    public function create(): Server
    {
        return Server::builder()
            ->setServerInfo('acme-crm', '1.0.0')
            ->setContainer($this->mcpTools)
            ->setSession(new FileSessionStore($this->sessionDir))
            ->addTool([SearchOverdueInvoicesTool::class, '__invoke'], 'search_overdue_invoices')
            ->build();
    }
}

In the controller, convert the Symfony request to PSR-7 with symfony/psr-http-message-bridge, pass it to new StreamableHttpTransport($psrRequest), call $server->run($transport) and convert the response back. The SDK documentation has the full controller. Route every HTTP method to it, not only POST, because the transport also answers DELETE and OPTIONS.

What does PHP-FPM change for an MCP server?

PHP's request model is the main technical difference from a Node or Python server, and it shapes three decisions:

  1. Sessions need storage. Under the handshake-era revisions (2025-11-25 and earlier) a client opens a session with initialize and sends its id on every request. FPM workers share no memory, so sessions go to the file store, a PSR-16 cache or Redis. The bundle isolates each server's store by default; do not let two servers share one, because session ids are not namespaced and that would let a session cross a firewall boundary.
  2. The stateless revision helps PHP. In 2026-07-28 every request carries its protocol version and capabilities, and sessions are gone. The SDK serves both eras on the same endpoint, so a PHP server stops depending on session storage as clients move to the new revision.
  3. Long streams tie up workers. In the SDK's HTTP transport, GET returns 405, and features that hold a stream open, such as change subscriptions, occupy an FPM worker for the duration. Most business tools are plain request and response, so this rarely matters, but size your FPM pool if you enable streaming features.

Our production setup: a Node MCP server in front of a Symfony API

Our internal project board is a Symfony API with a React front end. It holds projects, tasks, sprints, time tracking, documents and our sales pipeline. Claude reaches it through an MCP server we wrote in TypeScript with the official TypeScript SDK, and our team and AI coding agents use it every day to read and update tasks, write reports, log time and work through leads. We started it in May 2026, before the PHP SDK and the bundle had reached their current shape; that timing, more than any principle, is why it is in Node.

How it is built:

  • Separate service, own Docker image. Express plus the SDK's Streamable HTTP transport, about 5,400 lines of TypeScript. It has its own version, changelog and image, and deploys independently of the Symfony app.
  • Stateless. Each POST creates a fresh server and transport and returns plain JSON. No session store, any instance can answer any request. GET and DELETE return 405.
  • Auth by personal API key. Each person generates a key in their board profile and puts it in their MCP client config. The MCP server reads it from the Authorization: Bearer header and forwards it to the Symfony API. It holds no admin key of its own, and every permission decision stays in Symfony. This is an internal tool, so we skipped OAuth.
  • Tools shaped by job. It grew from 10 tools in its first release to 87 across about 30 releases: tasks, comments, sprints, documents, attachments, labels, time, leads and finance. The tools accept Markdown, and the server converts it to the HTML our TipTap editor stores, so agents never write HTML.
  • A health endpoint that reports the version. /health returns the name and version from package.json, which is how we confirm what is actually running.

What went wrong with our MCP server, and what would differ in PHP?

Most problems were not about the protocol. They were about the gap between two codebases that must agree.

What happened Cause What we changed In-process PHP?
New tools did not appear after a deploy Clients load the tool list when they connect and keep it; our stateless server cannot push a list-changed notice After each release, reconnect the client; the release notes say so Same problem: it is client behaviour, not language
A deploy "succeeded" but the old version kept running The image tag was pinned in the server's env file; a plain pull and restart kept the old container An update script sets the tag, restarts and waits until /health reports the new version Ships with the app, so one deploy instead of two
list_tasks returned nothing Without a sprint id the API meant "backlog only", and a filtered PHP array was serialized as a JSON object instead of a list (a missing array_values) The tool sweeps all sprints and normalizes both shapes The tool would call the repository and get a PHP array, no JSON round trip
Some API endpoints did not check project membership Attachment, sprint and project-update endpoints trusted any valid key Each tool first loads the task or project through an endpoint that does check; the API fix is tracked separately Tools would call services guarded by voters, but only if those voters exist
Agents retried on errors A bare "400" or "403" gave the model nothing to act on Pass the API's own error text through; turn 403 into a plain refusal Same work: write errors for the model, throw ToolCallException with a reason
File uploads arrived as "no file provided" A hand-set multipart Content-Type without the boundary made the body unreadable for PHP Let fetch set the header No HTTP hop, no multipart
A URL-fetching tool could reach internal hosts The server downloads a URL it is given Allow only http(s), block private and metadata addresses, re-check every redirect Same risk in any language
New tool fields needed a newer API The MCP server and the API ship separately Release notes state the minimum API version; older APIs ignore unknown fields Not an issue, both change in one commit

The pattern in that table: a sidecar is honest about your API. Every hole in the API's authorization or every odd JSON shape shows up in the MCP server, because it is just another API client. That is useful to learn, and it is extra work. An in-process PHP server avoids the HTTP layer, but it is only as safe as the services it calls; a service that skips the voter is as open to an assistant as an endpoint that skips it.

PHP SDK in-process vs a Node sidecar: how do they compare?

Factor PHP SDK + MCP Bundle inside Symfony Node/TypeScript sidecar calling your API
SDK maturity (October 2026) Tier 3, v0.9.0, bundle experimental Tier 1, the most examples and client testing
Symfony version Bundle needs 7.3+; plain SDK works on 6.4 with your own wiring Any version with a usable API
Access to business logic Calls services, Doctrine and voters directly Only what the HTTP API exposes
Permission checks Reuses the firewall and voters API must check every endpoint; gaps leak through
Deploy Ships with the app, one version to track Separate image, versioning and rollout to keep in step
Scaling and failure Shares FPM workers and the database pool with the web app Scales and fails on its own
Sessions File, cache or Redis store for handshake-era clients Easy to run stateless in a long-lived Node process
OAuth Resource server only; bring Keycloak, Auth0 or league/oauth2-server Same split; SDK helpers exist on the TypeScript side too
Team skills One language for the whole back end Needs someone comfortable owning a Node service
Typical first version 5-15 tools in 2-4 weeks on a clean codebase 5-15 tools in 2-4 weeks on a clean API

The time estimates are typical ranges for a focused first server with authentication, logging and real-assistant tests, not quotes.

Which option should you choose?

Choose by your situation, not by the language you like:

  1. Symfony 7.3+ and one PHP team: start in-process with the MCP Bundle. Put tools in their own namespace, call your existing services, protect /mcp with a firewall, and pin mcp/sdk and the bundle to exact minor versions so upgrades are deliberate.
  2. Symfony 6.4 or older, upgrade not planned soon: either wire mcp/sdk yourself in a controller, or run a small TypeScript sidecar against your API. If the API is thin or inconsistent, the sidecar will spend its time working around it; fix the API first. Our guide on modernizing a legacy PHP application covers that path.
  3. Customer-facing server with OAuth and several assistants to support: either option works, but the OAuth authorization server is a separate piece in both. Many teams pick the TypeScript SDK here for its maturity and client test coverage, and keep the Symfony API as the only source of rules.
  4. Internal server for your team and agents: personal API keys, stateless HTTP, read tools first. This is what we run, and it has paid for itself many times over in daily use.

Whichever you pick, the parts that take the time are the same: choosing jobs, writing tool descriptions, making write tools safe and testing with real assistants. Those are covered in our step-by-step MCP server guide.

How we can help

Symfony is our main back-end stack: Lytvynov Production has built PHP and Symfony systems since 2020, including a custom ERP for a US aircraft service company, and we run MCP servers in production for our own tools. We build MCP servers in PHP when your Symfony version and codebase allow it, and in TypeScript when a sidecar is the safer choice, on top of your existing services and permission model.

A focused first server usually fits into one AI Sprint: $10,000 fixed for 4 weeks. See our MCP server development services and Symfony development services, or send us your Symfony version, a link to your API docs and five things your users would ask an assistant to do. We will tell you in a 30-minute call which option fits and where your API needs work first.

Case studies

Frequently asked questions

Yes. The official PHP SDK is the Composer package mcp/sdk, maintained in the modelcontextprotocol/php-sdk repository together with the PHP Foundation and the Symfony team. As of October 2026 its latest release is v0.9.0 (October 18, 2026), it requires PHP 8.1 or newer, and the MCP project lists it as Tier 3, which means experimental and not yet at 1.0.

Not as of October 2026. The released symfony/mcp-bundle (v0.14.1) requires Symfony 7.3 or 8.0 components. The underlying mcp/sdk package works with older Symfony versions, so on Symfony 6.4 you can call the SDK from your own controller and console command, or run a separate Node or Python MCP server that calls your API.

Yes, for request and response traffic. The SDK's HTTP transport is a PSR-7 handler, so it runs inside a normal Symfony controller on PHP-FPM. Under the older handshake-based protocol revisions each client has a session, and because FPM workers share no memory, you need a file, cache or Redis session store. The 2026-07-28 revision is stateless and avoids that.

Use OAuth for servers that customers connect from Claude, ChatGPT or other assistants. The PHP SDK and the MCP Bundle act only as an OAuth resource server, validating tokens; you still need an authorization server such as Keycloak, Auth0 or league/oauth2-server. For an internal server used by your own team and agents, personal API keys checked by your existing Symfony security are a reasonable shortcut.

Because the TypeScript SDK is Tier 1 and has the most examples and client testing, and because a separate service deploys, scales and fails independently of the main app. The cost is a second codebase and a second deploy that must stay in step with your API. It makes most sense when your Symfony API is already clean and enforces permissions on every endpoint.

With us, a focused first MCP server over an existing Symfony API, with 5 to 15 tools, authentication, logging and tests against real assistants, usually fits into one AI Sprint: $10,000 fixed for 4 weeks. Larger tool sets, OAuth for customer accounts or API fixes found along the way are quoted after a short scoping call; projects start from $10,000.

Let’s start your project
Book a call