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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpException> {
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")) { _, _ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down