Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Comment on lines +96 to +104

@Test
fun `notification should not be send when removed tool does not exists`() = runTest {
// Track notifications
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down Expand Up @@ -55,30 +58,48 @@ internal class FeatureRegistry<T : Feature>(private val featureType: String) {
* Adds the specified feature to the registry.
*
* @param feature The feature to be added to the registry.
* @throws IllegalArgumentException If a feature with the same key is already registered.
*/
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.put(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.
* @throws IllegalArgumentException If the list contains duplicate keys or a feature with
* the same key is already registered.
*/
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.putAll(newEntries)
}

logger.info { "Added ${featureType}s: ${features.size}" }
for (feature in features) {
val oldFeature = oldMap[feature.key]
notifyFeatureUpdated(oldFeature, feature)
notifyFeatureUpdated(null, feature)
}
}

Expand Down
Loading
Loading