[Agent] Add MCP client bridge - #2497
Conversation
6b68040 to
5a041a4
Compare
5a041a4 to
f6c79cc
Compare
9a03600 to
c99b014
Compare
|
|
||
| $result = $this->toolset->callTool($remoteName, $arguments); | ||
|
|
||
| if (null !== $result->structuredContent) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed on the current head, $connected is cleared before the SDK call.
| { | ||
| $tools = []; | ||
| foreach ($this->toolboxes as $toolbox) { | ||
| foreach ($toolbox->getTools() as $metadata) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Created #2530 as follow up - scope is huge already, but def valid topic! 👍
| { | ||
| foreach ($this->toolboxes as $toolbox) { | ||
| foreach ($toolbox->getTools() as $metadata) { | ||
| if ($metadata->getName() === $toolCall->getName()) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
3adf927 to
9dc0a3c
Compare
wachterjohannes
left a comment
There was a problem hiding this comment.
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.
04fbf6d to
4492f86
Compare
4cecea3 to
298d9c4
Compare
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
298d9c4 to
9384792
Compare
wachterjohannes
left a comment
There was a problem hiding this comment.
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.
…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
|
Thanks for this. It covers most of what I asked for in #2003, and What workedIn the demo I pointed an # 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 In-process instead of a transportFor an app talking to itself, the transport isn't needed. I implemented $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 Three things I ran into
The prototype is one demo-only commit on top of |
|
@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 |
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/listandtools/callalready 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\AbstractToolboxholds 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\ChainToolboxoffers the tools of several toolboxes to one agent.Toolboxmoves onto the base class without a behavior change.symfony/ai-mcp-toolbridge (src/agent/src/Bridge/Mcp):McpToolboxturns a toolset'stools/listintoTooldefinitions and forwards each call astools/call.ClientToolsetreaches a server through the SDK's own client for standalone use;ToolsetInterfacekeeps 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-bundlegains anmcp_servertool entry next toserviceandagent, naming a connection configured undermcp.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 withtools: trueand with an explicit tool list alike.What a remote toolbox also has to get right:
properties: {}) is described without parameters, which OpenAI would otherwise reject as[].retryAfterseconds (60 by default) rather than costing its connect timeout on every listing.ToolConfigurationExceptioninstead of silently running the first.ai.profiler_toolboxservices, 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:
In a Symfony application:
Runnable as
examples/toolbox/mcp.php.