Skip to content

Commit ea5c5ec

Browse files
chr-hertelclaude
andcommitted
[Server] Untangle origin tracking from Registry
Drop the isManual flag from ElementReference and the discovery-state methods from RegistryInterface. The Registry becomes a flat last-write-wins map; DiscoveryLoader owns its own owned-set bookkeeping so re-running discovery only removes elements it itself contributed. Adds ChainLoader for explicit composition. Manual-over-discovered precedence is preserved via loader ordering, not a flag on the reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06917f6 commit ea5c5ec

23 files changed

Lines changed: 839 additions & 338 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to `mcp/sdk` will be documented in this file.
66
-----
77

88
* Allow overriding the default name pattern for Discovery
9+
* Add `ChainLoader` to compose multiple `LoaderInterface` implementations via explicit ordering.
10+
* Add `RegistryInterface::unregisterTool()`, `unregisterResource()`, `unregisterResourceTemplate()`, `unregisterPrompt()` — idempotent removals.
11+
* Add `RegistryInterface::hasTool()`, `hasResource()`, `hasResourceTemplate()`, `hasPrompt()` — by-name existence checks.
12+
* `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.
13+
* [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.
14+
* [BC Break] `RegistryInterface::registerTool()`, `registerResource()`, `registerResourceTemplate()`, `registerPrompt()` lost their trailing `bool $isManual = false` parameter. Callers using positional arguments must drop the flag.
15+
* [BC Break] Removed `RegistryInterface::clear()`, `getDiscoveryState()`, `setDiscoveryState()`. Rediscovery now goes through `DiscoveryLoader::load()` directly.
16+
* `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.
917

1018
0.5.0
1119
-----

src/Capability/Discovery/Discoverer.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
243243
meta: $instance->meta,
244244
outputSchema: $outputSchema,
245245
);
246-
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
246+
$tools[$name] = new ToolReference($tool, [$className, $methodName]);
247247
++$discoveredCount['tools'];
248248
break;
249249

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

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

@@ -295,7 +295,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
295295
$meta = $instance->meta ?? null;
296296
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta);
297297
$completionProviders = $this->getCompletionProviders($method);
298-
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders);
298+
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders);
299299
++$discoveredCount['resourceTemplates'];
300300
break;
301301
}

src/Capability/Discovery/DiscoveryState.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ public function getResourceTemplates(): array
7272
return $this->resourceTemplates;
7373
}
7474

75+
/**
76+
* Returns the subset of this state whose keys are absent from $next.
77+
*
78+
* Asymmetric by design: entries whose keys exist in both states are excluded
79+
* regardless of value. Used to identify owned entries that a fresh discovery
80+
* no longer produces.
81+
*/
82+
public function obsoletedBy(self $next): self
83+
{
84+
return new self(
85+
array_diff_key($this->tools, $next->tools),
86+
array_diff_key($this->resources, $next->resources),
87+
array_diff_key($this->prompts, $next->prompts),
88+
array_diff_key($this->resourceTemplates, $next->resourceTemplates),
89+
);
90+
}
91+
7592
/**
7693
* Check if this state contains any discovered elements.
7794
*/

src/Capability/Registry.php

Lines changed: 76 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
namespace Mcp\Capability;
1313

14-
use Mcp\Capability\Discovery\DiscoveryState;
1514
use Mcp\Capability\Registry\PromptReference;
1615
use Mcp\Capability\Registry\ResourceReference;
1716
use Mcp\Capability\Registry\ResourceTemplateReference;
@@ -68,129 +67,120 @@ public function __construct(
6867
) {
6968
}
7069

71-
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
70+
public function registerTool(Tool $tool, callable|array|string $handler): ToolReference
7271
{
73-
$toolName = $tool->name;
74-
$existing = $this->tools[$toolName] ?? null;
75-
76-
if ($existing && !$isManual && $existing->isManual) {
77-
$this->logger->debug(
78-
\sprintf('Ignoring discovered tool "%s" as it conflicts with a manually registered one.', $toolName),
79-
);
80-
81-
return;
82-
}
83-
84-
if (!$this->nameValidator->isValid($toolName)) {
72+
if (!$this->nameValidator->isValid($tool->name)) {
8573
$this->logger->warning(
86-
\sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $toolName),
74+
\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),
8775
);
8876
}
8977

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

9281
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
82+
83+
return $reference;
9384
}
9485

95-
public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
86+
public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference
9687
{
97-
$uri = $resource->uri;
98-
$existing = $this->resources[$uri] ?? null;
99-
100-
if ($existing && !$isManual && $existing->isManual) {
101-
$this->logger->debug(
102-
\sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri),
103-
);
104-
105-
return;
106-
}
107-
108-
$this->resources[$uri] = new ResourceReference($resource, $handler, $isManual);
88+
$reference = new ResourceReference($resource, $handler);
89+
$this->resources[$resource->uri] = $reference;
10990

11091
$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
92+
93+
return $reference;
11194
}
11295

11396
public function registerResourceTemplate(
11497
ResourceTemplate $template,
11598
callable|array|string $handler,
11699
array $completionProviders = [],
117-
bool $isManual = false,
118-
): void {
119-
$uriTemplate = $template->uriTemplate;
120-
$existing = $this->resourceTemplates[$uriTemplate] ?? null;
121-
122-
if ($existing && !$isManual && $existing->isManual) {
123-
$this->logger->debug(
124-
\sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate),
125-
);
126-
127-
return;
128-
}
129-
130-
$this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference(
131-
$template,
132-
$handler,
133-
$isManual,
134-
$completionProviders,
135-
);
100+
): ResourceTemplateReference {
101+
$reference = new ResourceTemplateReference($template, $handler, $completionProviders);
102+
$this->resourceTemplates[$template->uriTemplate] = $reference;
136103

137104
$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
105+
106+
return $reference;
138107
}
139108

140109
public function registerPrompt(
141110
Prompt $prompt,
142111
callable|array|string $handler,
143112
array $completionProviders = [],
144-
bool $isManual = false,
145-
): void {
146-
$promptName = $prompt->name;
147-
$existing = $this->prompts[$promptName] ?? null;
148-
149-
if ($existing && !$isManual && $existing->isManual) {
150-
$this->logger->debug(
151-
\sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName),
152-
);
113+
): PromptReference {
114+
$reference = new PromptReference($prompt, $handler, $completionProviders);
115+
$this->prompts[$prompt->name] = $reference;
153116

117+
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
118+
119+
return $reference;
120+
}
121+
122+
public function unregisterTool(string $name): void
123+
{
124+
if (!isset($this->tools[$name])) {
154125
return;
155126
}
156127

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

159-
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
130+
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
160131
}
161132

162-
public function clear(): void
133+
public function unregisterResource(string $uri): void
163134
{
164-
$clearCount = 0;
165-
166-
foreach ($this->tools as $name => $tool) {
167-
if (!$tool->isManual) {
168-
unset($this->tools[$name]);
169-
++$clearCount;
170-
}
171-
}
172-
foreach ($this->resources as $uri => $resource) {
173-
if (!$resource->isManual) {
174-
unset($this->resources[$uri]);
175-
++$clearCount;
176-
}
177-
}
178-
foreach ($this->prompts as $name => $prompt) {
179-
if (!$prompt->isManual) {
180-
unset($this->prompts[$name]);
181-
++$clearCount;
182-
}
135+
if (!isset($this->resources[$uri])) {
136+
return;
183137
}
184-
foreach ($this->resourceTemplates as $uriTemplate => $template) {
185-
if (!$template->isManual) {
186-
unset($this->resourceTemplates[$uriTemplate]);
187-
++$clearCount;
188-
}
138+
139+
unset($this->resources[$uri]);
140+
141+
$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
142+
}
143+
144+
public function unregisterResourceTemplate(string $uriTemplate): void
145+
{
146+
if (!isset($this->resourceTemplates[$uriTemplate])) {
147+
return;
189148
}
190149

191-
if ($clearCount > 0) {
192-
$this->logger->debug(\sprintf('Removed %d discovered elements from internal registry.', $clearCount));
150+
unset($this->resourceTemplates[$uriTemplate]);
151+
152+
$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
153+
}
154+
155+
public function unregisterPrompt(string $name): void
156+
{
157+
if (!isset($this->prompts[$name])) {
158+
return;
193159
}
160+
161+
unset($this->prompts[$name]);
162+
163+
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
164+
}
165+
166+
public function hasTool(string $name): bool
167+
{
168+
return isset($this->tools[$name]);
169+
}
170+
171+
public function hasResource(string $uri): bool
172+
{
173+
return isset($this->resources[$uri]);
174+
}
175+
176+
public function hasResourceTemplate(string $uriTemplate): bool
177+
{
178+
return isset($this->resourceTemplates[$uriTemplate]);
179+
}
180+
181+
public function hasPrompt(string $name): bool
182+
{
183+
return isset($this->prompts[$name]);
194184
}
195185

196186
public function hasTools(): bool
@@ -338,59 +328,6 @@ public function getPrompt(string $name): PromptReference
338328
return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
339329
}
340330

341-
/**
342-
* Get the current discovery state (only discovered elements, not manual ones).
343-
*/
344-
public function getDiscoveryState(): DiscoveryState
345-
{
346-
return new DiscoveryState(
347-
tools: array_filter($this->tools, static fn ($tool) => !$tool->isManual),
348-
resources: array_filter($this->resources, static fn ($resource) => !$resource->isManual),
349-
prompts: array_filter($this->prompts, static fn ($prompt) => !$prompt->isManual),
350-
resourceTemplates: array_filter($this->resourceTemplates, static fn ($template) => !$template->isManual),
351-
);
352-
}
353-
354-
/**
355-
* Set the discovery state, replacing all discovered elements.
356-
* Manual elements are preserved.
357-
*/
358-
public function setDiscoveryState(DiscoveryState $state): void
359-
{
360-
// Clear existing discovered elements
361-
$this->clear();
362-
363-
// Import new discovered elements
364-
foreach ($state->getTools() as $name => $tool) {
365-
$this->tools[$name] = $tool;
366-
}
367-
368-
foreach ($state->getResources() as $uri => $resource) {
369-
$this->resources[$uri] = $resource;
370-
}
371-
372-
foreach ($state->getPrompts() as $name => $prompt) {
373-
$this->prompts[$name] = $prompt;
374-
}
375-
376-
foreach ($state->getResourceTemplates() as $uriTemplate => $template) {
377-
$this->resourceTemplates[$uriTemplate] = $template;
378-
}
379-
380-
// Dispatch events for the imported elements
381-
if ($this->eventDispatcher instanceof EventDispatcherInterface) {
382-
if (!empty($state->getTools())) {
383-
$this->eventDispatcher->dispatch(new ToolListChangedEvent());
384-
}
385-
if (!empty($state->getResources()) || !empty($state->getResourceTemplates())) {
386-
$this->eventDispatcher->dispatch(new ResourceListChangedEvent());
387-
}
388-
if (!empty($state->getPrompts())) {
389-
$this->eventDispatcher->dispatch(new PromptListChangedEvent());
390-
}
391-
}
392-
}
393-
394331
/**
395332
* Calculate next cursor for pagination.
396333
*

src/Capability/Registry/ElementReference.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ class ElementReference
2323
*/
2424
public function __construct(
2525
public readonly \Closure|array|string $handler,
26-
public readonly bool $isManual = false,
2726
) {
2827
}
2928
}

src/Capability/Registry/Loader/ArrayLoader.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public function load(RegistryInterface $registry): void
124124
meta: $data['meta'] ?? null,
125125
outputSchema: $data['outputSchema'] ?? null,
126126
);
127-
$registry->registerTool($tool, $data['handler'], true);
127+
$registry->registerTool($tool, $data['handler']);
128128

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

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

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

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

0 commit comments

Comments
 (0)