feat: #502 resource templates with basic template matcher (#606)

This commit is contained in:
Konstantin Pavlov 2026-03-19 12:30:22 +02:00 • committed by GitHub
commit ee6561c185
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1189 additions and 59 deletions

View file

@ -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 <init> (I)V
public fun <init> (ILjava/lang/String;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
@ -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 {
@ -4731,3 +4733,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 <init> (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 <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V
public synthetic fun <init> (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;
}

View file

@ -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
@ -312,6 +313,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})" }
@ -322,19 +324,20 @@ 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})" }
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.message.orEmpty(), data = cause.data)
} else {
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})"

View file

@ -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)

View file

@ -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 */

View file

@ -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<String>
// Maps segment index to variable name; indices absent from this map are literal segments.
private val variableIndices: Map<Int, String>
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<Int, String>()
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<String, String>()
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)
}
}

View file

@ -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<String, String>, 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
}

View file

@ -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

View file

@ -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
}

View file

@ -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<IllegalArgumentException> {
matcher(" ")
}
}
@Test
fun `should throw on empty variable name`() {
shouldThrow<IllegalArgumentException> {
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<IllegalArgumentException> {
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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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