Ask an AI assistant "show me how to validate a form with a FormRequest", and it answers with three blocks of code to paste into a project you may not have at hand. You read them, you nod, and you never try them.
The playground on apprendre.forelse.fr already runs a Symfony, Laravel or plain PHP project in the browser, and shares it with a simple link. One thing was missing: letting the AI build that link itself. That is what the Model Context Protocol (MCP) is for: it is the protocol that gives tools to assistants. So I added an MCP server to the site, in Symfony, with the Symfony AI bundle.
The result: you ask Claude "create a Symfony playground with a /hello/{name} controller that answers in JSON", and it answers with a link. One click on "Run", and the code runs.
And the case I care about most: a bug in your own project. From Claude Code or Cursor, the AI has your code at hand. It isolates what goes wrong, the controller, the service and the faulty line in services.yaml, and turns it into a playground that reproduces the bug. The link goes into an issue or on Slack, and whoever helps you sees the problem running instead of guessing it.
What the server does, and what it doesn't
A playground link already carries all the code, after the #: the changed files, as JSON, compressed with deflate and encoded in base64url. I described this format in Sharing a Symfony project in a URL. The site also offers short links, kept on the server when you tick the box while sharing, but the MCP server always builds the long link: it stores nothing.
An AI can't compress deflate in its head. So the MCP server does that computation for it, and nothing more:
-
get_starterreturns the starting project: its files, the editable paths, the configuration (on request, since Laravel's weighs 45 KB) and the Composer packages you can add (for Symfony, each comes with its Flex recipe); -
create_sandboxtakes the files the AI wrote and returns the link; -
read_sandboxdoes the opposite: give the AI a link, and it reads the code to explain or fix it.
Nothing runs on the server, and nothing is saved. The code then runs in the person's browser, in WebAssembly, and only after they click, as for any shared link.
symfony/mcp-bundle: a tool is an attribute
Symfony AI provides symfony/mcp-bundle, built on the official MCP PHP SDK (mcp/sdk), developed with the PHP Foundation. It installs with a Flex recipe:
composer require symfony/mcp-bundle
The recipe adds the configuration file and the routes import. In it, I declare a server, the namespace where its tools live, and its path: /mcp/<server name> by default, which I set to /mcp:
mcp:
servers:
playground:
name: 'php-playground'
version: '1.0.0'
instructions: |
Creates links to an online PHP playground (Symfony 8, Laravel 13, or plain PHP 8.5)...
Call get_starter for the starting project, then create_sandbox with only the files
you change, and give the user the link.
registry:
tools: ['App\Mcp\']
http:
path: /mcp
A tool, then, is a service method with the #[McpTool] attribute. The SDK generates the JSON schema of the parameters from the PHP signature:
#[McpTool(name: 'create_sandbox', annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true))]
public function create(#[Schema(description: 'The playground: "symfony", "laravel" or "php" (no framework).')]ScratchFramework $framework, #[Schema(definition: ['type' => 'object', 'description' => 'The files to change or add, by path, with their full content.', 'additionalProperties' => ['type' => 'string'], ])]array $files, #[Schema(description: 'Language of the page: "fr" for a French-speaking user, else "en".', enum: ['en', 'fr'])]string $language = 'en', // $packages and the RequestContext left out): array {
// ...
return ['url' => $this->url($framework, $language).'#'.$this->links->encode($changed)];
}
Two details saved me time:
-
A PHP enum becomes a JSON
enum.ScratchFrameworkis a string-backed enum (symfony,laravel,php): the SDK takes the list of values for the schema, and converts the incoming argument before the call. - The PHPDoc comment becomes the description. It is what the model reads to decide when to call the tool. So I wrote it for the model, saying when to use the tool and in which order.
A PHP array, on the other hand, doesn't say whether it is a list or an object. For files, a "path to content" map, you have to give the full schema with #[Schema(definition: ...)].
Errors written for the model
The playground doesn't accept just any file. The code, the views and the configuration can be edited (src/*/*.php, templates/*.html.twig, config/packages/*.yaml…), but not what depends on the packages: composer.json, config/bundles.php and vendor/ only change through the list of offered packages. If the AI sends config/bundles.php directly, the page silently ignores the file.
So the server rejects the call, with a ToolCallException. The SDK turns it into an error result that the model reads, not a protocol error:
if ([] !== $refused) {
throw new ToolCallException(sprintf(
'These paths cannot be edited in the %s playground: %s. Editable paths: %s ("\*" does not cross a "/").',
$framework->label(), implode(', ', $refused), implode(', ', $exercise->editable),
));
}
The message says what is wrong and what is allowed. The model fixes its call on the next turn, without the user seeing anything. Same for Composer packages: an unknown package gets the catalog list back, and a package added to Laravel gets a clear answer, since that playground comes with its own packages.
The server also drops from the link any file identical to the starting project. Models like to send back everything they were shown, and an unchanged file would only make the URL longer.
A public server with no account: the precautions
The server has no accounts and no private data, but it is still an open endpoint. Four settings:
-
A rate limit per IP address , with Symfony's RateLimiter: 300 requests per hour, then a 429 response with
Retry-After. A conversation makes a few calls, not thousands. -
The same limits as the page : 100 files and 1 MB of code at most. A link the page would refuse is never built, and
read_sandboxreads links with the same caution (size before and after decompression, paths climbing up with..rejected). -
A firewall with security disabled on
/mcp, so no platform session gets mixed in. -
DNS rebinding protection turned off (
allowed_hosts: false). By default, the SDK only acceptslocalhost,127.0.0.1and[::1], which suits a local MCP server. A public server can list its domains instead. I chose to turn it off because the site's engine runs on several instances, each with its own domain, and the server protects no data.
A trap during testing: in PHP, an empty JSON object {} and an empty list [] become the same empty array. The SDK fixes this for arguments, but not for a nested object: a "files": {} was seen as a list, and the schema validator rejected the call before it even reached the tool. A real assistant always sends at least one file, but the test was sending {}. In the other direction, the server casts (object) $files before encoding the link, so the page always receives an object.
Measuring what an AI does with the server
The site's analytics, Umami, only sees browsers. An AI assistant calls /mcp from a server: Umami doesn't see it. So the MCP server sends an event to Umami itself on each tool call (mcp-depart, mcp-lien-cree, mcp-lien-lu), with the framework, the language, the number of files and the name of the MCP client announced at connection (claude-ai, cursor-vscode…). The code itself is never sent. Sending waits one second at most, and an unreachable Umami never makes the tool fail.
The links created also carry utm_source=mcp. That way I'll know how many links assistants build, and how many people actually open them.
Testing an MCP server like a client
The functional test behaves like an assistant: a JSON-RPC handshake (initialize), the list of tools, then a call. With Symfony's KernelBrowser, it is a series of POST requests:
$this->client->request('POST', '/mcp', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json, text/event-stream',
'HTTP_MCP_SESSION_ID' => $this->session,
], content: json_encode([
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call',
'params' => ['name' => 'create_sandbox', 'arguments' => [
'framework' => 'php',
'files' => ['public/index.php' => "<?php echo 'Hello';"],
]],
]));
The response contains a structuredContent with the URL. The test then decodes the fragment to check that it carries the file.
What remains is making sure PHP and the page speak the same format. Two tests take care of it, one in each direction: on the PHP side, ScratchLinkTest reads a fragment built by fflate, the page's library; on the JavaScript side, a test reads a fragment produced by gzdeflate. Both produce the same raw deflate, and if either implementation changes, one of the two tests breaks.
Try it
The server lives at https://apprendre.forelse.fr/mcp, with no account. In Claude Code:
claude mcp add --transport http php-playground https://apprendre.forelse.fr/mcp
For Claude, ChatGPT, Cursor or VS Code, the page The playground from your AI assistant explains the steps. Then ask it for a playground, ask it to reproduce the bug you're stuck on, or give it a link a colleague sent you and ask why the route returns a 404.
The bundle and the SDK are still experimental: they are not covered by Symfony's backward compatibility promise, and you'll have to follow their releases. But to expose a handful of tools, a few attributes on a service are enough, along with the firewall, the RateLimiter and the functional tests you already know.
Top comments (0)