From 66944e1603c97b780ac548dda98bfd10627a96b7 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:14:42 +0200 Subject: [PATCH 1/6] feat(core): Implement RFC 6570 URI template matching and expansion utilities Add `UriTemplate`, `UriTemplateMatcher`, and `MatchResult` classes to support [RFC-6570](https://www.rfc-editor.org/rfc/rfc6570.txt) URI template matching and expansion. Implemented unit tests for conformance with all expansion levels and path operators. --- kotlin-sdk-core/api/kotlin-sdk-core.api | 39 ++ .../kotlin/sdk/utils/UriTemplate.kt | 300 ++++++++++++ .../kotlin/sdk/utils/UriTemplateMatcher.kt | 120 +++++ .../kotlin/sdk/utils/UriTemplateParser.kt | 225 +++++++++ .../sdk/utils/UriTemplateExpansionTest.kt | 436 ++++++++++++++++++ .../sdk/utils/UriTemplateMatcherTest.kt | 173 +++++++ .../sdk/utils/UriTemplateParsingTest.kt | 338 ++++++++++++++ .../kotlin/sdk/utils/UriTemplateTest.kt | 42 ++ 8 files changed, 1673 insertions(+) create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate.kt create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher.kt create mode 100644 kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParser.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateExpansionTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcherTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParsingTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateTest.kt diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index af22323e2..c59e86234 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -4731,3 +4731,42 @@ 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 final fun component1 ()Ljava/util/Map; + public final fun component2 ()I + public final fun copy (Ljava/util/Map;I)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;Ljava/util/Map;IILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; + public fun equals (Ljava/lang/Object;)Z + public final fun get (Ljava/lang/String;)Ljava/lang/String; + 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/UriTemplate { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/UriTemplate$Companion; + public fun (Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public static final fun expand (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; + public final fun expand (Ljava/util/Map;)Ljava/lang/String; + public final fun getLiteralLength ()I + public final fun getTemplate ()Ljava/lang/String; + public fun hashCode ()I + public final fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; + public final fun matcher ()Lio/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher; + public static final fun matches (Ljava/lang/String;Ljava/lang/String;)Z + public fun toString ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate$Companion { + public final fun expand (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; + public final fun matches (Ljava/lang/String;Ljava/lang/String;)Z +} + +public final class io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher { + public final fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; + public final fun matches (Ljava/lang/String;)Z +} + diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate.kt new file mode 100644 index 000000000..7e9321106 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplate.kt @@ -0,0 +1,300 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Modifier +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.OpInfo +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Part +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.VarSpec +import kotlin.jvm.JvmInline +import kotlin.jvm.JvmStatic + +/** + * Result of matching a URI against a [UriTemplate]. + * + * @property variables Variable values extracted from the URI, keyed by variable name. + * All variables from every expression are captured, including multi-variable expressions + * like `{x,y}` or `{?x,y}`. Values are the raw (pct-encoded) substrings from the URI. + * @property score Specificity score — total literal character count of the matched template. + * Higher scores indicate more specific templates. Use this to resolve ambiguity when + * multiple templates match the same URI. + * + * Example: selecting the most specific match + * ``` + * val templates = listOf( + * UriTemplate("users/{id}"), // score = 6 ("users/") + * UriTemplate("users/profile"), // score = 13 ("users/profile") + * ) + * val best = templates + * .mapNotNull { t -> t.match(uri)?.let { t to it } } + * .maxByOrNull { (_, result) -> result.score } + * ``` + */ +public data class MatchResult(val variables: Map, val score: Int) { + /** Returns the value of variable [name], or `null` if it was not captured. */ + public operator fun get(name: String): String? = variables[name] +} + +/** + * RFC 6570 URI Template implementation. + * + * Supports all four levels of URI template expansion: + * - **Level 1** – Simple string expansion: `{var}` + * - **Level 2** – Reserved (`{+var}`) and fragment (`{#var}`) expansion + * - **Level 3** – Label (`{.var}`), path (`{/var}`), path-parameter (`{;var}`), + * query (`{?var}`), and query-continuation (`{&var}`) expansion with multiple variables + * - **Level 4** – Prefix modifiers (`{var:3}`) and explode modifiers (`{var*}`) + * + * The template string is parsed eagerly on construction. + * + * Variable values supplied to [expand] may be: + * - `null` or absent key → undefined (skipped in expansion) + * - [String] → simple string value + * - [List] of [String] → list value (an empty list is treated as undefined) + * - [Map] of [String] to [String] → associative array (an empty map is treated as undefined) + * + * @param template The URI template string. + * @throws IllegalArgumentException if the template is malformed. + * @see [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) + */ +@Suppress("TooManyFunctions") +public class UriTemplate(public val template: String) { + + //region Typed variable value (expansion-only) ───────────────────────────── + + private sealed interface VarValue { + @JvmInline + value class Str(val value: String) : VarValue + data class Lst(val values: List) : VarValue + data class Assoc(val pairs: List>) : VarValue + } + + //endregion + //region Parsed model ─────────────────────────────────────────────────────── + + private val parts: List = UriTemplateParser.parse(template) + + /** + * The total number of encoded literal characters in this template. + * + * This value equals [MatchResult.score] when this template successfully matches a URI, + * making it useful for pre-ranking templates before matching. + */ + public val literalLength: Int = parts.sumOf { if (it is Part.Literal) it.text.length else 0 } + + //endregion + //region Public API ───────────────────────────────────────────────────────── + + /** + * Expands the URI template with the given [variables]. + * + * @return The expanded URI string. + */ + public fun expand(variables: Map): String = buildString { + for (part in parts) { + when (part) { + is Part.Literal -> append(part.text) + is Part.Expression -> append(expandExpression(part, variables)) + } + } + } + + /** + * Returns a compiled [UriTemplateMatcher] for this template (cached lazily). + * Use [match] for one-off checks; use [matcher] when matching against many URIs. + */ + public fun matcher(): UriTemplateMatcher = _matcher + + private val _matcher: UriTemplateMatcher by lazy { + UriTemplateMatcher.build(parts, literalLength) + } + + /** + * Matches [uri] against this template and returns extracted variables plus a + * specificity [MatchResult.score], or `null` if the URI does not match. + * + * Example: + * ``` + * UriTemplate("https://api.example.com/users/{id}/posts/{postId}") + * .match("https://api.example.com/users/alice/posts/99") + * // MatchResult(variables = {"id": "alice", "postId": "99"}, score = 36) + * ``` + */ + public fun match(uri: String): MatchResult? = _matcher.match(uri) + + /** + * Compares this instance of [UriTemplate] with another object for equality. + * + * @param other The object to compare with this instance. It may be `null` or of any type. + * @return `true` if the given object is a [UriTemplate] and its `template` property is equal + * to that of this instance, otherwise `false`. + */ + override fun equals(other: Any?): Boolean = other is UriTemplate && template == other.template + + /** + * Returns a hash code value for this instance. + * + * The hash code is based on the `template` property. + */ + override fun hashCode(): Int = template.hashCode() + + /** + * Returns a string representation of this instance. + * + * The string representation is in the format `UriTemplate(template)`. + */ + override fun toString(): String = "UriTemplate($template)" + + public companion object { + /** + * Expands [template] with [variables] in a single call. + * + * Equivalent to `UriTemplate(template).expand(variables)`. The template is parsed on + * every call without caching. Prefer constructing a [UriTemplate] instance when the + * same template is expanded repeatedly. + * + * Particularly useful from Java, where it is available as a static method. + */ + @JvmStatic + public fun expand(template: String, variables: Map): String = + UriTemplate(template).expand(variables) + + /** + * Returns `true` if [uri] matches [template], without retaining a [UriTemplateMatcher] instance. + * Equivalent to `UriTemplate(template).matcher().matches(uri)`. + */ + @JvmStatic + public fun matches(template: String, uri: String): Boolean = UriTemplate(template).matcher().matches(uri) + } + + //endregion + //region Expression expansion ─────────────────────────────────────────────── + + private fun expandExpression(part: Part.Expression, variables: Map): String { + if (part.varSpecs.isEmpty()) return "{}" + val op = part.op + return buildString { + var firstItem = true + for (varSpec in part.varSpecs) { + val value = resolveValue(variables[varSpec.name]) ?: continue + for (item in itemsForVar(varSpec, value, op)) { + append(if (firstItem) op.first else op.sep) + firstItem = false + append(item) + } + } + } + } + + private fun itemsForVar(spec: VarSpec, value: VarValue, op: OpInfo): List = when (value) { + is VarValue.Str -> listOf(expandStr(spec, value.value, op)) + is VarValue.Lst -> expandList(spec, value.values, op) + is VarValue.Assoc -> expandAssoc(spec, value.pairs, op) + } + + //endregion + //region String expansion ─────────────────────────────────────────────────── + + private fun expandStr(spec: VarSpec, raw: String, op: OpInfo): String = buildString { + if (op.named) { + append(UriTemplateParser.pctEncodeUnreserved(spec.name)) + if (raw.isEmpty()) { + append(op.ifemp) + return@buildString + } + append('=') + } + val encoded = when (val mod = spec.modifier) { + is Modifier.Prefix -> UriTemplateParser.pctEncode( + UriTemplateParser.truncateCodePoints(raw, mod.length), + op.allowReserved, + ) + + else -> UriTemplateParser.pctEncode(raw, op.allowReserved) + } + append(encoded) + } + + //endregion + //region List expansion ───────────────────────────────────────────────────── + + private fun expandList(spec: VarSpec, values: List, op: OpInfo): List = when (spec.modifier) { + Modifier.Explode -> values.map { v -> + if (op.named) { + val name = UriTemplateParser.pctEncodeUnreserved(spec.name) + if (v.isEmpty()) { + "$name${op.ifemp}" + } else { + "$name=${UriTemplateParser.pctEncode(v, op.allowReserved)}" + } + } else { + UriTemplateParser.pctEncode(v, op.allowReserved) + } + } + + else -> listOf( + buildString { + if (op.named) { + append(UriTemplateParser.pctEncodeUnreserved(spec.name)) + append('=') + } + append(values.joinToString(",") { UriTemplateParser.pctEncode(it, op.allowReserved) }) + }, + ) + } + + //endregion + //region Associative-array expansion ──────────────────────────────────────── + + private fun expandAssoc(spec: VarSpec, pairs: List>, op: OpInfo): List = + when (spec.modifier) { + Modifier.Explode -> pairs.map { (k, v) -> + val encK = UriTemplateParser.pctEncode(k, op.allowReserved) + if (v.isEmpty()) { + "$encK${op.ifemp}" + } else { + "$encK=${UriTemplateParser.pctEncode(v, op.allowReserved)}" + } + } + + else -> listOf( + buildString { + if (op.named) { + append(UriTemplateParser.pctEncodeUnreserved(spec.name)) + append('=') + } + append( + pairs.joinToString(",") { (k, v) -> + "${UriTemplateParser.pctEncode(k, op.allowReserved)},${ + UriTemplateParser.pctEncode( + v, + op.allowReserved, + ) + }" + }, + ) + }, + ) + } + + //endregion + //region Value resolution ─────────────────────────────────────────────────── + + private fun resolveValue(raw: Any?): VarValue? = when (raw) { + null -> null + + is String -> VarValue.Str(raw) + + is List<*> -> { + val strs = raw.filterNotNull().map { it.toString() } + if (strs.isEmpty()) null else VarValue.Lst(strs) + } + + is Map<*, *> -> { + val pairs = raw.entries + .mapNotNull { (k, v) -> if (k != null && v != null) k.toString() to v.toString() else null } + if (pairs.isEmpty()) null else VarValue.Assoc(pairs) + } + + else -> VarValue.Str(raw.toString()) + } + //endregion +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher.kt new file mode 100644 index 000000000..c72a4944a --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcher.kt @@ -0,0 +1,120 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Part + +/** + * Compiled regex-based matcher for an RFC 6570 URI template. + * + * Created via [UriTemplate.matcher]. Each instance holds a pre-compiled [Regex] and + * the ordered list of captured variable names, so repeated matching is allocation-cheap. + * + * All variables in every expression are captured, including multi-variable expressions + * like `{x,y}` or `{?x,y}`. + * + * Example: + * ``` + * val matcher = UriTemplate("users/{userId}/posts/{postId}").matcher() + * val result = matcher.match("users/alice/posts/99") + * // MatchResult(variables={"userId":"alice","postId":"99"}, score=12) + * ``` + */ +public class UriTemplateMatcher internal constructor( + private val regex: Regex, + private val variableNames: List, + private val score: Int, +) { + /** + * Matches [uri] against the compiled template regex. + * + * @return a [MatchResult] with extracted variables and score, or `null` if [uri] does not match. + */ + @Suppress("ReturnCount") + public fun match(uri: String): MatchResult? { + if (variableNames.isEmpty()) { + return if (regex.matches(uri)) MatchResult(emptyMap(), score) else null + } + val matchResult = regex.matchEntire(uri) ?: return null + val variables = variableNames.mapIndexed { idx, name -> + name to matchResult.groupValues[idx + 1] + }.toMap() + return MatchResult(variables = variables, score = score) + } + + /** Returns `true` if [uri] fully matches this template pattern. */ + public fun matches(uri: String): Boolean = regex.matches(uri) + + internal companion object { + /** + * Builds a [UriTemplateMatcher] from pre-parsed template [parts] and the template's [score]. + * + * All variables in each expression are registered as capture groups. + * The `+` and `#` operators allow slashes and reserved characters; all others stop at + * URI delimiters (`/`, `?`, `#`, `,`). + */ + @Suppress("CyclomaticComplexMethod") + internal fun build(parts: List, score: Int): UriTemplateMatcher { + val variableNames = mutableListOf() + val pattern = buildString { + append('^') + for (part in parts) { + when (part) { + is Part.Literal -> append(Regex.escape(part.text)) + + is Part.Expression -> { + val op = part.op + if (part.varSpecs.isEmpty()) continue + + var firstVar = true + for (varSpec in part.varSpecs) { + val name = varSpec.name + variableNames.add(name) + + // Emit the operator prefix (first var) or separator (subsequent vars). + if (firstVar) { + // For named operators the prefix includes the variable name. + val prefix = when (op.char) { + ';', '?', '&' -> "${op.first}$name=" + else -> op.first // NUL/+: "", #: "#", .: ".", /: "/" + } + if (prefix.isNotEmpty()) append(Regex.escape(prefix)) + firstVar = false + } else { + // For named operators the separator includes the variable name. + val sep = if (op.named) "${op.sep}$name=" else op.sep + append(Regex.escape(sep)) + } + + // Each operator restricts which characters a value may contain. + // The capture pattern uses the tightest stop-char set per RFC 6570. + val capture = when (op.char) { + // '+' and '#' allow reserved chars (including ',') in values + // per RFC 6570 §3.2.3–3.2.4, so comma-containing values + // cannot be reverse-matched correctly. + // RFC 6570 §1.4 acknowledges this: "Variable matching only works well + // if the template expressions are delimited by characters + // that cannot be part of the expansion." + '+', '#' -> "([^,]*)" + + // path operators: stop at all URI path and query delimiters + '.', '/' -> "([^/?#,;=&]*)" + + // path-parameter: stop at ';' (next param) and query delimiters + ';' -> "([^/?#,;=&]*)" + + // query operators: stop at '&' (next pair) too + '?', '&' -> "([^/?#,;&=]*)" + + // NUL: unreserved characters only + else -> "([^/?#,;=&]*)" + } + append(capture) + } + } + } + } + append('$') + } + return UriTemplateMatcher(Regex(pattern), variableNames, score) + } + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParser.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParser.kt new file mode 100644 index 000000000..6cae91ee5 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParser.kt @@ -0,0 +1,225 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +/** + * Internal parser for RFC 6570 URI Templates. + * + * Parses a template string into a structured [Part] list and provides helpers + * for PCT-encoding values during expansion. + */ +@Suppress("TooManyFunctions") +internal object UriTemplateParser { + + //region Model types ──────────────────────────────────────────────────────── + + internal data class OpInfo( + val char: Char?, + val first: String, + val sep: String, + val named: Boolean, + val ifemp: String, + val allowReserved: Boolean, + ) + + internal sealed interface Modifier { + data object None : Modifier + data object Explode : Modifier + data class Prefix(val length: Int) : Modifier + } + + internal data class VarSpec(val name: String, val modifier: Modifier) + + /** A parsed segment of a URI template. */ + internal sealed interface Part { + /** Literal text, already pct-encoded for direct output. */ + data class Literal(val text: String) : Part + + /** An expression `{…}` with its operator metadata and variable list. */ + data class Expression(val op: OpInfo, val varSpecs: List) : Part + } + + //endregion + //region Operator constants ───────────────────────────────────────────────── + // Pre-allocated singletons — one per object, shared across all parse calls. + + private const val HEX_CHARS = "0123456789ABCDEF" + + internal val NUL_OP = OpInfo(null, "", ",", named = false, ifemp = "", allowReserved = false) + private val PLUS_OP = OpInfo('+', "", ",", named = false, ifemp = "", allowReserved = true) + private val HASH_OP = OpInfo('#', "#", ",", named = false, ifemp = "", allowReserved = true) + private val DOT_OP = OpInfo('.', ".", ".", named = false, ifemp = "", allowReserved = false) + private val SLASH_OP = OpInfo('/', "/", "/", named = false, ifemp = "", allowReserved = false) + private val SEMI_OP = OpInfo(';', ";", ";", named = true, ifemp = "", allowReserved = false) + private val QUERY_OP = OpInfo('?', "?", "&", named = true, ifemp = "=", allowReserved = false) + private val AMP_OP = OpInfo('&', "&", "&", named = true, ifemp = "=", allowReserved = false) + + //endregion + //region parsing entry point ───────────────────────────────────────── + + /** + * Parses [template] into an ordered list of [Part]s. + * + * @throws IllegalArgumentException if the template contains an unclosed brace. + */ + internal fun parse(template: String): List { + val result = mutableListOf() + val literalBuf = StringBuilder() + var i = 0 + + fun flushLiteral() { + if (literalBuf.isNotEmpty()) { + result.add(Part.Literal(literalBuf.toString())) + literalBuf.clear() + } + } + + @Suppress("LoopWithTooManyJumpStatements") + while (i < template.length) { + if (template[i] != '{') { + literalBuf.append(encodeLiteralChar(template[i])) + i++ + continue + } + val end = template.indexOf('}', i + 1) + require(end != -1) { + "Unclosed brace at index $i in URI template: \"$template\"" + } + flushLiteral() + result.add(parseExpression(template.substring(i + 1, end))) + i = end + 1 + } + flushLiteral() + return result + } + + //endregion + //region PCT-encoding helpers (used by UriTemplate expand logic) ─────────── + + /** PCT-encodes [s], passing reserved characters through when [allowReserved] is true. */ + internal fun pctEncode(s: String, allowReserved: Boolean): String { + val sb = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + when { + isUnreserved(c) -> sb.append(c) + + allowReserved && isReserved(c) -> sb.append(c) + + // Preserve existing pct-encoded triplets in reserved/fragment mode. + allowReserved && + c == '%' && + i + 2 < s.length && + isHexDigit(s[i + 1]) && + isHexDigit(s[i + 2]) -> { + sb.append(c).append(s[i + 1]).append(s[i + 2]) + i += 2 + } + + // Surrogate pair — encode the full code point as UTF-8. + c.isHighSurrogate() && i + 1 < s.length && s[i + 1].isLowSurrogate() -> { + pctEncodeBytes(sb, s.substring(i, i + 2).encodeToByteArray()) + i++ // skip low surrogate; outer increment follows + } + + else -> pctEncodeBytes(sb, c.toString().encodeToByteArray()) + } + i++ + } + return sb.toString() + } + + internal fun pctEncodeUnreserved(s: String): String = pctEncode(s, allowReserved = false) + + /** + * Truncates [s] to at most [n] Unicode code points. + * Surrogate pairs count as a single code point. + */ + internal fun truncateCodePoints(s: String, n: Int): String { + var count = 0 + var i = 0 + while (i < s.length && count < n) { + if (s[i].isHighSurrogate() && i + 1 < s.length && s[i + 1].isLowSurrogate()) i++ + i++ + count++ + } + return s.substring(0, i) + } + + //endregion + //region Private parsing helpers ──────────────────────────────────────────── + + private fun parseExpression(expression: String): Part.Expression { + if (expression.isEmpty()) return Part.Expression(NUL_OP, emptyList()) + val (opInfo, varListStr) = detectOperator(expression) + val varSpecs = if (varListStr.isEmpty()) emptyList() else parseVarList(varListStr) + return Part.Expression(opInfo, varSpecs) + } + + private fun detectOperator(expression: String): Pair { + val op = when (expression.firstOrNull()) { + '+' -> PLUS_OP + '#' -> HASH_OP + '.' -> DOT_OP + '/' -> SLASH_OP + ';' -> SEMI_OP + '?' -> QUERY_OP + '&' -> AMP_OP + else -> return Pair(NUL_OP, expression) + } + return Pair(op, expression.substring(1)) + } + + private fun parseVarList(varListStr: String): List = + varListStr.split(',').mapNotNull { parseVarSpec(it.trim()) } + + @Suppress("ReturnCount") + private fun parseVarSpec(raw: String): VarSpec? { + if (raw.isEmpty()) return null + if (raw.endsWith('*')) return VarSpec(raw.dropLast(1), Modifier.Explode) + val colon = raw.indexOf(':') + if (colon > 0) { + val len = raw.substring(colon + 1).toIntOrNull() + if (len != null && len > 0) return VarSpec(raw.substring(0, colon), Modifier.Prefix(len)) + } + return VarSpec(raw, Modifier.None) + } + + //endregion + //region Private encoding helpers ─────────────────────────────────────────── + + /** + * Encodes a single literal template character. + * Unreserved, reserved, and `%` characters are passed through unchanged; + * everything else is PCT-encoded as UTF-8. + */ + private fun encodeLiteralChar(c: Char): String = if (isUnreserved(c) || isReserved(c) || c == '%') { + c.toString() + } else { + buildString { pctEncodeBytes(this, c.toString().encodeToByteArray()) } + } + + @Suppress("MagicNumber") + private fun pctEncodeBytes(sb: StringBuilder, bytes: ByteArray) { + for (b in bytes) { + val n = b.toInt() and 0xFF + sb.append('%') + sb.append(HEX_CHARS[n ushr 4]) + sb.append(HEX_CHARS[n and 0xF]) + } + } + + private fun isUnreserved(c: Char): Boolean = + c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '.' || c == '_' || c == '~' + + /** + * Returns `true` for reserved characters (gen-delims | sub-delims) per RFC 3986. + * + * gen-delims: `:/?#[]@` + * sub-delims: `!$&'()*+,;=` + */ + private fun isReserved(c: Char): Boolean = c in ":/?#[]@!$&'()*+,;=" + + private fun isHexDigit(c: Char): Boolean = c in '0'..'9' || c in 'A'..'F' || c in 'a'..'f' + + //endregion +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateExpansionTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateExpansionTest.kt new file mode 100644 index 000000000..11f59872e --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateExpansionTest.kt @@ -0,0 +1,436 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +/** + * Unit tests for [UriTemplate] covering all RFC 6570 levels and operators. + * + * Variable definitions follow Appendix A of RFC 6570: + * count = ("one", "two", "three") + * dom = ("example", "com") + * dub = "me/too" + * hello = "Hello World!" + * half = "50%" + * var = "value" + * who = "fred" + * base = "http://example.com/home/" + * path = "/foo/bar" + * list = ("red", "green", "blue") + * keys = [("semi",";"), ("dot","."), ("comma",",")] + * v = "6" + * x = "1024" + * y = "768" + * empty = "" + * empty_keys = [] + * undef = null + */ +class UriTemplateExpansionTest { + + //region Shared variable set ──────────────────────────────────────────────── + + private val vars: Map = mapOf( + "count" to listOf("one", "two", "three"), + "dom" to listOf("example", "com"), + "dub" to "me/too", + "hello" to "Hello World!", + "half" to "50%", + "var" to "value", + "who" to "fred", + "base" to "http://example.com/home/", + "path" to "/foo/bar", + "list" to listOf("red", "green", "blue"), + "keys" to mapOf("semi" to ";", "dot" to ".", "comma" to ","), + "v" to "6", + "x" to "1024", + "y" to "768", + "empty" to "", + "empty_keys" to emptyMap(), + "undef" to null, + ) + + private fun expand(template: String): String = UriTemplate.expand(template, vars) + + //endregion + //region Level 1: Simple string expansion {var} ───────────────────────────── + + @Test + fun `level 1 - simple variable expansion`() { + expand("{var}") shouldBe "value" + expand("{hello}") shouldBe "Hello%20World%21" + expand("{half}") shouldBe "50%25" + } + + @Test + fun `level 1 - undefined and empty variables`() { + expand("O{empty}X") shouldBe "OX" + expand("O{undef}X") shouldBe "OX" + } + + @Test + fun `level 1 - multiple variables in expression`() { + expand("{x,y}") shouldBe "1024,768" + expand("{x,hello,y}") shouldBe "1024,Hello%20World%21,768" + expand("?{x,empty}") shouldBe "?1024," + expand("?{x,undef}") shouldBe "?1024" + expand("?{undef,y}") shouldBe "?768" + } + + //endregion + //region Level 1: Prefix modifier {var:N} ────────────────────────────────── + + @Test + fun `level 1 - prefix modifier on simple string`() { + expand("{var:3}") shouldBe "val" + expand("{var:30}") shouldBe "value" + } + + //endregion + //region Level 1: List and map without explode ────────────────────────────── + + @Test + fun `level 1 - list expansion without explode`() { + expand("{list}") shouldBe "red,green,blue" + expand("{list*}") shouldBe "red,green,blue" + } + + @Test + fun `level 1 - map expansion without explode`() { + expand("{keys}") shouldBe "semi,%3B,dot,.,comma,%2C" + expand("{keys*}") shouldBe "semi=%3B,dot=.,comma=%2C" + } + + //endregion + //region Level 2: Reserved expansion {+var} ──────────────────────────────── + + @Test + fun `reserved expansion - basic`() { + expand("{+var}") shouldBe "value" + expand("{+hello}") shouldBe "Hello%20World!" + expand("{+half}") shouldBe "50%25" + } + + @Test + fun `reserved expansion - allows reserved chars and pct-encoded passthrough`() { + expand("{base}index") shouldBe "http%3A%2F%2Fexample.com%2Fhome%2Findex" + expand("{+base}index") shouldBe "http://example.com/home/index" + expand("O{+empty}X") shouldBe "OX" + expand("O{+undef}X") shouldBe "OX" + } + + @Test + fun `reserved expansion - path variable`() { + expand("{+path}/here") shouldBe "/foo/bar/here" + expand("here?ref={+path}") shouldBe "here?ref=/foo/bar" + expand("up{+path}{var}/here") shouldBe "up/foo/barvalue/here" + } + + @Test + fun `reserved expansion - multiple variables`() { + expand("{+x,hello,y}") shouldBe "1024,Hello%20World!,768" + expand("{+path,x}/here") shouldBe "/foo/bar,1024/here" + } + + @Test + fun `reserved expansion - prefix modifier`() { + expand("{+path:6}/here") shouldBe "/foo/b/here" + } + + @Test + fun `reserved expansion - list and map`() { + expand("{+list}") shouldBe "red,green,blue" + expand("{+list*}") shouldBe "red,green,blue" + expand("{+keys}") shouldBe "semi,;,dot,.,comma,," + expand("{+keys*}") shouldBe "semi=;,dot=.,comma=," + } + + //endregion + //region Level 2: Fragment expansion {#var} ──────────────────────────────── + + @Test + fun `fragment expansion - basic`() { + expand("{#var}") shouldBe "#value" + expand("{#hello}") shouldBe "#Hello%20World!" + expand("{#half}") shouldBe "#50%25" + expand("foo{#empty}") shouldBe "foo#" + expand("foo{#undef}") shouldBe "foo" + } + + @Test + fun `fragment expansion - multiple variables`() { + expand("{#x,hello,y}") shouldBe "#1024,Hello%20World!,768" + expand("{#path,x}/here") shouldBe "#/foo/bar,1024/here" + } + + @Test + fun `fragment expansion - prefix modifier and composites`() { + expand("{#path:6}/here") shouldBe "#/foo/b/here" + expand("{#list}") shouldBe "#red,green,blue" + expand("{#list*}") shouldBe "#red,green,blue" + expand("{#keys}") shouldBe "#semi,;,dot,.,comma,," + expand("{#keys*}") shouldBe "#semi=;,dot=.,comma=," + } + + //endregion + //region Level 3: Label expansion {.var} ──────────────────────────────────── + + @Test + fun `label expansion - basic`() { + expand("{.who}") shouldBe ".fred" + expand("{.who,who}") shouldBe ".fred.fred" + expand("{.half,who}") shouldBe ".50%25.fred" + expand("www{.dom*}") shouldBe "www.example.com" + expand("X{.var}") shouldBe "X.value" + expand("X{.empty}") shouldBe "X." + expand("X{.undef}") shouldBe "X" + } + + @Test + fun `label expansion - prefix modifier`() { + expand("X{.var:3}") shouldBe "X.val" + } + + @Test + fun `label expansion - composites`() { + expand("X{.list}") shouldBe "X.red,green,blue" + expand("X{.list*}") shouldBe "X.red.green.blue" + expand("X{.keys}") shouldBe "X.semi,%3B,dot,.,comma,%2C" + expand("X{.keys*}") shouldBe "X.semi=%3B.dot=..comma=%2C" + expand("X{.empty_keys}") shouldBe "X" + expand("X{.empty_keys*}") shouldBe "X" + } + + //endregion + //region Level 3: Path segment expansion {/var} ───────────────────────────── + + @Test + fun `path segment expansion - basic`() { + expand("{/who}") shouldBe "/fred" + expand("{/who,who}") shouldBe "/fred/fred" + expand("{/half,who}") shouldBe "/50%25/fred" + expand("{/who,dub}") shouldBe "/fred/me%2Ftoo" + expand("{/var}") shouldBe "/value" + expand("{/var,empty}") shouldBe "/value/" + expand("{/var,undef}") shouldBe "/value" + expand("{/var,x}/here") shouldBe "/value/1024/here" + } + + @Test + fun `path segment expansion - prefix modifier`() { + expand("{/var:1,var}") shouldBe "/v/value" + } + + @Test + fun `path segment expansion - composites`() { + expand("{/list}") shouldBe "/red,green,blue" + expand("{/list*}") shouldBe "/red/green/blue" + expand("{/list*,path:4}") shouldBe "/red/green/blue/%2Ffoo" + expand("{/keys}") shouldBe "/semi,%3B,dot,.,comma,%2C" + expand("{/keys*}") shouldBe "/semi=%3B/dot=./comma=%2C" + } + + //endregion + //region Level 3: Path-style parameter expansion {;var} ───────────────────── + + @Test + fun `path parameter expansion - basic`() { + expand("{;who}") shouldBe ";who=fred" + expand("{;half}") shouldBe ";half=50%25" + expand("{;empty}") shouldBe ";empty" + expand("{;v,empty,who}") shouldBe ";v=6;empty;who=fred" + expand("{;v,bar,who}") shouldBe ";v=6;who=fred" + expand("{;x,y}") shouldBe ";x=1024;y=768" + expand("{;x,y,empty}") shouldBe ";x=1024;y=768;empty" + expand("{;x,y,undef}") shouldBe ";x=1024;y=768" + } + + @Test + fun `path parameter expansion - prefix modifier`() { + expand("{;hello:5}") shouldBe ";hello=Hello" + } + + @Test + fun `path parameter expansion - composites`() { + expand("{;list}") shouldBe ";list=red,green,blue" + expand("{;list*}") shouldBe ";list=red;list=green;list=blue" + expand("{;keys}") shouldBe ";keys=semi,%3B,dot,.,comma,%2C" + expand("{;keys*}") shouldBe ";semi=%3B;dot=.;comma=%2C" + } + + //endregion + //region Level 3: Form-style query expansion {?var} ───────────────────────── + + @Test + fun `query expansion - basic`() { + expand("{?who}") shouldBe "?who=fred" + expand("{?half}") shouldBe "?half=50%25" + expand("{?x,y}") shouldBe "?x=1024&y=768" + expand("{?x,y,empty}") shouldBe "?x=1024&y=768&empty=" + expand("{?x,y,undef}") shouldBe "?x=1024&y=768" + } + + @Test + fun `query expansion - prefix modifier`() { + expand("{?var:3}") shouldBe "?var=val" + } + + @Test + fun `query expansion - composites`() { + expand("{?list}") shouldBe "?list=red,green,blue" + expand("{?list*}") shouldBe "?list=red&list=green&list=blue" + expand("{?keys}") shouldBe "?keys=semi,%3B,dot,.,comma,%2C" + expand("{?keys*}") shouldBe "?semi=%3B&dot=.&comma=%2C" + } + + //endregion + //region Level 3: Form-style query continuation {&var} ────────────────────── + + @Test + fun `query continuation - basic`() { + expand("{&who}") shouldBe "&who=fred" + expand("{&half}") shouldBe "&half=50%25" + expand("?fixed=yes{&x}") shouldBe "?fixed=yes&x=1024" + expand("{&x,y,empty}") shouldBe "&x=1024&y=768&empty=" + expand("{&x,y,undef}") shouldBe "&x=1024&y=768" + } + + @Test + fun `query continuation - prefix modifier`() { + expand("{&var:3}") shouldBe "&var=val" + } + + @Test + fun `query continuation - composites`() { + expand("{&list}") shouldBe "&list=red,green,blue" + expand("{&list*}") shouldBe "&list=red&list=green&list=blue" + expand("{&keys}") shouldBe "&keys=semi,%3B,dot,.,comma,%2C" + expand("{&keys*}") shouldBe "&semi=%3B&dot=.&comma=%2C" + } + + //endregion + //region List operator behaviour (Section 3.2.1) ──────────────────────────── + + @Test + fun `list expansion across operators`() { + expand("{count}") shouldBe "one,two,three" + expand("{count*}") shouldBe "one,two,three" + expand("{/count}") shouldBe "/one,two,three" + expand("{/count*}") shouldBe "/one/two/three" + expand("{;count}") shouldBe ";count=one,two,three" + expand("{;count*}") shouldBe ";count=one;count=two;count=three" + expand("{?count}") shouldBe "?count=one,two,three" + expand("{?count*}") shouldBe "?count=one&count=two&count=three" + expand("{&count*}") shouldBe "&count=one&count=two&count=three" + } + + //endregion + //region Literal-only templates ───────────────────────────────────────────── + + @Test + fun `literal template without expressions is returned verbatim`() { + expand("https://example.com/api/v1") shouldBe "https://example.com/api/v1" + } + + @Test + fun `literal characters outside expressions pass through or get encoded`() { + // Unreserved and reserved chars are allowed in literals + expand("http://example.com/~user") shouldBe "http://example.com/~user" + // Space in literal gets pct-encoded + expand("hello world") shouldBe "hello%20world" + } + + //endregion + //region Empty and undefined edge-cases ───────────────────────────────────── + + @Test + fun `all variables undefined yields empty string for expression`() { + // RFC 6570 §3.2.1: when all variables are undefined the expression expands to "" + // and operator prefix/first characters are NOT emitted — verified for all 8 operators + UriTemplate.expand("{undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{+undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{#undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{.undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{/undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{;undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{?undef}", mapOf("undef" to null)) shouldBe "" + UriTemplate.expand("{&undef}", mapOf("undef" to null)) shouldBe "" + } + + @Test + fun `empty list and empty map are treated as undefined`() { + UriTemplate.expand("{list}", mapOf("list" to emptyList())) shouldBe "" + UriTemplate.expand("{keys}", mapOf("keys" to emptyMap())) shouldBe "" + } + + @Test + fun `empty expression braces return malformed marker`() { + expand("{}") shouldBe "{}" + } + + @Test + fun `template with unclosed brace throws IllegalArgumentException`() { + shouldThrow { UriTemplate("{unclosed") } + } + + //endregion + //region Companion function ───────────────────────────────────────────────── + + @Test + fun `companion expand delegates to instance expand`() { + UriTemplate.expand("http://example.com/{path}", mapOf("path" to "home")) shouldBe + "http://example.com/home" + } + + //endregion + //region Prefix modifier clamping ─────────────────────────────────────────── + + @Test + fun `prefix longer than value uses full value`() { + UriTemplate.expand("{var:30}", mapOf("var" to "short")) shouldBe "short" + } + + @Test + fun `prefix of zero is treated as positive constraint clamped to empty`() { + // :0 is not valid per the spec (max-length = %x31-39 0*3DIGIT), but we handle it gracefully. + UriTemplate.expand("{var:0}", mapOf("var" to "value")) shouldBe "" + } + + //endregion + //region Non-String variable values ───────────────────────────────────────── + + @Test + fun `map entries with null values are filtered out in expansion`() { + // resolveValue Map branch: entries where value is null are dropped via mapNotNull + val result = UriTemplate.expand("{keys}", mapOf("keys" to mapOf("a" to "1", "b" to null, "c" to "3"))) + result shouldBe "a,1,c,3" + } + + @Test + fun `non-String non-List non-Map value is converted via toString`() { + // resolveValue's else branch: Int, Boolean, etc. are coerced to String + UriTemplate.expand("{n}", mapOf("n" to 42)) shouldBe "42" + UriTemplate.expand("{flag}", mapOf("flag" to true)) shouldBe "true" + } + + //endregion + //region Empty-string element in exploded named-operator list ─────────────── + + @Test + fun `exploded list with empty-string element uses ifemp for named operators`() { + // {;list*} where one element is "" → ;list instead of ;list= + UriTemplate.expand("{;list*}", mapOf("list" to listOf("a", "", "b"))) shouldBe ";list=a;list;list=b" + // {?list*} where one element is "" → ?list=&... (ifemp = "=") + UriTemplate.expand("{?list*}", mapOf("list" to listOf("a", "", "b"))) shouldBe "?list=a&list=&list=b" + } + + @Test + fun `exploded assoc with empty-string value uses ifemp for named operators`() { + // {;keys*} where a value is "" → ;key instead of ;key= + UriTemplate.expand("{;keys*}", mapOf("keys" to mapOf("a" to "1", "b" to "", "c" to "3"))) shouldBe + ";a=1;b;c=3" + // {?keys*} where a value is "" → ?a=1&b=&c=3 (ifemp = "=") + UriTemplate.expand("{?keys*}", mapOf("keys" to mapOf("a" to "1", "b" to "", "c" to "3"))) shouldBe + "?a=1&b=&c=3" + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcherTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcherTest.kt new file mode 100644 index 000000000..d1f9157d4 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateMatcherTest.kt @@ -0,0 +1,173 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +/** + * Unit tests for [UriTemplateMatcher] and [UriTemplate.match] / [UriTemplate.matcher]. + */ +class UriTemplateMatcherTest { + + //region match() – variable extraction ───────────────────────────────────── + + @Test + fun `match returns variable values for simple template`() { + val result = UriTemplate("https://example.com/users/{id}").match("https://example.com/users/42") + result shouldNotBeNull { + variables shouldBe mapOf("id" to "42") + } + } + + @Test + fun `match returns multiple variables`() { + val result = UriTemplate("https://api.example.com/users/{userId}/posts/{postId}") + .match("https://api.example.com/users/alice/posts/99") + result shouldNotBeNull { + variables shouldBe mapOf("userId" to "alice", "postId" to "99") + } + } + + @Test + fun `match returns null when URI does not match template`() { + UriTemplate("https://example.com/items/{id}").match("https://example.com/other/42") shouldBe null + } + + @Test + fun `match works with custom scheme template`() { + val result = UriTemplate("test://template/{id}/data").match("test://template/abc123/data") + result shouldNotBeNull { + variables shouldBe mapOf("id" to "abc123") + } + } + + @Test + fun `match returns empty variables for literal-only template`() { + val result = UriTemplate("https://example.com/static").match("https://example.com/static") + result shouldNotBeNull { + variables shouldBe emptyMap() + } + } + + @Test + fun `match returns null when URI is shorter than template`() { + UriTemplate("https://example.com/a/{id}/b").match("https://example.com/a/") shouldBe null + } + + @Test + fun `match operator get returns extracted variable value`() { + val result = UriTemplate("{id}").match("42") + result shouldNotBeNull { + get("id") shouldBe "42" + get("missing") shouldBe null + } + } + + //endregion + //region UriTemplate.matches() companion ─────────────────────────────────── + + @Test + fun `companion matches returns true when URI fits template`() { + UriTemplate.matches("test://items/{id}", "test://items/42") shouldBe true + } + + @Test + fun `companion matches returns false when URI does not fit template`() { + UriTemplate.matches("test://items/{id}", "test://other/42") shouldBe false + } + + //endregion + //region matcher() ───────────────────────────────────────────────────────── + + @Test + fun `matcher returns a UriTemplateMatcher consistent with match`() { + val tmpl = UriTemplate("test://items/{id}") + val matcher = tmpl.matcher() + matcher.match("test://items/42") shouldBe tmpl.match("test://items/42") + } + + @Test + fun `matcher is cached - same instance on repeated calls`() { + val tmpl = UriTemplate("test://items/{id}") + tmpl.matcher() shouldBe tmpl.matcher() + } + + @Test + fun `match score equals template literalLength`() { + val tmpl = UriTemplate("https://example.com/users/{id}") + tmpl.match("https://example.com/users/42")!!.score shouldBe tmpl.literalLength + } + + @Test + fun `matches returns true for matching URI and false otherwise`() { + val matcher = UriTemplate("test://items/{id}").matcher() + matcher.matches("test://items/42") shouldBe true + matcher.matches("test://other/42") shouldBe false + } + + @Test + fun `matches returns false when URI has extra path segments beyond template`() { + // Verifies the regex is anchored end-to-end; a prefix match must not be accepted + val matcher = UriTemplate("test://items/{id}").matcher() + matcher.matches("test://items/42/extra") shouldBe false + matcher.matches("test://items/42?query=1") shouldBe false + } + + //endregion + //region multi-variable expression matching ──────────────────────────────── + + @Test + fun `match extracts all variables from multi-variable query expression`() { + val result = UriTemplate("test://search{?x,y}").match("test://search?x=1024&y=768") + result shouldNotBeNull { + variables shouldBe mapOf("x" to "1024", "y" to "768") + } + } + + @Test + fun `match extracts all variables from multi-variable path expression`() { + val result = UriTemplate("test://items/{a},{b}").match("test://items/foo,bar") + result shouldNotBeNull { + variables shouldBe mapOf("a" to "foo", "b" to "bar") + } + } + + //endregion + //region literalLength / score ────────────────────────────────────────────── + + @Test + fun `literalLength counts only literal characters`() { + // "https://example.com/users/" = 26 chars + UriTemplate("https://example.com/users/{id}").literalLength shouldBe 26 + } + + @Test + fun `more specific template has higher score than generic one`() { + val generic = UriTemplate("test://items/{id}") + val specific = UriTemplate("test://items/special") + + val uri = "test://items/special" + val genericResult = generic.match(uri) + val specificResult = specific.match(uri) + + genericResult.shouldNotBeNull() + specificResult.shouldNotBeNull() + (specificResult.score > genericResult.score) shouldBe true + } + + @Test + fun `selecting most specific template via score`() { + val templates = listOf( + UriTemplate("test://items/{id}"), + UriTemplate("test://items/{id}/details"), + UriTemplate("test://items/special/details"), + ) + val uri = "test://items/special/details" + val best = templates.mapNotNull { it.match(uri) }.maxByOrNull { it.score } + + best.shouldNotBeNull() + best.score shouldBe "test://items/special/details".length + } + + //endregion +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParsingTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParsingTest.kt new file mode 100644 index 000000000..03217968a --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateParsingTest.kt @@ -0,0 +1,338 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Modifier +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.Part +import io.modelcontextprotocol.kotlin.sdk.utils.UriTemplateParser.VarSpec +import kotlin.test.Test + +/** + * Unit tests for [UriTemplateParser] — verifies the parsed [Part] model, + * operator properties, modifier detection, literal encoding, and encoding + * helpers. These test the intermediate representation, not just the final + * expanded string. + */ +class UriTemplateParsingTest { + + //region parse() — structural model ───────────────────────────────────────── + + @Test + fun `empty template produces no parts`() { + UriTemplateParser.parse("") shouldBe emptyList() + } + + @Test + fun `literal-only template produces a single Literal part`() { + UriTemplateParser.parse("hello/world") shouldBe listOf(Part.Literal("hello/world")) + } + + @Test + fun `single expression produces an Expression part with NUL operator`() { + val parts = UriTemplateParser.parse("{var}") + parts.size shouldBe 1 + val expr = parts[0] as Part.Expression + expr.op shouldBe UriTemplateParser.NUL_OP + expr.varSpecs shouldBe listOf(VarSpec("var", Modifier.None)) + } + + @Test + fun `mixed literal and expression produces two parts in order`() { + val parts = UriTemplateParser.parse("users/{id}") + parts shouldBe listOf( + Part.Literal("users/"), + Part.Expression(UriTemplateParser.NUL_OP, listOf(VarSpec("id", Modifier.None))), + ) + } + + @Test + fun `adjacent expressions with no literal produce two Expression parts`() { + val parts = UriTemplateParser.parse("{a}{b}") + parts.size shouldBe 2 + (parts[0] as Part.Expression).varSpecs[0].name shouldBe "a" + (parts[1] as Part.Expression).varSpecs[0].name shouldBe "b" + } + + @Test + fun `expression surrounded by literals produces three parts`() { + val parts = UriTemplateParser.parse("prefix/{id}/suffix") + parts shouldBe listOf( + Part.Literal("prefix/"), + Part.Expression(UriTemplateParser.NUL_OP, listOf(VarSpec("id", Modifier.None))), + Part.Literal("/suffix"), + ) + } + + //endregion + //region Operator detection ───────────────────────────────────────────────── + + @Test + fun `all seven operator chars are parsed correctly`() { + val cases = mapOf( + "{+var}" to '+', + "{#var}" to '#', + "{.var}" to '.', + "{/var}" to '/', + "{;var}" to ';', + "{?var}" to '?', + "{&var}" to '&', + ) + for ((template, expectedChar) in cases) { + val op = (UriTemplateParser.parse(template)[0] as Part.Expression).op + op.char shouldBe expectedChar + } + } + + @Test + fun `only plus and hash operators allow reserved characters`() { + val allowReserved = setOf('+', '#') + val allOps = listOf("+", "#", ".", "/", ";", "?", "&", "") + for (prefix in allOps) { + val op = (UriTemplateParser.parse("{${prefix}var}")[0] as Part.Expression).op + op.allowReserved shouldBe (op.char in allowReserved) + } + } + + @Test + fun `named operators are semicolon question mark and ampersand`() { + val namedOps = setOf(';', '?', '&') + val allOps = listOf("+", "#", ".", "/", ";", "?", "&", "") + for (prefix in allOps) { + val op = (UriTemplateParser.parse("{${prefix}var}")[0] as Part.Expression).op + op.named shouldBe (op.char in namedOps) + } + } + + @Test + fun `ifemp is = for query operators and empty for others`() { + val queryOps = setOf('?', '&') + val allOps = listOf("+", "#", ".", "/", ";", "?", "&", "") + for (prefix in allOps) { + val op = (UriTemplateParser.parse("{${prefix}var}")[0] as Part.Expression).op + op.ifemp shouldBe (if (op.char in queryOps) "=" else "") + } + } + + @Test + fun `operator first and sep strings match RFC 6570 Appendix A table`() { + // RFC 6570 Appendix A value table (first, sep per operator): + // NUL "" "," | + "" "," | # "#" "," + // . "." "." | / "/" "/" | ; ";" ";" + // ? "?" "&" | & "&" "&" + val expected = mapOf( + "" to ("" to ","), + "+" to ("" to ","), + "#" to ("#" to ","), + "." to ("." to "."), + "/" to ("/" to "/"), + ";" to (";" to ";"), + "?" to ("?" to "&"), + "&" to ("&" to "&"), + ) + for ((prefix, firstSep) in expected) { + val op = (UriTemplateParser.parse("{${prefix}var}")[0] as Part.Expression).op + val (expectedFirst, expectedSep) = firstSep + op.first shouldBe expectedFirst + op.sep shouldBe expectedSep + } + } + + //endregion + //region VarSpec modifier detection ───────────────────────────────────────── + + @Test + fun `no modifier produces Modifier None`() { + val spec = (UriTemplateParser.parse("{var}")[0] as Part.Expression).varSpecs[0] + spec.modifier shouldBe Modifier.None + } + + @Test + fun `trailing asterisk produces Modifier Explode`() { + val spec = (UriTemplateParser.parse("{list*}")[0] as Part.Expression).varSpecs[0] + spec.name shouldBe "list" + spec.modifier shouldBe Modifier.Explode + } + + @Test + fun `colon-integer produces Modifier Prefix with correct length`() { + (UriTemplateParser.parse("{var:3}")[0] as Part.Expression).varSpecs[0].modifier shouldBe Modifier.Prefix(3) + (UriTemplateParser.parse("{var:30}")[0] as Part.Expression).varSpecs[0].modifier shouldBe Modifier.Prefix(30) + (UriTemplateParser.parse("{var:1000}")[0] as Part.Expression).varSpecs[0].modifier shouldBe + Modifier.Prefix(1000) + } + + @Test + fun `prefix length of zero is not valid and falls back to None`() { + // RFC 6570: max-length = %x31-39 0*3DIGIT (must start with 1-9) + val spec = (UriTemplateParser.parse("{var:0}")[0] as Part.Expression).varSpecs[0] + spec.modifier shouldBe Modifier.None + } + + @Test + fun `non-numeric prefix falls back to None modifier`() { + val spec = (UriTemplateParser.parse("{var:abc}")[0] as Part.Expression).varSpecs[0] + spec.modifier shouldBe Modifier.None + } + + @Test + fun `multiple variables in one expression are all parsed`() { + val specs = (UriTemplateParser.parse("{x,y,z}")[0] as Part.Expression).varSpecs + specs.map { it.name } shouldBe listOf("x", "y", "z") + specs.all { it.modifier == Modifier.None } shouldBe true + } + + @Test + fun `mixed modifiers in multi-variable expression`() { + val specs = (UriTemplateParser.parse("{var:3,list*,plain}")[0] as Part.Expression).varSpecs + specs[0] shouldBe VarSpec("var", Modifier.Prefix(3)) + specs[1] shouldBe VarSpec("list", Modifier.Explode) + specs[2] shouldBe VarSpec("plain", Modifier.None) + } + + //endregion + //region Empty expression ─────────────────────────────────────────────────── + + @Test + fun `empty braces produce Expression with NUL_OP and no varSpecs`() { + val expr = UriTemplateParser.parse("{}")[0] as Part.Expression + expr.op shouldBe UriTemplateParser.NUL_OP + expr.varSpecs shouldBe emptyList() + } + + //endregion + //region Malformed templates ──────────────────────────────────────────────── + + @Test + fun `unclosed brace throws IllegalArgumentException`() { + shouldThrow { + UriTemplateParser.parse("{unclosed") + }.message shouldContain "Unclosed brace" + } + + @Test + fun `unclosed brace mid-template throws IllegalArgumentException`() { + shouldThrow { + UriTemplateParser.parse("foo/{unclosed") + }.message shouldContain "Unclosed brace" + } + + @Test + fun `unclosed brace at end of otherwise valid template throws`() { + shouldThrow { + UriTemplateParser.parse("users/{id}/posts/{") + }.message shouldContain "Unclosed brace" + } + + @Test + fun `error message includes the offending template string`() { + val template = "bad/{template" + shouldThrow { + UriTemplateParser.parse(template) + }.message shouldContain template + } + + //endregion + //region Literal PCT-encoding ─────────────────────────────────────────────── + + @Test + fun `space in literal is pct-encoded to space`() { + UriTemplateParser.parse("hello world") shouldBe listOf(Part.Literal("hello%20world")) + } + + @Test + fun `percent sign in literal passes through unchanged`() { + // % is allowed verbatim in literals (already pct-encoded in source) + UriTemplateParser.parse("50%25") shouldBe listOf(Part.Literal("50%25")) + } + + @Test + fun `reserved characters in literals pass through unchanged`() { + // All of :/?#[]@!$&'()*+,;= are allowed verbatim in URI template literals + UriTemplateParser.parse("https://host/path?q=1&r=2") shouldBe + listOf(Part.Literal("https://host/path?q=1&r=2")) + } + + @Test + fun `non-ascii character in literal is pct-encoded as utf-8`() { + // é (U+00E9) → UTF-8 0xC3 0xA9 → %C3%A9 + UriTemplateParser.parse("caf\u00E9") shouldBe listOf(Part.Literal("caf%C3%A9")) + } + + //endregion + //region pctEncode ───────────────────────────────────────────────────────── + + @Test + fun `pctEncode leaves unreserved characters unchanged`() { + val unreserved = "abcxyzABCXYZ0189-._~" + UriTemplateParser.pctEncode(unreserved, allowReserved = false) shouldBe unreserved + UriTemplateParser.pctEncode(unreserved, allowReserved = true) shouldBe unreserved + } + + @Test + fun `pctEncode encodes space in both modes`() { + UriTemplateParser.pctEncode(" ", allowReserved = false) shouldBe "%20" + UriTemplateParser.pctEncode(" ", allowReserved = true) shouldBe "%20" + } + + @Test + fun `pctEncode passes reserved chars through only when allowReserved is true`() { + UriTemplateParser.pctEncode(":/", allowReserved = true) shouldBe ":/" + UriTemplateParser.pctEncode(":/", allowReserved = false) shouldBe "%3A%2F" + } + + @Test + fun `pctEncode preserves existing pct-triplets only when allowReserved is true`() { + UriTemplateParser.pctEncode("50%25", allowReserved = true) shouldBe "50%25" + // When allowReserved is false, % itself is encoded + UriTemplateParser.pctEncode("50%25", allowReserved = false) shouldBe "50%2525" + } + + @Test + fun `pctEncode encodes non-ascii as utf-8 pct-triplets`() { + // é (U+00E9) → UTF-8 0xC3 0xA9 → %C3%A9 + UriTemplateParser.pctEncode("\u00E9", allowReserved = false) shouldBe "%C3%A9" + } + + //endregion + //region truncateCodePoints ───────────────────────────────────────────────── + + @Test + fun `truncateCodePoints truncates ascii strings by code point count`() { + UriTemplateParser.truncateCodePoints("hello", 3) shouldBe "hel" + UriTemplateParser.truncateCodePoints("hello", 10) shouldBe "hello" + UriTemplateParser.truncateCodePoints("hello", 0) shouldBe "" + } + + @Test + fun `truncateCodePoints treats each BMP character as one code point`() { + // é (U+00E9) is a BMP character — one Char, one code point + UriTemplateParser.truncateCodePoints("caf\u00E9", 3) shouldBe "caf" + UriTemplateParser.truncateCodePoints("caf\u00E9", 4) shouldBe "caf\u00E9" + } + + @Test + fun `truncateCodePoints counts a surrogate pair as one code point`() { + // 😀 U+1F600 → surrogate pair \uD83D\uDE00 (two Chars, one code point) + val emoji = "\uD83D\uDE00" + UriTemplateParser.truncateCodePoints("a${emoji}b", 1) shouldBe "a" + UriTemplateParser.truncateCodePoints("a${emoji}b", 2) shouldBe "a$emoji" + UriTemplateParser.truncateCodePoints("a${emoji}b", 3) shouldBe "a${emoji}b" + } + + @Test + fun `pctEncode encodes surrogate pair as utf-8 pct-triplets`() { + // 😀 U+1F600 → UTF-8 F0 9F 98 80 → %F0%9F%98%80 + val emoji = "\uD83D\uDE00" + UriTemplateParser.pctEncode(emoji, allowReserved = false) shouldBe "%F0%9F%98%80" + UriTemplateParser.pctEncode(emoji, allowReserved = true) shouldBe "%F0%9F%98%80" + } + + @Test + fun `pctEncode recognises lowercase hex digits in pct-triplets when allowReserved is true`() { + // %2f uses lowercase hex — must be preserved as-is, not re-encoded + UriTemplateParser.pctEncode("a%2fb", allowReserved = true) shouldBe "a%2fb" + // Lowercase that isn't a valid triplet gets encoded normally + UriTemplateParser.pctEncode("a%gg", allowReserved = true) shouldBe "a%25gg" + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateTest.kt new file mode 100644 index 000000000..64931d966 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriTemplateTest.kt @@ -0,0 +1,42 @@ +package io.modelcontextprotocol.kotlin.sdk.utils + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class UriTemplateTest { + + @Test + fun `equal template strings produce equal UriTemplate instances`() { + UriTemplate("test://items/{id}") shouldBe UriTemplate("test://items/{id}") + } + + @Test + fun `different template strings produce unequal UriTemplate instances`() { + val a = UriTemplate("test://items/{id}") + val b = UriTemplate("test://items/{name}") + (a == b) shouldBe false + } + + @Test + fun `hashCode is consistent with equals`() { + val a = UriTemplate("test://items/{id}") + val b = UriTemplate("test://items/{id}") + a.hashCode() shouldBe b.hashCode() + } + + @Test + fun `toString includes the template string`() { + UriTemplate("test://items/{id}").toString() shouldBe "UriTemplate(test://items/{id})" + } + + @Test + fun `constructor throws IllegalArgumentException for unclosed brace`() { + shouldThrow { UriTemplate("test://items/{id") } + } + + @Test + fun `constructor throws IllegalArgumentException for unclosed brace mid-template`() { + shouldThrow { UriTemplate("users/{id}/posts/{") } + } +} From 395c9c851899d9208558496c4a5644c9ea6c7393 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 2/6] 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 c59e86234..6e870506f 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 4a5c22d089d36ad11298d8ace85a5054ec179012 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 3/6] 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 d53ccdcd535df9e151bbdf3664176008956ce0b3 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 4/6] 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 48d96b6c705e6bf9a6ae8a134320254cdcc0156b 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 5/6] 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 6e870506f..d87793859 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 9ad995002d31dca932cf76354a915be83ef384a6 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 6/6] 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;