diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index e50ce5b30..5cde93d22 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -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 diff --git a/docs/bundles/profiler.png b/docs/bundles/images/profiler-ai.png similarity index 100% rename from docs/bundles/profiler.png rename to docs/bundles/images/profiler-ai.png diff --git a/docs/bundles/images/profiler-mcp.png b/docs/bundles/images/profiler-mcp.png new file mode 100644 index 000000000..b28245135 Binary files /dev/null and b/docs/bundles/images/profiler-mcp.png differ diff --git a/docs/bundles/mcp-bundle.rst b/docs/bundles/mcp-bundle.rst index 40bdd4f63..ec3419af2 100644 --- a/docs/bundles/mcp-bundle.rst +++ b/docs/bundles/mcp-bundle.rst @@ -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 ------------ diff --git a/src/mcp-bundle/config/services.php b/src/mcp-bundle/config/services.php index 9f836a81b..273b97555 100644 --- a/src/mcp-bundle/config/services.php +++ b/src/mcp-bundle/config/services.php @@ -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() @@ -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')]) @@ -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'); }; diff --git a/src/mcp-bundle/src/Profiler/DataCollector.php b/src/mcp-bundle/src/Profiler/DataCollector.php new file mode 100644 index 000000000..d54a85d4f --- /dev/null +++ b/src/mcp-bundle/src/Profiler/DataCollector.php @@ -0,0 +1,162 @@ + + * + * 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 + */ +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}> + */ + public function getTools(): array + { + return $this->data['tools'] ?? []; + } + + /** + * @return array}> + */ + public function getPrompts(): array + { + return $this->data['prompts'] ?? []; + } + + /** + * @return array + */ + public function getResources(): array + { + return $this->data['resources'] ?? []; + } + + /** + * @return array + */ + 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'; + } +} diff --git a/src/mcp-bundle/src/Profiler/Loader/ProfilingLoader.php b/src/mcp-bundle/src/Profiler/Loader/ProfilingLoader.php new file mode 100644 index 000000000..27e3edc9c --- /dev/null +++ b/src/mcp-bundle/src/Profiler/Loader/ProfilingLoader.php @@ -0,0 +1,34 @@ + + * + * 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 + */ +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; + } +} diff --git a/src/mcp-bundle/templates/data_collector.html.twig b/src/mcp-bundle/templates/data_collector.html.twig new file mode 100644 index 000000000..98ded5082 --- /dev/null +++ b/src/mcp-bundle/templates/data_collector.html.twig @@ -0,0 +1,99 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% if collector.totalCount > 0 %} + {% set icon %} + {{ include('@Mcp/icon.svg', { y: 18 }) }} + {{ collector.totalCount }} + + capabilities + + {% endset %} + + {% set text %} +
+ Tools + {{ collector.tools|length }} +
+
+ Prompts + {{ collector.prompts|length }} +
+
+ Resources + {{ collector.resources|length }} +
+
+ Resource Templates + {{ collector.resourceTemplates|length }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ include('@Mcp/icon.svg', { y: 16 }) }} + MCP + {{ collector.totalCount }} + +{% endblock %} + +{% block panel %} +

MCP Capabilities

+
+
+
+ {{ collector.tools|length }} + Tools +
+
+ {{ collector.prompts|length }} + Prompts +
+
+
+
+
+ {{ collector.resources|length }} + Resources +
+
+ {{ collector.resourceTemplates|length }} + Resource Templates +
+
+
+ +
+
+

Tools {{ collector.tools|length }}

+
+ {{ include('@Mcp/tools.html.twig') }} +
+
+ +
+

Prompts {{ collector.prompts|length }}

+
+ {{ include('@Mcp/prompts.html.twig') }} +
+
+ +
+

Resources {{ collector.resources|length }}

+
+ {{ include('@Mcp/resources.html.twig') }} +
+
+ +
+

Resource Templates {{ collector.resourceTemplates|length }}

+
+ {{ include('@Mcp/resource_templates.html.twig') }} +
+
+
+{% endblock %} diff --git a/src/mcp-bundle/templates/icon.svg b/src/mcp-bundle/templates/icon.svg new file mode 100644 index 000000000..f3e3deb4a --- /dev/null +++ b/src/mcp-bundle/templates/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/mcp-bundle/templates/prompts.html.twig b/src/mcp-bundle/templates/prompts.html.twig new file mode 100644 index 000000000..7b2b3203f --- /dev/null +++ b/src/mcp-bundle/templates/prompts.html.twig @@ -0,0 +1,50 @@ +{% if collector.prompts|length %} +
+ {% for prompt in collector.prompts %} +
+

{{ prompt.name }}

+
+ {% if prompt.description %} +

Description: {{ prompt.description }}

+ {% endif %} + + {% if prompt.arguments|length %} +

Arguments

+ + + + + + + + + + {% for arg in prompt.arguments %} + + + + + + {% endfor %} + +
NameRequiredDescription
{{ arg.name }} + {% if arg.required %} + Yes + {% else %} + No + {% endif %} + {{ arg.description ?? '-' }}
+ {% else %} +
+

This prompt has no arguments.

+
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+

No prompts were registered.

+
+{% endif %} diff --git a/src/mcp-bundle/templates/resource_templates.html.twig b/src/mcp-bundle/templates/resource_templates.html.twig new file mode 100644 index 000000000..3111547f3 --- /dev/null +++ b/src/mcp-bundle/templates/resource_templates.html.twig @@ -0,0 +1,35 @@ +{% if collector.resourceTemplates|length %} +
+ {% for template in collector.resourceTemplates %} +
+

{{ template.name }}

+
+ + + + + + + {% if template.description %} + + + + + {% endif %} + {% if template.mimeType %} + + + + + {% endif %} + +
URI Template{{ template.uriTemplate }}
Description{{ template.description }}
MIME Type{{ template.mimeType }}
+
+
+ {% endfor %} +
+{% else %} +
+

No resource templates were registered.

+
+{% endif %} diff --git a/src/mcp-bundle/templates/resources.html.twig b/src/mcp-bundle/templates/resources.html.twig new file mode 100644 index 000000000..7eca09bec --- /dev/null +++ b/src/mcp-bundle/templates/resources.html.twig @@ -0,0 +1,35 @@ +{% if collector.resources|length %} +
+ {% for resource in collector.resources %} +
+

{{ resource.name }}

+
+ + + + + + + {% if resource.description %} + + + + + {% endif %} + {% if resource.mimeType %} + + + + + {% endif %} + +
URI{{ resource.uri }}
Description{{ resource.description }}
MIME Type{{ resource.mimeType }}
+
+
+ {% endfor %} +
+{% else %} +
+

No resources were registered.

+
+{% endif %} diff --git a/src/mcp-bundle/templates/tools.html.twig b/src/mcp-bundle/templates/tools.html.twig new file mode 100644 index 000000000..cbf658ca1 --- /dev/null +++ b/src/mcp-bundle/templates/tools.html.twig @@ -0,0 +1,78 @@ +{% if collector.tools|length %} +
+ {% for tool in collector.tools %} +
+

{{ tool.name }}

+
+ {% if tool.description %} +

Description: {{ tool.description }}

+ {% endif %} + + {% if tool.inputSchema.properties is defined and tool.inputSchema.properties|length %} +

Parameters

+ + + + + + + + + + + + {% for name, prop in tool.inputSchema.properties %} + + + + + + + + {% endfor %} + +
NameTypeRequiredDefaultDescription
{{ name }}{{ prop.type is iterable ? prop.type|join('|') : (prop.type ?? 'any') }} + {% if tool.inputSchema.required is defined and name in tool.inputSchema.required %} + Yes + {% else %} + No + {% endif %} + + {% if prop.default is defined %} + {% if prop.default is iterable %} + {{ prop.default|json_encode }} + {% else %} + {{ prop.default }} + {% endif %} + {% else %} + - + {% endif %} + {{ prop.description ?? '-' }}
+ {% else %} +
+

This tool has no parameters.

+
+ {% endif %} + +

Full Schema

+ + + + + + + + + +
+ +
{{ dump(tool.inputSchema) }}
+
+
+ {% endfor %} +
+{% else %} +
+

No tools were registered.

+
+{% endif %}