Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes to `mcp/sdk` will be documented in this file.
-----

* Allow overriding the default name pattern for Discovery
* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering.
* Add `RegistryInterface::unregisterTool()`, `unregisterResource()`, `unregisterResourceTemplate()`, `unregisterPrompt()` — idempotent removals.
* Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks.
* `DiscoveryLoader` now refreshes only its own previously written entries; manual registrations (via `Builder::addTool()` etc. or runtime `$registry->registerTool()` calls) survive rediscovery, and a same-name manual registration takes precedence over discovery on collision.
* [BC Break] Removed `ElementReference::$isManual` public property and the `bool $isManual` parameter from all `*Reference` constructors. Origin tracking is no longer carried on the element; manual-over-discovered precedence is encoded by loader execution order.
* [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag.
* [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly.
* `Registry::register*()` semantics changed to plain last-write-wins (overwrites silently) and the methods now return the stored `*Reference`. The previous "discovered registration is ignored when a manual one already exists" precedence rule still applies, but is now enforced by `DiscoveryLoader` via reference-identity tracking — and still emits a debug log when a discovery is skipped due to a conflicting registration.

0.5.0
-----
Expand Down
8 changes: 4 additions & 4 deletions src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
meta: $instance->meta,
outputSchema: $outputSchema,
);
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
$tools[$name] = new ToolReference($tool, [$className, $methodName]);
++$discoveredCount['tools'];
break;

Expand All @@ -261,7 +261,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
$instance->icons,
$instance->meta,
);
$resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false);
$resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]);

++$discoveredCount['resources'];
break;
Expand All @@ -282,7 +282,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
}
$prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta);
$completionProviders = $this->getCompletionProviders($method);
$prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders);
$prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders);
++$discoveredCount['prompts'];
break;

Expand All @@ -295,7 +295,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
$meta = $instance->meta ?? null;
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta);
$completionProviders = $this->getCompletionProviders($method);
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders);
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders);
++$discoveredCount['resourceTemplates'];
break;
}
Expand Down
17 changes: 17 additions & 0 deletions src/Capability/Discovery/DiscoveryState.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ public function getResourceTemplates(): array
return $this->resourceTemplates;
}

/**
* Returns the subset of this state whose keys are absent from $next.
*
* Asymmetric by design: entries whose keys exist in both states are excluded
* regardless of value. Used to identify owned entries that a fresh discovery
* no longer produces.
*/
public function obsoletedBy(self $next): self
{
return new self(
array_diff_key($this->tools, $next->tools),
array_diff_key($this->resources, $next->resources),
array_diff_key($this->prompts, $next->prompts),
array_diff_key($this->resourceTemplates, $next->resourceTemplates),
);
}

/**
* Check if this state contains any discovered elements.
*/
Expand Down
215 changes: 76 additions & 139 deletions src/Capability/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace Mcp\Capability;

use Mcp\Capability\Discovery\DiscoveryState;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ResourceReference;
use Mcp\Capability\Registry\ResourceTemplateReference;
Expand Down Expand Up @@ -68,129 +67,120 @@ public function __construct(
) {
}

public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
{
$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)) {
if (!$this->nameValidator->isValid($tool->name)) {
$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),
\sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $tool->name),
);
}

$this->tools[$toolName] = new ToolReference($tool, $handler, $isManual);
$reference = new ToolReference($tool, $handler);
$this->tools[$tool->name] = $reference;

$this->eventDispatcher?->dispatch(new ToolListChangedEvent());

return $reference;
}

public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference
{
$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);
$reference = new ResourceReference($resource, $handler);
$this->resources[$resource->uri] = $reference;

$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());

return $reference;
}

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,
);
): ResourceTemplateReference {
$reference = new ResourceTemplateReference($template, $handler, $completionProviders);
$this->resourceTemplates[$template->uriTemplate] = $reference;

$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());

return $reference;
}

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),
);
): PromptReference {
$reference = new PromptReference($prompt, $handler, $completionProviders);
$this->prompts[$prompt->name] = $reference;

$this->eventDispatcher?->dispatch(new PromptListChangedEvent());

return $reference;
}

public function unregisterTool(string $name): void
{
if (!isset($this->tools[$name])) {
return;
}

$this->prompts[$promptName] = new PromptReference($prompt, $handler, $isManual, $completionProviders);
unset($this->tools[$name]);

$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
}

public function clear(): void
public function unregisterResource(string $uri): 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;
}
if (!isset($this->resources[$uri])) {
return;
}
foreach ($this->resourceTemplates as $uriTemplate => $template) {
if (!$template->isManual) {
unset($this->resourceTemplates[$uriTemplate]);
++$clearCount;
}

unset($this->resources[$uri]);

$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
}

public function unregisterResourceTemplate(string $uriTemplate): void
{
if (!isset($this->resourceTemplates[$uriTemplate])) {
return;
}

if ($clearCount > 0) {
$this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount));
unset($this->resourceTemplates[$uriTemplate]);

$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
}

public function unregisterPrompt(string $name): void
{
if (!isset($this->prompts[$name])) {
return;
}

unset($this->prompts[$name]);

$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
}

public function hasTool(string $name): bool
{
return isset($this->tools[$name]);
}

public function hasResource(string $uri): bool
{
return isset($this->resources[$uri]);
}

public function hasResourceTemplate(string $uriTemplate): bool
{
return isset($this->resourceTemplates[$uriTemplate]);
}

public function hasPrompt(string $name): bool
{
return isset($this->prompts[$name]);
}

public function hasTools(): bool
Expand Down Expand Up @@ -338,59 +328,6 @@ 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, static fn ($tool) => !$tool->isManual),
resources: array_filter($this->resources, static fn ($resource) => !$resource->isManual),
prompts: array_filter($this->prompts, static fn ($prompt) => !$prompt->isManual),
resourceTemplates: array_filter($this->resourceTemplates, static fn ($template) => !$template->isManual),
);
}

/**
* Set the 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.
*
Expand Down
1 change: 0 additions & 1 deletion src/Capability/Registry/ElementReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ class ElementReference
*/
public function __construct(
public readonly \Closure|array|string $handler,
public readonly bool $isManual = false,
) {
}
}
8 changes: 4 additions & 4 deletions src/Capability/Registry/Loader/ArrayLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public function load(RegistryInterface $registry): void
meta: $data['meta'] ?? null,
outputSchema: $data['outputSchema'] ?? null,
);
$registry->registerTool($tool, $data['handler'], true);
$registry->registerTool($tool, $data['handler']);

$handlerDesc = $this->getHandlerDescription($data['handler']);
$this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}");
Expand Down Expand Up @@ -164,7 +164,7 @@ public function load(RegistryInterface $registry): void
icons: $data['icons'] ?? null,
meta: $data['meta'] ?? null,
);
$registry->registerResource($resource, $data['handler'], true);
$registry->registerResource($resource, $data['handler']);

$handlerDesc = $this->getHandlerDescription($data['handler']);
$this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}");
Expand Down Expand Up @@ -203,7 +203,7 @@ public function load(RegistryInterface $registry): void
meta: $data['meta'] ?? null,
);
$completionProviders = $this->getCompletionProviders($reflection);
$registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true);
$registry->registerResourceTemplate($template, $data['handler'], $completionProviders);

$handlerDesc = $this->getHandlerDescription($data['handler']);
$this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}");
Expand Down Expand Up @@ -261,7 +261,7 @@ public function load(RegistryInterface $registry): void
meta: $data['meta'] ?? null
);
$completionProviders = $this->getCompletionProviders($reflection);
$registry->registerPrompt($prompt, $data['handler'], $completionProviders, true);
$registry->registerPrompt($prompt, $data['handler'], $completionProviders);

$handlerDesc = $this->getHandlerDescription($data['handler']);
$this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}");
Expand Down
Loading