Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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",
),
)
}
}
40 changes: 40 additions & 0 deletions kotlin-sdk-core/api/kotlin-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -3310,6 +3310,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 +4732,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 <init> (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 <init> (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
}

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -312,6 +313,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
return
}

@Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException")
try {
val result = handler(request, RequestHandlerExtra())
logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
Expand All @@ -322,19 +324,18 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
result = result ?: EmptyResult(),
),
)
} catch (e: CancellationException) {
throw e
} catch (cause: Throwable) {
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }

try {
transport?.send(
JSONRPCError(
id = request.id,
error = RPCError(
code = RPCError.ErrorCode.INTERNAL_ERROR,
message = cause.message ?: "Internal error",
),
),
)
val rpcError = if (cause is McpException) {
RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
} else {
RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
}
transport?.send(JSONRPCError(id = request.id, error = rpcError))
} catch (sendError: Throwable) {
logger.error(sendError) {
"Failed to send error response for request: ${request.method} (id: ${request.id})"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading
Loading