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 494d66c0c..5b266e0ba 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 @@ -105,6 +105,25 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() { exception.data shouldBe buildJsonObject { put("uri", "test://users/42") } } + @Test + fun `readResource should not invoke template handler for blank URI`() = runTest { + var handlerCalled = false + server.addResourceTemplate("{id}", "Item") { _, _ -> + handlerCalled = true + ReadResourceResult(emptyList()) + } + + listOf("", " ", "\t").forEach { uri -> + val exception = shouldThrow { + client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri))) + } + + exception.code shouldBe RPCError.ErrorCode.RESOURCE_NOT_FOUND + exception.data shouldBe buildJsonObject { put("uri", uri) } + handlerCalled shouldBe false + } + } + @Test fun `resourceTemplates property should reflect registered templates`() { server.addResourceTemplate(ResourceTemplate("test://a/{x}", "A")) { _, _ -> 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 index aa2abb994..0841935d8 100644 --- 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 @@ -31,9 +31,10 @@ private const val VARIABLE_MATCH_SCORE = 1 * * ### Matching rules * + * - Blank URIs are rejected. * - 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. + * - `{variable}` segments capture a non-empty 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. * @@ -121,6 +122,7 @@ public class PathSegmentTemplateMatcher @JvmOverloads constructor( logger.debug { "URL is too long (max=$maxUriLength)" } return null } + if (resourceUri.isBlank()) 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. @@ -140,6 +142,7 @@ public class PathSegmentTemplateMatcher @JvmOverloads constructor( val urlSegment = urlParts[i].decodeURLPart() val variableName = variableIndices[i] if (variableName != null) { + if (urlSegment.isEmpty()) return null variables[variableName] = urlSegment score += VARIABLE_MATCH_SCORE } else if (templateParts[i] == urlSegment) { 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 index a9e33f5f6..f922bb8c0 100644 --- 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 @@ -127,11 +127,40 @@ class PathSegmentTemplateMatcherTest { } @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("") + fun `should return null for blank URI`() { + listOf("", " ", "\t", "\r\n").forEach { uri -> + withClue(uri) { + matcher("{id}").match(uri) shouldBe null + } + } + } + + @Test + fun `should return null for empty variable segments`() { + listOf("{id}" to "/", "test://{host}/items" to "test:///items").forEach { (template, uri) -> + withClue("$template matching $uri") { + matcher(template).match(uri) shouldBe null + } + } + } + + @Test + fun `should preserve empty literal segments in file URI`() { + val result = matcher("file:///{path}").match("file:///report.txt") result.shouldNotBeNull { - variables["id"] shouldBe "" + variables["path"] shouldBe "report.txt" + } + } + + @Test + fun `should preserve percent-encoded whitespace in variable values`() { + matcher("{id}").match("%20")?.variables shouldBe mapOf("id" to " ") + } + + @Test + fun `should preserve literal root URI`() { + matcher("/").match("/").shouldNotBeNull { + variables.shouldBeEmpty() } }