452 lines
14 KiB
PHP
452 lines
14 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the official PHP MCP SDK.
|
|
*
|
|
* A collaboration between Symfony and the PHP Foundation.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Mcp\Capability;
|
|
|
|
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\Tool\NameValidator;
|
|
use Mcp\Event\PromptListChangedEvent;
|
|
use Mcp\Event\ResourceListChangedEvent;
|
|
use Mcp\Event\ResourceTemplateListChangedEvent;
|
|
use Mcp\Event\ToolListChangedEvent;
|
|
use Mcp\Exception\InvalidCursorException;
|
|
use Mcp\Exception\PromptNotFoundException;
|
|
use Mcp\Exception\ResourceNotFoundException;
|
|
use Mcp\Exception\ToolNotFoundException;
|
|
use Mcp\Schema\Page;
|
|
use Mcp\Schema\Prompt;
|
|
use Mcp\Schema\Resource;
|
|
use Mcp\Schema\ResourceTemplate;
|
|
use Mcp\Schema\Tool;
|
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\NullLogger;
|
|
|
|
/**
|
|
* Registry implementation that manages MCP element registration and access.
|
|
*
|
|
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
|
*/
|
|
final class Registry implements RegistryInterface
|
|
{
|
|
/**
|
|
* @var array<string, ToolReference>
|
|
*/
|
|
private array $tools = [];
|
|
|
|
/**
|
|
* @var array<string, ResourceReference>
|
|
*/
|
|
private array $resources = [];
|
|
|
|
/**
|
|
* @var array<string, PromptReference>
|
|
*/
|
|
private array $prompts = [];
|
|
|
|
/**
|
|
* @var array<string, ResourceTemplateReference>
|
|
*/
|
|
private array $resourceTemplates = [];
|
|
|
|
public function __construct(
|
|
private readonly ?EventDispatcherInterface $eventDispatcher = null,
|
|
private readonly LoggerInterface $logger = new NullLogger(),
|
|
private readonly NameValidator $nameValidator = new NameValidator(),
|
|
) {
|
|
}
|
|
|
|
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
|
|
{
|
|
$toolName = $tool->name;
|
|
$existing = $this->tools[$toolName] ?? null;
|
|
|
|
if ($existing && !$isManual && $existing->isManual) {
|
|
$this->logger->debug(
|
|
\sprintf('Ignoring discovered tool "%s" as it conflicts with a manually registered one.', $toolName),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->nameValidator->isValid($toolName)) {
|
|
$this->logger->warning(
|
|
\sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $toolName),
|
|
);
|
|
}
|
|
|
|
$this->tools[$toolName] = new ToolReference($tool, $handler, $isManual);
|
|
|
|
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
|
|
}
|
|
|
|
public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
|
|
{
|
|
$uri = $resource->uri;
|
|
$existing = $this->resources[$uri] ?? null;
|
|
|
|
if ($existing && !$isManual && $existing->isManual) {
|
|
$this->logger->debug(
|
|
\sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->resources[$uri] = new ResourceReference($resource, $handler, $isManual);
|
|
|
|
$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
|
|
}
|
|
|
|
public function registerResourceTemplate(
|
|
ResourceTemplate $template,
|
|
callable|array|string $handler,
|
|
array $completionProviders = [],
|
|
bool $isManual = false,
|
|
): void {
|
|
$uriTemplate = $template->uriTemplate;
|
|
$existing = $this->resourceTemplates[$uriTemplate] ?? null;
|
|
|
|
if ($existing && !$isManual && $existing->isManual) {
|
|
$this->logger->debug(
|
|
\sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference(
|
|
$template,
|
|
$handler,
|
|
$isManual,
|
|
$completionProviders,
|
|
);
|
|
|
|
$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
|
|
}
|
|
|
|
public function registerPrompt(
|
|
Prompt $prompt,
|
|
callable|array|string $handler,
|
|
array $completionProviders = [],
|
|
bool $isManual = false,
|
|
): void {
|
|
$promptName = $prompt->name;
|
|
$existing = $this->prompts[$promptName] ?? null;
|
|
|
|
if ($existing && !$isManual && $existing->isManual) {
|
|
$this->logger->debug(
|
|
\sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders);
|
|
|
|
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
|
|
}
|
|
|
|
public function clear(): void
|
|
{
|
|
$clearCount = 0;
|
|
|
|
foreach ($this->tools as $name => $tool) {
|
|
if (!$tool->isManual) {
|
|
unset($this->tools[$name]);
|
|
++$clearCount;
|
|
}
|
|
}
|
|
foreach ($this->resources as $uri => $resource) {
|
|
if (!$resource->isManual) {
|
|
unset($this->resources[$uri]);
|
|
++$clearCount;
|
|
}
|
|
}
|
|
foreach ($this->prompts as $name => $prompt) {
|
|
if (!$prompt->isManual) {
|
|
unset($this->prompts[$name]);
|
|
++$clearCount;
|
|
}
|
|
}
|
|
foreach ($this->resourceTemplates as $uriTemplate => $template) {
|
|
if (!$template->isManual) {
|
|
unset($this->resourceTemplates[$uriTemplate]);
|
|
++$clearCount;
|
|
}
|
|
}
|
|
|
|
if ($clearCount > 0) {
|
|
$this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount));
|
|
}
|
|
}
|
|
|
|
public function hasTools(): bool
|
|
{
|
|
return [] !== $this->tools;
|
|
}
|
|
|
|
public function getTools(?int $limit = null, ?string $cursor = null): Page
|
|
{
|
|
$tools = [];
|
|
foreach ($this->tools as $toolReference) {
|
|
$tools[$toolReference->tool->name] = $toolReference->tool;
|
|
}
|
|
|
|
if (null === $limit) {
|
|
return new Page($tools, null);
|
|
}
|
|
|
|
$paginatedTools = $this->paginateResults($tools, $limit, $cursor);
|
|
|
|
$nextCursor = $this->calculateNextCursor(
|
|
\count($tools),
|
|
$cursor,
|
|
$limit
|
|
);
|
|
|
|
return new Page($paginatedTools, $nextCursor);
|
|
}
|
|
|
|
public function getTool(string $name): ToolReference
|
|
{
|
|
return $this->tools[$name] ?? throw new ToolNotFoundException($name);
|
|
}
|
|
|
|
public function hasResources(): bool
|
|
{
|
|
return [] !== $this->resources;
|
|
}
|
|
|
|
public function getResources(?int $limit = null, ?string $cursor = null): Page
|
|
{
|
|
$resources = [];
|
|
foreach ($this->resources as $resourceReference) {
|
|
$resources[$resourceReference->schema->uri] = $resourceReference->schema;
|
|
}
|
|
|
|
if (null === $limit) {
|
|
return new Page($resources, null);
|
|
}
|
|
|
|
$paginatedResources = $this->paginateResults($resources, $limit, $cursor);
|
|
|
|
$nextCursor = $this->calculateNextCursor(
|
|
\count($resources),
|
|
$cursor,
|
|
$limit
|
|
);
|
|
|
|
return new Page($paginatedResources, $nextCursor);
|
|
}
|
|
|
|
public function getResource(
|
|
string $uri,
|
|
bool $includeTemplates = true,
|
|
): ResourceReference|ResourceTemplateReference {
|
|
$registration = $this->resources[$uri] ?? null;
|
|
if ($registration) {
|
|
return $registration;
|
|
}
|
|
|
|
if ($includeTemplates) {
|
|
foreach ($this->resourceTemplates as $template) {
|
|
if ($template->matches($uri)) {
|
|
return $template;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->logger->debug('No resource matched URI.', ['uri' => $uri]);
|
|
|
|
throw new ResourceNotFoundException($uri);
|
|
}
|
|
|
|
public function hasResourceTemplates(): bool
|
|
{
|
|
return [] !== $this->resourceTemplates;
|
|
}
|
|
|
|
public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page
|
|
{
|
|
$templates = [];
|
|
foreach ($this->resourceTemplates as $templateReference) {
|
|
$templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate;
|
|
}
|
|
|
|
if (null === $limit) {
|
|
return new Page($templates, null);
|
|
}
|
|
|
|
$paginatedTemplates = $this->paginateResults($templates, $limit, $cursor);
|
|
|
|
$nextCursor = $this->calculateNextCursor(
|
|
\count($templates),
|
|
$cursor,
|
|
$limit
|
|
);
|
|
|
|
return new Page($paginatedTemplates, $nextCursor);
|
|
}
|
|
|
|
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
|
|
{
|
|
return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate);
|
|
}
|
|
|
|
public function hasPrompts(): bool
|
|
{
|
|
return [] !== $this->prompts;
|
|
}
|
|
|
|
public function getPrompts(?int $limit = null, ?string $cursor = null): Page
|
|
{
|
|
$prompts = [];
|
|
foreach ($this->prompts as $promptReference) {
|
|
$prompts[$promptReference->prompt->name] = $promptReference->prompt;
|
|
}
|
|
|
|
if (null === $limit) {
|
|
return new Page($prompts, null);
|
|
}
|
|
|
|
$paginatedPrompts = $this->paginateResults($prompts, $limit, $cursor);
|
|
|
|
$nextCursor = $this->calculateNextCursor(
|
|
\count($prompts),
|
|
$cursor,
|
|
$limit
|
|
);
|
|
|
|
return new Page($paginatedPrompts, $nextCursor);
|
|
}
|
|
|
|
public function getPrompt(string $name): PromptReference
|
|
{
|
|
return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
|
|
}
|
|
|
|
/**
|
|
* Get the current discovery state (only discovered elements, not manual ones).
|
|
*/
|
|
public function getDiscoveryState(): DiscoveryState
|
|
{
|
|
return new DiscoveryState(
|
|
tools: array_filter($this->tools, fn ($tool) => !$tool->isManual),
|
|
resources: array_filter($this->resources, fn ($resource) => !$resource->isManual),
|
|
prompts: array_filter($this->prompts, fn ($prompt) => !$prompt->isManual),
|
|
resourceTemplates: array_filter($this->resourceTemplates, fn ($template) => !$template->isManual),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set discovery state, replacing all discovered elements.
|
|
* Manual elements are preserved.
|
|
*/
|
|
public function setDiscoveryState(DiscoveryState $state): void
|
|
{
|
|
// Clear existing discovered elements
|
|
$this->clear();
|
|
|
|
// Import new discovered elements
|
|
foreach ($state->getTools() as $name => $tool) {
|
|
$this->tools[$name] = $tool;
|
|
}
|
|
|
|
foreach ($state->getResources() as $uri => $resource) {
|
|
$this->resources[$uri] = $resource;
|
|
}
|
|
|
|
foreach ($state->getPrompts() as $name => $prompt) {
|
|
$this->prompts[$name] = $prompt;
|
|
}
|
|
|
|
foreach ($state->getResourceTemplates() as $uriTemplate => $template) {
|
|
$this->resourceTemplates[$uriTemplate] = $template;
|
|
}
|
|
|
|
// Dispatch events for the imported elements
|
|
if ($this->eventDispatcher instanceof EventDispatcherInterface) {
|
|
if (!empty($state->getTools())) {
|
|
$this->eventDispatcher->dispatch(new ToolListChangedEvent());
|
|
}
|
|
if (!empty($state->getResources()) || !empty($state->getResourceTemplates())) {
|
|
$this->eventDispatcher->dispatch(new ResourceListChangedEvent());
|
|
}
|
|
if (!empty($state->getPrompts())) {
|
|
$this->eventDispatcher->dispatch(new PromptListChangedEvent());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate next cursor for pagination.
|
|
*
|
|
* @param int $totalItems Count of all items
|
|
* @param string|null $currentCursor Current cursor position
|
|
* @param int $limit Number requested/returned per page
|
|
*/
|
|
private function calculateNextCursor(int $totalItems, ?string $currentCursor, int $limit): ?string
|
|
{
|
|
$currentOffset = 0;
|
|
|
|
if (null !== $currentCursor) {
|
|
$decodedCursor = base64_decode($currentCursor, true);
|
|
if (false !== $decodedCursor && is_numeric($decodedCursor)) {
|
|
$currentOffset = (int) $decodedCursor;
|
|
}
|
|
}
|
|
|
|
$nextOffset = $currentOffset + $limit;
|
|
|
|
if ($nextOffset < $totalItems) {
|
|
return base64_encode((string) $nextOffset);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Helper method to paginate results using cursor-based pagination.
|
|
*
|
|
* @param array<int|string, mixed> $items The full array of items to paginate The full array of items to paginate
|
|
* @param int $limit Maximum number of items to return
|
|
* @param string|null $cursor Base64 encoded offset position
|
|
*
|
|
* @return array<int|string, mixed> Paginated results
|
|
*
|
|
* @throws InvalidCursorException When cursor is invalid (MCP error code -32602)
|
|
*/
|
|
private function paginateResults(array $items, int $limit, ?string $cursor = null): array
|
|
{
|
|
$offset = 0;
|
|
if (null !== $cursor) {
|
|
$decodedCursor = base64_decode($cursor, true);
|
|
|
|
if (false === $decodedCursor || !is_numeric($decodedCursor)) {
|
|
throw new InvalidCursorException($cursor);
|
|
}
|
|
|
|
$offset = (int) $decodedCursor;
|
|
|
|
// Validate offset is within reasonable bounds
|
|
if ($offset < 0 || $offset > \count($items)) {
|
|
throw new InvalidCursorException($cursor);
|
|
}
|
|
}
|
|
|
|
return array_values(\array_slice($items, $offset, $limit));
|
|
}
|
|
}
|