Skip to content

Commit ee6561c

Browse files
authored
feat: #502 resource templates with basic template matcher (#606)
1 parent 2542c9c commit ee6561c

22 files changed

Lines changed: 1189 additions & 59 deletions

File tree

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Conformance test baseline - expected failures
22
# Add entries here as tests are identified as known SDK limitations
3-
server:
4-
- resources-templates-read
3+
server: []
54

65
client:
76
- elicitation-sep1034-client-defaults

conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,17 @@ fun Server.registerConformanceResources() {
4545
}
4646

4747
// 3. Template resource
48-
// Note: The SDK does not currently support addResourceTemplate().
49-
// Register as a static resource; template listing is handled separately.
50-
addResource(
51-
uri = "test://template/{id}/data",
48+
addResourceTemplate(
49+
uriTemplate = "test://template/{id}/data",
5250
name = "template",
5351
description = "A template resource for testing",
5452
mimeType = "application/json",
55-
) { request ->
53+
) { request, variables ->
54+
val id = variables["id"]
5655
ReadResourceResult(
5756
listOf(
5857
TextResourceContents(
59-
text = "content for ${request.uri}",
58+
text = """{"id": "$id"}""",
6059
uri = request.uri,
6160
mimeType = "application/json",
6261
),

integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.modelcontextprotocol.kotlin.sdk.client
22

3+
import io.kotest.matchers.shouldBe
34
import io.modelcontextprotocol.kotlin.sdk.server.Server
45
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
56
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
@@ -266,8 +267,8 @@ class ClientTest {
266267
client.connect(failingTransport)
267268
}
268269

269-
assertEquals(-32600, exception.code)
270-
assertEquals("MCP error -32600: Invalid Request", exception.message)
270+
exception.code shouldBe -32600
271+
exception.message shouldBe "Invalid Request"
271272

272273
assertTrue(closed)
273274
}

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
22

33
import io.kotest.assertions.withClue
4+
import io.kotest.matchers.shouldBe
45
import io.kotest.matchers.string.shouldContain
56
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
67
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
78
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
89
import io.modelcontextprotocol.kotlin.sdk.types.McpException
910
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
1011
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
11-
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
1212
import io.modelcontextprotocol.kotlin.sdk.types.Role
1313
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
1414
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
@@ -688,13 +688,13 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
688688
}
689689
}
690690

691-
val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt"
691+
val expectedMessage = "Prompt not found: non-existent-prompt"
692692

693-
assertEquals(
694-
RPCError.ErrorCode.INTERNAL_ERROR,
695-
exception.code,
696-
"Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}",
697-
)
698-
assertEquals(expectedMessage, exception.message, "Unexpected error message for non-existent prompt")
693+
withClue("Exception code should be INTERNAL_ERROR: -32603") {
694+
exception.code shouldBe -32603
695+
}
696+
withClue("Unexpected error message for non-existent prompt") {
697+
exception.message shouldBe expectedMessage
698+
}
699699
}
700700
}

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,14 +213,11 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
213213
}
214214
}
215215

216-
val expectedMessage = "MCP error -32603: Resource not found: test://nonexistent.txt"
217-
218216
assertEquals(
219-
RPCError.ErrorCode.INTERNAL_ERROR,
217+
RPCError.ErrorCode.RESOURCE_NOT_FOUND,
220218
exception.code,
221-
"Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}",
219+
"Exception code should be RESOURCE_NOT_FOUND: ${RPCError.ErrorCode.RESOURCE_NOT_FOUND}",
222220
)
223-
assertEquals(expectedMessage, exception.message, "Unexpected error message for invalid resource URI")
224221
}
225222

226223
@Test
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server
2+
3+
import io.kotest.matchers.collections.shouldBeEmpty
4+
import io.kotest.matchers.collections.shouldHaveSize
5+
import io.kotest.matchers.maps.shouldContainKey
6+
import io.kotest.matchers.nulls.shouldNotBeNull
7+
import io.kotest.matchers.shouldBe
8+
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
9+
import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest
10+
import io.modelcontextprotocol.kotlin.sdk.types.McpException
11+
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
12+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
13+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
14+
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
15+
import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate
16+
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
17+
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
18+
import kotlinx.coroutines.CompletableDeferred
19+
import kotlinx.coroutines.test.runTest
20+
import org.junit.jupiter.api.Test
21+
import org.junit.jupiter.api.assertThrows
22+
23+
class ServerResourceTemplateTest : AbstractServerFeaturesTest() {
24+
25+
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
26+
resources = ServerCapabilities.Resources(listChanged = null, subscribe = null),
27+
)
28+
29+
@Test
30+
fun `listResourceTemplates should return registered templates`() = runTest {
31+
server.addResourceTemplate("test://data/{id}", "Test Data", mimeType = "text/plain") { _, _ ->
32+
ReadResourceResult(listOf(TextResourceContents("content", "test://data/1")))
33+
}
34+
35+
val result = client.listResourceTemplates(ListResourceTemplatesRequest())
36+
37+
result.resourceTemplates shouldHaveSize 1
38+
result.resourceTemplates[0] shouldNotBeNull {
39+
uriTemplate shouldBe "test://data/{id}"
40+
name shouldBe "Test Data"
41+
mimeType shouldBe "text/plain"
42+
}
43+
}
44+
45+
@Test
46+
fun `listResourceTemplates should return empty list when none registered`() = runTest {
47+
val result = client.listResourceTemplates(ListResourceTemplatesRequest())
48+
49+
result.resourceTemplates.shouldBeEmpty()
50+
}
51+
52+
@Test
53+
fun `readResource should match URI against template and invoke handler`() = runTest {
54+
server.addResourceTemplate("test://items/{itemId}", "Item", mimeType = "text/plain") { request, variables ->
55+
val itemId = variables["itemId"] ?: "unknown"
56+
ReadResourceResult(
57+
listOf(TextResourceContents(text = "item=$itemId", uri = request.uri, mimeType = "text/plain")),
58+
)
59+
}
60+
61+
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/42")))
62+
63+
result.contents shouldBe
64+
listOf(TextResourceContents(uri = "test://items/42", mimeType = "text/plain", text = "item=42"))
65+
}
66+
67+
@Test
68+
fun `readResource should extract multiple URI template variables`() = runTest {
69+
val capturedVars = CompletableDeferred<Map<String, String>>()
70+
server.addResourceTemplate(
71+
uriTemplate = "test://users/{userId}/posts/{postId}",
72+
name = "User Post",
73+
mimeType = "text/plain",
74+
) { _, variables ->
75+
capturedVars.complete(variables)
76+
ReadResourceResult(listOf(TextResourceContents("ok", "test://users/alice/posts/99")))
77+
}
78+
79+
client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/alice/posts/99")))
80+
81+
val vars = capturedVars.await()
82+
vars shouldContainKey "userId"
83+
vars shouldContainKey "postId"
84+
vars["userId"] shouldBe "alice"
85+
vars["postId"] shouldBe "99"
86+
}
87+
88+
@Test
89+
fun `readResource should prefer exact resource match over template`() = runTest {
90+
var exactHandlerCalled = false
91+
server.addResource("test://items/special", "Special Item", "An exact resource") {
92+
exactHandlerCalled = true
93+
ReadResourceResult(listOf(TextResourceContents("exact", "test://items/special")))
94+
}
95+
server.addResourceTemplate("test://items/{itemId}", "Item Template") { _, _ ->
96+
ReadResourceResult(listOf(TextResourceContents("template", "test://items/special")))
97+
}
98+
99+
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/special")))
100+
101+
exactHandlerCalled shouldBe true
102+
(result.contents[0] as TextResourceContents).text shouldBe "exact"
103+
}
104+
105+
@Test
106+
fun `readResource should select most specific template when multiple match`() = runTest {
107+
// "test://users/profile" has more literal chars than "test://users/{id}" — should win
108+
server.addResourceTemplate("test://users/{id}", "Generic User") { _, variables ->
109+
ReadResourceResult(listOf(TextResourceContents("generic:${variables["id"]}", "test://users/profile")))
110+
}
111+
server.addResourceTemplate("test://users/profile", "Profile") { _, _ ->
112+
ReadResourceResult(listOf(TextResourceContents("profile-page", "test://users/profile")))
113+
}
114+
115+
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/profile")))
116+
117+
(result.contents[0] as TextResourceContents).text shouldBe "profile-page"
118+
}
119+
120+
@Test
121+
fun `readResource should return RESOURCE_NOT_FOUND error when no match`() = runTest {
122+
val exception = assertThrows<McpException> {
123+
client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://nonexistent/uri")))
124+
}
125+
126+
exception.code shouldBe RPCError.ErrorCode.RESOURCE_NOT_FOUND
127+
}
128+
129+
@Test
130+
fun `resourceTemplates property should reflect registered templates`() {
131+
server.addResourceTemplate(ResourceTemplate("test://a/{x}", "A")) { _, _ ->
132+
ReadResourceResult(emptyList())
133+
}
134+
server.addResourceTemplate(ResourceTemplate("test://b/{y}", "B")) { _, _ ->
135+
ReadResourceResult(emptyList())
136+
}
137+
138+
val templates = server.resourceTemplates
139+
140+
templates shouldBe listOf(
141+
ResourceTemplate("test://a/{x}", "A"),
142+
ResourceTemplate("test://b/{y}", "B"),
143+
)
144+
}
145+
146+
@Test
147+
fun `removeResourceTemplate should remove a registered template`() {
148+
server.addResourceTemplate("test://items/{id}", "Item") { _, _ ->
149+
ReadResourceResult(emptyList())
150+
}
151+
152+
val removed = server.removeResourceTemplate("test://items/{id}")
153+
154+
removed shouldBe true
155+
server.resourceTemplates.size shouldBe 0
156+
}
157+
158+
@Test
159+
fun `removeResourceTemplate should return false when template does not exist`() {
160+
val removed = server.removeResourceTemplate("test://nonexistent/{id}")
161+
162+
removed shouldBe false
163+
}
164+
165+
@Test
166+
fun `addResourceTemplate should throw when resources capability is not supported`() {
167+
val noResourcesServer = Server(
168+
serverInfo = Implementation("test", "1.0"),
169+
options = ServerOptions(capabilities = ServerCapabilities()),
170+
)
171+
172+
assertThrows<IllegalStateException> {
173+
noResourcesServer.addResourceTemplate("test://{id}", "Test") { _, _ ->
174+
ReadResourceResult(emptyList())
175+
}
176+
}
177+
}
178+
179+
@Test
180+
fun `addResourceTemplate with ResourceTemplate object should register correctly`() = runTest {
181+
val template = ResourceTemplate(
182+
uriTemplate = "test://docs/{section}",
183+
name = "Documentation",
184+
description = "API docs",
185+
mimeType = "text/html",
186+
)
187+
server.addResourceTemplate(template) { request, variables ->
188+
val section = variables["section"] ?: "index"
189+
ReadResourceResult(
190+
listOf(TextResourceContents("docs for $section", request.uri, mimeType = "text/html")),
191+
)
192+
}
193+
194+
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://docs/api")))
195+
196+
result.contents shouldHaveSize 1
197+
(result.contents[0] as TextResourceContents).text shouldBe "docs for api"
198+
}
199+
200+
@Test
201+
fun `listResourceTemplates should include description from template`() = runTest {
202+
server.addResourceTemplate(
203+
uriTemplate = "test://data/{id}",
204+
name = "Data",
205+
description = "Parameterized data resource",
206+
mimeType = "application/json",
207+
) { _, _ ->
208+
ReadResourceResult(emptyList())
209+
}
210+
211+
val result = client.listResourceTemplates(ListResourceTemplatesRequest())
212+
213+
result.resourceTemplates shouldBe listOf(
214+
ResourceTemplate(
215+
name = "Data",
216+
uriTemplate = "test://data/{id}",
217+
description = "Parameterized data resource",
218+
mimeType = "application/json",
219+
),
220+
)
221+
}
222+
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2713,6 +2713,7 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty
27132713
}
27142714

27152715
public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
2716+
public fun <init> (I)V
27162717
public fun <init> (ILjava/lang/String;)V
27172718
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
27182719
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
@@ -3310,6 +3311,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode {
33103311
public static final field METHOD_NOT_FOUND I
33113312
public static final field PARSE_ERROR I
33123313
public static final field REQUEST_TIMEOUT I
3314+
public static final field RESOURCE_NOT_FOUND I
33133315
}
33143316

33153317
public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest {
@@ -4731,3 +4733,36 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl
47314733
public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject;
47324734
}
47334735

4736+
public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult {
4737+
public fun <init> (Ljava/util/Map;I)V
4738+
public fun equals (Ljava/lang/Object;)Z
4739+
public final fun getScore ()I
4740+
public final fun getVariables ()Ljava/util/Map;
4741+
public fun hashCode ()I
4742+
public fun toString ()Ljava/lang/String;
4743+
}
4744+
4745+
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
4746+
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion;
4747+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V
4748+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V
4749+
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V
4750+
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
4751+
public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
4752+
public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
4753+
public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4754+
}
4755+
4756+
public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion {
4757+
public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
4758+
}
4759+
4760+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
4761+
public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
4762+
public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
4763+
}
4764+
4765+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory {
4766+
public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher;
4767+
}
4768+

0 commit comments

Comments
 (0)