Skip to content
This repository was archived by the owner on Nov 12, 2025. It is now read-only.

Commit 647caef

Browse files
committed
feat(mcp): add support for resources
- bump MCP protocol version to 2025-06-18
1 parent c78e28d commit 647caef

29 files changed

Lines changed: 1326 additions & 1 deletion

src/Attribute/AsResource.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Attribute;
6+
7+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
8+
class AsResource
9+
{
10+
public function __construct(
11+
public readonly string $uri, // Unique identifier for the resource
12+
public readonly string $name, // The name of the resource
13+
public readonly ?string $title = null, // Optional human-readable title
14+
public readonly ?string $description = null, // Optional description
15+
public readonly ?string $mimeType = null, // Optional MIME type of the resource
16+
public readonly ?string $size = null, // Optional size of the resource, in bytes
17+
) {
18+
}
19+
20+
public function isTemplatedResource(): bool
21+
{
22+
return str_contains($this->uri, '{') && str_contains($this->uri, '}');
23+
}
24+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Command;
6+
7+
use Ecourty\McpServerBundle\Resource\AbstractResourceDefinition;
8+
use Ecourty\McpServerBundle\Service\ResourceRegistry;
9+
use Symfony\Component\Console\Attribute\AsCommand;
10+
use Symfony\Component\Console\Command\Command;
11+
use Symfony\Component\Console\Input\InputArgument;
12+
use Symfony\Component\Console\Input\InputInterface;
13+
use Symfony\Component\Console\Output\OutputInterface;
14+
use Symfony\Component\Console\Style\SymfonyStyle;
15+
16+
/**
17+
* Command to display information about MCP resources.
18+
*
19+
* This command allows users to view details about specific MCP resources or all available resources.
20+
* It provides a table format for easy readability of resource attributes such as name, uri, description,
21+
* mimeType...
22+
*/
23+
#[AsCommand(
24+
name: 'debug:mcp-resources',
25+
description: 'Display current MCP resources',
26+
)]
27+
class DebugResourceCommand extends Command
28+
{
29+
public function __construct(
30+
private readonly ResourceRegistry $resourceRegistry,
31+
) {
32+
parent::__construct();
33+
}
34+
35+
protected function configure(): void
36+
{
37+
$this
38+
->addArgument('resource', InputArgument::OPTIONAL, 'Resource name');
39+
}
40+
41+
protected function execute(InputInterface $input, OutputInterface $output): int
42+
{
43+
$io = new SymfonyStyle($input, $output);
44+
45+
$resourceName = $input->getArgument('resource');
46+
47+
if ($resourceName !== null) {
48+
return $this->displaySingleResourceInformation($io, $resourceName);
49+
}
50+
51+
return $this->displayAllResourcesInformation($io);
52+
}
53+
54+
private function displaySingleResourceInformation(SymfonyStyle $io, string $resourceName): int
55+
{
56+
$resourceDefinition = $this->resourceRegistry->getResourceDefinition($resourceName);
57+
58+
if ($resourceDefinition === null) {
59+
$io->error(\sprintf('Resource "%s" not found.', $resourceName));
60+
61+
return self::FAILURE;
62+
}
63+
64+
$io->table(
65+
['Name', 'URI', 'Title', 'Description', 'MimeType', 'Size'],
66+
[
67+
[
68+
$resourceDefinition->name,
69+
$resourceDefinition->uri,
70+
$resourceDefinition->title,
71+
$resourceDefinition->description,
72+
$resourceDefinition->mimeType,
73+
$resourceDefinition->size ?? null,
74+
],
75+
],
76+
);
77+
78+
return self::SUCCESS;
79+
}
80+
81+
private function displayAllResourcesInformation(SymfonyStyle $io): int
82+
{
83+
$io->title('MCP Resources Debug Information');
84+
85+
$resourceDefinitions = $this->resourceRegistry->getResourceDefinitions();
86+
87+
if (empty($resourceDefinitions) === true) {
88+
$io->warning('No resources found.');
89+
90+
return self::SUCCESS;
91+
}
92+
93+
$io->table(
94+
['Name', 'URI', 'Title', 'Description', 'MimeType', 'Size'],
95+
array_map(static function (AbstractResourceDefinition $resourceDefinition) {
96+
return [
97+
$resourceDefinition->name,
98+
$resourceDefinition->uri,
99+
$resourceDefinition->title,
100+
$resourceDefinition->description,
101+
$resourceDefinition->mimeType,
102+
$resourceDefinition->size ?? null,
103+
];
104+
}, $resourceDefinitions),
105+
);
106+
107+
return self::SUCCESS;
108+
}
109+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\Contract\Resource;
6+
7+
interface ResourceResultInterface
8+
{
9+
public function toArray(): array;
10+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\DependencyInjection\CompilerPass;
6+
7+
use Ecourty\McpServerBundle\Attribute\AsResource;
8+
use Ecourty\McpServerBundle\IO\Resource\ResourceResult;
9+
use Ecourty\McpServerBundle\Service\ResourceRegistry;
10+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
11+
use Symfony\Component\DependencyInjection\ContainerBuilder;
12+
13+
class ResourcePass implements CompilerPassInterface
14+
{
15+
public function process(ContainerBuilder $container): void
16+
{
17+
$promptRegistry = $container->getDefinition(ResourceRegistry::class);
18+
19+
foreach ($container->getDefinitions() as $definition) {
20+
$class = $definition->getClass();
21+
22+
try {
23+
if ($class === null || class_exists($class) === false) {
24+
continue;
25+
}
26+
} catch (\Throwable) {
27+
continue;
28+
}
29+
30+
$refClass = new \ReflectionClass($class);
31+
32+
$attributes = $refClass->getAttributes(AsResource::class);
33+
if (\count($attributes) === 0) {
34+
continue;
35+
}
36+
37+
if (\count($attributes) > 1) {
38+
throw new \LogicException(\sprintf(
39+
'Multiple AsResource attributes found on class "%s". Only one is allowed.',
40+
$class,
41+
));
42+
}
43+
44+
if ($refClass->hasMethod('__invoke') === false) {
45+
throw new \LogicException(\sprintf(
46+
'Class "%s" must implement the __invoke method to be used as a resource.',
47+
$class,
48+
));
49+
}
50+
51+
/** @var AsResource|null $attr */
52+
$attr = $attributes[0]->newInstance();
53+
if ($attr === null) {
54+
throw new \LogicException(\sprintf(
55+
'Failed to instantiate AsResource attribute for class "%s".',
56+
$class,
57+
));
58+
}
59+
60+
$returnType = $refClass->getMethod('__invoke')->getReturnType();
61+
if ($returnType?->getName() !== ResourceResult::class) { // @phpstan-ignore method.notFound
62+
throw new \LogicException(\sprintf(
63+
'The __invoke method in class "%s" must return an instance of %s.',
64+
$class,
65+
ResourceResult::class,
66+
));
67+
}
68+
69+
$definition
70+
->addTag(name: 'mcp_server.resource', attributes: [
71+
'uri' => $attr->uri,
72+
]);
73+
74+
if ($attr->isTemplatedResource() === true) {
75+
$promptRegistry->addMethodCall(method: 'addTemplateResourceDefinition', arguments: [
76+
$attr->name,
77+
$attr->uri,
78+
$attr->title,
79+
$attr->description,
80+
$attr->mimeType,
81+
$attr->size,
82+
]);
83+
} else {
84+
$promptRegistry->addMethodCall(method: 'addDirectResourceDefinition', arguments: [
85+
$attr->name,
86+
$attr->uri,
87+
$attr->title,
88+
$attr->description,
89+
$attr->mimeType,
90+
]);
91+
}
92+
}
93+
}
94+
}

src/IO/Resource/BinaryResource.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\IO\Resource;
6+
7+
use Ecourty\McpServerBundle\Contract\Resource\ResourceResultInterface;
8+
9+
class BinaryResource implements ResourceResultInterface
10+
{
11+
public function __construct(
12+
private readonly string $uri,
13+
private readonly string $name,
14+
private readonly string $title,
15+
private readonly ?string $mimeType,
16+
private readonly string $blob,
17+
) {
18+
}
19+
20+
public function toArray(): array
21+
{
22+
return [
23+
'uri' => $this->uri,
24+
'name' => $this->name,
25+
'title' => $this->title,
26+
'mimeType' => $this->mimeType,
27+
'blob' => $this->blob,
28+
];
29+
}
30+
}

src/IO/Resource/ResourceResult.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\IO\Resource;
6+
7+
use Ecourty\McpServerBundle\Contract\Resource\ResourceResultInterface;
8+
9+
/**
10+
* Represents the result of a resource operation, containing an array of resources.
11+
*/
12+
class ResourceResult
13+
{
14+
/** @var ResourceResultInterface[] */
15+
private readonly array $resources;
16+
17+
public function __construct(
18+
array $resources = [],
19+
) {
20+
foreach ($resources as $resource) {
21+
if ($resource instanceof ResourceResultInterface === false) {
22+
throw new \InvalidArgumentException(
23+
'All resources must implement ResourceResultInterface.',
24+
);
25+
}
26+
}
27+
28+
$this->resources = $resources;
29+
}
30+
31+
/**
32+
* @return array[]
33+
*/
34+
public function toArray(): array
35+
{
36+
return array_map(function (ResourceResultInterface $resource) {
37+
return $resource->toArray();
38+
}, $this->resources);
39+
}
40+
}

src/IO/Resource/TextResource.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ecourty\McpServerBundle\IO\Resource;
6+
7+
use Ecourty\McpServerBundle\Contract\Resource\ResourceResultInterface;
8+
9+
class TextResource implements ResourceResultInterface
10+
{
11+
public function __construct(
12+
private readonly string $uri,
13+
private readonly string $name,
14+
private readonly string $title,
15+
private readonly ?string $mimeType,
16+
private readonly string $text,
17+
) {
18+
}
19+
20+
public function toArray(): array
21+
{
22+
return [
23+
'uri' => $this->uri,
24+
'name' => $this->name,
25+
'title' => $this->title,
26+
'mimeType' => $this->mimeType,
27+
'text' => $this->text,
28+
];
29+
}
30+
}

src/McpServerBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\MethodHandlerPass;
88
use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\PromptPass;
9+
use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\ResourcePass;
910
use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\ToolPass;
1011
use Ecourty\McpServerBundle\DependencyInjection\McpServerBundleExtension;
1112
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -31,5 +32,6 @@ public function build(ContainerBuilder $container): void
3132
$container->addCompilerPass(new MethodHandlerPass());
3233
$container->addCompilerPass(new ToolPass());
3334
$container->addCompilerPass(new PromptPass());
35+
$container->addCompilerPass(new ResourcePass());
3436
}
3537
}

src/MethodHandler/InitializeMethodHandler.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#[AsMethodHandler(methodName: 'initialize')]
2020
class InitializeMethodHandler implements MethodHandlerInterface
2121
{
22-
public const string PROTOCOL_VERSION = '2025-03-26';
22+
public const string PROTOCOL_VERSION = '2025-06-18';
2323

2424
public function __construct(
2525
private readonly string $serverName,
@@ -41,6 +41,10 @@ public function handle(JsonRpcRequest $request): array
4141
'tools' => [
4242
'listChanged' => false,
4343
],
44+
'resources' => [
45+
'subscribe' => false,
46+
'listChanged' => false,
47+
],
4448
],
4549
'serverInfo' => [
4650
'name' => $this->serverName,

0 commit comments

Comments
 (0)