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..c285f8b21 100644 --- a/src/mcp-bundle/config/services.php +++ b/src/mcp-bundle/config/services.php @@ -11,8 +11,11 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Mcp\Capability\Registry; use Mcp\Server; use Mcp\Server\Builder; +use Symfony\AI\McpBundle\Profiler\DataCollector; +use Symfony\AI\McpBundle\Profiler\TraceableRegistry; return static function (ContainerConfigurator $container): void { $container->services() @@ -21,6 +24,12 @@ ->args(['mcp']) ->tag('monolog.logger', ['channel' => 'mcp']) + ->set('ai.mcp.registry.inner', Registry::class) + ->args([service('event_dispatcher'), service('monolog.logger.mcp')]) + + ->set('ai.mcp.registry', TraceableRegistry::class) + ->args([service('ai.mcp.registry.inner')]) + ->set('mcp.server.builder', Builder::class) ->factory([Server::class, 'builder']) ->call('setServerInfo', [param('mcp.app'), param('mcp.version')]) @@ -28,11 +37,14 @@ ->call('setInstructions', [param('mcp.instructions')]) ->call('setLogger', [service('monolog.logger.mcp')]) ->call('setEventDispatcher', [service('event_dispatcher')]) + ->call('setRegistry', [service('ai.mcp.registry')]) ->call('setSession', [service('mcp.session.store')]) ->call('setDiscovery', [param('kernel.project_dir'), param('mcp.discovery.scan_dirs'), param('mcp.discovery.exclude_dirs')]) ->set('mcp.server', Server::class) ->factory([service('mcp.server.builder'), 'build']) - ; + ->set('ai.mcp.data_collector', DataCollector::class) + ->args([service('ai.mcp.registry')]) + ->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..e3c54d932 --- /dev/null +++ b/src/mcp-bundle/src/Profiler/DataCollector.php @@ -0,0 +1,128 @@ + + * + * 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 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 TraceableRegistry $registry, + ) { + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + } + + public function lateCollect(): void + { + $tools = []; + foreach ($this->registry->getTools()->references as $tool) { + $tools[] = [ + 'name' => $tool->name, + 'description' => $tool->description, + 'inputSchema' => $tool->inputSchema, + ]; + } + + $prompts = []; + foreach ($this->registry->getPrompts()->references as $prompt) { + $prompts[] = [ + 'name' => $prompt->name, + 'description' => $prompt->description, + 'arguments' => array_map(fn ($arg) => [ + 'name' => $arg->name, + 'description' => $arg->description, + 'required' => $arg->required, + ], $prompt->arguments ?? []), + ]; + } + + $resources = []; + foreach ($this->registry->getResources()->references as $resource) { + $resources[] = [ + 'uri' => $resource->uri, + 'name' => $resource->name, + 'description' => $resource->description, + 'mimeType' => $resource->mimeType, + ]; + } + + $resourceTemplates = []; + foreach ($this->registry->getResourceTemplates()->references as $template) { + $resourceTemplates[] = [ + 'uriTemplate' => $template->uriTemplate, + 'name' => $template->name, + 'description' => $template->description, + 'mimeType' => $template->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/TraceableRegistry.php b/src/mcp-bundle/src/Profiler/TraceableRegistry.php new file mode 100644 index 000000000..369d4fd4a --- /dev/null +++ b/src/mcp-bundle/src/Profiler/TraceableRegistry.php @@ -0,0 +1,140 @@ + + * + * 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\Capability\Discovery\DiscoveryState; +use Mcp\Capability\Registry\PromptReference; +use Mcp\Capability\Registry\ResourceReference; +use Mcp\Capability\Registry\ResourceTemplateReference; +use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Page; +use Mcp\Schema\Prompt; +use Mcp\Schema\Resource; +use Mcp\Schema\ResourceTemplate; +use Mcp\Schema\Tool; + +/** + * Decorator for Registry that provides access to capabilities for the profiler. + * + * @author Camille Islasse + */ +final class TraceableRegistry implements RegistryInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + ) { + } + + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void + { + $this->registry->registerTool($tool, $handler, $isManual); + } + + public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void + { + $this->registry->registerResource($resource, $handler, $isManual); + } + + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $this->registry->registerResourceTemplate($template, $handler, $completionProviders, $isManual); + } + + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void { + $this->registry->registerPrompt($prompt, $handler, $completionProviders, $isManual); + } + + public function clear(): void + { + $this->registry->clear(); + } + + public function getDiscoveryState(): DiscoveryState + { + return $this->registry->getDiscoveryState(); + } + + public function setDiscoveryState(DiscoveryState $state): void + { + $this->registry->setDiscoveryState($state); + } + + public function hasTools(): bool + { + return $this->registry->hasTools(); + } + + public function getTools(?int $limit = null, ?string $cursor = null): Page + { + return $this->registry->getTools($limit, $cursor); + } + + public function getTool(string $name): ToolReference + { + return $this->registry->getTool($name); + } + + public function hasResources(): bool + { + return $this->registry->hasResources(); + } + + public function getResources(?int $limit = null, ?string $cursor = null): Page + { + return $this->registry->getResources($limit, $cursor); + } + + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference + { + return $this->registry->getResource($uri, $includeTemplates); + } + + public function hasResourceTemplates(): bool + { + return $this->registry->hasResourceTemplates(); + } + + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page + { + return $this->registry->getResourceTemplates($limit, $cursor); + } + + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference + { + return $this->registry->getResourceTemplate($uriTemplate); + } + + public function hasPrompts(): bool + { + return $this->registry->hasPrompts(); + } + + public function getPrompts(?int $limit = null, ?string $cursor = null): Page + { + return $this->registry->getPrompts($limit, $cursor); + } + + public function getPrompt(string $name): PromptReference + { + return $this->registry->getPrompt($name); + } +} 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..50145eb47 --- /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 %}