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
9 changed files with 333 additions and 28 deletions

View file

@ -77,23 +77,6 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
)
}
server.addResource(
uri = testResourceUri,
name = testResourceName,
description = testResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = testResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = binaryResourceUri,
name = binaryResourceName,

View file

@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.collections.shouldHaveSize
@ -57,6 +58,39 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() {
tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing", "new-a", "new-b")
}
@Test
fun `addTools should throw and register nothing when a tool is already registered`() = runTest {
server.addTool("existing", "Existing") { CallToolResult(emptyList()) }
shouldThrow<IllegalArgumentException> {
server.addTools(
listOf(
RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) },
RegisteredTool(Tool("existing", ToolSchema())) { CallToolResult(emptyList()) },
),
)
}
val tools = client.listTools().tools
tools shouldHaveSize 1
tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing")
}
@Test
fun `addTools should throw when batch contains duplicate names`() = runTest {
shouldThrow<IllegalArgumentException> {
server.addTools(
listOf(
RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) },
RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) },
),
)
}
client.listTools().tools.shouldBeEmpty()
}
// ── addPrompts ─────────────────────────────────────────────────────────────
@Test
@ -89,6 +123,25 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() {
prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt", "new-p")
}
@Test
fun `addPrompts should throw and register nothing when a prompt is already registered`() = runTest {
server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) }
shouldThrow<IllegalArgumentException> {
server.addPrompts(
listOf(
RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) },
RegisteredPrompt(Prompt("existing-prompt")) { GetPromptResult(messages = emptyList()) },
),
)
}
val prompts = client.listPrompts().prompts
prompts shouldHaveSize 1
prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt")
}
// ── addResources ───────────────────────────────────────────────────────────
@Test
@ -123,6 +176,27 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() {
resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res", "test://new-res")
}
@Test
fun `addResources should throw and register nothing when a resource is already registered`() = runTest {
server.addResource("test://existing-res", "Existing", "Existing resource") {
ReadResourceResult(emptyList())
}
shouldThrow<IllegalArgumentException> {
server.addResources(
listOf(
RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) },
RegisteredResource(Resource("test://existing-res", "Dup")) { ReadResourceResult(emptyList()) },
),
)
}
val resources = client.listResources().resources
resources shouldHaveSize 1
resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res")
}
// ── Partial-batch remove ───────────────────────────────────────────────────
@Test

View file

@ -1,5 +1,8 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.Method
@ -110,6 +113,26 @@ class ServerPromptsTest : AbstractServerFeaturesTest() {
assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist")
}
@Test
fun `addPrompt should throw when prompt with same name is already registered`() = runTest {
server.addPrompt(Prompt("test-prompt", "Original Prompt", null)) {
GetPromptResult(description = "original", messages = emptyList())
}
val exception = assertThrows<IllegalArgumentException> {
server.addPrompt(Prompt("test-prompt", "Duplicate Prompt", null)) {
GetPromptResult(description = "duplicate", messages = emptyList())
}
}
exception.message.orEmpty() shouldContain "Prompt \"test-prompt\" is already registered"
// The original registration is intact
val prompts = client.listPrompts().prompts
assertEquals(1, prompts.size, "Only the original prompt should be registered")
val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("test-prompt")))
assertEquals("original", result.description)
}
@Test
fun `removePrompt should throw when prompts capability is not supported`() = runTest {
var promptListChangedNotificationReceived = false

View file

@ -5,6 +5,7 @@ import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.maps.shouldContainKey
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest
import io.modelcontextprotocol.kotlin.sdk.types.McpException
@ -162,6 +163,24 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() {
removed shouldBe false
}
@Test
fun `addResourceTemplate should throw when template with same uriTemplate is already registered`() {
server.addResourceTemplate("test://items/{id}", "Item") { _, _ ->
ReadResourceResult(emptyList())
}
val exception = assertThrows<IllegalArgumentException> {
server.addResourceTemplate("test://items/{id}", "Duplicate Item") { _, _ ->
ReadResourceResult(emptyList())
}
}
exception.message.orEmpty() shouldContain "ResourceTemplate \"test://items/{id}\" is already registered"
// The original registration is intact
server.resourceTemplates shouldHaveSize 1
server.resourceTemplates[0].name shouldBe "Item"
}
@Test
fun `addResourceTemplate should throw when resources capability is not supported`() {
val noResourcesServer = Server(

View file

@ -1,7 +1,10 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
@ -115,6 +118,24 @@ class ServerResourcesTest : AbstractServerFeaturesTest() {
)
}
@Test
fun `addResource should throw when resource with same uri is already registered`() = runTest {
server.addResource("test://resource", "Original", "Original resource") {
ReadResourceResult(listOf(TextResourceContents(text = "original", uri = "test://resource")))
}
val exception = assertThrows<IllegalArgumentException> {
server.addResource("test://resource", "Duplicate", "Duplicate resource") {
ReadResourceResult(listOf(TextResourceContents(text = "duplicate", uri = "test://resource")))
}
}
exception.message.orEmpty() shouldContain "Resource \"test://resource\" is already registered"
// The original registration is intact
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource")))
assertEquals("original", (result.contents.single() as TextResourceContents).text)
}
@Test
fun `removeResource should throw when resources capability is not supported`() = runTest {
// Create server without resources capability

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.test.runTest
import org.awaitility.kotlin.await
import org.awaitility.kotlin.untilAsserted
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -81,6 +82,27 @@ class ServerToolsNotificationTest : AbstractServerFeaturesTest() {
}
}
@Test
fun `addTool should not send notification when duplicate is rejected`() = runTest {
// Track notifications
val notifications = mutableListOf<ToolListChangedNotification>()
client.setNotificationHandler<ToolListChangedNotification>(
Method.Defined.NotificationsToolsListChanged,
) { notification ->
notifications.add(notification)
CompletableDeferred(Unit)
}
server.addTool("test-tool", "Test Tool") { CallToolResult(emptyList()) }
assertThrows<IllegalArgumentException> {
server.addTool("test-tool", "Duplicate Tool") { CallToolResult(emptyList()) }
}
// Close the server to stop processing further events and flush notifications
server.close()
assertEquals(1, notifications.size, "Only the first registration should send a notification")
}
@Test
fun `notification should not be send when removed tool does not exists`() = runTest {
// Track notifications

View file

@ -1,5 +1,8 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.Method
@ -108,6 +111,42 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
assertTrue(result, "Tool with non-conforming name should have been registered and removable")
}
@Test
fun `addTool should throw when tool with same name is already registered`() = runTest {
server.addTool("test-tool", "Original Tool") {
CallToolResult(listOf(TextContent("Original result")))
}
val exception = assertThrows<IllegalArgumentException> {
server.addTool("test-tool", "Duplicate Tool") {
CallToolResult(listOf(TextContent("Duplicate result")))
}
}
exception.message.orEmpty() shouldContain "Tool \"test-tool\" is already registered"
// The original registration is intact
val tools = client.listTools().tools
assertEquals(1, tools.size, "Only the original tool should be registered")
val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool")))
assertEquals("Original result", (result.content.single() as TextContent).text)
}
@Test
fun `addTool should succeed after removing existing tool`() = runTest {
server.addTool("test-tool", "Original Tool") {
CallToolResult(listOf(TextContent("Original result")))
}
server.removeTool("test-tool")
server.addTool("test-tool", "Replacement Tool") {
CallToolResult(listOf(TextContent("Replacement result")))
}
assertEquals(1, client.listTools().tools.size, "Re-adding after removal should not duplicate the tool")
val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool")))
assertEquals("Replacement result", (result.content.single() as TextContent).text)
}
@Test
fun `removeTool should throw when tools capability is not supported`() = runTest {
var toolListChangedNotificationReceived = false

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")
}
}