Skip to content

[Agent] Add MCP client bridge - #2497

Merged
chr-hertel merged 1 commit into
symfony:mainfrom
chr-hertel:mcp-client-bridge
Sep 16, 2026
Merged

chr-hertel merged 1 commit into
symfony:mainfrom
chr-hertel:mcp-client-bridge

Conversation

@chr-hertel

@chr-hertel chr-hertel commented Sep 5, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? yes
Docs? yes
Issues -
License MIT

Lets an agent use the tools a remote MCP server advertises, on top of the client side the MCP bundle already owns.

An agent is not an MCP client: it never asks a server for a prompt or reads one of its resources, it only draws tools from it. And a server that answers tools/list and tools/call already is a toolbox, so it becomes one rather than a tool inside another - its arguments follow a JSON schema the server publishes instead of a PHP method signature, and nothing reflects on its tool definitions.

  • Agent\Toolbox\AbstractToolbox holds what executing a tool call shares across toolboxes - looking the tool up, the tool call events, the error handling - leaving subclasses to say only how a call turns into a value. Agent\Toolbox\ChainToolbox offers the tools of several toolboxes to one agent. Toolbox moves onto the base class without a behavior change.
  • New symfony/ai-mcp-tool bridge (src/agent/src/Bridge/Mcp): McpToolbox turns a toolset's tools/list into Tool definitions and forwards each call as tools/call. ClientToolset reaches a server through the SDK's own client for standalone use; ToolsetInterface keeps the bridge open for a connection managed elsewhere. The tool-name prefix sits on the toolbox, since it exists to keep two servers advertising the same tool name apart within one agent.
  • symfony/ai-bundle gains an mcp_server tool entry next to service and agent, naming a connection configured under mcp.clients, so both bundles share one connection instead of opening a second one to the same server (a stdio server would otherwise be spawned twice). Each server becomes a toolbox next to the agent's local one, so remote tools compose with tools: true and with an explicit tool list alike.

What a remote toolbox also has to get right:

  • An argument-less tool (properties: {}) is described without parameters, which OpenAI would otherwise reject as [].
  • Structured output drops only the text block mirroring its JSON; genuine text, images and resources are kept.
  • A transport that dies mid-session is dropped, so the next call reconnects; a server-returned error keeps the connection.
  • An unreachable server contributes no tools instead of failing the run, and is skipped for retryAfter seconds (60 by default) rather than costing its connect timeout on every listing.
  • A tool name offered by two toolboxes in a chain throws a ToolConfigurationException instead of silently running the first.
  • The profiler lists tools from ai.profiler_toolbox services, so rendering any page no longer opens every MCP connection.

Refreshing a cached tool list in long-running processes is tracked separately in #2530.

Standalone:

$toolset = new ClientToolset(
    'filesystem',
    Client::builder()->build(),
    new StdioTransport('npx', ['-y', '@modelcontextprotocol/server-filesystem', '/tmp']),
);

$agent = new Agent($platform, 'gpt-4o-mini', toolbox: new McpToolbox($toolset));

In a Symfony application:

mcp:
    clients:
        research:
            servers:
                filesystem:
                    transport: stdio
                    command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', '%kernel.project_dir%/var']

ai:
    agent:
        my_agent:
            tools:
                - { mcp_server: 'research.filesystem' }

Runnable as examples/toolbox/mcp.php.

@carsonbot carsonbot added Agent Issues & PRs about the AI Agent component Feature New feature Status: Needs Review labels Sep 5, 2026
@carsonbot carsonbot changed the title [Agent][AiBundle] Add MCP client bridge [Agent][AI Bundle] Add MCP client bridge Sep 5, 2026
@carsonbot carsonbot changed the title [Agent][AI Bundle] Add MCP client bridge [Agent] Add MCP client bridge Sep 5, 2026
@chr-hertel
chr-hertel force-pushed the mcp-client-bridge branch 6 times, most recently from 6b68040 to 5a041a4 Compare September 11, 2026 23:43
Comment thread src/agent/src/Bridge/Mcp/McpToolbox.php Outdated

$result = $this->toolset->callTool($remoteName, $arguments);

if (null !== $result->structuredContent) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content

Structured content is returned as a JSON object in the structuredContent field of a result. For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.

In older MCP versions as far as I remember SHOULD was MUST, so we shoudln't just drop content if structuredContent is present to support older versions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and worth being stricter than "SHOULD": a server pairing structuredContent with a genuine human-readable message, not just its JSON mirror, would silently lose it here. Keep the TextContent blocks rather than dropping them when structuredContent is present.

try {
$this->client->connect($this->transport);
} catch (McpSdkExceptionInterface $e) {
throw ConnectionException::failed($this->name, $e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exception is thrown, but connection never removed.
This should set $this->connected to false, so on next call it would try to reconnect properly.

/**
 * Closes the connection. Idempotent, and reconnects transparently on the next call.
 */
public function disconnect(): void

docblock already says about this behavior.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on the current head, $connected is cleared before the SDK call.

Comment thread src/agent/src/Toolbox/ChainToolbox.php Outdated
{
$tools = [];
foreach ($this->toolboxes as $toolbox) {
foreach ($toolbox->getTools() as $metadata) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClientToolset::getTools() throws ConnectionException and here we are propagating it.

So any slow/broken MCP server in a chain will kill whole chain, or will make it wait until timeout.

What I do in agent is just skipping broken servers, or ones who couldn't get connection on time, and ship partial catalog instead of failing all servers/tools.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't reproduce on the current head: McpToolbox::getTools() already catches and logs, returns an empty list instead of throwing, so a broken server can't take the rest of the chain down.

public function getTools(): array
{
if (isset($this->toolsMetadata)) {
return $this->toolsMetadata;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW]
Caching tools is fine for HTTP, but for long living processes it could be a problem.
I don't really know how to solve it, I'm caching tools and have /mcp reconnect command.

But ideally should maybe check tools/listChanged or somehow refresh tools list later.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #2530 as follow up - scope is huge already, but def valid topic! 👍

Comment thread src/agent/src/Toolbox/ChainToolbox.php Outdated
{
foreach ($this->toolboxes as $toolbox) {
foreach ($toolbox->getTools() as $metadata) {
if ($metadata->getName() === $toolCall->getName()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW]
What if multiple toolboxes would have same name?
IMHO it's better to throw error in that case to let developer know than get first from list.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, a silent first-match hides a real misconfiguration. Throwing on a duplicate tool name across toolboxes surfaces it at wiring time instead of at the wrong tool getting called.

@chr-hertel
chr-hertel force-pushed the mcp-client-bridge branch 3 times, most recently from 3adf927 to 9dc0a3c Compare September 12, 2026 11:19

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature, and the Toolbox/AbstractToolbox split holds up, verified behavior-preserving. ineersa's earlier review already has two real fixes in code with no reply, and two points still open, replied inline on both.

@chr-hertel
chr-hertel force-pushed the mcp-client-bridge branch 2 times, most recently from 04fbf6d to 4492f86 Compare September 14, 2026 22:13
@chr-hertel chr-hertel changed the title [Agent] Add MCP client bridge [Agent][AiBundle] Add MCP client bridge Sep 14, 2026
@chr-hertel
chr-hertel force-pushed the mcp-client-bridge branch 2 times, most recently from 4cecea3 to 298d9c4 Compare September 14, 2026 22:27
Lets an agent use the tools a remote MCP server advertises.

An agent is not an MCP client: it never asks a server for a prompt or reads
one of its resources, it only draws tools from it. The bridge therefore models
a toolset behind an MCP connection rather than the server itself.

* A server that answers `tools/list` and `tools/call` already is a toolbox, so
  it becomes one instead of a tool inside another: its arguments follow a JSON
  schema the server publishes rather than a PHP method signature, and nothing
  reflects on its tool definitions. `Toolbox\AbstractToolbox` holds what every
  toolbox shares when executing a call - looking the tool up, the tool call
  events and the error handling - and `Toolbox\ChainToolbox` offers the tools
  of several toolboxes to one agent.
* The new `symfony/ai-mcp-tool` bridge asks a server for its `tools/list` and
  turns each entry into a `Tool`; calls are forwarded as `tools/call`.
  `ClientToolset` reaches a server through the SDK's own client for standalone
  use, while `ToolsetInterface` keeps the bridge open for a connection that is
  managed elsewhere. `McpToolbox` owns the tool-name prefix, which is what
  keeps two servers advertising the same tool name apart within one agent.
* `symfony/ai-bundle` gains an `mcp_server` tool entry next to `service` and
  `agent`, naming a connection configured under the MCP bundle's `mcp.clients`,
  so both bundles share one connection to a server rather than opening a
  second. Each server becomes a toolbox next to the agent's local one, so
  remote tools compose with `tools: true` and with an explicit tool list alike.

A remote toolbox also faces a few things a local one never does:

* A tool taking no arguments arrives as `properties: {}`, which the SDK turns
  into a `stdClass`. Passing that on as the empty PHP array made it reach the
  platform as `[]` rather than `{}`, which OpenAI rejects. An argument-less
  remote tool is described without parameters now, like a local one.
* Structured output keeps every content block a text mirror cannot carry: an
  image, audio blob or embedded resource returned alongside it is kept. An
  empty structured payload does not win over a text block with something to say.
* A transport dying mid-session drops the connection, so the next call reopens
  it rather than failing against a dead one for the life of the object. An
  error the server itself answered with leaves the connection intact.
* A text block is dropped next to structured output only when it is the JSON
  mirror of it (key order ignored, types compared strictly), so a server's
  human-readable message survives alongside the structured value.
* A tool name offered by more than one toolbox in a chain is refused with a
  `ToolConfigurationException` instead of silently running on the first one.
  Calls resolve against the latest listing, so a local tool call does not
  re-contact a server that failed to list.
* A server that cannot be reached contributes no tools instead of taking the
  agent's other tools down with it. Tools are listed before the first model
  request, so throwing there killed the whole call, local tools included.
  Noticing a server is down can take its whole connect timeout, so a failed
  listing is not retried for `retryAfter` seconds (60 by default); the docs
  show lowering `init_timeout` and `max_retries` for a server not worth the wait.
* The profiler builds its tool table from services tagged `ai.profiler_toolbox`
  rather than from the agent's outer toolbox. Listing tools is free reflection
  for a local toolbox, but an MCP one has to connect, and for a stdio server
  start a process: in dev that cost 2.7s on every page against 65ms without.

Claude-Session: https://claude.ai/code/session_01XbqB4exhFUqHaNcJku5yX3
Claude-Session: https://claude.ai/code/session_01JisppPPrmgCV6ZHjqGf2Pp
Claude-Session: https://claude.ai/code/session_011qEhfZKSYb1y2DwLr9oZa5

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both open points fixed and verified: ChainToolbox now throws on a tool-name collision, and McpToolbox only drops TextContent when mirrors() confirms it's a genuine JSON duplicate of structuredContent. Approving.

@carsonbot carsonbot changed the title [Agent][AiBundle] Add MCP client bridge [Agent][AI Bundle] Add MCP client bridge Sep 15, 2026
@carsonbot carsonbot changed the title [Agent][AI Bundle] Add MCP client bridge [Agent] Add MCP client bridge Sep 15, 2026
@chr-hertel
chr-hertel merged commit 18e6b32 into symfony:main Sep 16, 2026
104 of 107 checks passed
@chr-hertel
chr-hertel deleted the mcp-client-bridge branch September 16, 2026 06:40
chr-hertel added a commit that referenced this pull request Sep 16, 2026
…tel)

This PR was merged into the main branch.

Discussion
----------

[Demo] Add chat example using remote MCP servers

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no
| Docs?         | no
| Issues        | -
| License       | MIT

Stacked on #2497 - its commit shows up here until that one is merged. Only the last commit belongs to this PR.

A chat under `/mcp` whose agent has no tools of its own: every tool it offers the model comes from one of three public, key-less MCP servers configured under `mcp.clients.remotes`.

* `livescore` - live football scores, fixtures and lineups
* `transit` - real-time NYC subway arrivals and service advisories
* `weather` - current conditions worldwide, including a rain radar drawn as text characters

`livescore` only speaks the legacy HTTP+SSE transport, which the SDK's `HttpTransport` does not implement, so it is reached as a stdio child process running `npx -y mcp-remote` - the one part of the example needing Node.js, which the README now mentions.

It is listed among the `demos` driving the navigation and the start page, and its chat follows the shared layout.

The example also surfaced a bug in the bridge: a tool that takes no arguments is sent as `properties: {}`, which reached the platform as `[]` rather than `{}` and made OpenAI reject the whole request. That fix now lives in #2497, where the code it fixes comes from.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_011qEhfZKSYb1y2DwLr9oZa5

Commits
-------

443148c [Demo] Add chat example using remote MCP servers
@tacman

tacman commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for this. It covers most of what I asked for in #2003, and ToolsetInterface turned out to be the right seam. I tried it on main right after the merge, using the case that started #2003: an app whose #[McpTool] services serve external agents and its own chat page. It's a newspaper archive, and I'd rather not maintain a duplicate #[AsTool] layer that drifts. The demo has the same duplication today: movie_search exists as both #[AsTool] and #[McpTool].

What worked

In the demo I pointed an mcp.clients entry at the app's own server and added an agent with tools: [{ mcp_server: 'self.demo' }]:

# mcp.yaml
clients:
    self:
        servers:
            demo:
                transport: stdio
                command: ['php', '%kernel.project_dir%/bin/console', 'mcp:server', 'demo']

With no other changes, the agent's toolbox lists demo_current-time, demo_movie_search and demo_movie_details, and executing them through it returns the server's results. Each call takes 0.05-0.10 s, plus about 0.3 s to spawn the child and list its tools. Under php-fpm that spawn is paid again on every request.

In-process instead of a transport

For an app talking to itself, the transport isn't needed. I implemented ToolsetInterface over the server's own Builder::buildStateless(), so tools/list and tools/call run through the same stateless protocol an HTTP client would hit: the same schema validation, result formatting and MCP App handler, with no child process. Results are identical to the stdio path at 0.00-0.03 s per call, and a real chat works through it. The core is:

$this->protocol ??= $this->builder->buildStateless();
$params['_meta'] = [
    RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
    RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
];
$result = $this->protocol->handle(json_encode([...]), [
    McpHeader::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
    McpHeader::METHOD => $method,
    McpHeader::NAME => $params['name'], // for tools/call
]);

The only way to wire it today is to decorate ai.toolbox.<agent>.mcp_toolset.<client>.<server>, so a stdio client has to be configured even though it never starts. Would a first-class form be welcome, say { mcp_server: 'demo' } with no client part, meaning "this app's own server mcp.servers.demo, in-process"? That would make "write the tool once as #[McpTool], use it from both the MCP server and the chat agent" a single line of config. I'm happy to open a PR.

Three things I ran into

  1. An mcp_server-only list still injects every #[AsTool]. testMcpToolboxesSitNextToTheTaggedToolsOfTheLocalToolbox asserts this on purpose, but it contradicts the "tools are opt-in, a list is explicit" rule, and the demo's AGENTS.md says the mcp agent "has no local tools at all". It actually offers similarity_search, clock, wikipedia_search, wikipedia_article, movie_search alongside the remote tools. It changed real behavior in my test: asked about a movie "in this application's catalog", gpt-5-mini picked the local movie_search over demo_movie_search. There's currently no way to say "only these MCP servers".
  2. The server's error message never reaches the model. When a server rejects a call (a missing required slug, a wrong argument type), the model only sees An error occurred while executing tool "demo_movie_details"., over stdio and in-process alike. ToolCallException::returnedError() doesn't implement ToolExecutionExceptionInterface, so FaultTolerantToolbox masks it. MCP tool errors are meant to go back to the model so it can correct the call, so passing through returnedError() at least (not connection failures) seems right.
  3. A server that fails to list is silent. Returning no tools is the right call, but in practice my tools just disappeared and I found out from the log. That might be worth a profiler hint, or a line in the docs on where to look.

The prototype is one demo-only commit on top of main: tacman/ai@7de2c53 (the local_mcp agent plus App\Mcp\LocalServerToolset). I'm happy to turn any of the above into PRs, whichever is useful.

@OskarStark

Copy link
Copy Markdown
Contributor

@tacman not sure about the issues itself, but I propose to create dedicates issues which can be tackled independently instead of a comment in a merged PR. Thanks

@tacman

tacman commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Thanks @OskarStark, done. Split into independent issues, three with PRs:

#2534 also corrects one detail of my comment above: the invalid-argument cases I tested fail through callFailed(), not returnedError().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Issues & PRs about the AI Agent component Feature New feature Status: Reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants