Writing Your First MCP Server in Symfony
MCP, the Model Context Protocol, is an open protocol that lets AI clients talk to external tools and data sources. It's a standardized way for AI to call your code. Symfony now has an official bundle for it, and it's surprisingly simple to get started.
What is MCP?
MCP allows AI assistants to discover and use tools you define. You build a server that exposes capabilities, and the AI client connects to it, sees what's available, and calls those tools during a conversation.
MCP defines four types of capabilities:
- Tools: actions the AI can execute (like fetching data or running calculations)
- Prompts: predefined instructions that give the AI extra context
- Resources: static data the AI can read
- Resource Templates: dynamic resources with parameters
We'll focus on tools first, then add a prompt and a resource.
Setting up the project
I'm assuming you already have a Symfony project. If not:
symfony new mcp-demo
cd mcp-demo
Install the MCP bundle:
composer require symfony/mcp-bundle
Add the routing configuration in config/routes.yaml:
# config/routes.yaml
mcp:
resource: .
type: mcp
And configure the bundle in config/packages/mcp.yaml:
# config/packages/mcp.yaml
mcp:
app: 'my-mcp-server'
version: '1.0.0'
description: 'My first MCP server built with Symfony.'
client_transports:
stdio: true
http: true
The bundle supports two transport types: STDIO (for command-line clients) and HTTP (for web-based clients). We're enabling both.
Your first tool
Let's expose a tool that returns the current weather for a given city. We'll fake the data for now.
Create src/Mcp/Tool/WeatherTool.php:
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use Mcp\Capability\Attribute\McpTool;
#[McpTool(name: 'get-weather', description: 'Get the current weather for a city')]
final class WeatherTool
{
public function __invoke(string $city): string
{
// In a real app, you'd call a weather API here
return sprintf('The weather in %s is 18°C and sunny.', $city);
}
}
The #[McpTool] attribute is all Symfony needs to discover and register the tool. The method parameters automatically become the tool's input schema, so when an AI client connects, it knows it needs a city string.
No manual registration, no YAML config, no service tags. The bundle scans your src/ directory and picks up anything with an MCP attribute.
Testing with the MCP Inspector
Before connecting an AI client, test your server with the MCP Inspector. It gives you a browser-based UI to see your tools, invoke them, and inspect the JSON-RPC messages.
No install needed, just run it with npx:
npx @modelcontextprotocol/inspector php bin/console mcp:server
This starts your MCP server via STDIO and opens the Inspector at http://localhost:6274. Click "Connect", go to the "Tools" tab, and hit "List Tools". Your get-weather tool should show up. Click it, fill in a city name, and run it.
Testing with HTTP
You can also test the HTTP transport. Start your Symfony dev server:
symfony serve
Your MCP endpoint is available at http://localhost:8000/_mcp. In the Inspector, switch the transport type to "Streamable HTTP" and point it to that URL. The path is configurable:
# config/packages/mcp.yaml
mcp:
http:
path: /_mcp
Adding more capabilities
Let's add a prompt and a resource.
A prompt gives the AI extra context for specific scenarios:
<?php
declare(strict_types=1);
namespace App\Mcp\Prompt;
use Mcp\Capability\Attribute\McpPrompt;
final class WeatherPrompt
{
#[McpPrompt(name: 'weather-assistant')]
public function getWeatherAssistantPrompt(): array
{
return [
[
'role' => 'user',
'content' => 'You are a helpful weather assistant. Always include temperature in Celsius and mention if an umbrella is needed.',
],
];
}
}
A resource exposes static data that the AI can read:
<?php
declare(strict_types=1);
namespace App\Mcp\Resource;
use Mcp\Capability\Attribute\McpResource;
final class SupportedCitiesResource
{
#[McpResource(uri: 'weather://cities', name: 'supported-cities')]
public function getSupportedCities(): array
{
return [
'uri' => 'weather://cities',
'mimeType' => 'application/json',
'text' => json_encode([
'cities' => ['Amsterdam', 'London', 'Berlin', 'Paris', 'Tokyo'],
]),
];
}
}
The AI client can discover all of these and use them as needed.
Two patterns for organizing tools
The WeatherTool above uses the invokable pattern: the attribute sits on the class, the logic goes in __invoke(). One class, one tool.
For related tools, you can group them with the method-based pattern instead:
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use Mcp\Capability\Attribute\McpTool;
final class DateTimeTool
{
#[McpTool(name: 'current-time')]
public function getCurrentTime(string $format = 'Y-m-d H:i:s'): string
{
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format($format);
}
#[McpTool(name: 'time-difference')]
public function getTimeDifference(string $from, string $to): string
{
$start = new \DateTimeImmutable($from);
$end = new \DateTimeImmutable($to);
$diff = $start->diff($end);
return $diff->format('%d days, %h hours, %i minutes');
}
}
Here the #[McpTool] attributes go on individual methods, and each method becomes a separate tool.
Debugging with the profiler
The MCP bundle adds its own panel to the Symfony Web Profiler. It shows all registered tools, prompts, resources, and resource templates. Useful when a tool isn't showing up or you want to inspect the input schema.
Connecting to Claude Code
To use your MCP server with an actual AI assistant, Claude Code works well. It runs in your terminal, works on Linux, and adding an MCP server is one command.
From your project directory:
claude mcp add my-mcp-server -- php bin/console mcp:server
The -- separates Claude Code's flags from the command that starts your server. By default this uses STDIO and is scoped to the current project. To make it available everywhere, add -s user:
claude mcp add -s user my-mcp-server -- php bin/console mcp:server
Start Claude Code and type /mcp to check the connection status. You should see my-mcp-server: connected. Ask "What's the weather in Amsterdam?" and Claude will discover your tool and ask permission to call it.
For the HTTP transport, make sure your Symfony dev server is running:
claude mcp add --transport http my-mcp-server http://localhost:8000/_mcp
Other useful commands:
claude mcp list # verify config
claude mcp remove my-mcp-server # remove a server
What's next?
There's more you can do: event listeners that react when capabilities are registered, dedicated logging for your MCP server, or session management for the HTTP transport with custom stores and TTL configuration.
The client side (using your Symfony app as an MCP client to connect to other servers) is not implemented yet, but it's on the roadmap.
Check out the official Symfony MCP Bundle docs and the MCP protocol specification if you want to dig deeper.