You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[AI Bundle] Let an agent use the tools of the application's own MCP server, in-process #2539
Use case. An application exposes its tools as an MCP server (#[McpTool] services under mcp.servers) so external agents can use them, and it also has its own chat page. The chat agent should use the same tools. Writing them a second time as #[AsTool] duplicates names, descriptions and schemas, and they drift. The demo has this duplication today: movie_search exists as both.
What works today. Since #2497, the agent can reach its own server as if it were remote:
It works, but the application spawns a copy of itself as a child process (about 0.3 s to start and list tools, paid again per request under php-fpm), or makes an HTTP request back into itself.
Proposal. Let mcp_server name one of the application's own servers directly, without a client: { mcp_server: 'demo' }. The tools are then called in-process through the server's own stateless protocol (Builder::buildStateless()), so schema validation, result formatting and tool errors are exactly what external clients get, with no transport. A prototype in the demo returns identical results at 0.00-0.03 s per call, and a real gpt-5-mini chat works through it.
I would reject this feature as against design purposes and would prefer to be explicit here. What's the benefit of using MCP infrastructure if it's the same application?
If you really want to couple that, why not use #[AsTool] and #[McpTool] on the same method/class?
Fair question — the benefit isn't MCP for its own sake, so let me make the case concrete.
The application
A newspaper archive. One class, ArticleCorpus, publishes nine tools to the corpus MCP server so Claude Desktop and other agents can query the OCR corpus — list_datasets, search_text, get_block, get_page_image, and so on. Parameter constraints come from #[Schema], descriptions from the docblocks:
useMcp\Capability\Attribute\McpTool;
useMcp\Capability\Attribute\Schema;
/** * Search the OCR text of every block, including blocks no record claimed. By default the query * is a literal phrase: case- and line-break-insensitive, punctuation-exact. Set `literal: false` * to pass SQLite FTS5 syntax instead (`obituar*`, `NEAR(funeral church, 5)`). */
#[McpTool('search_text', annotations: newToolAnnotations(readOnlyHint: true, openWorldHint: false))]
publicfunction searchText(
string$dataset,
string$query,
bool$literal = true,
#[Schema(enum: ['article', 'advertisement', 'obituary', 'none'])] ?string$kind = null,
#[Schema(minimum: 1, maximum: 200)] int$limit = 20,
#[Schema(minimum: 0, maximum: 5000)] int$textChars = 300,
): array
The same application has its own chat page, and the chat agent should search the corpus with exactly these tools.
What the double-attribute route looks like here
#[AsTool] is class-level, so nine repeats on the class, each naming its method and repeating the description that already exists in the docblock. Then every constrained parameter carries two attributes — and both are called Schema, from different namespaces, so they need aliasing:
Every constraint and every description now exists twice, and nothing checks that the copies agree. When kind gains a value, or a maximum moves, or the description of literal gets clearer, one side gets edited and the other doesn't. The copy that goes stale is the app's own chat — and because the chat no longer exercises the server, testing the chat stops telling me what external clients get.
What the in-process version replaces it with
No second attribute layer at all, and three lines of config:
The agent gets corpus_search_text with the schema the server publishes, because it is the server's schema — listed and called through Builder::buildStateless(), the same protocol path an HTTP client hits. One definition, and the chat page is a test of the thing external clients use.
Why the two attributes aren't parity
Beyond the duplication, they don't produce the same tool:
Two schema generators — the platform's JSON Schema factory vs the SDK's. Same method, two schemas that can differ, and only one of them is what external clients see.
Two names to keep in step: server tools reach the model as <server>_<name>, the #[AsTool] copy as whatever it's called.
It isn't available for everything on the MCP side. MCP Apps (#[AsMcpAppTool], appOnly, templates), prompts, resources, anything using request meta or progress have no #[AsTool] form to add. The demo shows the shape of that too: movie_search exists as #[AsTool] on MovieSearch and again as #[AsMcpApp(name: 'movie_search')] on MovieApp wrapping that same service, each with its own description; current-time is MCP-only, so the chat agent can't call it without a wrapper.
On explicitness
I agree that's the thing to get right, and I'd rather change the syntax than the idea. { mcp_server: 'corpus' } was meant to read as "the corpus server in mcp.servers, right here", but if a bare name is too quiet next to 'self.corpus', let's make it say so: { mcp_server: 'corpus', transport: 'local' }, or an explicit in-process transport under mcp.clients so the whole topology stays in one place. Say which of those reads right and I'll rework #2540.
For what it's worth, the alternative that exists today is a mcp.clients entry pointing back at the same application: a stdio child process running bin/console mcp:server corpus (~0.3 s to start and list tools, paid per request under php-fpm), or an HTTP request into itself. In-process measured 0.00–0.03 s per call with identical results.
If the syntax is the sticking point I'll rework it; if you'd rather let it sit until more people hit this, that's fine too — mostly I want the use case on the record, since "define every tool twice" is the current answer for any application that is both an MCP server and an AI agent.
Sorry, this is not convincing to bring in MCP without transport between to Symfony services running in the same app. Please use two attributes or look into reusing the service directly in the config:
tools:
# Referencing a service with #[AsTool] attribute
- 'Symfony\AI\Agent\Bridge\SimilaritySearch\SimilaritySearch'# Referencing a service without #[AsTool] attribute
- service: 'App\Agent\Tool\CompanyName'name: 'company_name'description: 'Provides the name of your company'method: 'foo'# Optional with default value '__invoke'
Your agent already points out that the two layers are drifting (e.g. schema) and no, you shouldn't go through the mcp:server command as alternative. don't use MCP at all here. it's like using a CLI command via HTTP controller. that layer buys you nothing but wrong coupling. reuse the service layer.
Activity
chr-hertel commentedon Sep 19, 2026
I would reject this feature as against design purposes and would prefer to be explicit here. What's the benefit of using MCP infrastructure if it's the same application?
If you really want to couple that, why not use
#[AsTool]and#[McpTool]on the same method/class?tacman commentedon Sep 20, 2026
Fair question — the benefit isn't MCP for its own sake, so let me make the case concrete.
The application
A newspaper archive. One class,
ArticleCorpus, publishes nine tools to thecorpusMCP server so Claude Desktop and other agents can query the OCR corpus —list_datasets,search_text,get_block,get_page_image, and so on. Parameter constraints come from#[Schema], descriptions from the docblocks:The same application has its own chat page, and the chat agent should search the corpus with exactly these tools.
What the double-attribute route looks like here
#[AsTool]is class-level, so nine repeats on the class, each naming its method and repeating the description that already exists in the docblock. Then every constrained parameter carries two attributes — and both are calledSchema, from different namespaces, so they need aliasing:Every constraint and every description now exists twice, and nothing checks that the copies agree. When
kindgains a value, or amaximummoves, or the description ofliteralgets clearer, one side gets edited and the other doesn't. The copy that goes stale is the app's own chat — and because the chat no longer exercises the server, testing the chat stops telling me what external clients get.What the in-process version replaces it with
No second attribute layer at all, and three lines of config:
The agent gets
corpus_search_textwith the schema the server publishes, because it is the server's schema — listed and called throughBuilder::buildStateless(), the same protocol path an HTTP client hits. One definition, and the chat page is a test of the thing external clients use.Why the two attributes aren't parity
Beyond the duplication, they don't produce the same tool:
<server>_<name>, the#[AsTool]copy as whatever it's called.CallToolResultcontent andisError— which is also what [Agent][MCP] A tool error reported by an MCP server never reaches the model #2534 / [Agent][MCP] Show an MCP server's tool error to the model #2535 are about.#[AsMcpAppTool],appOnly, templates), prompts, resources, anything using request meta or progress have no#[AsTool]form to add. The demo shows the shape of that too:movie_searchexists as#[AsTool]onMovieSearchand again as#[AsMcpApp(name: 'movie_search')]onMovieAppwrapping that same service, each with its own description;current-timeis MCP-only, so the chat agent can't call it without a wrapper.On explicitness
I agree that's the thing to get right, and I'd rather change the syntax than the idea.
{ mcp_server: 'corpus' }was meant to read as "thecorpusserver inmcp.servers, right here", but if a bare name is too quiet next to'self.corpus', let's make it say so:{ mcp_server: 'corpus', transport: 'local' }, or an explicit in-process transport undermcp.clientsso the whole topology stays in one place. Say which of those reads right and I'll rework #2540.For what it's worth, the alternative that exists today is a
mcp.clientsentry pointing back at the same application: a stdio child process runningbin/console mcp:server corpus(~0.3 s to start and list tools, paid per request under php-fpm), or an HTTP request into itself. In-process measured 0.00–0.03 s per call with identical results.If the syntax is the sticking point I'll rework it; if you'd rather let it sit until more people hit this, that's fine too — mostly I want the use case on the record, since "define every tool twice" is the current answer for any application that is both an MCP server and an AI agent.
chr-hertel commentedon Sep 20, 2026
Sorry, this is not convincing to bring in MCP without transport between to Symfony services running in the same app. Please use two attributes or look into reusing the service directly in the config:
Your agent already points out that the two layers are drifting (e.g. schema) and no, you shouldn't go through the mcp:server command as alternative. don't use MCP at all here. it's like using a CLI command via HTTP controller. that layer buys you nothing but wrong coupling. reuse the service layer.
tacman commentedon Sep 20, 2026
Understood — I'll wire it through the service layer. Thanks for the pointer to the
service:config form, that's the piece I'd missed.