Skip to content

Commit dabe078

Browse files
committed
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.
1 parent eb49f5c commit dabe078

13 files changed

Lines changed: 801 additions & 12 deletions

File tree

kotlin-sdk-core/api/kotlin-sdk-core.api

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4732,3 +4732,36 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl
47324732
public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject;
47334733
}
47344734

4735+
public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult {
4736+
public fun <init> (Ljava/util/Map;I)V
4737+
public fun equals (Ljava/lang/Object;)Z
4738+
public final fun getScore ()I
4739+
public final fun getVariables ()Ljava/util/Map;
4740+
public fun hashCode ()I
4741+
public fun toString ()Ljava/lang/String;
4742+
}
4743+
4744+
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
4745+
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion;
4746+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V
4747+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V
4748+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V
4749+
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
4750+
public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
4751+
public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
4752+
public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4753+
}
4754+
4755+
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion {
4756+
public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
4757+
}
4758+
4759+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
4760+
public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
4761+
public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4762+
}
4763+
4764+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory {
4765+
public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher;
4766+
}
4767+
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package io.modelcontextprotocol.kotlin.sdk.utils
2+
3+
import io.github.oshai.kotlinlogging.KotlinLogging
4+
import io.ktor.http.decodeURLPart
5+
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
6+
import kotlinx.collections.immutable.toImmutableMap
7+
import kotlin.jvm.JvmOverloads
8+
import kotlin.jvm.JvmStatic
9+
10+
// Max URL/path length to prevent DoS or unexpected large payloads.
11+
private const val MAX_URL_LENGTH = 2048
12+
13+
// Max template/uri depth to prevent overly nested or complex templates.
14+
private const val MAX_DEPTH = 50
15+
16+
// Literal segments are more specific than variable captures.
17+
private const val LITERAL_MATCH_SCORE = 2
18+
private const val VARIABLE_MATCH_SCORE = 1
19+
20+
/**
21+
* A [ResourceTemplateMatcher] that matches resource URIs against an RFC 6570 Level 1
22+
* URI template by splitting both the URI and the template on `/` and comparing each
23+
* segment in order.
24+
*
25+
* ### Supported template syntax
26+
*
27+
* Only RFC 6570 **Level 1** is supported: simple `{variable}` expressions where the
28+
* entire path segment is a single variable. Operator expressions (`{+var}`, `{#var}`,
29+
* `{.var}`, `{/var}`, etc.) and multi-variable expressions (`{a,b}`) are **not**
30+
* recognized — segments containing them are treated as literals.
31+
*
32+
* ### Matching rules
33+
*
34+
* - The URI and template must have the same number of `/`-delimited segments.
35+
* - Literal segments must match exactly (after percent-decoding the URI segment).
36+
* - `{variable}` segments capture the percent-decoded URI segment value.
37+
* - Query strings and fragments (`?`, `#`) are **not** stripped — they become part of
38+
* the captured variable value for the segment that contains them.
39+
*
40+
* ### Specificity scoring
41+
*
42+
* When multiple templates match the same URI, each matched literal segment contributes
43+
* 2 points and each variable capture contributes 1 point. The highest-scoring match wins.
44+
*
45+
* ### Safety limits
46+
*
47+
* - URIs longer than [maxUriLength] characters are rejected.
48+
* - Templates and URIs with more than [maxDepth] segments are rejected.
49+
*
50+
* ### Security contract for handler authors
51+
*
52+
* Values in [MatchResult.variables] are attacker-controlled strings extracted from
53+
* the incoming URI. They are percent-decoded (one pass only) before being returned.
54+
* Handlers **must** treat them as untrusted input and validate or sanitize them
55+
* before using them to construct file paths, database queries, downstream URLs, or
56+
* any other security-sensitive operation.
57+
*
58+
* In particular:
59+
* - A decoded value may contain `/`, `..`, null bytes (`\u0000`), `?`, or `#`.
60+
* - `%252F` in the URI becomes `%2F` in the variable — exactly one decode pass is applied.
61+
*
62+
* ### Platform limitations
63+
*
64+
* Dot-segment normalization (resolving `..` and `.` in the URI path) is performed
65+
* on JVM and JS targets using the platform-native URI parser. On native and WASM targets
66+
* no normalizer is available, so dot-segment traversal is **not** mitigated at this
67+
* layer — handlers on those targets must normalize paths themselves.
68+
*
69+
* @param resourceTemplate The resource template to match against. Must follow RFC 6570 Level 1 syntax.
70+
* @param maxUriLength Maximum allowed length for incoming URIs. Defaults to 2048.
71+
* @param maxDepth Maximum allowed segment count for the template/uri. Defaults to 50.
72+
*
73+
* @property resourceTemplate The resource template against which resource URIs will be matched.
74+
*/
75+
public class PathSegmentTemplateMatcher @JvmOverloads constructor(
76+
override val resourceTemplate: ResourceTemplate,
77+
private val maxDepth: Int = MAX_DEPTH,
78+
private val maxUriLength: Int = MAX_URL_LENGTH,
79+
) : ResourceTemplateMatcher {
80+
81+
public companion object {
82+
/**
83+
* A [ResourceTemplateMatcherFactory] that creates [PathSegmentTemplateMatcher] instances
84+
* with default limits. Pass this to [io.modelcontextprotocol.kotlin.sdk.server.ServerOptions]
85+
* to use path-segment matching, or supply a custom factory to override the matching strategy.
86+
*/
87+
@JvmStatic
88+
public val factory: ResourceTemplateMatcherFactory = ResourceTemplateMatcherFactory {
89+
PathSegmentTemplateMatcher(it)
90+
}
91+
92+
@JvmStatic
93+
private val logger = KotlinLogging.logger {}
94+
}
95+
96+
private val templateParts: List<String>
97+
98+
// Maps segment index to variable name; indices absent from this map are literal segments.
99+
private val variableIndices: Map<Int, String>
100+
101+
init {
102+
val template = resourceTemplate.uriTemplate
103+
require(template.isNotBlank()) { "Resource template cannot be blank" }
104+
templateParts = template.trim('/').split("/")
105+
require(templateParts.size <= maxDepth) {
106+
"Template is too complex (max depth=$maxDepth)"
107+
}
108+
109+
val vars = mutableMapOf<Int, String>()
110+
for (i in templateParts.indices) {
111+
val segment = templateParts[i]
112+
if (segment.startsWith("{") && segment.endsWith("}")) {
113+
val name = segment.removeSurrounding("{", "}").trim()
114+
require(name.isNotEmpty()) { "Invalid variable name in template: $segment" }
115+
vars[i] = name
116+
}
117+
}
118+
variableIndices = vars.toImmutableMap()
119+
}
120+
121+
@Suppress("ReturnCount")
122+
override fun match(resourceUri: String): MatchResult? {
123+
if (resourceUri.length > maxUriLength) {
124+
logger.debug { "URL is too long (max=$maxUriLength)" }
125+
return null
126+
}
127+
128+
// Resolve dot-segments (e.g. /a/../b → /a/b) before splitting.
129+
// Prevents traversal attacks on JVM/JS; no-op on native/WASM targets.
130+
val normalized = normalizeUri(resourceUri)
131+
132+
val urlParts = normalized.trim('/').split("/")
133+
if (urlParts.size > maxDepth) {
134+
logger.debug { "URI has too many segments (max=$maxDepth)" }
135+
return null
136+
}
137+
if (urlParts.size != templateParts.size) return null
138+
139+
val variables = mutableMapOf<String, String>()
140+
var score = 0
141+
142+
for (i in templateParts.indices) {
143+
val urlSegment = urlParts[i].decodeURLPart()
144+
val variableName = variableIndices[i]
145+
if (variableName != null) {
146+
variables[variableName] = urlSegment
147+
score += VARIABLE_MATCH_SCORE
148+
} else if (templateParts[i] == urlSegment) {
149+
score += LITERAL_MATCH_SCORE
150+
} else {
151+
return null
152+
}
153+
}
154+
155+
return MatchResult(variables = variables.toImmutableMap(), score = score)
156+
}
157+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package io.modelcontextprotocol.kotlin.sdk.utils
2+
3+
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
4+
5+
/**
6+
* Represents the result of a successful template match.
7+
*
8+
* A higher [score] indicates a more specific match. Implementations must ensure that
9+
* literal segment matches contribute more to the score than variable captures, so that
10+
* a fully literal template (e.g., `users/profile`) always outscores a parameterized
11+
* template (e.g., `users/{id}`) for the same URI.
12+
*
13+
* @property variables A mapping of variable names in the template to their matched values in the URL.
14+
* @property score A non-negative measure of match specificity — higher means more literal segments matched.
15+
*/
16+
public class MatchResult(public val variables: Map<String, String>, public val score: Int) {
17+
override fun equals(other: Any?): Boolean {
18+
if (this === other) return true
19+
if (other !is MatchResult) return false
20+
return score == other.score && variables == other.variables
21+
}
22+
23+
override fun hashCode(): Int = 31 * variables.hashCode() + score
24+
25+
override fun toString(): String = "MatchResult(variables=$variables, score=$score)"
26+
}
27+
28+
/**
29+
* Matches resource URIs against a [ResourceTemplate].
30+
*
31+
* Implementations parse a URI template once at construction time and then match
32+
* candidate URIs against that template via [match]. The returned [MatchResult.score]
33+
* must reflect match specificity so that a selection algorithm can prefer the most
34+
* specific template when multiple templates match the same URI.
35+
*/
36+
public interface ResourceTemplateMatcher {
37+
38+
public val resourceTemplate: ResourceTemplate
39+
40+
/**
41+
* Matches a given resource URI against the defined resource template.
42+
*
43+
* @param resourceUri The resource URI to be matched.
44+
* @return A [MatchResult] containing the mapping of variables and a match score
45+
* if the URI matches the template, or null if no match is found.
46+
*/
47+
public fun match(resourceUri: String): MatchResult?
48+
}
49+
50+
/**
51+
* Factory interface for creating instances of [ResourceTemplateMatcher].
52+
*
53+
* A [ResourceTemplateMatcher] is used to match resource URIs against a given
54+
* [ResourceTemplate], which adheres to the RFC 6570 URI Template specification.
55+
* This factory abstracts the creation process of a matcher, allowing different
56+
* implementations to define custom matching logic or safeguards (e.g., security
57+
* measures or restrictions on template complexity).
58+
*/
59+
public fun interface ResourceTemplateMatcherFactory {
60+
/**
61+
* Creates a resource template matcher for the given resource template.
62+
*
63+
* @param resourceTemplate The resource template to create a matcher for.
64+
* @return A [ResourceTemplateMatcher] instance that can match URIs against the provided template.
65+
*/
66+
public fun create(resourceTemplate: ResourceTemplate): ResourceTemplateMatcher
67+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.modelcontextprotocol.kotlin.sdk.utils
2+
3+
/**
4+
* Normalizes [uri] by resolving dot-segments (`/a/b/../c` → `/a/c`) using the
5+
* platform-native URI parser.
6+
*
7+
* On JVM uses [java.net.URI.normalize]. On JS uses the browser/Node.js `URL` API.
8+
* On native and WASM targets [uri] is returned unchanged — no platform normalizer
9+
* is available, so callers on those targets do not receive dot-segment resolution.
10+
*
11+
* Returns [uri] unchanged if it cannot be parsed or normalized.
12+
*
13+
* **Security note**: call this before splitting a URI into segments to prevent
14+
* dot-segment traversal attacks (e.g. `public/../private/secret`).
15+
*/
16+
internal expect fun normalizeUri(uri: String): String

0 commit comments

Comments
 (0)