main MCP: Exposing Your API to AI Agents

API Platform integrates with the Model Context Protocol (MCP) to expose your API as tools and resources that AI agents (LLMs) can discover and interact with.

MCP defines a standard way for AI models to discover available tools, understand their input schemas, and invoke them. API Platform leverages its existing metadata system — state processors, validation, serialization — to turn your PHP classes into MCP-compliant tool definitions.

# Installation

Install the MCP Bundle:

composer require symfony/mcp-bundle

# Configuring the MCP Server

# Symfony

Enable the MCP server and configure the transport in your Symfony configuration:

# config/packages/mcp.yaml
mcp:
    client_transports:
        http: true
        stdio: false
    http:
        path: '/mcp'
        session:
            store: 'file'
            directory: '%kernel.cache_dir%/mcp'
            ttl: 3600

# Laravel

MCP is enabled by default in the Laravel configuration:

// config/api-platform.php
return [
    // ...
    'mcp' => [
        'enabled' => true,
    ],
];

The MCP endpoint is automatically registered at /mcp.

# Declaring MCP Tools

MCP tools let AI agents invoke operations on your API. The primary pattern uses #[McpTool] as a class attribute: the class properties define the tool’s input schema, and a state processor handles the command.

This follows a CQRS-style approach: tools receive input from AI agents and process it through your application logic.

# Simple Tool

<?php
// api/src/ApiResource/ProcessMessage.php with Symfony or app/ApiResource/ProcessMessage.php with Laravel
namespace App\ApiResource;

use ApiPlatform\Metadata\McpTool;

#[McpTool(
    name: 'process_message',
    description: 'Process a message with priority',
    processor: [self::class, 'process']
)]
class ProcessMessage
{
    public function __construct(
        private string $message,
        private int $priority = 1,
    ) {}

    public function getMessage(): string
    {
        return $this->message;
    }

    public function setMessage(string $message): void
    {
        $this->message = $message;
    }

    public function getPriority(): int
    {
        return $this->priority;
    }

    public function setPriority(int $priority): void
    {
        $this->priority = $priority;
    }

    public static function process($data): mixed
    {
        $data->setMessage('Processed: ' . $data->getMessage());
        $data->setPriority($data->getPriority() + 10);

        return $data;
    }
}

The class properties ($message, $priority) become the tool’s inputSchema. When an AI agent calls this tool, API Platform deserializes the input into a ProcessMessage instance and passes it to the processor. The returned object is serialized back as structured content.

You can also use a dedicated state processor service instead of a static method — any callable or service class implementing ProcessorInterface works.

# Using a Separate Input DTO

When the tool’s input schema should differ from the class itself, use the input option to specify a separate DTO:

<?php
namespace App\Dto;

class SearchQuery
{
    public string $search;
}
<?php
namespace App\ApiResource;

use ApiPlatform\Metadata\McpTool;
use App\Dto\SearchQuery;
use App\State\SearchBooksProcessor;

#[McpTool(
    name: 'search_books',
    description: 'Search books by keyword',
    input: SearchQuery::class,
    processor: SearchBooksProcessor::class,
)]
class BookSearchResult
{
    public int $id;
    public string $title;
    public string $isbn;
}

Here, SearchQuery defines the tool’s inputSchema (what the AI agent sends), while BookSearchResult defines the output structure. The processor receives a SearchQuery instance and returns the result:

<?php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use Doctrine\Persistence\ManagerRegistry;

class SearchBooksProcessor implements ProcessorInterface
{
    public function __construct(private readonly ManagerRegistry $managerRegistry) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?iterable
    {
        return $this->managerRegistry->getRepository(Book::class)->findAll();
    }
}

# Returning Custom Results

By default, tool results are serialized using API Platform’s serialization system with structured content (JSON). If you need full control over the response, return a CallToolResult directly from your processor and set structuredContent: false:

<?php
namespace App\ApiResource;

use ApiPlatform\Metadata\McpTool;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Result\CallToolResult;

#[McpTool(
    name: 'generate_report',
    description: 'Generate a markdown report',
    processor: [self::class, 'process'],
    structuredContent: false
)]
class Report
{
    public function __construct(
        private string $title,
        private string $content,
    ) {}

    // getters and setters...

    public static function process($data): CallToolResult
    {
        $markdown = "# {$data->getTitle()}\n\n{$data->getContent()}";

        return new CallToolResult(
            [new TextContent($markdown)],
            false
        );
    }
}

Setting structuredContent: false disables the automatic JSON serialization. When returning a CallToolResult, the response is sent as-is to the AI agent.

# Validation

MCP tool inputs support validation using the same mechanisms as regular API Platform operations.

On Symfony, use Symfony Validator constraints:

<?php
namespace App\ApiResource;

use ApiPlatform\Metadata\McpTool;
use Symfony\Component\Validator\Constraints as Assert;

#[McpTool(
    name: 'submit_contact',
    description: 'Submit a contact form',
    processor: [self::class, 'process']
)]
class ContactForm
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 50)]
    private ?string $name = null;

    #[Assert\NotNull]
    #[Assert\Email]
    private ?string $email = null;

    #[Assert\Positive]
    private ?int $age = null;

    // getters, setters, and processor...
}

On Laravel, use validation rules:

#[McpTool(
    name: 'submit_contact',
    description: 'Submit a contact form',
    processor: [self::class, 'process'],
    rules: [
        'name' => 'required|min:3|max:50',
        'email' => 'required|email',
    ]
)]

# Declaring MCP Resources

MCP resources expose read-only content that AI agents can retrieve — documentation, configuration, reference data, etc. Use the McpResource attribute with a state provider:

<?php
namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\McpResource;

#[ApiResource(
    operations: [],
    mcp: [
        'api_docs' => new McpResource(
            uri: 'resource://my-app/documentation',
            name: 'App-Documentation',
            description: 'Application API documentation',
            mimeType: 'text/markdown',
            provider: [self::class, 'provide']
        ),
    ]
)]
class Documentation
{
    public function __construct(
        private string $content,
        private string $uri,
    ) {}

    // getters and setters...

    public static function provide(): self
    {
        return new self(
            content: '# My API Documentation\n\nWelcome to the API.',
            uri: 'resource://my-app/documentation'
        );
    }
}

The uri must be unique across the MCP server and follows the resource:// URI scheme.

# McpTool Options

The McpTool attribute accepts all standard operation options plus:

OptionDescription
nameTool name exposed to AI agents (defaults to the class short name)
descriptionHuman-readable description of the tool (defaults to class DocBlock)
structuredContentWhether to include JSON structured content in responses (default: true)
inputA separate DTO class to use as the tool’s input schema
outputA separate DTO class to use as the tool’s output representation
annotationsMCP tool annotations describing behavior hints
iconsList of icon URLs representing the tool
metaArbitrary metadata
rulesLaravel validation rules (Laravel only)

# McpResource Options

The McpResource attribute accepts all standard operation options plus:

OptionDescription
uriUnique URI identifying this resource (required, uses resource:// scheme)
nameHuman-readable name for the resource
descriptionDescription of the resource (defaults to class DocBlock)
structuredContentWhether to include JSON structured content (default: true)
mimeTypeMIME type of the resource content
sizeSize in bytes, if known
annotationsMCP resource annotations
iconsList of icon URLs
metaArbitrary metadata

You can also help us improve the documentation of this page.

Made with love by

Les-Tilleuls.coop can help you design and develop your APIs and web projects, and train your teams in API Platform, Symfony, Next.js, Kubernetes and a wide range of other technologies.

Learn more

Copyright © 2023 Kévin Dunglas

Sponsored by Les-Tilleuls.coop