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:
parent
737b46f032
commit
9ef4f80f85
7 changed files with 333 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue