Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/bundles/ai-bundle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,7 @@ Profiler

The profiler panel provides insights into the agent's execution:

.. image:: profiler.png
.. image:: images/profiler-ai.png
:alt: Profiler Panel

Message stores
Expand Down
File renamed without changes
Binary file added docs/bundles/images/profiler-mcp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions docs/bundles/mcp-bundle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ You can customize the logging level and destination according to your needs:
channels: ['mcp']
webhook_url: '%env(SLACK_WEBHOOK)%'

Profiler
--------

When the Symfony Web Profiler is enabled, the MCP Bundle automatically adds a dedicated panel showing all registered MCP capabilities in your application:

.. image:: images/profiler-mcp.png
:alt: MCP Profiler Panel

The profiler displays:

- **Tools**: All registered MCP tools with their descriptions and input schemas
- **Prompts**: Available prompts with their arguments and requirements
- **Resources**: Static resources with their URIs and MIME types
- **Resource Templates**: Dynamic resource templates with URI patterns

This makes it easy to inspect and debug your MCP server capabilities during development.

Event System
------------

Expand Down
9 changes: 8 additions & 1 deletion src/mcp-bundle/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use Mcp\Server;
use Mcp\Server\Builder;
use Symfony\AI\McpBundle\Profiler\DataCollector;
use Symfony\AI\McpBundle\Profiler\Loader\ProfilingLoader;

return static function (ContainerConfigurator $container): void {
$container->services()
Expand All @@ -21,6 +23,8 @@
->args(['mcp'])
->tag('monolog.logger', ['channel' => 'mcp'])

->set('ai.mcp.profiling_loader', ProfilingLoader::class)

->set('mcp.server.builder', Builder::class)
->factory([Server::class, 'builder'])
->call('setServerInfo', [param('mcp.app'), param('mcp.version')])
Expand All @@ -30,9 +34,12 @@
->call('setEventDispatcher', [service('event_dispatcher')])
->call('setSession', [service('mcp.session.store')])
->call('setDiscovery', [param('kernel.project_dir'), param('mcp.discovery.scan_dirs'), param('mcp.discovery.exclude_dirs')])
->call('addLoaders', [service('ai.mcp.profiling_loader')])

->set('mcp.server', Server::class)
->factory([service('mcp.server.builder'), 'build'])

;
->set('ai.mcp.data_collector', DataCollector::class)
->args([service('ai.mcp.profiling_loader')])
->tag('data_collector');
};
162 changes: 162 additions & 0 deletions src/mcp-bundle/src/Profiler/DataCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\McpBundle\Profiler;

use Mcp\Schema\Prompt;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;
use Symfony\AI\McpBundle\Profiler\Loader\ProfilingLoader;
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;

/**
* Collects MCP server capabilities for the Web Profiler.
*
* @author Camille Islasse <[email protected]>
*/
final class DataCollector extends AbstractDataCollector implements LateDataCollectorInterface
{
public function __construct(
private readonly ProfilingLoader $profilingLoader,
) {
}

public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
}

public function lateCollect(): void
{
$registry = $this->profilingLoader->getRegistry();

if (null === $registry) {
$this->data = [
'tools' => [],
'prompts' => [],
'resources' => [],
'resourceTemplates' => [],
];

return;
}

$tools = [];
foreach ($registry->getTools()->references as $item) {
if (!$item instanceof Tool) {
continue;
}

$tools[] = [
'name' => $item->name,
'description' => $item->description,
'inputSchema' => $item->inputSchema,
];
}

$prompts = [];
foreach ($registry->getPrompts()->references as $item) {
if (!$item instanceof Prompt) {
continue;
}

$prompts[] = [
'name' => $item->name,
'description' => $item->description,
'arguments' => array_map(fn ($arg) => [
'name' => $arg->name,
'description' => $arg->description,
'required' => $arg->required,
], $item->arguments ?? []),
];
}

$resources = [];
foreach ($registry->getResources()->references as $item) {
if (!$item instanceof Resource) {
continue;
}

$resources[] = [
'uri' => $item->uri,
'name' => $item->name,
'description' => $item->description,
'mimeType' => $item->mimeType,
];
}

$resourceTemplates = [];
foreach ($registry->getResourceTemplates()->references as $item) {
if (!$item instanceof ResourceTemplate) {
continue;
}

$resourceTemplates[] = [
'uriTemplate' => $item->uriTemplate,
'name' => $item->name,
'description' => $item->description,
'mimeType' => $item->mimeType,
];
}

$this->data = [
'tools' => $tools,
'prompts' => $prompts,
'resources' => $resources,
'resourceTemplates' => $resourceTemplates,
];
}

/**
* @return array<array{name: string, description: ?string, inputSchema: array<mixed>}>
*/
public function getTools(): array
{
return $this->data['tools'] ?? [];
}

/**
* @return array<array{name: string, description: ?string, arguments: array<mixed>}>
*/
public function getPrompts(): array
{
return $this->data['prompts'] ?? [];
}

/**
* @return array<array{uri: string, name: string, description: ?string, mimeType: ?string}>
*/
public function getResources(): array
{
return $this->data['resources'] ?? [];
}

/**
* @return array<array{uriTemplate: string, name: string, description: ?string, mimeType: ?string}>
*/
public function getResourceTemplates(): array
{
return $this->data['resourceTemplates'] ?? [];
}

public function getTotalCount(): int
{
return \count($this->getTools()) + \count($this->getPrompts()) + \count($this->getResources()) + \count($this->getResourceTemplates());
}

public static function getTemplate(): string
{
return '@Mcp/data_collector.html.twig';
}
}
34 changes: 34 additions & 0 deletions src/mcp-bundle/src/Profiler/Loader/ProfilingLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\McpBundle\Profiler\Loader;

use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ReferenceRegistryInterface;

/**
* @author Camille Islasse <[email protected]>
*/
final class ProfilingLoader implements LoaderInterface
{
private ?ReferenceProviderInterface $registry = null;

public function load(ReferenceRegistryInterface $registry): void
{
$this->registry = $registry instanceof ReferenceProviderInterface ? $registry : null;
}

public function getRegistry(): ?ReferenceProviderInterface
{
return $this->registry;
}
}
99 changes: 99 additions & 0 deletions src/mcp-bundle/templates/data_collector.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}

{% block toolbar %}
{% if collector.totalCount > 0 %}
{% set icon %}
{{ include('@Mcp/icon.svg', { y: 18 }) }}
<span class="sf-toolbar-value">{{ collector.totalCount }}</span>
<span class="sf-toolbar-info-piece-additional-detail">
<span class="sf-toolbar-label">capabilities</span>
</span>
{% endset %}

{% set text %}
<div class="sf-toolbar-info-piece">
<b class="label">Tools</b>
<span class="sf-toolbar-status">{{ collector.tools|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b class="label">Prompts</b>
<span class="sf-toolbar-status">{{ collector.prompts|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b class="label">Resources</b>
<span class="sf-toolbar-status">{{ collector.resources|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b class="label">Resource Templates</b>
<span class="sf-toolbar-status">{{ collector.resourceTemplates|length }}</span>
</div>
{% endset %}

{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }}
{% endif %}
{% endblock %}

{% block menu %}
<span class="label">
<span class="icon">{{ include('@Mcp/icon.svg', { y: 16 }) }}</span>
<strong>MCP</strong>
<span class="count">{{ collector.totalCount }}</span>
</span>
{% endblock %}

{% block panel %}
<h2>MCP Capabilities</h2>
<section class="metrics">
<div class="metric-group">
<div class="metric">
<span class="value">{{ collector.tools|length }}</span>
<span class="label">Tools</span>
</div>
<div class="metric">
<span class="value">{{ collector.prompts|length }}</span>
<span class="label">Prompts</span>
</div>
</div>
<div class="metric-divider"></div>
<div class="metric-group">
<div class="metric">
<span class="value">{{ collector.resources|length }}</span>
<span class="label">Resources</span>
</div>
<div class="metric">
<span class="value">{{ collector.resourceTemplates|length }}</span>
<span class="label">Resource Templates</span>
</div>
</div>
</section>

<div class="sf-tabs">
<div class="tab {{ collector.tools is empty ? 'disabled' }}">
<h3 class="tab-title">Tools <span class="badge">{{ collector.tools|length }}</span></h3>
<div class="tab-content">
{{ include('@Mcp/tools.html.twig') }}
</div>
</div>

<div class="tab {{ collector.prompts is empty ? 'disabled' }}">
<h3 class="tab-title">Prompts <span class="badge">{{ collector.prompts|length }}</span></h3>
<div class="tab-content">
{{ include('@Mcp/prompts.html.twig') }}
</div>
</div>

<div class="tab {{ collector.resources is empty ? 'disabled' }}">
<h3 class="tab-title">Resources <span class="badge">{{ collector.resources|length }}</span></h3>
<div class="tab-content">
{{ include('@Mcp/resources.html.twig') }}
</div>
</div>

<div class="tab {{ collector.resourceTemplates is empty ? 'disabled' }}">
<h3 class="tab-title">Resource Templates <span class="badge">{{ collector.resourceTemplates|length }}</span></h3>
<div class="tab-content">
{{ include('@Mcp/resource_templates.html.twig') }}
</div>
</div>
</div>
{% endblock %}
12 changes: 12 additions & 0 deletions src/mcp-bundle/templates/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading