From 965f6f77c8e446816748f555992c3023a2c2c7c1 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:41:22 +0200 Subject: [PATCH 1/8] feat(server): add support for resource templates Introduce `ResourceTemplate` and `RegisteredResourceTemplate` implementation to register and manage parameterized resource templates. Added support for listing, reading, and removing resource templates. Updated error handling for unmatched URIs with a new `RESOURCE_NOT_FOUND` RPC error. Includes corresponding tests and API updates. --- .../kotlin/AbstractResourceIntegrationTest.kt | 7 +- .../sdk/server/ServerResourceTemplateTest.kt | 221 ++++++++++++++++++ kotlin-sdk-core/api/kotlin-sdk-core.api | 2 + .../kotlin/sdk/shared/Protocol.kt | 16 +- .../kotlin/sdk/types/McpException.kt | 5 +- .../kotlin/sdk/types/jsonRpc.kt | 3 + kotlin-sdk-server/api/kotlin-sdk-server.api | 5 + .../kotlin/sdk/server/Feature.kt | 20 ++ .../kotlin/sdk/server/Server.kt | 104 ++++++++- 9 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt index 40b955204..4fc53a52c 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt @@ -213,14 +213,11 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { } } - val expectedMessage = "MCP error -32603: Resource not found: test://nonexistent.txt" - assertEquals( - RPCError.ErrorCode.INTERNAL_ERROR, + RPCError.ErrorCode.RESOURCE_NOT_FOUND, exception.code, - "Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}", + "Exception code should be RESOURCE_NOT_FOUND: ${RPCError.ErrorCode.RESOURCE_NOT_FOUND}", ) - assertEquals(expectedMessage, exception.message, "Unexpected error message for invalid resource URI") } @Test diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt new file mode 100644 index 000000000..4c35ffd69 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt @@ -0,0 +1,221 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.collections.shouldBeEmpty +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.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +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.ResourceTemplate +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ServerResourceTemplateTest : AbstractServerFeaturesTest() { + + override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( + resources = ServerCapabilities.Resources(listChanged = null, subscribe = null), + ) + + @Test + fun `listResourceTemplates should return registered templates`() = runTest { + server.addResourceTemplate("test://data/{id}", "Test Data", mimeType = "text/plain") { _, _ -> + ReadResourceResult(listOf(TextResourceContents("content", "test://data/1"))) + } + + val result = client.listResourceTemplates(ListResourceTemplatesRequest()) + + result.resourceTemplates shouldHaveSize 1 + result.resourceTemplates[0] shouldNotBeNull { + uriTemplate shouldBe "test://data/{id}" + name shouldBe "Test Data" + mimeType shouldBe "text/plain" + } + } + + @Test + fun `listResourceTemplates should return empty list when none registered`() = runTest { + val result = client.listResourceTemplates(ListResourceTemplatesRequest()) + + result.resourceTemplates.shouldBeEmpty() + } + + @Test + fun `readResource should match URI against template and invoke handler`() = runTest { + server.addResourceTemplate("test://items/{itemId}", "Item", mimeType = "text/plain") { request, variables -> + val itemId = variables["itemId"] ?: "unknown" + ReadResourceResult( + listOf(TextResourceContents(text = "item=$itemId", uri = request.uri, mimeType = "text/plain")), + ) + } + + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/42"))) + + result.contents shouldBe + listOf(TextResourceContents(uri = "test://items/42", mimeType = "text/plain", text = "item=42")) + } + + @Test + fun `readResource should extract multiple URI template variables`() = runTest { + val capturedVars = CompletableDeferred>() + server.addResourceTemplate( + uriTemplate = "test://users/{userId}/posts/{postId}", + name = "User Post", + mimeType = "text/plain", + ) { _, variables -> + capturedVars.complete(variables) + ReadResourceResult(listOf(TextResourceContents("ok", "test://users/alice/posts/99"))) + } + + client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/alice/posts/99"))) + + val vars = capturedVars.await() + vars shouldContainKey "userId" + vars shouldContainKey "postId" + vars["userId"] shouldBe "alice" + vars["postId"] shouldBe "99" + } + + @Test + fun `readResource should prefer exact resource match over template`() = runTest { + var exactHandlerCalled = false + server.addResource("test://items/special", "Special Item", "An exact resource") { + exactHandlerCalled = true + ReadResourceResult(listOf(TextResourceContents("exact", "test://items/special"))) + } + server.addResourceTemplate("test://items/{itemId}", "Item Template") { _, _ -> + ReadResourceResult(listOf(TextResourceContents("template", "test://items/special"))) + } + + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/special"))) + + exactHandlerCalled shouldBe true + (result.contents[0] as TextResourceContents).text shouldBe "exact" + } + + @Test + fun `readResource should select most specific template when multiple match`() = runTest { + // "test://users/profile" has more literal chars than "test://users/{id}" — should win + server.addResourceTemplate("test://users/{id}", "Generic User") { _, variables -> + ReadResourceResult(listOf(TextResourceContents("generic:${variables["id"]}", "test://users/profile"))) + } + server.addResourceTemplate("test://users/profile", "Profile") { _, _ -> + ReadResourceResult(listOf(TextResourceContents("profile-page", "test://users/profile"))) + } + + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/profile"))) + + (result.contents[0] as TextResourceContents).text shouldBe "profile-page" + } + + @Test + fun `readResource should return RESOURCE_NOT_FOUND error when no match`() = runTest { + val exception = assertThrows { + client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://nonexistent/uri"))) + } + + exception.code shouldBe RPCError.ErrorCode.RESOURCE_NOT_FOUND + } + + @Test + fun `resourceTemplates property should reflect registered templates`() { + server.addResourceTemplate(ResourceTemplate("test://a/{x}", "A")) { _, _ -> + ReadResourceResult(emptyList()) + } + server.addResourceTemplate(ResourceTemplate("test://b/{y}", "B")) { _, _ -> + ReadResourceResult(emptyList()) + } + + val templates = server.resourceTemplates + + templates.size shouldBe 2 + templates shouldContainKey "test://a/{x}" + templates shouldContainKey "test://b/{y}" + } + + @Test + fun `removeResourceTemplate should remove a registered template`() { + server.addResourceTemplate("test://items/{id}", "Item") { _, _ -> + ReadResourceResult(emptyList()) + } + + val removed = server.removeResourceTemplate("test://items/{id}") + + removed shouldBe true + server.resourceTemplates.size shouldBe 0 + } + + @Test + fun `removeResourceTemplate should return false when template does not exist`() { + val removed = server.removeResourceTemplate("test://nonexistent/{id}") + + removed shouldBe false + } + + @Test + fun `addResourceTemplate should throw when resources capability is not supported`() { + val noResourcesServer = Server( + serverInfo = Implementation("test", "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + assertThrows { + noResourcesServer.addResourceTemplate("test://{id}", "Test") { _, _ -> + ReadResourceResult(emptyList()) + } + } + } + + @Test + fun `addResourceTemplate with ResourceTemplate object should register correctly`() = runTest { + val template = ResourceTemplate( + uriTemplate = "test://docs/{section}", + name = "Documentation", + description = "API docs", + mimeType = "text/html", + ) + server.addResourceTemplate(template) { request, variables -> + val section = variables["section"] ?: "index" + ReadResourceResult( + listOf(TextResourceContents("docs for $section", request.uri, mimeType = "text/html")), + ) + } + + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://docs/api"))) + + result.contents shouldHaveSize 1 + (result.contents[0] as TextResourceContents).text shouldBe "docs for api" + } + + @Test + fun `listResourceTemplates should include description from template`() = runTest { + server.addResourceTemplate( + uriTemplate = "test://data/{id}", + name = "Data", + description = "Parameterized data resource", + mimeType = "application/json", + ) { _, _ -> + ReadResourceResult(emptyList()) + } + + val result = client.listResourceTemplates(ListResourceTemplatesRequest()) + + result.resourceTemplates shouldBe listOf( + ResourceTemplate( + name = "Data", + uriTemplate = "test://data/{id}", + description = "Parameterized data resource", + mimeType = "application/json", + ), + ) + } +} diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index af22323e2..20b46669e 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -2719,6 +2719,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/ public synthetic fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCode ()I public final fun getData ()Lkotlinx/serialization/json/JsonElement; + public final fun getErrorMessage ()Ljava/lang/String; } public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock { @@ -3310,6 +3311,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode { public static final field METHOD_NOT_FOUND I public static final field PARSE_ERROR I public static final field REQUEST_TIMEOUT I + public static final field RESOURCE_NOT_FOUND I } public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt index e9d833a2e..84ebcbe9a 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -312,6 +312,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio return } + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") try { val result = handler(request, RequestHandlerExtra()) logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } @@ -326,15 +327,12 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } try { - transport?.send( - JSONRPCError( - id = request.id, - error = RPCError( - code = RPCError.ErrorCode.INTERNAL_ERROR, - message = cause.message ?: "Internal error", - ), - ), - ) + val rpcError = if (cause is McpException) { + RPCError(code = cause.code, message = cause.errorMessage, data = cause.data) + } else { + RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") + } + transport?.send(JSONRPCError(id = request.id, error = rpcError)) } catch (sendError: Throwable) { logger.error(sendError) { "Failed to send error response for request: ${request.method} (id: ${request.id})" diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt index f63f4a9b7..d4d8fb53f 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt @@ -16,4 +16,7 @@ public class McpException @JvmOverloads public constructor( message: String, public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception("MCP error $code: $message", cause) +) : Exception("MCP error $code: $message", cause) { + /** The raw MCP error message (without the "MCP error $code:" prefix). */ + public val errorMessage: String = message +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt index 68b2f0274..6a53d350f 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt @@ -251,6 +251,9 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle /** Request timed out */ public const val REQUEST_TIMEOUT: Int = -32001 + /** Resource not found */ + public const val RESOURCE_NOT_FOUND: Int = -32002 + // Standard JSON-RPC 2.0 error codes /** Invalid JSON was received */ diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index a6f3b6d72..a312b4834 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -97,6 +97,9 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { public final fun addPrompts (Ljava/util/List;)V public final fun addResource (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;)V public static synthetic fun addResource$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V + public final fun addResourceTemplate (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;Lkotlin/jvm/functions/Function4;)V + public final fun addResourceTemplate (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function4;)V + public static synthetic fun addResourceTemplate$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V public final fun addResources (Ljava/util/List;)V public final fun addTool (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function3;)V public final fun addTool (Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/functions/Function3;)V @@ -113,6 +116,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { protected final fun getInstructionsProvider ()Lkotlin/jvm/functions/Function0; protected final fun getOptions ()Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions; public final fun getPrompts ()Ljava/util/Map; + public final fun getResourceTemplates ()Ljava/util/Map; public final fun getResources ()Ljava/util/Map; protected final fun getServerInfo ()Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getSessions ()Ljava/util/Map; @@ -128,6 +132,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { public final fun removePrompt (Ljava/lang/String;)Z public final fun removePrompts (Ljava/util/List;)I public final fun removeResource (Ljava/lang/String;)Z + public final fun removeResourceTemplate (Ljava/lang/String;)Z public final fun removeResources (Ljava/util/List;)I public final fun removeTool (Ljava/lang/String;)Z public final fun removeTools (Ljava/util/List;)I diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt index 609aaa234..0b9f24988 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt @@ -8,7 +8,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.Prompt import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.Resource +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplate +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateMatcher internal typealias FeatureKey = String @@ -57,3 +60,20 @@ public data class RegisteredResource( ) : Feature { override val key: String = resource.uri } + +/** + * A registered resource template with its associated read handler. + * + * @property resourceTemplate The [ResourceTemplate] definition (RFC 6570 URI template). + * @property readHandler A suspend function invoked when a client reads a URI that matches + * this template. The second parameter contains the URI variables extracted from the match. + */ +internal data class RegisteredResourceTemplate( + val resourceTemplate: ResourceTemplate, + val readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, +) : Feature { + override val key: String = resourceTemplate.uriTemplate + + // Excluded from data class equals/hashCode/copy — derived from resourceTemplate.uriTemplate. + val matcher: UriTemplateMatcher = UriTemplate(resourceTemplate.uriTemplate).matcher() +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index 7842c2b2e..de9f4dc8f 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -25,13 +25,16 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.Notification import io.modelcontextprotocol.kotlin.sdk.types.Prompt import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.Resource +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest @@ -44,6 +47,8 @@ import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import kotlin.time.ExperimentalTime private val logger = KotlinLogging.logger {} @@ -126,6 +131,11 @@ public open class Server( addListener(notificationService.resourceUpdatedListener) } } + private val resourceTemplateRegistry = FeatureRegistry("ResourceTemplate").apply { + if (options.capabilities.resources?.listChanged == true) { + addListener(notificationService.resourceListChangedListener) + } + } /** * Provides a snapshot of all sessions currently registered in the server @@ -151,6 +161,13 @@ public open class Server( public val resources: Map get() = resourceRegistry.values + /** + * Provides a snapshot of all resource templates currently registered in the server. + * Keys are URI template strings; values are the [ResourceTemplate] MCP type. + */ + public val resourceTemplates: Map + get() = resourceTemplateRegistry.values.mapValues { it.value.resourceTemplate } + init { block(this) } @@ -542,6 +559,60 @@ public open class Server( return resourceRegistry.removeAll(uris) } + /** + * Registers a resource template. Clients can discover it via `resources/templates/list` + * and read matching URIs via `resources/read`. + * + * @param template The [ResourceTemplate] describing the URI template pattern. + * @param readHandler A suspend function invoked when a client reads a URI that matches + * the template. The second parameter contains the URI variables extracted from the match. + * @throws IllegalStateException If the server does not support resources. + */ + public fun addResourceTemplate( + template: ResourceTemplate, + readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, + ) { + checkNotNull(options.capabilities.resources) { + "Server does not support resources capability." + } + resourceTemplateRegistry.add(RegisteredResourceTemplate(template, readHandler)) + } + + /** + * Registers a resource template by constructing a [ResourceTemplate] from given parameters. + * + * @param uriTemplate The RFC 6570 URI template string (e.g. `"file:///{path}"`). + * @param name A human-readable name for the template. + * @param description A human-readable description of the resource template. + * @param mimeType The MIME type of resource content served by this template. + * @param readHandler A suspend function invoked when a client reads a URI that matches + * the template. The second parameter contains the URI variables extracted from the match. + * @throws IllegalStateException If the server does not support resources. + */ + public fun addResourceTemplate( + uriTemplate: String, + name: String, + description: String? = null, + mimeType: String? = null, + readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, + ) { + addResourceTemplate(ResourceTemplate(uriTemplate, name, description, mimeType), readHandler) + } + + /** + * Removes a resource template by its URI template string. + * + * @param uriTemplate The URI template string identifying the template to remove. + * @return True if the template was removed, false if it was not found. + * @throws IllegalStateException If the server does not support resources. + */ + public fun removeResourceTemplate(uriTemplate: String): Boolean { + checkNotNull(options.capabilities.resources) { + "Server does not support resources capability." + } + return resourceTemplateRegistry.remove(uriTemplate) + } + // --- Internal Handlers --- private fun handleSubscribeResources(session: ServerSession, request: SubscribeRequest) { if (options.capabilities.resources?.subscribe ?: false) { @@ -621,21 +692,34 @@ public open class Server( } private suspend fun handleReadResource(session: ServerSession, request: ReadResourceRequest): ReadResourceResult { - val requestParams = request.params - logger.debug { "Handling read resource request for: ${requestParams.uri}" } - val resource = resourceRegistry.get(requestParams.uri) + val uri = request.params.uri + logger.debug { "Handling read resource request for: $uri" } + + // Priority 1: exact URI match + resourceRegistry.get(uri)?.let { resource -> + return resource.run { session.clientConnection.readHandler(request) } + } + + // Priority 2 & 3: most-specific matching template (highest score wins) + val (template, matchResult) = resourceTemplateRegistry.values.values + .mapNotNull { tmpl -> tmpl.matcher.match(uri)?.let { tmpl to it } } + .maxByOrNull { (_, result) -> result.score } ?: run { - logger.error { "Resource not found: ${requestParams.uri}" } - throw IllegalArgumentException("Resource not found: ${requestParams.uri}") + logger.error { "Resource not found: $uri" } + throw McpException( + code = RPCError.ErrorCode.RESOURCE_NOT_FOUND, + message = "Resource not found", + data = buildJsonObject { put("uri", uri) }, + ) } - return resource.run { - session.clientConnection.readHandler(request) - } + + logger.debug { "Matched resource template '${template.key}' for URI: $uri" } + return template.run { session.clientConnection.readHandler(request, matchResult.variables) } } private fun handleListResourceTemplates(): ListResourceTemplatesResult { - // If you have resource templates, return them here. For now, return empty. - return ListResourceTemplatesResult(listOf()) + logger.debug { "Handling list resource templates request" } + return ListResourceTemplatesResult(resourceTemplateRegistry.values.values.map { it.resourceTemplate }) } // Start the ServerSession / ClientConnection redirection section From 997d6c2aac98a57137d3ee208fa0a7c03c121f06 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:42:28 +0200 Subject: [PATCH 2/8] test(confirmance): finalize and enable resource templates confirmance test --- conformance-test/conformance-baseline.yml | 3 +-- .../kotlin/sdk/conformance/ConformanceResources.kt | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/conformance-test/conformance-baseline.yml b/conformance-test/conformance-baseline.yml index 378884748..337d8ce61 100644 --- a/conformance-test/conformance-baseline.yml +++ b/conformance-test/conformance-baseline.yml @@ -1,7 +1,6 @@ # Conformance test baseline - expected failures # Add entries here as tests are identified as known SDK limitations -server: - - resources-templates-read +server: [] client: - elicitation-sep1034-client-defaults diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt index a09903771..89d83f68d 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt @@ -45,18 +45,17 @@ fun Server.registerConformanceResources() { } // 3. Template resource - // Note: The SDK does not currently support addResourceTemplate(). - // Register as a static resource; template listing is handled separately. - addResource( - uri = "test://template/{id}/data", + addResourceTemplate( + uriTemplate = "test://template/{id}/data", name = "template", description = "A template resource for testing", mimeType = "application/json", - ) { request -> + ) { request, variables -> + val id = variables["id"] ReadResourceResult( listOf( TextResourceContents( - text = "content for ${request.uri}", + text = """{"id": "$id"}""", uri = request.uri, mimeType = "application/json", ), From 76ea47eb1bbf9172b5f75a3d9c7bbe9f3b670a0b Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:10:44 +0200 Subject: [PATCH 3/8] fix(server): replace null checks with `checkNotNull` in capability validation Replaced `check(options.capabilities.* != null)` with `checkNotNull` for improved clarity and consistency in validating server capabilities. --- .../kotlin/sdk/server/Server.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index de9f4dc8f..91c599257 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -314,7 +314,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun addTool(tool: Tool, handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult) { - check(options.capabilities.tools != null) { + checkNotNull(options.capabilities.tools) { logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" } "Server does not support tools capability. Enable it in ServerOptions." } @@ -368,7 +368,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun addTools(toolsToAdd: List) { - check(options.capabilities.tools != null) { + checkNotNull(options.capabilities.tools) { logger.error { "Failed to add tools: Server does not support tools capability" } "Server does not support tools capability." } @@ -383,7 +383,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun removeTool(name: String): Boolean { - check(options.capabilities.tools != null) { + checkNotNull(options.capabilities.tools) { logger.error { "Failed to remove tool '$name': Server does not support tools capability" } "Server does not support tools capability." } @@ -398,7 +398,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun removeTools(toolNames: List): Int { - check(options.capabilities.tools != null) { + checkNotNull(options.capabilities.tools) { logger.error { "Failed to remove tools: Server does not support tools capability" } "Server does not support tools capability." } @@ -418,7 +418,7 @@ public open class Server( prompt: Prompt, promptProvider: suspend ClientConnection.(GetPromptRequest) -> GetPromptResult, ) { - check(options.capabilities.prompts != null) { + checkNotNull(options.capabilities.prompts) { logger.error { "Failed to add prompt '${prompt.name}': Server does not support prompts capability" } "Server does not support prompts capability." } @@ -451,7 +451,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun addPrompts(promptsToAdd: List) { - check(options.capabilities.prompts != null) { + checkNotNull(options.capabilities.prompts) { logger.error { "Failed to add prompts: Server does not support prompts capability" } "Server does not support prompts capability." } @@ -466,7 +466,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun removePrompt(name: String): Boolean { - check(options.capabilities.prompts != null) { + checkNotNull(options.capabilities.prompts) { logger.error { "Failed to remove prompt '$name': Server does not support prompts capability" } "Server does not support prompts capability." } @@ -482,7 +482,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun removePrompts(promptNames: List): Int { - check(options.capabilities.prompts != null) { + checkNotNull(options.capabilities.prompts) { logger.error { "Failed to remove prompts: Server does not support prompts capability" } "Server does not support prompts capability." } @@ -507,7 +507,7 @@ public open class Server( mimeType: String = "text/html", readHandler: suspend ClientConnection.(ReadResourceRequest) -> ReadResourceResult, ) { - check(options.capabilities.resources != null) { + checkNotNull(options.capabilities.resources) { logger.error { "Failed to add resource '$name': Server does not support resources capability" } "Server does not support resources capability." } @@ -522,7 +522,7 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun addResources(resourcesToAdd: List) { - check(options.capabilities.resources != null) { + checkNotNull(options.capabilities.resources) { logger.error { "Failed to add resources: Server does not support resources capability" } "Server does not support resources capability." } @@ -537,7 +537,7 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun removeResource(uri: String): Boolean { - check(options.capabilities.resources != null) { + checkNotNull(options.capabilities.resources) { logger.error { "Failed to remove resource '$uri': Server does not support resources capability" } "Server does not support resources capability." } @@ -552,7 +552,7 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun removeResources(uris: List): Int { - check(options.capabilities.resources != null) { + checkNotNull(options.capabilities.resources) { logger.error { "Failed to remove resources: Server does not support resources capability" } "Server does not support resources capability." } From d6c6cf2f32c142fb28fe502daa82c6bc432cadf0 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:04:01 +0200 Subject: [PATCH 4/8] revert: McpException --- .../kotlin/sdk/server/ServerResourceTemplateTest.kt | 7 ++++--- kotlin-sdk-core/api/kotlin-sdk-core.api | 1 - .../io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt | 3 ++- .../modelcontextprotocol/kotlin/sdk/types/McpException.kt | 7 ++----- .../io/modelcontextprotocol/kotlin/sdk/server/Server.kt | 5 ++--- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt index 4c35ffd69..c8ace1915 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt @@ -137,9 +137,10 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() { val templates = server.resourceTemplates - templates.size shouldBe 2 - templates shouldContainKey "test://a/{x}" - templates shouldContainKey "test://b/{y}" + templates shouldBe listOf( + ResourceTemplate("test://a/{x}", "A"), + ResourceTemplate("test://b/{y}", "B"), + ) } @Test diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index 20b46669e..479bb02f3 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -2719,7 +2719,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/ public synthetic fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCode ()I public final fun getData ()Lkotlinx/serialization/json/JsonElement; - public final fun getErrorMessage ()Ljava/lang/String; } public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock { diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt index 84ebcbe9a..8139b6205 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -39,6 +39,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -328,7 +329,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio try { val rpcError = if (cause is McpException) { - RPCError(code = cause.code, message = cause.errorMessage, data = cause.data) + RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) } else { RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt index d4d8fb53f..39060f33b 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt @@ -13,10 +13,7 @@ import kotlin.jvm.JvmOverloads */ public class McpException @JvmOverloads public constructor( public val code: Int, - message: String, + message: String?, public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception("MCP error $code: $message", cause) { - /** The raw MCP error message (without the "MCP error $code:" prefix). */ - public val errorMessage: String = message -} +) : Exception("MCP error $code: $message", cause) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index 91c599257..84819de2f 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -163,10 +163,9 @@ public open class Server( /** * Provides a snapshot of all resource templates currently registered in the server. - * Keys are URI template strings; values are the [ResourceTemplate] MCP type. */ - public val resourceTemplates: Map - get() = resourceTemplateRegistry.values.mapValues { it.value.resourceTemplate } + public val resourceTemplates: List + get() = resourceTemplateRegistry.values.values.map { it.resourceTemplate } init { block(this) From eb49f5c26d427470398e0e10233eb6da9ef1ee7f Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:04:42 +0200 Subject: [PATCH 5/8] fix(core): rethrow `CancellationException` to avoid unintended suppression Add specific handling for `CancellationException` in `Protocol.kt` to ensure it is rethrown instead of being suppressed by the generic `Throwable` catch block. --- .../io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt | 2 ++ kotlin-sdk-server/api/kotlin-sdk-server.api | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt index 8139b6205..bc26b9bb8 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -324,6 +324,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio result = result ?: EmptyResult(), ), ) + } catch (e: CancellationException) { + throw e } catch (cause: Throwable) { logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index a312b4834..25e9bf37f 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -116,7 +116,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { protected final fun getInstructionsProvider ()Lkotlin/jvm/functions/Function0; protected final fun getOptions ()Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions; public final fun getPrompts ()Ljava/util/Map; - public final fun getResourceTemplates ()Ljava/util/Map; + public final fun getResourceTemplates ()Ljava/util/List; public final fun getResources ()Ljava/util/Map; protected final fun getServerInfo ()Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getSessions ()Ljava/util/Map; From dabe0787408ec4169656f6321916ac0063e37539 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:46:35 +0200 Subject: [PATCH 6/8] feat(core, server): introduce `ResourceTemplateMatcher` for enhanced URI matching - Added `ResourceTemplateMatcher` interface and its implementation `SimpleUriResourceTemplateMatcher` to improve URI matching specificity and safety. - Replaced usage of `UriTemplate` and `UriTemplateMatcher` with `ResourceTemplateMatcher` in `RegisteredResourceTemplate`. - Updated `ServerOptions` to include a configurable `ResourceTemplateMatcherFactory`, defaulting to `SimpleUriResourceTemplateMatcher.factory`. - Enhanced resource registration logic to utilize matchers for improved URI matching robustness. - Improved template selection logic by prioritizing specificity, variable minimization, and registration order. --- kotlin-sdk-core/api/kotlin-sdk-core.api | 33 +++ .../sdk/utils/PathSegmentTemplateMatcher.kt | 157 ++++++++++++ .../sdk/utils/ResourceTemplateMatcher.kt | 67 +++++ .../kotlin/sdk/utils/UriUtils.kt | 16 ++ .../PathSegmentTemplateMatcherSecurityTest.kt | 221 ++++++++++++++++ .../utils/PathSegmentTemplateMatcherTest.kt | 236 ++++++++++++++++++ .../kotlin/sdk/utils/UriUtils.js.kt | 16 ++ .../kotlin/sdk/utils/UriUtils.jvm.kt | 14 ++ .../kotlin/sdk/utils/UriUtils.native.kt | 3 + .../kotlin/sdk/utils/UriUtils.wasmJs.kt | 4 + kotlin-sdk-server/api/kotlin-sdk-server.api | 5 +- .../kotlin/sdk/server/Feature.kt | 9 +- .../kotlin/sdk/server/Server.kt | 32 ++- 13 files changed, 801 insertions(+), 12 deletions(-) create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt create mode 100644 kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt create mode 100644 kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt create mode 100644 kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt create mode 100644 kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index 479bb02f3..21c2f98ee 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -4732,3 +4732,36 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject; } +public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult { + public fun (Ljava/util/Map;I)V + public fun equals (Ljava/lang/Object;)Z + public final fun getScore ()I + public final fun getVariables ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion; + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; + public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate; + public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; +} + +public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion { + public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher { + public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate; + public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory { + public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher; +} + diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt new file mode 100644 index 000000000..c5619d597 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt @@ -0,0 +1,157 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.decodeURLPart +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate +import kotlinx.collections.immutable.toImmutableMap +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic + +// Max URL/path length to prevent DoS or unexpected large payloads. +private const val MAX_URL_LENGTH = 2048 + +// Max template/uri depth to prevent overly nested or complex templates. +private const val MAX_DEPTH = 50 + +// Literal segments are more specific than variable captures. +private const val LITERAL_MATCH_SCORE = 2 +private const val VARIABLE_MATCH_SCORE = 1 + +/** + * A [ResourceTemplateMatcher] that matches resource URIs against an RFC 6570 Level 1 + * URI template by splitting both the URI and the template on `/` and comparing each + * segment in order. + * + * ### Supported template syntax + * + * Only RFC 6570 **Level 1** is supported: simple `{variable}` expressions where the + * entire path segment is a single variable. Operator expressions (`{+var}`, `{#var}`, + * `{.var}`, `{/var}`, etc.) and multi-variable expressions (`{a,b}`) are **not** + * recognized — segments containing them are treated as literals. + * + * ### Matching rules + * + * - The URI and template must have the same number of `/`-delimited segments. + * - Literal segments must match exactly (after percent-decoding the URI segment). + * - `{variable}` segments capture the percent-decoded URI segment value. + * - Query strings and fragments (`?`, `#`) are **not** stripped — they become part of + * the captured variable value for the segment that contains them. + * + * ### Specificity scoring + * + * When multiple templates match the same URI, each matched literal segment contributes + * 2 points and each variable capture contributes 1 point. The highest-scoring match wins. + * + * ### Safety limits + * + * - URIs longer than [maxUriLength] characters are rejected. + * - Templates and URIs with more than [maxDepth] segments are rejected. + * + * ### Security contract for handler authors + * + * Values in [MatchResult.variables] are attacker-controlled strings extracted from + * the incoming URI. They are percent-decoded (one pass only) before being returned. + * Handlers **must** treat them as untrusted input and validate or sanitize them + * before using them to construct file paths, database queries, downstream URLs, or + * any other security-sensitive operation. + * + * In particular: + * - A decoded value may contain `/`, `..`, null bytes (`\u0000`), `?`, or `#`. + * - `%252F` in the URI becomes `%2F` in the variable — exactly one decode pass is applied. + * + * ### Platform limitations + * + * Dot-segment normalization (resolving `..` and `.` in the URI path) is performed + * on JVM and JS targets using the platform-native URI parser. On native and WASM targets + * no normalizer is available, so dot-segment traversal is **not** mitigated at this + * layer — handlers on those targets must normalize paths themselves. + * + * @param resourceTemplate The resource template to match against. Must follow RFC 6570 Level 1 syntax. + * @param maxUriLength Maximum allowed length for incoming URIs. Defaults to 2048. + * @param maxDepth Maximum allowed segment count for the template/uri. Defaults to 50. + * + * @property resourceTemplate The resource template against which resource URIs will be matched. + */ +public class PathSegmentTemplateMatcher @JvmOverloads constructor( + override val resourceTemplate: ResourceTemplate, + private val maxDepth: Int = MAX_DEPTH, + private val maxUriLength: Int = MAX_URL_LENGTH, +) : ResourceTemplateMatcher { + + public companion object { + /** + * A [ResourceTemplateMatcherFactory] that creates [PathSegmentTemplateMatcher] instances + * with default limits. Pass this to [io.modelcontextprotocol.kotlin.sdk.server.ServerOptions] + * to use path-segment matching, or supply a custom factory to override the matching strategy. + */ + @JvmStatic + public val factory: ResourceTemplateMatcherFactory = ResourceTemplateMatcherFactory { + PathSegmentTemplateMatcher(it) + } + + @JvmStatic + private val logger = KotlinLogging.logger {} + } + + private val templateParts: List + + // Maps segment index to variable name; indices absent from this map are literal segments. + private val variableIndices: Map + + init { + val template = resourceTemplate.uriTemplate + require(template.isNotBlank()) { "Resource template cannot be blank" } + templateParts = template.trim('/').split("/") + require(templateParts.size <= maxDepth) { + "Template is too complex (max depth=$maxDepth)" + } + + val vars = mutableMapOf() + for (i in templateParts.indices) { + val segment = templateParts[i] + if (segment.startsWith("{") && segment.endsWith("}")) { + val name = segment.removeSurrounding("{", "}").trim() + require(name.isNotEmpty()) { "Invalid variable name in template: $segment" } + vars[i] = name + } + } + variableIndices = vars.toImmutableMap() + } + + @Suppress("ReturnCount") + override fun match(resourceUri: String): MatchResult? { + if (resourceUri.length > maxUriLength) { + logger.debug { "URL is too long (max=$maxUriLength)" } + return null + } + + // Resolve dot-segments (e.g. /a/../b → /a/b) before splitting. + // Prevents traversal attacks on JVM/JS; no-op on native/WASM targets. + val normalized = normalizeUri(resourceUri) + + val urlParts = normalized.trim('/').split("/") + if (urlParts.size > maxDepth) { + logger.debug { "URI has too many segments (max=$maxDepth)" } + return null + } + if (urlParts.size != templateParts.size) return null + + val variables = mutableMapOf() + var score = 0 + + for (i in templateParts.indices) { + val urlSegment = urlParts[i].decodeURLPart() + val variableName = variableIndices[i] + if (variableName != null) { + variables[variableName] = urlSegment + score += VARIABLE_MATCH_SCORE + } else if (templateParts[i] == urlSegment) { + score += LITERAL_MATCH_SCORE + } else { + return null + } + } + + return MatchResult(variables = variables.toImmutableMap(), score = score) + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt new file mode 100644 index 000000000..5ba956d29 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt @@ -0,0 +1,67 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate + +/** + * Represents the result of a successful template match. + * + * A higher [score] indicates a more specific match. Implementations must ensure that + * literal segment matches contribute more to the score than variable captures, so that + * a fully literal template (e.g., `users/profile`) always outscores a parameterized + * template (e.g., `users/{id}`) for the same URI. + * + * @property variables A mapping of variable names in the template to their matched values in the URL. + * @property score A non-negative measure of match specificity — higher means more literal segments matched. + */ +public class MatchResult(public val variables: Map, public val score: Int) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MatchResult) return false + return score == other.score && variables == other.variables + } + + override fun hashCode(): Int = 31 * variables.hashCode() + score + + override fun toString(): String = "MatchResult(variables=$variables, score=$score)" +} + +/** + * Matches resource URIs against a [ResourceTemplate]. + * + * Implementations parse a URI template once at construction time and then match + * candidate URIs against that template via [match]. The returned [MatchResult.score] + * must reflect match specificity so that a selection algorithm can prefer the most + * specific template when multiple templates match the same URI. + */ +public interface ResourceTemplateMatcher { + + public val resourceTemplate: ResourceTemplate + + /** + * Matches a given resource URI against the defined resource template. + * + * @param resourceUri The resource URI to be matched. + * @return A [MatchResult] containing the mapping of variables and a match score + * if the URI matches the template, or null if no match is found. + */ + public fun match(resourceUri: String): MatchResult? +} + +/** + * Factory interface for creating instances of [ResourceTemplateMatcher]. + * + * A [ResourceTemplateMatcher] is used to match resource URIs against a given + * [ResourceTemplate], which adheres to the RFC 6570 URI Template specification. + * This factory abstracts the creation process of a matcher, allowing different + * implementations to define custom matching logic or safeguards (e.g., security + * measures or restrictions on template complexity). + */ +public fun interface ResourceTemplateMatcherFactory { + /** + * Creates a resource template matcher for the given resource template. + * + * @param resourceTemplate The resource template to create a matcher for. + * @return A [ResourceTemplateMatcher] instance that can match URIs against the provided template. + */ + public fun create(resourceTemplate: ResourceTemplate): ResourceTemplateMatcher +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt new file mode 100644 index 000000000..452333b86 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt @@ -0,0 +1,16 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +/** + * Normalizes [uri] by resolving dot-segments (`/a/b/../c` → `/a/c`) using the + * platform-native URI parser. + * + * On JVM uses [java.net.URI.normalize]. On JS uses the browser/Node.js `URL` API. + * On native and WASM targets [uri] is returned unchanged — no platform normalizer + * is available, so callers on those targets do not receive dot-segment resolution. + * + * Returns [uri] unchanged if it cannot be parsed or normalized. + * + * **Security note**: call this before splitting a URI into segments to prevent + * dot-segment traversal attacks (e.g. `public/../private/secret`). + */ +internal expect fun normalizeUri(uri: String): String diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt new file mode 100644 index 000000000..fe3fca46d --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt @@ -0,0 +1,221 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.assertions.withClue +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate +import kotlin.test.Test + +/** + * Security regression tests for [PathSegmentTemplateMatcher]. + * + * Every test **fails** when an attack succeeds — green = defended. + * + * Findings addressed: + * - F1: Exactly one percent-decode pass (no double-decoding). + * - F2: Variable values are attacker-controlled; handlers must validate them. + * - F3: URI segment depth cap prevents allocation amplification. + * - F4: Dot-segment normalization blocks traversal on JVM/JS (no-op on native/WASM). + * - F5: Double-slash — collapsed by JVM normalization or causes size mismatch on native/WASM. + */ +class PathSegmentTemplateMatcherSecurityTest { + + // region: F4: Dot-segment traversal + + /** + * URIs containing `..` or `.` path segments. + * + * On JVM/JS: [normalizeUri] resolves these to their canonical target path, so the + * traversal URI correctly routes to the same template the normalized path would match. + * On native/WASM: [normalizeUri] is a no-op — the extra segments produce a size mismatch + * and the match returns null (different reason, same safety outcome). + */ + private data class TraversalCase(val label: String, val template: String, val uri: String) + + private val traversalNoMatchCases = listOf( + TraversalCase( + label = "single .. traversal to reach private template", + template = "app://host/private/{name}", + uri = "app://host/public/../private/secret", + ), + TraversalCase( + label = "double .. traversal", + template = "app://host/admin/{name}", + uri = "app://host/a/b/../../admin/config", + ), + TraversalCase( + label = ". current-segment in path", + template = "app://host/users/{id}", + uri = "app://host/./users/42", + ), + ) + + @Test + fun `dot-segment traversal resolves to correct target without raw path markers in variables`() { + // On JVM/JS: normalizeUri() resolves `..` and `.` before segment matching. + // → The traversal URI correctly routes to the normalized target template. + // On native/WASM: no normalization — extra segments cause a size mismatch → null. + // In all cases, captured variable values must not contain raw traversal markers. + for ((label, template, uri) in traversalNoMatchCases) { + withClue("traversal '$label': uri='$uri', template='$template'") { + val result = matcher(template).match(uri) + result?.variables?.values?.forEach { value -> + withClue("variable value '$value' must not contain raw '..' or '.' traversal marker") { + value.contains("..") shouldBe false + value shouldNotBe "." + } + } + } + } + } + + @Test + fun `dot-segment URI matches the template it normalizes to`() { + // "public/../users/42" normalizes to "users/42" on JVM/JS, no-op on native/WASM. + // On JVM/JS this must match; the test is platform-conditional via expect/actual. + val result = matcher("users/{id}").match("users/./42") + // After normalization "users/./42" → "users/42" (JVM/JS) or stays 3 segments (native/WASM). + // Either null (native/WASM — 3 segments vs 2) or a valid match (JVM/JS) is acceptable. + // What must NOT happen is a wrong-template match. We check the value if it matches. + result?.variables?.get("id") shouldBe result?.let { "42" } + } + + // endregion + // region: F1: Single percent-decode pass (no double-decoding) + + /** + * %25 is the encoding of '%'. %252F = "%2F" after one decode pass — must NOT become "/". + * If two decode passes were applied, %252F → %2F → /, enabling path traversal. + */ + @Test + fun `double-encoded percent-sign is decoded only once`() { + val result = matcher("files/{path}").match("files/%252Fetc%252Fpasswd") + result.shouldNotBeNull { + // One decode pass: %25 → %, %2F → /... but only %25 is decoded first. + // %252F → %2F (the %25 decodes to %, leaving literal "%2F") + withClue("double-encoded %2F must not become a slash after one decode pass") { + variables["path"]?.contains('/') shouldBe false + } + } + } + + @Test + fun `percent-encoded slash percent-2F in variable is decoded to slash`() { + // %2F IS decoded (one pass) — the result is "/" in the variable value. + // This documents the known behavior: handlers receive decoded values and must + // validate that path separators are not present when constructing file paths. + val result = matcher("files/{path}").match("files/..%2Fetc%2Fpasswd") + result.shouldNotBeNull { + withClue("decoded %2F becomes a path separator — handler must validate") { + variables["path"] shouldBe "../etc/passwd" + } + } + } + + @Test + fun `null byte percent-00 is decoded and passed to handler`() { + // %00 decodes to the null character. Handlers using C-string APIs or file paths + // must reject values containing null bytes. + val result = matcher("items/{id}").match("items/foo%00bar") + result.shouldNotBeNull { + withClue("decoded null byte is present — handler must reject") { + variables["id"] shouldBe "foo\u0000bar" + } + } + } + + @Test + fun `pct-encoded identity bypass`() { + // %69 = 'i'. "adm%69n" decodes to "admin". If a handler compares the raw URI + // against a deny-list of "admin", it must use the decoded value. + val result = matcher("svc/{role}").match("svc/adm%69n") + result.shouldNotBeNull { + variables["role"] shouldBe "admin" + } + } + + // endregion + // region: F3: URI segment depth cap + + @Test + fun `URI exceeding MAX_DEPTH segments returns null`() { + // 51 slash-separated single-char segments → well over the depth cap of 50 + val deepUri = (1..51).joinToString("/") { "x" } + matcher("{a}").match(deepUri) shouldBe null + } + + @Test + fun `URI at exactly MAX_DEPTH segments is not rejected by depth cap`() { + // A URI with exactly 50 segments must be evaluated normally (still non-matching + // against a 1-segment template, but not rejected by the depth guard itself). + val atLimit = (1..50).joinToString("/") { "x" } + // Template has 1 segment, URI has 50 — size mismatch, not depth rejection. + // Both code paths return null; this test verifies no exception is thrown. + matcher("{id}").match(atLimit) shouldBe null + } + + @Test + fun `many-segment URI against many templates completes quickly`() { + val deepUri = (1..49).joinToString("/") { "seg$it" } + val matchers = (1..100).map { matcher("{v$it}") } + // Must not allocate or compute excessively — just verify it finishes. + matchers.forEach { it.match(deepUri) } + } + + // endregion + // region: F5: Double-slash does not produce spurious matches + + @Test + fun `double-slash in URI path is normalized on JVM or causes size mismatch elsewhere`() { + // On JVM: URI("users//42").normalize() collapses "//" to "/" → "users/42" → matches. + // On native/WASM: no normalization — split produces 3 segments vs 2 in template → null. + // Either outcome is safe: no empty-segment injection reaches the handler unchecked. + val result = matcher("users/{id}").match("users//42") + // If matched (JVM), the captured value must be the legitimate path segment. + result?.variables?.get("id") shouldBe result?.let { "42" } + } + + @Test + fun `double-slash in scheme authority path is normalized on JVM or causes size mismatch elsewhere`() { + // On JVM: URI("app://host//injected").normalize() collapses the path "//" to "/" + // → "app://host/injected" → matches "app://host/{id}". + // On native/WASM: no normalization — extra empty segment causes size mismatch → null. + val result = matcher("app://host/{id}").match("app://host//injected") + // If matched (JVM), the captured value must be the legitimate path segment. + result?.variables?.get("id") shouldBe result?.let { "injected" } + } + + // endregion + // region: Query string and fragment pass-through + + @Test + fun `query string and fragment are captured in variable values - handlers must validate`() { + // PathSegmentTemplateMatcher splits on '/' only and does not strip query + // strings or fragments. They are captured verbatim inside the variable value. + // Handlers MUST reject or strip '?' and '#' before using values in file paths, + // database queries, or any other security-sensitive operation. + + // Segment split of "api://host/foo?bar=baz" → ["api:", "", "host", "foo?bar=baz"] (4). + // Template "api://host/{id}" → ["api:", "", "host", "{id}"] (4). + // → Matches on all platforms; query string ends up in the variable value. + val queryResult = matcher("api://host/{id}").match("api://host/foo?bar=baz") + queryResult.shouldNotBeNull { + variables["id"] shouldBe "foo?bar=baz" + } + + // Fragment is also captured verbatim — same reasoning. + val fragmentResult = matcher("api://host/{id}").match("api://host/foo#section") + fragmentResult.shouldNotBeNull { + variables["id"] shouldBe "foo#section" + } + } + + // endregion + // region: Helpers + + private fun matcher(uriTemplate: String): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher( + resourceTemplate = ResourceTemplate(uriTemplate, "Test"), + ) + // endregion +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt new file mode 100644 index 000000000..2f43b5e90 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt @@ -0,0 +1,236 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.maps.shouldBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate +import kotlin.test.Test + +class PathSegmentTemplateMatcherTest { + + // region: Construction + + @Test + fun `should throw on blank template`() { + shouldThrow { + matcher(" ") + } + } + + @Test + fun `should throw on empty variable name`() { + shouldThrow { + matcher("users/{}") + } + } + + @Test + fun `should throw when depth exceeds maxTemplateDepth`() { + val deep = "a/b/c/d/e/f/g/h/i/j/k" // 11 segments + shouldThrow { + matcher(deep, maxDepth = 10) + } + } + + @Test + fun `should accept template at exactly maxTemplateDepth`() { + val atLimit = "a/b/c/d/e/f/g/h/i/j" // 10 segments + matcher(atLimit, maxDepth = 10) // must not throw + } + + // endregion + // region: Basic matching + + @Test + fun `should return null for URI with fewer segments than template`() { + matcher("users/{id}/posts").match("users/42") shouldBe null + } + + @Test + fun `should return null for URI with more segments than template`() { + matcher("users/{id}").match("users/42/extra") shouldBe null + } + + @Test + fun `should match all-literal template`() { + val result = matcher("users/profile").match("users/profile") + result.shouldNotBeNull { + variables.shouldBeEmpty() + } + } + + @Test + fun `should return null when literal segment does not match`() { + matcher("users/profile").match("users/settings") shouldBe null + } + + @Test + fun `should extract single variable`() { + val result = matcher("users/{id}").match("users/42") + result.shouldNotBeNull { + variables["id"] shouldBe "42" + } + } + + @Test + fun `should extract multiple variables`() { + val result = matcher("users/{userId}/posts/{postId}").match("users/alice/posts/99") + result.shouldNotBeNull { + variables["userId"] shouldBe "alice" + variables["postId"] shouldBe "99" + } + } + + @Test + fun `should match template with scheme`() { + val result = matcher("test://items/{id}").match("test://items/42") + result.shouldNotBeNull { + variables["id"] shouldBe "42" + } + } + + // endregion + // region:Scoring + + @Test + fun `all-literal template scores higher than parameterized template for same URI`() { + val literal = matcher("users/profile").match("users/profile")!! + val parameterized = matcher("users/{id}").match("users/profile")!! + (literal.score > parameterized.score) shouldBe true + } + + @Test + fun `score increases with number of segments`() { + val short = matcher("a/b").match("a/b")!! + val long = matcher("a/b/c").match("a/b/c")!! + (long.score > short.score) shouldBe true + } + + @Test + fun `all-literal two-segment template score is 4`() { + // 2 literal segments × LITERAL_MATCH_SCORE(2) = 4 + matcher("users/profile").match("users/profile")!!.score shouldBe 4 + } + + @Test + fun `one-variable two-segment template score is 3`() { + // 1 literal × 2 + 1 variable × 1 = 3 + matcher("users/{id}").match("users/42")!!.score shouldBe 3 + } + + @Test + fun `all-variable template scores one per segment`() { + // 2 variables × VARIABLE_MATCH_SCORE(1) = 2 + matcher("{a}/{b}").match("x/y")!!.score shouldBe 2 + } + + // endregion + // region:URL decoding + + @Test + fun `should URL-decode percent-encoded variable value`() { + val result = matcher("search/{query}").match("search/hello%20world") + result.shouldNotBeNull { + variables["query"] shouldBe "hello world" + } + } + + @Test + fun `should URL-decode percent-encoded literal segment before comparing`() { + // %66 decodes to 'f', so "pro%66ile" == "profile" after decoding — it matches + matcher("users/profile").match("users/pro%66ile").shouldNotBeNull() + } + + // endregion + // region:Length guard + + @Test + fun `should return null when URI exceeds maxUrlLength`() { + val longUri = "a/" + "x".repeat(2048) + matcher("a/{id}", maxUrlLength = 2048).match(longUri) shouldBe null + } + + @Test + fun `should match URI at exactly maxUrlLength`() { + // URI of exactly maxUrlLength characters must be accepted + val uri = "a/" + "x".repeat(2046) // length = 2048 + matcher("a/{id}", maxUrlLength = 2048).match(uri).shouldNotBeNull() + } + + // endregion + // region:Edge cases + + @Test + fun `should match root-level single segment template`() { + val result = matcher("{id}").match("42") + result.shouldNotBeNull { + variables["id"] shouldBe "42" + } + } + + @Test + fun `should treat leading and trailing slashes as equivalent`() { + val result = matcher("/users/{id}/").match("/users/7/") + result.shouldNotBeNull { + variables["id"] shouldBe "7" + } + } + + @Test + fun `should capture empty string for single-segment variable when URI is empty`() { + // "".trim('/').split("/") == [""] — one segment — so {id} captures "" + val result = matcher("{id}").match("") + result.shouldNotBeNull { + variables["id"] shouldBe "" + } + } + + @Test + fun `factory creates matcher equal to direct construction`() { + val template = ResourceTemplate("items/{id}", "Items") + val fromFactory = PathSegmentTemplateMatcher.factory.create(template) + val direct = PathSegmentTemplateMatcher(template) + + val uriToMatch = "items/99" + fromFactory.match(uriToMatch) shouldBe direct.match(uriToMatch) + } + + // endregion + // region: MatchResult equality + + @Test + fun `MatchResult equals by value`() { + val a = MatchResult(mapOf("id" to "1"), score = 3) + val b = MatchResult(mapOf("id" to "1"), score = 3) + a shouldBe b + } + + @Test + fun `MatchResult not equal when score differs`() { + val a = MatchResult(mapOf("id" to "1"), score = 3) + val b = MatchResult(mapOf("id" to "1"), score = 2) + (a == b) shouldBe false + } + + @Test + fun `MatchResult not equal when variables differ`() { + val a = MatchResult(mapOf("id" to "1"), score = 3) + val b = MatchResult(mapOf("id" to "2"), score = 3) + (a == b) shouldBe false + } + + // endregion + // region: Helpers + + private fun matcher( + uriTemplate: String, + maxUrlLength: Int = 2048, + maxDepth: Int = 10, + ): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher( + resourceTemplate = ResourceTemplate(uriTemplate, "Test"), + maxUriLength = maxUrlLength, + maxDepth = maxDepth, + ) + // endregion +} diff --git a/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt b/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt new file mode 100644 index 000000000..fb8aaf450 --- /dev/null +++ b/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt @@ -0,0 +1,16 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +private external class URL(url: String) { + val href: String +} + +/** + * Normalizes [uri] via the browser/Node.js `URL` API, resolving dot segments and + * normalizing the scheme, host, and path. Returns [uri] unchanged if it cannot be + * parsed (e.g. relative URIs or schemes unsupported by the runtime). + */ +internal actual fun normalizeUri(uri: String): String = try { + URL(uri).href +} catch (_: Throwable) { + uri +} diff --git a/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt b/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt new file mode 100644 index 000000000..0cde506be --- /dev/null +++ b/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt @@ -0,0 +1,14 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import java.net.URI +import java.net.URISyntaxException + +/** + * Normalizes [uri] by resolving dot segments (e.g. `/a/b/../c` → `/a/c`) via [URI.normalize]. + * Returns [uri] unchanged if it is not a valid URI. + */ +internal actual fun normalizeUri(uri: String): String = try { + URI(uri).normalize().toString() +} catch (_: URISyntaxException) { + uri +} diff --git a/kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt b/kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt new file mode 100644 index 000000000..623fdfc95 --- /dev/null +++ b/kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt @@ -0,0 +1,3 @@ +package io.modelcontextprotocol.kotlin.sdk.utils +/** No platform-native URI normalizer is available on native targets; [uri] is returned unchanged. */ +internal actual fun normalizeUri(uri: String): String = uri diff --git a/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt b/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt new file mode 100644 index 000000000..7ad7bce9f --- /dev/null +++ b/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt @@ -0,0 +1,4 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +/** No platform-native URI normalizer is available on WASM targets; [uri] is returned unchanged. */ +internal actual fun normalizeUri(uri: String): String = uri diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 25e9bf37f..75f790643 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -146,9 +146,12 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { } public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelcontextprotocol/kotlin/sdk/shared/Protocol { diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt index 0b9f24988..ce55f3e98 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt @@ -10,8 +10,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.Resource import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate import io.modelcontextprotocol.kotlin.sdk.types.Tool -import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplate -import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateMatcher +import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher internal typealias FeatureKey = String @@ -68,12 +67,10 @@ public data class RegisteredResource( * @property readHandler A suspend function invoked when a client reads a URI that matches * this template. The second parameter contains the URI variables extracted from the match. */ -internal data class RegisteredResourceTemplate( +internal class RegisteredResourceTemplate( val resourceTemplate: ResourceTemplate, + val matcher: ResourceTemplateMatcher, val readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, ) : Feature { override val key: String = resourceTemplate.uriTemplate - - // Excluded from data class equals/hashCode/copy — derived from resourceTemplate.uriTemplate. - val matcher: UriTemplateMatcher = UriTemplate(resourceTemplate.uriTemplate).matcher() } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index 84819de2f..26de3bf30 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -44,11 +44,16 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.utils.MatchResult +import io.modelcontextprotocol.kotlin.sdk.utils.PathSegmentTemplateMatcher +import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher +import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcherFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlin.jvm.JvmOverloads import kotlin.time.ExperimentalTime private val logger = KotlinLogging.logger {} @@ -58,9 +63,15 @@ private val logger = KotlinLogging.logger {} * * @property capabilities The capabilities this server supports. * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. + * @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances + * for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory]. */ -public class ServerOptions(public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true) : - ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) +public class ServerOptions @JvmOverloads public constructor( + public val capabilities: ServerCapabilities, + enforceStrictCapabilities: Boolean = true, + public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = + PathSegmentTemplateMatcher.factory, +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) /** * An MCP server is responsible for storing features and handling new connections. @@ -574,7 +585,14 @@ public open class Server( checkNotNull(options.capabilities.resources) { "Server does not support resources capability." } - resourceTemplateRegistry.add(RegisteredResourceTemplate(template, readHandler)) + val matcher = options.resourceTemplateMatcherFactory.create(template) + resourceTemplateRegistry.add( + RegisteredResourceTemplate( + template, + matcher, + readHandler = readHandler, + ), + ) } /** @@ -699,10 +717,14 @@ public open class Server( return resource.run { session.clientConnection.readHandler(request) } } - // Priority 2 & 3: most-specific matching template (highest score wins) + // Priority 2: most-specific matching template. + // Selection: highest score wins; on tie, fewest variables wins; on tie, registration order wins. val (template, matchResult) = resourceTemplateRegistry.values.values .mapNotNull { tmpl -> tmpl.matcher.match(uri)?.let { tmpl to it } } - .maxByOrNull { (_, result) -> result.score } + .maxWithOrNull( + compareBy> { (_, result) -> result.score } + .thenByDescending { (_, result) -> result.variables.size }, + ) ?: run { logger.error { "Resource not found: $uri" } throw McpException( From 15608bd9c6ec9c886e2253ead17b55547be81677 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:43:28 +0200 Subject: [PATCH 7/8] feat(core, server): improve error handling and add synthetic constructors - Enhanced `McpException` default message logic to ensure consistency and eliminate redundant prefixes. - Simplified test assertions in integration tests using `shouldBe` for better readability. - Added synthetic constructors in `ServerOptions` and `McpException` for better Kotlin compatibility. - Updated `Protocol` to rethrow `CancellationException` explicitly, ensuring proper cancellation behavior. --- .../kotlin/sdk/client/ClientTest.kt | 5 +++-- .../kotlin/AbstractPromptIntegrationTest.kt | 15 ++++++++------- kotlin-sdk-core/api/kotlin-sdk-core.api | 1 + .../kotlin/sdk/shared/Protocol.kt | 2 ++ .../kotlin/sdk/types/McpException.kt | 7 ++++--- kotlin-sdk-server/api/kotlin-sdk-server.api | 1 + .../kotlin/sdk/server/Server.kt | 13 +++++++++---- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt index 0726fa829..b117dbfb4 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt @@ -1,5 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.client +import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.ServerSession @@ -266,8 +267,8 @@ class ClientTest { client.connect(failingTransport) } - assertEquals(-32600, exception.code) - assertEquals("MCP error -32600: Invalid Request", exception.message) + exception.code shouldBe -32600 + exception.message shouldBe "Invalid Request" assertTrue(closed) } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index 9380401d8..ee088f023 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin import io.kotest.assertions.withClue +import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams @@ -688,13 +689,13 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { } } - val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt" + val expectedMessage = "Prompt not found: non-existent-prompt" - assertEquals( - RPCError.ErrorCode.INTERNAL_ERROR, - exception.code, - "Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}", - ) - assertEquals(expectedMessage, exception.message, "Unexpected error message for non-existent prompt") + withClue("Exception code should be INTERNAL_ERROR: -32603") { + exception.code shouldBe -32603 + } + withClue("Unexpected error message for non-existent prompt") { + exception.message shouldBe expectedMessage + } } } diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index 21c2f98ee..dc3da3e44 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -2713,6 +2713,7 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty } public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { + public fun (I)V public fun (ILjava/lang/String;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt index bc26b9bb8..0e3db35cd 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -336,6 +336,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") } transport?.send(JSONRPCError(id = request.id, error = rpcError)) + } catch (e: CancellationException) { + throw e } catch (sendError: Throwable) { logger.error(sendError) { "Failed to send error response for request: ${request.method} (id: ${request.id})" diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt index 39060f33b..6b1249274 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt @@ -8,12 +8,13 @@ import kotlin.jvm.JvmOverloads * * @property code The MCP/JSON‑RPC error code. * @property data Optional additional error payload as a JSON element; `null` when not provided. - * @param message The error message. + * @param message The error message. Used verbatim as [Exception.message] — no error code prefix is prepended. + * Defaults to `"MCP error $code"` when not provided. * @param cause The original cause. */ public class McpException @JvmOverloads public constructor( public val code: Int, - message: String?, + message: String = "MCP error $code", public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception("MCP error $code: $message", cause) +) : Exception(message, cause) diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 75f790643..b1b898a33 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -148,6 +148,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index 26de3bf30..e221482cb 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -66,12 +66,17 @@ private val logger = KotlinLogging.logger {} * @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances * for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory]. */ -public class ServerOptions @JvmOverloads public constructor( +public class ServerOptions( public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true, - public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = - PathSegmentTemplateMatcher.factory, -) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) + public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = PathSegmentTemplateMatcher.factory, +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) { + @JvmOverloads + public constructor( + capabilities: ServerCapabilities, + enforceStrictCapabilities: Boolean = true, + ) : this(capabilities, enforceStrictCapabilities, PathSegmentTemplateMatcher.factory) +} /** * An MCP server is responsible for storing features and handling new connections. From 9ce68b7dd19b23369b4ef52e3f813b685b563cf3 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:05:59 +0200 Subject: [PATCH 8/8] chore(tests): remove unused import in `AbstractPromptIntegrationTest` --- .../sdk/integration/kotlin/AbstractPromptIntegrationTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index ee088f023..d14ae88e8 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -9,7 +9,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage -import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.Role import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent