Skip to content

MCP Bundle

Edit this page

Symfony integration bundle for Model Context Protocol using the official MCP SDK mcp/sdk.

Supports MCP capabilities (tools, prompts, resources) as server via HTTP transport and STDIO. Resource templates implementation ready but awaiting MCP SDK support.

Installation

1
$ composer require symfony/mcp-bundle

Usage

An application can act as an MCP server (exposing tools, prompts and resources to clients), as an MCP client (consuming remote MCP servers), or as both. The two live side by side in the mcp section of config/packages/mcp.yaml: servers under servers:, clients under clients:. Both keys take named entries, so an application can expose several servers and use several clients at once.

You also need to add few lines in the routing configuration for this bundle:

1
2
3
4
# config/routes.yaml
mcp:
    resource: .
    type: mcp

Act as Server

To use your application as an MCP server, exposing tools, prompts, resources, and resource templates to clients like Claude Desktop, declare a server under servers:. Each server names the transports it offers (STDIO, HTTP or both) and the capabilities it exposes:

1
2
3
4
5
6
7
8
9
10
11
# config/packages/mcp.yaml
mcp:
    servers:
        default:
            name: 'my-app'
            transports:
                stdio: true
                http: true
            http:
                path: /mcp
            registry: '*'         # expose every registered capability

A server exposes only what its registry: lists. It takes either one list covering every kind of capability, or a map narrowing each kind separately. Entries are service ids, class names, namespace prefixes (written with a trailing backslash) or the * wildcard, as described in Exposing capabilities below.

Creating MCP Capabilities

MCP capabilities are registered using PHP attributes on services: every service carrying one of the MCP attributes is picked up automatically at container compile time. In a default Symfony application (with autoconfiguration enabled and the classes in src/ registered as services) it is enough to add the attribute to a class.

Tools

Actions that can be executed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;

class CurrentTimeTool
{
    #[McpTool(name: 'current-time')]
    public function getCurrentTime(
        #[Schema(description: 'PHP date format string. Default: Y-m-d H:i:s')]
        string $format = 'Y-m-d H:i:s'
    ): string
    {
        return (new \DateTime('now', new \DateTimeZone('UTC')))->format($format);
    }
}
Prompts

System instructions for AI context:

1
2
3
4
5
6
7
8
9
10
11
12
use Mcp\Capability\Attribute\McpPrompt;

class TimePrompts
{
    #[McpPrompt(name: 'time-analysis')]
    public function getTimeAnalysisPrompt(): array
    {
        return [
            ['role' => 'user', 'content' => 'You are a time management expert.']
        ];
    }
}
Resources

Static data that can be read:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Mcp\Capability\Attribute\McpResource;

class TimeResource
{
    #[McpResource(uri: 'time://current', name: 'current-time')]
    public function getCurrentTimeResource(): array
    {
        return [
            'uri' => 'time://current',
            'mimeType' => 'text/plain',
            'text' => (new \DateTime('now'))->format('Y-m-d H:i:s')
        ];
    }
}
Resource Templates

Dynamic resources with parameters:

Note

Resource Templates are not yet functional as the underlying MCP SDK is missing the required handlers. See MCP SDK issue #9 for implementation status.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use Mcp\Capability\Attribute\McpResourceTemplate;

class TimeResourceTemplate
{
    #[McpResourceTemplate(uriTemplate: 'time://{timezone}', name: 'time-by-timezone')]
    public function getTimeByTimezone(string $timezone): array
    {
        $time = (new \DateTime('now', new \DateTimeZone($timezone)))->format('Y-m-d H:i:s T');
        return [
            'uri' => "time://$timezone",
            'mimeType' => 'text/plain',
            'text' => $time
        ];
    }
}

All capabilities are collected from the service container when it is compiled: the attributes are reflected once, including the generation of the tool input schemas, and the result is cached in the compiled container. Classes that are not registered as services (for example excluded in services.yaml or shipped by a third-party package) must be registered as services to be exposed. For fully custom registration logic you can implement Mcp\Capability\Registry\Loader\LoaderInterface; implementations are autoconfigured with the mcp.loader tag and run when the server is built.

Exposing capabilities

Carrying an attribute makes a class available; a server's capability lists decide which of them it exposes. Each list entry matches one of:

  • '*' — every registered element of that kind;
  • App\Mcp\SearchTool — that exact class name, or a service id for services registered under one;
  • App\Mcp\Editor\ — every element whose class or service id starts with that namespace.
1
2
3
4
5
6
7
8
9
10
mcp:
    servers:
        default:
            registry:
                tools:
                    - 'App\Mcp\Tool\'          # a whole namespace
                    - 'App\Mcp\SearchTool'      # a single class
                    - 'app.mcp.legacy_lookup'    # a custom service id
                prompts: ['*']
                # resources, resource_templates and the app list default to [] — nothing exposed

When every kind comes from the same place, give registry the list directly instead of repeating it five times:

1
2
3
4
5
6
7
# config/packages/mcp.yaml
mcp:
    servers:
        default:
            registry: ['App\Mcp\']   # every kind, from this namespace
        everything:
            registry: '*'             # every kind, everywhere

Note

In YAML, write namespace prefixes in single quotes (or unquoted). Inside double quotes every backslash has to be doubled.

registry: is required and must end up with at least one non-empty kind, and a pattern that matches no service fails at compile time — that is nearly always a typo. The reverse is allowed on purpose: a service carrying an MCP attribute that no server lists (a tool shipped by a third-party bundle, say) is simply not exposed. Run debug:mcp to see those listed under Not exposed by any server.

Multiple Servers

Because servers: is a map, one application can expose several MCP servers on different routes, each with its own identity and capability set. A common shape is a public server next to a privileged one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# config/packages/mcp.yaml
mcp:
    servers:
        public:
            name: 'acme'
            transports: { http: true }
            http:
                path: /mcp
                allowed_hosts: ['acme.example']
            registry:
                tools: ['App\Mcp\Public\']
                resources: ['*']

        editors:
            name: 'acme-editors'
            instructions: 'Editorial tooling. Requires an authenticated editor.'
            transports: { http: true }
            http:
                path: /mcp/editors
            registry:
                tools: ['*']
                prompts: ['*']

Access control is plain Symfony security — the routes have distinct, stable paths, so a firewall or an access_control rule targets them directly:

1
2
3
4
# config/packages/security.yaml
security:
    access_control:
        - { path: ^/mcp/editors, roles: ROLE_EDITOR }

Each server gets its own registry, session store and HTTP route (named _mcp_endpoint_<name>), and its own services under mcp.server.<name>.*. Autowire a specific server with Mcp\Server $editorsServer.

Caution

Give every server its own session storage. Session ids are not namespaced by server, so two servers sharing a store would accept each other's sessions — across a firewall boundary that is a privilege escalation. The defaults already isolate them (%kernel.cache_dir%/mcp-sessions/<name> for the file store, the mcp-<name>- prefix for the cache and framework stores), and a configuration that makes two servers share a store is rejected when the container is compiled.

If http.path is not set, it defaults to /mcp/<name>. That default is always derived from the server's name rather than from how many servers exist, so adding a second server can never silently move the first one's endpoint.

Attribute Placement Patterns

The MCP SDK, and therefore the MCP Bundle, supports two patterns for placing attributes on your capabilities:

Invokable Pattern - Attribute on a class with __invoke() method:

1
2
3
4
5
6
7
8
#[McpTool(name: 'my-tool')]
class MyTool
{
    public function __invoke(string $param): string
    {
        // Implementation
    }
}

Method-Based Pattern - Multiple attributes on individual methods:

1
2
3
4
5
6
7
8
class MyTools
{
    #[McpTool(name: 'tool-one')]
    public function toolOne(): string { }

    #[McpTool(name: 'tool-two')]
    public function toolTwo(): string { }
}

MCP Apps

MCP Apps let a tool return an interactive HTML screen (an "app") that the host renders in a sandboxed iframe instead of plain text — for example a dashboard, a form, or a record viewer. The bundle registers the underlying UI resource for you and enables the MCP Apps server extension, so you only write the markup.

A single class is the whole app (similar to a Symfony UX LiveComponent): the #[AsMcpApp] attribute carries the linked tool's identity (name/description) and the HTML shell (template), the constructor carries service dependencies, and a handler method (render by default) produces the tool result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/Mcp/WeatherApp.php
use Symfony\AI\McpBundle\Attribute\AsMcpApp;

#[AsMcpApp(
    uri: 'ui://weather',
    name: 'get_weather',                 // the linked tool's name (model-facing)
    title: 'Weather',
    description: 'Show the weather for a city as an interactive dashboard.',
    template: 'mcp/weather.html.twig',
)]
class WeatherApp
{
    public function __construct(private WeatherClient $weather)
    {
    }

    // The returned value is the tool result, delivered to the iframe's JS render(model).
    // The input schema is derived from the method signature.
    public function render(string $city): array
    {
        return ['summary' => $this->weather->summaryFor($city)];
    }
}

The template extends the bundle's base template, which implements the MCP Apps postMessage handshake, iframe size reporting and a render(model) hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
{# templates/mcp/weather.html.twig #}
{% extends '@Mcp/app/base.html.twig' %}

{% block style %}.card { font: 1rem system-ui; }{% endblock %}
{% block body %}<div id="root" class="card"></div>{% endblock %}

{% block app_script %}
    // Called with the linked tool's result. The iframe shell is static HTML; the per-request
    // data arrives at runtime via the tool-result message, not through Twig.
    function render(model) {
        document.getElementById('root').textContent = model.summary;
    }
{% endblock %}

The bundle registers the UI resource (with the required _meta.ui descriptor marker), registers the tool with its ui link auto-set to this app (resourceUri plus visibility [model, app]), and enables the MCP Apps extension. uri defaults to ui://<kebab-class-name>; the tool name defaults to that URI slug with dashes replaced by underscores, and the handler method defaults to render (set the method argument to use another). The tool is registered only when the handler method exists — an app without it is a static, tool-less screen.

The base template exposes the blocks title, head, style, body and app_script (override render(model) / onToolInput(params) there), plus sendRpc, callTool and openLink JavaScript helpers. The default render(model) implements the HTML-over-the-wire path described below, and interactions are wired declaratively via data-call / data-open attributes (see Interactive apps below) — so most apps write no JavaScript at all; override app_script only for a fully JS-driven UI.

The template form requires symfony/twig-bundle. For a dynamic shell, omit template and give the class an __invoke(): TextResourceContents method instead (inject Symfony\AI\McpBundle\App\McpAppRenderer to render Twig with your own context); that method then owns the returned content and its _meta.ui.

You can declare CSP and permission requirements for the iframe directly on the attribute:

1
2
3
4
5
6
7
8
#[AsMcpApp(
    uri: 'ui://weather',
    name: 'get_weather',
    template: 'mcp/weather.html.twig',
    prefersBorder: true,
    cspConnect: ['https://api.weather.example.com'],
    geolocation: true,
)]

The MCP Apps extension is enabled on a server as soon as it exposes at least one #[AsMcpApp] class, which its apps: list decides like any other capability:

1
2
3
4
5
6
7
8
9
# config/packages/mcp.yaml
mcp:
    servers:
        default:
            registry:
                apps: ['*']                  # every app; [] (the default) exposes none
        editors:
            registry:
                apps: ['App\Mcp\App\Editor\'] # only these
Rendering with Twig (HTML-over-the-wire)

The recommended way to fill the screen is to render the markup with Twig on the server and ship it in the tool result, rather than building the DOM in JavaScript. The iframe shell stays static (the protocol reads the UI resource only once), but the per-request tool result can carry an html string: the base template's default render(model) injects model.html into the #root element (which the default body block already provides), so you write no client-side rendering code.

Name the fragment template on the attribute via toolTemplate and return a context array from the handler — the bundle renders that template into the html field for you, so the handler stays free of Twig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[AsMcpApp(
    uri: 'ui://weather',
    name: 'get_weather',
    template: 'mcp/weather.html.twig',       // the static iframe shell
    toolTemplate: 'mcp/_weather.html.twig',   // rendered into the tool result's `html`
)]
class WeatherApp
{
    public function __construct(private WeatherService $weather)
    {
    }

    // @return array{forecast: Forecast} — the Twig context, not HTML
    public function render(string $city): array
    {
        return ['forecast' => $this->weather->forecastFor($city)];
    }
}

The shell only needs styling — the rendered fragment lands in #root automatically:

1
2
3
4
{# templates/mcp/weather.html.twig #}
{% extends '@Mcp/app/base.html.twig' %}
{% block style %}.card { font: 1rem system-ui; }{% endblock %}
{# body defaults to <div id="root"></div>; the rendered `html` is injected there #}

Because the markup is Twig, the fragment can {% include %} partials and use filters such as markdown_to_html — the same building blocks as the rest of your application. Reach for the JS render(model) override (shown above) only when you need rich client-side interactivity over a structured model; for that case inject McpAppRenderer and return ['html' => $renderer->renderFragment(...)] yourself, or build the DOM from the structured result.

Interactive apps

Beyond the initial screen, an app can drive further work itself. Declare follow-up tools with #[AsMcpAppTool] on a method of the app class. Like the primary tool, set template to have the bundle render the returned context into html; set appOnly: true to keep the tool callable from the app but hidden from the model's tools/list (the default exposes it to both the model and the app):

1
2
3
4
5
6
7
8
9
use Symfony\AI\McpBundle\Attribute\AsMcpAppTool;

// ... on the same #[AsMcpApp] class, alongside render() ...

#[AsMcpAppTool(name: 'set_unit', template: 'mcp/_weather.html.twig', appOnly: true)]
public function setUnit(string $city, string $unit): array
{
    return ['forecast' => $this->weather->forecastFor($city, $unit)];
}

The tool name defaults to the method name in snake_case, its input schema is derived from the method signature, and the ui link to the enclosing app is set automatically.

Invoking such a tool from the iframe needs no JavaScript: the base template wires DOM attributes to tool calls. Put data-call="<tool>" on any control — a <button>, a link, or a <form data-call="<tool>"> (submitted on enter or by a submit button, including one elsewhere bound via form="<id>") — and the returned HTML replaces #root automatically. Arguments come from data-arg-* attributes (data-arg-city{ city }) or, for a form, from its named fields. data-open="https://…" opens an external link.

1
2
3
4
<button data-call="set_unit" data-arg-city="{{ city }}" data-arg-unit="celsius">°C</button>

{# the result HTML lands in #root; the search form below re-runs its tool on submit #}
<form data-call="get_weather"><input name="city"><button>Go</button></form>

The default render(model) also keeps the shell's forms in sync: after each result it writes any scalar context value into a form control of the same name. So having the handler return the value it was called with (['city' => $city, 'forecast' => ...]) refills <input name="city"> on every result — including the first render — with no extra wiring; a control the user is actively editing is left untouched.

For full client-side control you can still call callTool(name, args) from your own app_script and render the result yourself, but the declarative attributes cover the common case.

Transport Types

Each server declares its transports under transports::

  • STDIO Transport (stdio, default false) - For command-line clients, served by symfony console mcp:server <name>. A process owns one STDIN/STDOUT pair, so one invocation serves exactly one server; the argument may be omitted when only one server enables STDIO.
  • HTTP Transport (http, default true) - For web-based clients and MCP Inspector, using streamable HTTP connections on that server's route.

The HTTP transport uses the MCP SDK's StreamableHttpTransport which supports:

  • JSON-RPC 2.0 over HTTP POST requests
  • Session management with configurable storage (file/memory/cache/framework)
  • CORS headers for cross-origin requests
  • Proper MCP initialization handshake

DNS Rebinding Protection

By default, the MCP SDK protects the HTTP transport against DNS rebinding attacks by only accepting requests whose Origin/Host header points to localhost. To expose a public MCP server, configure the allowed hosts:

1
2
3
4
5
mcp:
    servers:
        default:
            http:
                allowed_hosts: ['example.com', 'mcp.example.com'] # Replaces the default localhost allowlist

Alternatively, disable the protection entirely (for example when the server sits behind a reverse proxy that already validates the Host header) by setting it to false:

1
2
3
4
5
mcp:
    servers:
        default:
            http:
                allowed_hosts: false

Session Storage

The MCP Bundle supports four types of session storage, configured per server. Every server needs its own storage — see the caution in Multiple Servers.

File Storage (default) - Stores sessions on the filesystem:

1
2
3
4
5
6
7
mcp:
    servers:
        default:
            session:
                store: file
                directory: '%kernel.cache_dir%/mcp-sessions/default' # defaults to this
                ttl: 3600

Memory Storage - Stores sessions in memory (non-persistent):

1
2
3
4
5
6
mcp:
    servers:
        default:
            session:
                store: memory
                ttl: 3600

PSR-16 Cache Storage - Stores sessions in any PSR-16 compliant cache (Redis, Doctrine, APCu, etc.):

1
2
3
4
5
6
7
8
mcp:
    servers:
        default:
            session:
                store: cache
                cache_pool: 'cache.mcp.sessions' # Reference to your cache pool service (PSR-16)
                prefix: 'mcp-default-' # Optional; defaults to "mcp-<name>-"
                ttl: 3600

By default, if you don't configure a custom cache pool, the bundle automatically creates cache.mcp.sessions as a PSR-16 wrapper around Symfony's default cache.app pool.

To use a custom cache backend, you need to configure a PSR-16 cache service in your config/services.yaml:

Note

Symfony cache pools are PSR-6 by default. The MCP session store requires PSR-16. Use Symfony\Component\Cache\Psr16Cache to wrap a PSR-6 pool into PSR-16.

1
2
3
4
5
6
7
# config/services.yaml
services:
    # Define a custom PSR-16 cache service wrapping a PSR-6 pool
    cache.mcp.sessions:
        class: Symfony\Component\Cache\Psr16Cache
        arguments:
            - '@cache.app' # or '@my_redis_pool', '@my_doctrine_pool', etc.

This allows you to store sessions in Redis, a SQL database via Doctrine, or any other PSR-6 cache adapter. See the Symfony Cache documentation for more details on configuring cache pools.

Framework Storage - Uses Symfony's SessionHandlerInterface for session persistence:

1
2
3
4
5
6
7
mcp:
    servers:
        default:
            session:
                store: framework
                prefix: 'mcp-default-' # Optional; defaults to "mcp-<name>-"
                ttl: 3600

This wraps the configured Symfony session handler (e.g. Redis, database, filesystem — whatever your application uses for HTTP sessions) with a JSON envelope for application-level TTL. Expired sessions are cleaned up lazily on read.

The Modern Era

The 2026-07-28 revision drops the initialize handshake and the session that went with it: every request describes itself, so any worker can answer any request, and no long-running connection is needed.

Both eras are served on the same endpoint, with nothing to configure. The SDK builds a dispatcher per era, and the HTTP transport classifies every request before anything else looks at it: an envelope claiming a modern revision goes to the modern dispatcher, the initialize handshake and its session's later requests go to the other. A client picks nothing.

STDIO carries the handshake era alone — it has no per-request envelope to classify — so a server configured for both transports simply serves the modern era over HTTP only.

By default the HTTP endpoint supports every revision the SDK supports and negotiates with the client. Narrow the supported revisions through protocol_versions:

1
2
3
4
5
6
7
8
# config/packages/mcp.yaml
mcp:
    servers:
        api:
            protocol_versions: ['2025-11-25', '2026-07-28']
            http:
                path: /mcp
            registry: '*'

Each era takes what belongs to it. The modern revisions listed become the only ones that leg answers for; listing none of them refuses the modern era, leaving the handshake one alone. A handshake-era revision pins the handshake to exactly that one — the SDK either negotiates over everything it knows or is pinned to a single revision, so narrowing that era to a subset is rejected when the container is compiled.

The options below tune the modern-era leg. They are inert for handshake-era clients, which keep being served exactly as before. The converse does not hold: the modern leg is on for every server, including one written before the revision existed, and it drops things the handshake era offers.

Caution

The 2026-07-28 revision has no server-initiated requests. Sampling and roots were removed with it, so a handler calling $gateway->sample() or $gateway->listRoots() fails for a 2026-07-28 client — "This protocol revision has no server-initiated requests: sampling and roots were removed with it, so take what you need through tool arguments, resource URIs or server configuration instead" — while the same handler keeps working for handshake-era clients on the same endpoint. Both calls are also deprecated as of mcp/sdk 0.8 (SEP-2577), as is $gateway->log().

Take what such a handler needs through tool arguments, resource URIs or server configuration instead, or guard the call with $gateway->supportsSampling() / $gateway->supportsRoots(), which report what the client of the current request declared. Elicitation is the one ask that survived, as a multi round-trip request; see Request State below.

Until the handlers are ready, protocol_versions is the opt-out: list only handshake-era revisions and the server refuses the modern era altogether.

1
2
3
4
5
6
# config/packages/mcp.yaml
mcp:
    servers:
        api:
            protocol_versions: ['2025-11-25'] # handshake era only, no modern leg
            registry: '*'
Request State

A handler that needs another round trip has nowhere to keep its progress once sessions are gone, so the state travels through the client and is signed to keep it honest:

1
2
3
4
5
6
mcp:
    servers:
        api:
            request_state:
                key: '%env(MCP_REQUEST_STATE_KEY)%'
                ttl: 600 # seconds a minted state stays valid

The key must be at least 32 bytes — below that the signature protecting the state is forgeable, so a shorter literal is refused when the container is compiled, and the SDK refuses one arriving from an env variable at runtime. The same value must reach every process that might serve the retry: a per-worker random key makes the follow-up request fail signature validation.

Two kinds of handler need it. One returns an InputRequiredResult itself. The other simply calls $gateway->elicit(): on the modern leg the ask ends the request and the client re-sends the whole call with the answer, so a handler that asks more than once has to carry the earlier answers to the next round, and carrying them is what needs the key. A handler that asks exactly once never mints state and works without one. Missing the key, the ask is answered with a JSON-RPC internal error and the reason is logged on the mcp channel.

Cache Hints

Without a session the client caches instead, and the revision expects the server to say for how long. ttl_ms: 0 (the default) refuses caching:

1
2
3
4
5
6
7
8
9
mcp:
    servers:
        api:
            cache:
                ttl_ms: 5000
                scope: private # or "public"
                methods:
                    'tools/list': { ttl_ms: 60000, scope: public }
                    'resources/read': { ttl_ms: 1000 }

Use public only for answers that do not vary by caller — anything shaped by the current user must stay private.

Subscriptions

subscriptions/listen replaces the held-open HTTP GET stream. Delivery needs a bus, and the choice depends on your runtime:

1
2
3
4
5
6
7
mcp:
    servers:
        api:
            subscriptions:
                bus: cache # none (default), memory, or cache
                cache_pool: 'cache.mcp.notifications'
                lifetime: 30.0 # seconds before the server closes a stream gracefully

Under PHP-FPM the process publishing a notification is not the one holding the stream, so memory cannot reach it — use cache. memory suits a single-process runtime; lifetime: 0 holds the stream until the client or the runtime ends it.

cache.mcp.notifications is auto-created as a PSR-16 wrapper around cache.app when you leave it at its default, exactly as the session store is. Each server namespaces its own keys inside that pool, so two servers can share it without reading each other's notifications.

Only registry changes — the four list_changed notifications — are published for you. Everything else, notifications/resources/updated above all, is your application publishing it. Inject the server's bus to do that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Mcp\Schema\Notification\ResourceUpdatedNotification;
use Mcp\Server\Subscription\NotificationBusInterface;

class DocumentSaver
{
    public function __construct(
        private NotificationBusInterface $apiNotificationBus,
    ) {
    }

    public function save(Document $document): void
    {
        // ...
        $this->apiNotificationBus->publish(new ResourceUpdatedNotification($document->uri()));
    }
}

The argument name follows the server name, as $defaultNotificationBus for a server called default; the service id is mcp.server.<name>.notification_bus.

Act as Client

To consume remote MCP servers, declare a client under clients:. A client is a named group of server connections: it carries the identity your application advertises during the handshake, and lists the remote servers it talks to over STDIO (a child process) or HTTP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# config/packages/mcp.yaml
mcp:
    clients:
        research:
            client_info:
                name: 'acme-research'
                version: '1.0.0'
            servers:
                github:
                    transport: http
                    url: 'https://api.githubcopilot.com/mcp/'
                    headers:
                        Authorization: 'Bearer %env(GITHUB_MCP_TOKEN)%'
                filesystem:
                    transport: stdio
                    command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', '/tmp']

        simple:
            servers:
                github:
                    transport: http
                    url: 'https://api.githubcopilot.com/mcp/'

Several clients can reach the same remote server; each keeps its own connection, since identity, timeouts and handlers are per client. Use a YAML anchor to avoid repeating a definition:

1
2
3
4
5
6
7
8
9
10
mcp:
    clients:
        research:
            servers:
                github: &github
                    transport: http
                    url: 'https://api.githubcopilot.com/mcp/'
        simple:
            servers:
                github: *github

You can find a list of example Servers in the MCP Server List.

Using a Client

Each client is available as Symfony\AI\McpBundle\Client\McpClientInterface, autowirable by the parameter name or with #[Target]. Its connections implement Symfony\AI\McpBundle\Client\ServerConnectionInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use Symfony\AI\McpBundle\Client\McpClientInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;

class ResearchService
{
    public function __construct(
        #[Target('research')] private McpClientInterface $client,
    ) {
    }

    public function run(string $path): string
    {
        $connection = $this->client->get('filesystem');

        foreach ($connection->getTools() as $tool) {
            // $tool is an Mcp\Schema\Tool
        }

        $result = $connection->callTool('read_file', ['path' => $path]);

        return $result->content[0]->text;
    }
}

A client is also iterable, which is handy for fanning a question out over every server it knows:

1
2
3
foreach ($this->client as $name => $connection) {
    $tools[$name] = $connection->getTools();
}

When exactly one client is configured, a plain McpClientInterface type hint resolves to it.

Connection Lifecycle

You never call connect(). A connection opens on its first request and closes on kernel reset, so resolving or iterating a client costs nothing until a request is actually made. That matters most for the STDIO transport, where connecting spawns a child process: a Messenger worker therefore starts a fresh process per message rather than carrying a stale one between them. Call disconnect() on a connection (or on the client, for all of them) to close early; it reconnects transparently afterwards.

Failures surface as bundle exceptions naming the client and server: Symfony\AI\McpBundle\Exception\ConnectionException when the handshake fails, and RemoteCallException when a request does, both implementing Symfony\AI\McpBundle\Exception\ExceptionInterface.

The HTTP transport uses the application's PSR-18 client (psr18.http_client, provided by symfony/http-client) unless the http_client option points at another service.

Server-initiated Requests

A remote server can ask the client to run a completion (sampling), to prompt the user (elicitation), or for the filesystem roots it may work in (roots). Point the matching option at a service implementing the SDK's callback interface; each capability is advertised only when a handler backs it, because advertising one without a handler earns a "method not found" from the client:

1
2
3
4
5
6
7
8
9
10
mcp:
    clients:
        research:
            roots: 'App\Mcp\RootsHandler'            # Mcp\Client\Handler\Request\RootsCallbackInterface
            sampling: 'App\Mcp\SamplingHandler'      # Mcp\Client\Handler\Request\SamplingCallbackInterface
            elicitation: 'App\Mcp\ElicitationHandler' # Mcp\Client\Handler\Request\ElicitationCallbackInterface
            capabilities:
                roots_list_changed: true
            servers:
                github: { transport: http, url: 'https://api.githubcopilot.com/mcp/' }

When the set of roots changes, tell the server with $connection->sendRootsListChanged().

Caution

Roots, sampling and MCP logging are deprecated as of protocol revision 2026-07-28 (SEP-2577), with 2027-07-28 as the earliest removal. mcp/sdk 0.8 triggers a deprecation when the handler behind each of them is instantiated, so an application still using them sees one per configured handler. Because forward_server_logs defaults to true, a client emits the logging one without opting into anything; set it to false to configure no logging handler at all. The replacements are the ones the revision names: directories through tool arguments or resource URIs rather than roots, an LLM provider's API directly rather than sampling, and stderr or OpenTelemetry rather than MCP logging. Elicitation is not deprecated.

Besides the tool, prompt and resource calls, a connection also exposes $connection->complete($ref, $argument) to complete one argument of a prompt or resource template, and $connection->getProtocolVersion() for the revision negotiated with that server (null until the first request opens the connection).

Logging notifications received from remote servers are written to the mcp logger channel; set forward_server_logs: false to drop them.

Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# config/packages/mcp.yaml
mcp:
    # MCP servers this application exposes
    servers:
        default:
            name: 'app' # Name advertised to clients (default: the configuration key)
            version: '1.0.0' # Version advertised to clients
            description: 'A sample MCP server for time management.' # Description advertised to clients
            icons:
                - src: 'https://example.com/icon.png' # Icon URL
                  mime_type: 'image/png' # MIME type of the icon
                  sizes: ['64x64'] # Sizes of the icon
            website_url: 'https://example.com' # Website URL advertised to clients
            pagination_limit: 50 # Maximum number of items returned per list request (default: 50)
            instructions: | # Instructions describing server purpose and usage context (for LLMs)
                This server provides time management capabilities for developers.

                Use when working with timestamps, time zones, or time-based calculations.

            transports:
                stdio: false # Serve over STDIO via "mcp:server <name>" (default: false)
                http: true # Serve over HTTP via a controller and route (default: true)

            http:
                path: /mcp # HTTP endpoint path (default: /mcp/<name>)
                allowed_hosts: ['example.com'] # DNS rebinding allowlist; false disables the protection

            # Revisions this server answers for, in either era. Unset (the default) inherits
            # the SDK's own support. Listing no modern revision refuses the modern era; a
            # handshake-era revision pins the handshake to it.
            protocol_versions: ['2025-11-25', '2026-07-28']

            request_state: # Signs the state a multi-round-trip answer carries through the client
                key: '%env(MCP_REQUEST_STATE_KEY)%' # Required when a handler asks for more input; 32 bytes minimum
                ttl: 600 # Seconds a minted state stays valid (default: 600)

            cache: # Cache hints the modern-era leg puts on its answers
                ttl_ms: 0 # Default freshness in milliseconds; 0 (default) refuses caching
                scope: private # 'private' (default) or 'public'
                methods: # Per-method overrides
                    'tools/list': { ttl_ms: 60000, scope: public }

            subscriptions: # Delivery for "subscriptions/listen" streams
                bus: none # 'none' (default), 'memory' or 'cache'
                cache_pool: 'cache.mcp.notifications' # PSR-16 service for the 'cache' bus
                lifetime: 30.0 # Seconds a stream is held; 0 means until the client or runtime ends it

            session: # The handshake era's sessions; the modern era has none
                store: file # 'file', 'memory', 'cache' or 'framework' (default: file)
                directory: '%kernel.cache_dir%/mcp-sessions/default' # File store (default: cache_dir/mcp-sessions/<name>)
                cache_pool: 'cache.mcp.sessions' # Cache pool service for the cache store (PSR-16)
                prefix: 'mcp-default-' # Key prefix for the cache/framework stores (default: mcp-<name>-)
                ttl: 3600 # Session TTL in seconds (default: 3600)

            # What this server exposes: service ids, class names, namespace prefixes or '*'.
            # Required. Either one list for every kind (registry: ['App\Mcp\'], registry: '*')
            # or the map below; each kind defaults to [], and at least one must be non-empty.
            registry:
                tools: ['*']
                prompts: ['*']
                resources: ['*']
                resource_templates: ['*']
                apps: ['*'] # MCP Apps (interactive HTML UI resources, registered with #[AsMcpApp])

    # MCP clients this application uses to reach remote MCP servers
    clients:
        research:
            client_info: # Identity advertised during the initialize handshake
                name: 'acme-research' # (default: the configuration key)
                version: '1.0.0'
                description: null
            # protocol_version: '2026-07-28' # MCP protocol version to negotiate (default: the SDK default)
            capabilities:
                roots_list_changed: false # The "roots" capability itself follows the handler below
            roots: null # Service id implementing RootsCallbackInterface; enables the capability
            sampling: null # Service id implementing SamplingCallbackInterface; enables the capability
            elicitation: null # Service id implementing ElicitationCallbackInterface; enables the capability
            forward_server_logs: true # Write logging notifications to the "mcp" channel

            # Defaults for every server of this client; each may override them
            init_timeout: 30
            request_timeout: 120
            max_retries: 3

            servers:
                github:
                    transport: http # 'stdio' or 'http'
                    url: 'https://api.githubcopilot.com/mcp/' # Required for the http transport
                    headers:
                        Authorization: 'Bearer %env(GITHUB_MCP_TOKEN)%'
                    http_client: null # Service id of a PSR-18 client (default: psr18.http_client)
                    # max_sse_buffer_bytes: 1048576 # Bytes buffered per SSE event (default: the SDK value)
                    request_timeout: 300 # Overrides the client-level value

                filesystem:
                    transport: stdio
                    # Required for the stdio transport; the first element is the program, not a shell string
                    command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', '/tmp']
                    cwd: null # Working directory of the child process
                    env: # Environment variables for the child process
                        LOG_LEVEL: 'debug'
                    inherit_env: true # Merge "env" over the current environment instead of replacing it
                    # max_buffer_size: 1048576 # Bytes buffered per line (default: the SDK value)

Logging Configuration

By default, MCP uses a dedicated logger channel that inherits your application's default logging configuration. It carries both the server side and the outbound client traffic, including the logging notifications remote servers send back. To configure MCP-specific logging, add the following to your config/packages/monolog.yaml:

1
2
3
4
5
6
7
8
9
10
# config/packages/monolog.yaml
monolog:
    channels: ['mcp']
    handlers:
        mcp:
            type: rotating_file
            path: '%kernel.logs_dir%/mcp.log'
            level: info
            channels: ['mcp']
            max_files: 30

You can customize the logging level and destination according to your needs:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Example: Different levels per environment
monolog:
    handlers:
        mcp_dev:
            type: stream
            path: '%kernel.logs_dir%/mcp.log'
            level: debug
            channels: ['mcp']
        mcp_prod:
            type: slack
            level: error
            channels: ['mcp']
            webhook_url: '%env(SLACK_WEBHOOK)%'

Debug Command

debug:mcp is the one debug command for the bundle: it covers both the servers this application exposes and the clients it uses. Without options it lists the MCP capabilities each configured server exposes — useful to verify that an attributed class was actually picked up (it must be a registered, autoconfigured service and be matched by a server's capability list) — followed by a summary of the configured clients:

1
2
3
4
5
6
7
8
# list every server's tools, prompts, resources, and resource templates with their handlers
$ php bin/console debug:mcp

# restrict the output to one server
$ php bin/console debug:mcp --server=editors

# show the details of a single element, including the tool's input schema
$ php bin/console debug:mcp current-time

Its last section, Not exposed by any server, lists the services that carry an MCP attribute but that no server's capability list matches.

The same command covers the client side. Both listings read the compiled container and open no connection; only --client connects, and it always disconnects afterwards:

1
2
3
4
5
# list the configured clients and their servers
$ php bin/console debug:mcp --clients

# connect and show the remote server's info, instructions, tools, prompts and resources
$ php bin/console debug:mcp --client=research --server=github

--server names a configured server on its own, and the client's remote server when combined with --client. It can be omitted when the client reaches exactly one server.

Profiler

When the Symfony Web Profiler is enabled, the MCP Bundle automatically adds a dedicated panel showing the registered MCP capabilities of every configured server:

MCP Profiler Panel

The profiler displays, per server:

  • Tools: All registered MCP tools with their descriptions and input schemas
  • Prompts: Available prompts with their arguments and requirements
  • Resources: Static resources with their URIs and MIME types
  • Resource Templates: Dynamic resource templates with URI patterns

This makes it easy to inspect and debug your MCP server capabilities during development.

Event System

The MCP Bundle automatically configures the Symfony EventDispatcher to work with the MCP SDK's event system. This allows you to listen for changes to your server's capabilities.

Available Events

The MCP SDK dispatches the following events when capabilities are registered:

  • Mcp\Event\ToolListChangedEvent - When a tool is registered
  • Mcp\Event\ResourceListChangedEvent - When a resource is registered
  • Mcp\Event\ResourceTemplateListChangedEvent - When a resource template is registered
  • Mcp\Event\PromptListChangedEvent - When a prompt is registered

Listening to Events

You can create event listeners to respond to capability changes:

1
2
3
4
5
6
7
8
9
10
11
12
use Mcp\Event\ToolListChangedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
class McpCapabilityListener
{
    public function onToolListChanged(ToolListChangedEvent $event): void
    {
        // Handle tool registration
        // For example: invalidate cache, log changes, notify clients
    }
}

The events are simple marker events that notify when lists have changed, but don't contain specific details about what was added or modified.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version