parent
2542c9cae1
commit
ee6561c185
4 changed files with 1189 additions and 59 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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
|
||||
|
|
@ -8,7 +9,6 @@ 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue