MCP Bundle
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;
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 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, defaultfalse) - For command-line clients, served bysymfony 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, defaulttrue) - 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, autowirable by the
parameter name or with #[Target]. Its connections implement
Symfony:
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 when the handshake fails, and
RemoteCallException when a request does, both implementing
Symfony.
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:
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 registeredMcp\Event\ResourceListChangedEvent- When a resource is registeredMcp\Event\ResourceTemplateListChangedEvent- When a resource template is registeredMcp\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.