Skip to content
3 changes: 1 addition & 2 deletions conformance-test/conformance-baseline.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.client

import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
Expand Down Expand Up @@ -266,8 +267,8 @@ class ClientTest {
client.connect(failingTransport)
}

assertEquals(-32600, exception.code)
assertEquals("MCP error -32600: Invalid Request", exception.message)
exception.code shouldBe -32600
exception.message shouldBe "Invalid Request"

assertTrue(closed)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin

import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
Expand Down Expand Up @@ -688,13 +688,13 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
}
}

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

assertEquals(
RPCError.ErrorCode.INTERNAL_ERROR,
exception.code,
"Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}",
)
assertEquals(expectedMessage, exception.message, "Unexpected error message for non-existent prompt")
withClue("Exception code should be INTERNAL_ERROR: -32603") {
exception.code shouldBe -32603
}
withClue("Unexpected error message for non-existent prompt") {
exception.message shouldBe expectedMessage
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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<Map<String, String>>()
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<McpException> {
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 shouldBe listOf(
ResourceTemplate("test://a/{x}", "A"),
ResourceTemplate("test://b/{y}", "B"),
)
}

@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<IllegalStateException> {
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",
),
)
}
}
35 changes: 35 additions & 0 deletions kotlin-sdk-core/api/kotlin-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -2713,6 +2713,7 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty
}

public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
public fun <init> (I)V
public fun <init> (ILjava/lang/String;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -4731,3 +4733,36 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpl
public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject;
}

public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult {
public fun <init> (Ljava/util/Map;I)V
public fun equals (Ljava/lang/Object;)Z
public final fun getScore ()I
public final fun getVariables ()Ljava/util/Map;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion;
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
}

public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion {
public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
}

public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher {
public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;
public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult;
}

public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory {
public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher;
}

Loading
Loading