Skip to content

[AI Bundle] Let an agent use the tools of the application's own MCP server, in-process #2539

Closed

Description

@tacman

Follow-up to #2497, split out from my comment there as suggested. This is the case behind #2003.

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:

mcp:
    clients:
        self:
            servers:
                demo:
                    transport: stdio
                    command: ['php', '%kernel.project_dir%/bin/console', 'mcp:server', 'demo']
ai:
    agent:
        chat:
            tools:
                - { mcp_server: 'self.demo' }

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.

Activity

  1. chr-hertel commented on Sep 19, 2026

    @chr-hertel
    Member

    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?

  2. tacman commented on Sep 20, 2026

    @tacman
    ContributorAuthor

    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:

    use Mcp\Capability\Attribute\McpTool;
    use Mcp\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: new ToolAnnotations(readOnlyHint: true, openWorldHint: false))]
    public function 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:

    use Mcp\Capability\Attribute\Schema as McpSchema;
    use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema as ToolSchema;
    
    #[AsTool(name: 'search_text', description: 'Search the OCR text of every block...', method: 'searchText')]
    #[AsTool(name: 'get_block', description: '...', method: 'getBlock')]
    // ...seven more
    final class ArticleCorpus
    {
        #[McpTool('search_text', ...)]
        public function searchText(
            string $dataset,
            string $query,
            bool $literal = true,
            #[McpSchema(enum: ['article', 'advertisement', 'obituary', 'none'])]
            #[ToolSchema(enum: ['article', 'advertisement', 'obituary', 'none'])]
            ?string $kind = null,
            #[McpSchema(minimum: 1, maximum: 200)]
            #[ToolSchema(minimum: 1, maximum: 200)]
            int $limit = 20,
            // ...

    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:

    ai:
        agent:
            corpus_chat:
                tools:
                    - { mcp_server: 'corpus' }

    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.
    • Different result handling: toolbox result conversion vs CallToolResult content and isError — 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.
    • 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.

  3. chr-hertel commented on Sep 20, 2026

    @chr-hertel
    Member

    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.

  4. tacman commented on Sep 20, 2026

    @tacman
    ContributorAuthor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    AI BundleIssues & PRs about the AI integration bundle

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions