Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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 %}
3 changes: 3 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.
50 changes: 50 additions & 0 deletions src/mcp-bundle/templates/prompts.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% if collector.prompts|length %}
<div class="sf-tabs">
{% for prompt in collector.prompts %}
<div class="tab">
<h3 class="tab-title">{{ prompt.name }}</h3>
<div class="tab-content">
{% if prompt.description %}
<p><strong>Description:</strong> {{ prompt.description }}</p>
{% endif %}

{% if prompt.arguments|length %}
<h4>Arguments</h4>
<table>
<thead>
<tr>
<th>Name</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for arg in prompt.arguments %}
<tr>
<td><code>{{ arg.name }}</code></td>
<td>
{% if arg.required %}
<span class="label status-error">Yes</span>
{% else %}
<span class="label">No</span>
{% endif %}
</td>
<td>{{ arg.description ?? '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
<p>This prompt has no arguments.</p>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">
<p>No prompts were registered.</p>
</div>
{% endif %}
Loading