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
7 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