fix(server): reject duplicate feature names on registration (#883)

Reject duplicate feature registration instead of silently overwriting
the existing entry.

## Motivation and Context

`FeatureRegistry.add`/`addAll` silently replaced a feature registered
under the same key and emitted a spurious `list_changed` notification.
Tool/prompt names and resource URIs are unique identifiers in MCP, and
the TypeScript SDK already rejects duplicates at registration time
(`Tool X is already registered`).

- `addTool`/`addPrompt`/`addResource`/`addResourceTemplate` now throw
`IllegalArgumentException` on a duplicate name/URI; the existing
registration stays intact and no notification is emitted.
- `addTools`/`addPrompts`/`addResources` are all-or-nothing: batches
with intra-batch duplicates or conflicts with existing keys are rejected
without registering anything.
- To replace a feature, remove it first (`removeTool` + `addTool`),
matching the TypeScript SDK's remove-then-re-register path. No new
public API.

## How Has This Been Tested?

New `FeatureRegistryTest` unit suite (duplicate rejection, batch
atomicity, no notification on rejection, re-add after remove) plus
integration tests for tools, prompts, resources, and resource templates.
The check also surfaced a real duplicate `addResource` in
`AbstractResourceIntegrationTest`, fixed here. `jvmTest` (all modules),
`apiCheck`, `ktlintCheck`, and `detekt` pass locally.

## Breaking Changes

No public API signature changes (`apiCheck` passes against the existing
dump). Behavioral: registering a duplicate name/URI now throws
`IllegalArgumentException` instead of silently overwriting; remove the
feature first to replace it.

## Types of changes

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist

- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Pavel Gorgulov 2026-07-21 18:37:21 +02:00 • committed by GitHub
commit 9ef4f80f85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 333 additions and 28 deletions

View file

@ -20,9 +20,12 @@ internal interface FeatureListener {
* A generic registry for managing features of a specified type. This class provides thread-safe
* operations for adding, removing, and retrieving features from the registry.
*
* Feature keys are unique: [add] and [addAll] reject keys that are already registered.
* To replace a feature, [remove] it first and add the new one.
*
* @param T The type of the feature, constrained to implement the [Feature] interface.
* @param featureType A string description of the type of feature being managed.
* Used primarily for logging purposes.
* Used for logging and error messages.
*/
internal class FeatureRegistry<T : Feature>(private val featureType: String) {
@ -44,11 +47,11 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
private val listeners = atomic(persistentListOf<FeatureListener>())
internal fun addListener(listener: FeatureListener) {
listeners.update { it.add(listener) }
listeners.update { it.adding(listener) }
}
internal fun removeListener(listener: FeatureListener) {
listeners.update { it.remove(listener) }
listeners.update { it.removing(listener) }
}
/**
@ -58,27 +61,42 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
*/
internal fun add(feature: T) {
logger.info { "Adding $featureType: \"${feature.key}\"" }
val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) }
val oldFeature = oldMap[feature.key]
registry.update { current ->
require(!current.containsKey(feature.key)) {
"$featureType \"${feature.key}\" is already registered. Remove it first to replace it."
}
current.putting(feature.key, feature)
}
logger.info { "Added $featureType: \"${feature.key}\"" }
notifyFeatureUpdated(oldFeature, feature)
notifyFeatureUpdated(null, feature)
}
/**
* Adds the given list of features to the registry. Each feature is mapped by its key
* and added to the current registry.
* and added to the current registry. The operation is all-or-nothing: if any key is
* rejected, no features are added.
*
* @param features The list of features to add to the registry.
*/
internal fun addAll(features: List<T>) {
logger.info { "Adding ${featureType}s: ${features.size}" }
val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) }
val newEntries = features.associateBy { it.key }
require(newEntries.size == features.size) {
val duplicated = features.groupingBy { it.key }.eachCount().filterValues { it > 1 }.keys
"Duplicate $featureType keys in batch: $duplicated"
}
registry.update { current ->
val conflicting = newEntries.keys.filter { current.containsKey(it) }
require(conflicting.isEmpty()) {
"${featureType}s already registered: $conflicting. Remove them first to replace them."
}
current.puttingAll(newEntries)
}
logger.info { "Added ${featureType}s: ${features.size}" }
for (feature in features) {
val oldFeature = oldMap[feature.key]
notifyFeatureUpdated(oldFeature, feature)
notifyFeatureUpdated(null, feature)
}
}
@ -90,7 +108,7 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
*/
internal fun remove(key: FeatureKey): Boolean {
logger.info { "Removing $featureType: \"$key\"" }
val oldMap = registry.getAndUpdate { current -> current.remove(key) }
val oldMap = registry.getAndUpdate { current -> current.removing(key) }
val removedFeature = oldMap[key]
val removed = removedFeature != null

View file

@ -0,0 +1,106 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.shouldBe
import kotlin.test.Test
import kotlin.test.assertFailsWith
private data class TestFeature(override val key: FeatureKey, val payload: String = "") : Feature
private class RecordingListener : FeatureListener {
val updatedKeys = mutableListOf<String>()
override fun onFeatureUpdated(featureKey: String) {
updatedKeys.add(featureKey)
}
}
class FeatureRegistryTest {
private val registry = FeatureRegistry<TestFeature>("Tool")
private val listener = RecordingListener()
init {
registry.addListener(listener)
}
@Test
fun `add should register feature and notify listener`() {
val feature = TestFeature("a")
registry.add(feature)
registry.values shouldBe mapOf("a" to feature)
listener.updatedKeys shouldBe listOf("a")
}
@Test
fun `add should throw when key is already registered`() {
registry.add(TestFeature("a"))
val exception = assertFailsWith<IllegalArgumentException> {
registry.add(TestFeature("a", payload = "override"))
}
exception.message shouldBe "Tool \"a\" is already registered. Remove it first to replace it."
}
@Test
fun `add should keep original feature and not notify when duplicate is rejected`() {
val original = TestFeature("a", payload = "original")
registry.add(original)
assertFailsWith<IllegalArgumentException> {
registry.add(TestFeature("a", payload = "override"))
}
registry.values shouldBe mapOf("a" to original)
listener.updatedKeys shouldBe listOf("a")
}
@Test
fun `addAll should register all features and notify per feature`() {
val a = TestFeature("a")
val b = TestFeature("b")
registry.addAll(listOf(a, b))
registry.values shouldBe mapOf("a" to a, "b" to b)
listener.updatedKeys shouldBe listOf("a", "b")
}
@Test
fun `addAll should throw on duplicate keys within batch and register nothing`() {
val exception = assertFailsWith<IllegalArgumentException> {
registry.addAll(listOf(TestFeature("a"), TestFeature("b"), TestFeature("a")))
}
exception.message shouldBe "Duplicate Tool keys in batch: [a]"
registry.values shouldBe emptyMap<FeatureKey, TestFeature>()
listener.updatedKeys shouldBe emptyList<String>()
}
@Test
fun `addAll should throw when any key is already registered and register nothing`() {
val existing = TestFeature("existing")
registry.add(existing)
val exception = assertFailsWith<IllegalArgumentException> {
registry.addAll(listOf(TestFeature("new"), TestFeature("existing", payload = "override")))
}
exception.message shouldBe "Tools already registered: [existing]. Remove them first to replace them."
registry.values shouldBe mapOf("existing" to existing)
listener.updatedKeys shouldBe listOf("existing")
}
@Test
fun `add should succeed after the existing feature is removed`() {
registry.add(TestFeature("a", payload = "original"))
registry.remove("a")
registry.add(TestFeature("a", payload = "replacement"))
registry.values shouldBe mapOf("a" to TestFeature("a", payload = "replacement"))
listener.updatedKeys shouldBe listOf("a", "a", "a")
}
}