feat! Add context to server call handlers (#515)
## Motivation and Context Adds a `ClientConnection` context receiver to server call handlers, which provides access to the client communication channel. This is necessary to allow tools to send notifications, logging, elicitations, etc. Supersedes https://github.com/modelcontextprotocol/kotlin-sdk/pull/144, discussed in https://github.com/modelcontextprotocol/kotlin-sdk/issues/295. ## How Has This Been Tested? With the added integration test. ## Breaking Changes It breaks binary compatibility and breaks source compatibility for tool handlers that already use `this`. If you care a lot about maintaining compatibility, I could add some overloads to ease the transition. But the new methods would likely need to be named differently for resolution. Future compatibility should be easier to maintain - `Context` is an interface (with experimental opt-in subclassing), so new fields can be added without breaking compatibility. Different types could also be introduced for different handles without breaking compatibility, as long as they extend `ClientConnection`. ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Co-authored-by: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com>
This commit is contained in:
parent
947d03dd44
commit
4a08959121
8 changed files with 1585 additions and 253 deletions
|
|
@ -32,7 +32,6 @@
|
|||
<ID>LongMethod:KotlinServerForTsClientSse.kt:HttpServerTransport$suspend fun handleRequest</ID>
|
||||
<ID>LongMethod:KotlinServerForTsClientSse.kt:KotlinServerForTsClient$fun createMcpServer: Server</ID>
|
||||
<ID>LongMethod:KotlinServerForTsClientSse.kt:KotlinServerForTsClient$fun start</ID>
|
||||
<ID>LongMethod:KotlinTestBase.kt:KotlinTestBase$protected fun setupServer</ID>
|
||||
<ID>LongMethod:ServerResourcesNotificationSubscribeTest.kt:ServerResourcesNotificationSubscribeTest$@Test fun `should send resource notifications`</ID>
|
||||
<ID>LongMethod:TsEdgeCasesTestSse.kt:TsEdgeCasesTestSse$@Test @Timeout(30, unit = TimeUnit.SECONDS) fun testComplexConcurrentRequests: Unit</ID>
|
||||
<ID>MatchingDeclarationName:PromptIntegrationTestSse.kt:SchemaPromptIntegrationTestSse : AbstractPromptIntegrationTest</ID>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -13,8 +20,31 @@ abstract class AbstractServerFeaturesTest {
|
|||
protected lateinit var server: Server
|
||||
protected lateinit var client: Client
|
||||
|
||||
protected fun addTool(name: String, block: suspend ClientConnection.() -> Unit) {
|
||||
server.addTool(name, "Test $name") {
|
||||
block()
|
||||
CallToolResult(listOf(TextContent("Success")))
|
||||
}
|
||||
}
|
||||
|
||||
protected fun addPrompt(name: String, block: suspend ClientConnection.() -> Unit) {
|
||||
server.addPrompt(name, "Test $name") {
|
||||
block()
|
||||
GetPromptResult(messages = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
protected fun addResource(uri: String, block: suspend ClientConnection.() -> Unit) {
|
||||
server.addResource(uri, uri, "Test resource $uri") {
|
||||
block()
|
||||
ReadResourceResult(contents = listOf(TextResourceContents(text = "content", uri = uri)))
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getServerCapabilities(): ServerCapabilities
|
||||
|
||||
protected open fun getClientCapabilities(): ClientCapabilities = ClientCapabilities()
|
||||
|
||||
protected open fun getServerInstructionsProvider(): (() -> String)? = null
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -33,6 +63,9 @@ abstract class AbstractServerFeaturesTest {
|
|||
|
||||
client = Client(
|
||||
clientInfo = Implementation(name = "test client", version = "1.0"),
|
||||
options = ClientOptions(
|
||||
capabilities = getClientCapabilities(),
|
||||
),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
|
||||
class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
logging = JsonObject(emptyMap()),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `notification should send logging message to client`(): Unit = runBlocking {
|
||||
val notificationReceived = CompletableDeferred<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
|
||||
notificationReceived.complete(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
val expectedLevel = LoggingLevel.Info
|
||||
val expectedData = JsonPrimitive("test-data-sample")
|
||||
|
||||
addTool("test-notification") {
|
||||
notification(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = expectedLevel,
|
||||
data = expectedData,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test-notification")))
|
||||
val received = notificationReceived.await()
|
||||
received.params.level shouldBe expectedLevel
|
||||
received.params.data shouldBe expectedData
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(LoggingLevel::class)
|
||||
fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking {
|
||||
val receivedMessages = mutableListOf<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
|
||||
receivedMessages.add(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
client.setLoggingLevel(minLevel)
|
||||
|
||||
addTool("test-notification-filtered") {
|
||||
LoggingLevel.entries.forEach { level ->
|
||||
notification(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = level,
|
||||
data = JsonPrimitive(level.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test-notification-filtered")))
|
||||
|
||||
val expectedLevels = LoggingLevel.entries.filter { it >= minLevel }
|
||||
receivedMessages shouldHaveSize expectedLevels.size
|
||||
receivedMessages.map { it.params.level } shouldBe expectedLevels
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(LoggingLevel::class)
|
||||
fun `sendLoggingMessage should send message at level`(expectedLevel: LoggingLevel): Unit = runBlocking {
|
||||
val notificationReceived = CompletableDeferred<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
|
||||
notificationReceived.complete(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
val expectedData = JsonObject(mapOf("key" to JsonPrimitive("value")))
|
||||
|
||||
addTool("test-logging") {
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = expectedLevel,
|
||||
data = expectedData,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test-logging")))
|
||||
val received = notificationReceived.await()
|
||||
received.params.level shouldBe expectedLevel
|
||||
received.params.data shouldBe expectedData
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(LoggingLevel::class)
|
||||
fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking {
|
||||
val receivedMessages = mutableListOf<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
|
||||
receivedMessages.add(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
client.setLoggingLevel(minLevel)
|
||||
|
||||
addTool("test-logging-level") {
|
||||
LoggingLevel.entries.forEach { level ->
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = level,
|
||||
data = JsonPrimitive(level.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level")))
|
||||
|
||||
val expectedLevels = LoggingLevel.entries.filter { it >= minLevel }
|
||||
receivedMessages shouldHaveSize expectedLevels.size
|
||||
receivedMessages.map { it.params.level } shouldBe expectedLevels
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking {
|
||||
val receivedMessages = mutableListOf<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
|
||||
receivedMessages.add(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
client.setLoggingLevel(LoggingLevel.Emergency)
|
||||
|
||||
addTool("test-logging-highest") {
|
||||
LoggingLevel.entries.dropLast(1).forEach { level ->
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = level,
|
||||
data = JsonPrimitive(level.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest")))
|
||||
receivedMessages.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldNotBeBlank
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Notification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Request
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RequestResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Root
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ClientConnectionTest : AbstractServerFeaturesTest() {
|
||||
|
||||
private inline fun <reified T : Notification> onClientNotification(method: Method): CompletableDeferred<T> {
|
||||
val deferred = CompletableDeferred<T>()
|
||||
client.setNotificationHandler<T>(method) {
|
||||
deferred.complete(it)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
return deferred
|
||||
}
|
||||
|
||||
private inline fun <reified Req : Request, reified Res : RequestResult> onClientRequest(
|
||||
method: Method,
|
||||
crossinline response: (Req) -> Res,
|
||||
) {
|
||||
client.setRequestHandler<Req>(method) { req, _ -> response(req) }
|
||||
}
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(listChanged = true),
|
||||
logging = ServerCapabilities.Logging,
|
||||
)
|
||||
|
||||
override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities(
|
||||
roots = ClientCapabilities.Roots(listChanged = true),
|
||||
)
|
||||
|
||||
private val sampleRoots = listOf(Root("file:///project", "Project Root"))
|
||||
|
||||
/** Holds all deferreds that capture what a server-side handler emits during [callAllMethods]. */
|
||||
private inner class Captures {
|
||||
val pingComplete = CompletableDeferred<Unit>()
|
||||
val rootsResult = CompletableDeferred<ListRootsResult>()
|
||||
val sessionId = CompletableDeferred<String>()
|
||||
val toolListChanged =
|
||||
onClientNotification<ToolListChangedNotification>(Method.Defined.NotificationsToolsListChanged)
|
||||
val resourceListChanged =
|
||||
onClientNotification<ResourceListChangedNotification>(Method.Defined.NotificationsResourcesListChanged)
|
||||
val promptListChanged =
|
||||
onClientNotification<PromptListChangedNotification>(Method.Defined.NotificationsPromptsListChanged)
|
||||
val loggingMessage = onClientNotification<LoggingMessageNotification>(Method.Defined.NotificationsMessage)
|
||||
val resourceUpdated =
|
||||
onClientNotification<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated)
|
||||
|
||||
init {
|
||||
onClientRequest<ListRootsRequest, ListRootsResult>(Method.Defined.RootsList) {
|
||||
ListRootsResult(sampleRoots)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assertAll() {
|
||||
val roots = rootsResult.await()
|
||||
val log = loggingMessage.await()
|
||||
val resourceUri = resourceUpdated.await().params.uri
|
||||
val capturedSessionId = sessionId.await()
|
||||
assertSoftly {
|
||||
withClue("ping() was not called inside the handler") {
|
||||
pingComplete.isCompleted shouldBe true
|
||||
}
|
||||
roots.roots shouldBe sampleRoots
|
||||
withClue("sendToolListChanged() did not deliver a notification to the client") {
|
||||
toolListChanged.isCompleted shouldBe true
|
||||
}
|
||||
withClue("sendResourceListChanged() did not deliver a notification to the client") {
|
||||
resourceListChanged.isCompleted shouldBe true
|
||||
}
|
||||
withClue("sendPromptListChanged() did not deliver a notification to the client") {
|
||||
promptListChanged.isCompleted shouldBe true
|
||||
}
|
||||
withClue("sendLoggingMessage() delivered unexpected payload") {
|
||||
log.params.data shouldBe JsonPrimitive("log")
|
||||
}
|
||||
withClue("sendResourceUpdated() delivered wrong URI") {
|
||||
resourceUri shouldBe "test://res"
|
||||
}
|
||||
capturedSessionId.shouldNotBeBlank()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exercises every [ClientConnection] method, recording results into [cap]. */
|
||||
private suspend fun ClientConnection.callAllMethods(cap: Captures) {
|
||||
ping()
|
||||
cap.pingComplete.complete(Unit)
|
||||
cap.rootsResult.complete(listRoots())
|
||||
sendToolListChanged()
|
||||
sendResourceListChanged()
|
||||
sendPromptListChanged()
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(level = LoggingLevel.Info, data = JsonPrimitive("log")),
|
||||
),
|
||||
)
|
||||
sendResourceUpdated(ResourceUpdatedNotification(ResourceUpdatedNotificationParams("test://res")))
|
||||
cap.sessionId.complete(sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all ClientConnection methods are callable from tool handler`() = runTest {
|
||||
val cap = Captures()
|
||||
addTool("test") { callAllMethods(cap) }
|
||||
|
||||
client.callTool(CallToolRequest(CallToolRequestParams("test")))
|
||||
|
||||
cap.assertAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all ClientConnection methods are callable from prompt handler`() = runTest {
|
||||
val cap = Captures()
|
||||
addPrompt("test") { callAllMethods(cap) }
|
||||
|
||||
client.getPrompt(GetPromptRequest(GetPromptRequestParams("test")))
|
||||
|
||||
cap.assertAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all ClientConnection methods are callable from resource handler`() = runTest {
|
||||
val cap = Captures()
|
||||
addResource("test://resource") { callAllMethods(cap) }
|
||||
|
||||
client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource")))
|
||||
|
||||
cap.assertAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Prompt
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Resource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Tool
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ServerBulkFeaturesTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = null),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = null),
|
||||
resources = ServerCapabilities.Resources(listChanged = null, subscribe = null),
|
||||
)
|
||||
|
||||
// ── addTools ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `addTools should register all provided tools`() = runTest {
|
||||
server.addTools(
|
||||
listOf(
|
||||
RegisteredTool(Tool("bulk-a", ToolSchema(), "Tool A")) { CallToolResult(emptyList()) },
|
||||
RegisteredTool(Tool("bulk-b", ToolSchema(), "Tool B")) { CallToolResult(emptyList()) },
|
||||
RegisteredTool(Tool("bulk-c", ToolSchema(), "Tool C")) { CallToolResult(emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val tools = client.listTools().tools
|
||||
|
||||
tools shouldHaveSize 3
|
||||
tools.map { it.name } shouldContainExactlyInAnyOrder listOf("bulk-a", "bulk-b", "bulk-c")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addTools should append to existing tools`() = runTest {
|
||||
server.addTool("existing", "Existing") { CallToolResult(emptyList()) }
|
||||
server.addTools(
|
||||
listOf(
|
||||
RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) },
|
||||
RegisteredTool(Tool("new-b", ToolSchema())) { CallToolResult(emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val tools = client.listTools().tools
|
||||
|
||||
tools shouldHaveSize 3
|
||||
tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing", "new-a", "new-b")
|
||||
}
|
||||
|
||||
// ── addPrompts ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `addPrompts should register all provided prompts`() = runTest {
|
||||
server.addPrompts(
|
||||
listOf(
|
||||
RegisteredPrompt(Prompt("bulk-p1", "Prompt 1")) { GetPromptResult(messages = emptyList()) },
|
||||
RegisteredPrompt(Prompt("bulk-p2", "Prompt 2")) { GetPromptResult(messages = emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val prompts = client.listPrompts().prompts
|
||||
|
||||
prompts shouldHaveSize 2
|
||||
prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("bulk-p1", "bulk-p2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addPrompts should append to existing prompts`() = runTest {
|
||||
server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) }
|
||||
server.addPrompts(
|
||||
listOf(
|
||||
RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val prompts = client.listPrompts().prompts
|
||||
|
||||
prompts shouldHaveSize 2
|
||||
prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt", "new-p")
|
||||
}
|
||||
|
||||
// ── addResources ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `addResources should register all provided resources`() = runTest {
|
||||
server.addResources(
|
||||
listOf(
|
||||
RegisteredResource(Resource("test://bulk-r1", "Resource 1")) { ReadResourceResult(emptyList()) },
|
||||
RegisteredResource(Resource("test://bulk-r2", "Resource 2")) { ReadResourceResult(emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val resources = client.listResources().resources
|
||||
|
||||
resources shouldHaveSize 2
|
||||
resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://bulk-r1", "test://bulk-r2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addResources should append to existing resources`() = runTest {
|
||||
server.addResource("test://existing-res", "Existing", "Existing resource") {
|
||||
ReadResourceResult(emptyList())
|
||||
}
|
||||
server.addResources(
|
||||
listOf(
|
||||
RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) },
|
||||
),
|
||||
)
|
||||
|
||||
val resources = client.listResources().resources
|
||||
|
||||
resources shouldHaveSize 2
|
||||
resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res", "test://new-res")
|
||||
}
|
||||
|
||||
// ── Partial-batch remove ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `removeTools should return count of actually removed tools`() = runTest {
|
||||
server.addTool("present", "Present") { CallToolResult(emptyList()) }
|
||||
client.listTools().tools shouldHaveSize 1
|
||||
|
||||
val count = server.removeTools(listOf("present", "absent-1", "absent-2"))
|
||||
|
||||
count shouldBe 1
|
||||
client.listTools().tools.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTools on empty list should return zero`() = runTest {
|
||||
val count = server.removeTools(emptyList())
|
||||
|
||||
count shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removePrompts should return count of actually removed prompts`() = runTest {
|
||||
server.addPrompt("present-p", "Present") { GetPromptResult(messages = emptyList()) }
|
||||
client.listPrompts().prompts shouldHaveSize 1
|
||||
|
||||
val count = server.removePrompts(listOf("present-p", "absent-p"))
|
||||
|
||||
count shouldBe 1
|
||||
client.listPrompts().prompts.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removePrompts on empty list should return zero`() = runTest {
|
||||
val count = server.removePrompts(emptyList())
|
||||
|
||||
count shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeResources should return count of actually removed resources`() = runTest {
|
||||
server.addResource("test://present-r", "Present", "Present resource") {
|
||||
ReadResourceResult(emptyList())
|
||||
}
|
||||
client.listResources().resources shouldHaveSize 1
|
||||
|
||||
val count = server.removeResources(listOf("test://present-r", "test://absent-r"))
|
||||
|
||||
count shouldBe 1
|
||||
client.listResources().resources.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeResources on empty list should return zero`() = runTest {
|
||||
val count = server.removeResources(emptyList())
|
||||
|
||||
count shouldBe 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
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.PromptMessage
|
||||
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.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class ServerFeaturesInvocationTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = null),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = null),
|
||||
resources = ServerCapabilities.Resources(listChanged = null, subscribe = null),
|
||||
)
|
||||
|
||||
// ── Tool invocation ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `callTool should return tool result content`() = runTest {
|
||||
server.addTool("echo", "Echo tool") {
|
||||
CallToolResult(listOf(TextContent("echo response")))
|
||||
}
|
||||
|
||||
val result = client.callTool(CallToolRequest(CallToolRequestParams("echo")))
|
||||
|
||||
assertSoftly(result) {
|
||||
isError shouldNotBe true
|
||||
(content.single() as TextContent).text shouldBe "echo response"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callTool should pass arguments to handler`() = runTest {
|
||||
server.addTool("greet", "Greeting tool") { request ->
|
||||
val name = request.params.arguments?.get("name")?.jsonPrimitive?.content ?: "stranger"
|
||||
CallToolResult(listOf(TextContent("Hello, $name!")))
|
||||
}
|
||||
|
||||
val result = client.callTool(
|
||||
CallToolRequest(
|
||||
CallToolRequestParams(
|
||||
name = "greet",
|
||||
arguments = JsonObject(mapOf("name" to JsonPrimitive("World"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
(result.content.single() as TextContent).text shouldBe "Hello, World!"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callTool should return error result when tool not found`() = runTest {
|
||||
val result = client.callTool(CallToolRequest(CallToolRequestParams("nonexistent")))
|
||||
|
||||
assertSoftly(result) {
|
||||
isError shouldBe true
|
||||
(content.single() as TextContent).text shouldBe "Tool nonexistent not found"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callTool should return error result when handler throws`() = runTest {
|
||||
server.addTool("failing", "Failing tool") {
|
||||
throw IllegalStateException("handler failure")
|
||||
}
|
||||
|
||||
val result = client.callTool(CallToolRequest(CallToolRequestParams("failing")))
|
||||
|
||||
assertSoftly(result) {
|
||||
isError shouldBe true
|
||||
(content.single() as TextContent).text shouldBe "Error executing tool failing: handler failure"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `callTool should pass empty arguments map to handler`() = runTest {
|
||||
server.addTool("greet", "Greeting tool") { request ->
|
||||
val name = request.params.arguments?.get("name")?.jsonPrimitive?.content ?: "stranger"
|
||||
CallToolResult(listOf(TextContent("Hello, $name!")))
|
||||
}
|
||||
|
||||
val result = client.callTool(
|
||||
CallToolRequest(
|
||||
CallToolRequestParams(
|
||||
name = "greet",
|
||||
arguments = JsonObject(emptyMap()),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
(result.content.single() as TextContent).text shouldBe "Hello, stranger!"
|
||||
}
|
||||
|
||||
// ── Prompt invocation ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `getPrompt should return prompt description and messages`() = runTest {
|
||||
server.addPrompt("my-prompt", "My prompt") {
|
||||
GetPromptResult(
|
||||
description = "Prompt result description",
|
||||
messages = listOf(
|
||||
PromptMessage(role = Role.User, content = TextContent("User message")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("my-prompt")))
|
||||
|
||||
assertSoftly(result) {
|
||||
description shouldBe "Prompt result description"
|
||||
messages shouldHaveSize 1
|
||||
(messages.single().content as TextContent).text shouldBe "User message"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPrompt should pass arguments to handler`() = runTest {
|
||||
server.addPrompt("templated", "Templated prompt") { request ->
|
||||
val topic = request.params.arguments?.get("topic") ?: "unknown"
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(role = Role.User, content = TextContent("Tell me about $topic")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
GetPromptRequestParams(
|
||||
name = "templated",
|
||||
arguments = mapOf("topic" to "Kotlin coroutines"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
(result.messages.single().content as TextContent).text shouldBe "Tell me about Kotlin coroutines"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPrompt should use default when arguments are absent`() = runTest {
|
||||
server.addPrompt("templated", "Templated prompt") { request ->
|
||||
val topic = request.params.arguments?.get("topic") ?: "unknown"
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(role = Role.User, content = TextContent("Tell me about $topic")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("templated")))
|
||||
|
||||
(result.messages.single().content as TextContent).text shouldBe "Tell me about unknown"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPrompt should throw when prompt not found`() = runTest {
|
||||
assertFailsWith<McpException> {
|
||||
client.getPrompt(GetPromptRequest(GetPromptRequestParams("nonexistent")))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource invocation ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `readResource should return resource content`() = runTest {
|
||||
val uri = "test://my-resource"
|
||||
server.addResource(uri, "My Resource", "Test resource") {
|
||||
ReadResourceResult(
|
||||
contents = listOf(TextResourceContents(text = "resource content", uri = uri)),
|
||||
)
|
||||
}
|
||||
|
||||
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri)))
|
||||
|
||||
(result.contents.single() as TextResourceContents).text shouldBe "resource content"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResource should pass request URI to handler`() = runTest {
|
||||
val uri = "test://uri-check"
|
||||
val receivedUri = CompletableDeferred<String>()
|
||||
server.addResource(uri, "URI Check", "Test") { request ->
|
||||
receivedUri.complete(request.params.uri)
|
||||
ReadResourceResult(
|
||||
contents = listOf(TextResourceContents(text = "ok", uri = uri)),
|
||||
)
|
||||
}
|
||||
|
||||
client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri)))
|
||||
|
||||
receivedUri.await() shouldBe uri
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResource should throw when resource not found`() = runTest {
|
||||
assertFailsWith<McpException> {
|
||||
client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://nonexistent")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Resource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ServerFeaturesListTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = null),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = null),
|
||||
resources = ServerCapabilities.Resources(listChanged = null, subscribe = null),
|
||||
)
|
||||
|
||||
// ── listTools ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `listTools should return empty list when no tools registered`() = runTest {
|
||||
client.listTools().tools.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listTools should return all registered tools`() = runTest {
|
||||
server.addTool("alpha", "First tool") { CallToolResult(emptyList()) }
|
||||
server.addTool("beta", "Second tool") { CallToolResult(emptyList()) }
|
||||
|
||||
val tools = client.listTools().tools
|
||||
|
||||
tools shouldHaveSize 2
|
||||
tools.map { it.name } shouldContainExactlyInAnyOrder listOf("alpha", "beta")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listTools should expose tool name and description`() = runTest {
|
||||
server.addTool("my-tool", "Detailed description") { CallToolResult(emptyList()) }
|
||||
|
||||
val tool = client.listTools().tools.single()
|
||||
|
||||
assertSoftly(tool) {
|
||||
name shouldBe "my-tool"
|
||||
description shouldBe "Detailed description"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listTools should reflect tool removal`() = runTest {
|
||||
server.addTool("keep", "Kept") { CallToolResult(emptyList()) }
|
||||
server.addTool("drop", "Dropped") { CallToolResult(emptyList()) }
|
||||
client.listTools().tools shouldHaveSize 2
|
||||
|
||||
server.removeTool("drop") shouldBe true
|
||||
|
||||
val tools = client.listTools().tools
|
||||
|
||||
tools shouldHaveSize 1
|
||||
tools.single().name shouldBe "keep"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTool should return false when tool not found`() = runTest {
|
||||
server.removeTool("ghost") shouldBe false
|
||||
}
|
||||
|
||||
// ── listPrompts ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `listPrompts should return empty list when no prompts registered`() = runTest {
|
||||
client.listPrompts().prompts.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listPrompts should return all registered prompts`() = runTest {
|
||||
server.addPrompt("prompt-a", "First prompt") { GetPromptResult(messages = emptyList()) }
|
||||
server.addPrompt("prompt-b", "Second prompt") { GetPromptResult(messages = emptyList()) }
|
||||
|
||||
val prompts = client.listPrompts().prompts
|
||||
|
||||
prompts shouldHaveSize 2
|
||||
prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("prompt-a", "prompt-b")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listPrompts should expose prompt description`() = runTest {
|
||||
server.addPrompt("my-prompt", "Helpful context prompt") { GetPromptResult(messages = emptyList()) }
|
||||
|
||||
val prompt = client.listPrompts().prompts.single()
|
||||
|
||||
assertSoftly(prompt) {
|
||||
name shouldBe "my-prompt"
|
||||
description shouldBe "Helpful context prompt"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listPrompts should include prompt arguments`() = runTest {
|
||||
val args = listOf(
|
||||
PromptArgument(name = "topic", description = "The topic to discuss", required = true),
|
||||
PromptArgument(name = "tone", description = "Tone of voice", required = false),
|
||||
)
|
||||
server.addPrompt("templated", "A templated prompt", args) { GetPromptResult(messages = emptyList()) }
|
||||
|
||||
val prompt = client.listPrompts().prompts.single()
|
||||
|
||||
val promptArgs = prompt.arguments.shouldNotBeNull()
|
||||
promptArgs shouldHaveSize 2
|
||||
promptArgs.map { it.name } shouldContainExactlyInAnyOrder listOf("topic", "tone")
|
||||
promptArgs.first { it.name == "topic" }.required shouldBe true
|
||||
promptArgs.first { it.name == "tone" }.required shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listPrompts should expose null description`() = runTest {
|
||||
server.addPrompt("no-desc", null) { GetPromptResult(messages = emptyList()) }
|
||||
|
||||
client.listPrompts().prompts.single().description shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listPrompts should reflect prompt removal`() = runTest {
|
||||
server.addPrompt("keep", "Kept") { GetPromptResult(messages = emptyList()) }
|
||||
server.addPrompt("drop", "Dropped") { GetPromptResult(messages = emptyList()) }
|
||||
client.listPrompts().prompts shouldHaveSize 2
|
||||
|
||||
server.removePrompt("drop") shouldBe true
|
||||
|
||||
val prompts = client.listPrompts().prompts
|
||||
|
||||
prompts shouldHaveSize 1
|
||||
prompts.single().name shouldBe "keep"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removePrompt should return false when prompt not found`() = runTest {
|
||||
server.removePrompt("ghost") shouldBe false
|
||||
}
|
||||
|
||||
// ── listResources ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `listResources should return empty list when no resources registered`() = runTest {
|
||||
client.listResources().resources.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listResources should return all registered resources`() = runTest {
|
||||
server.addResource("test://res-1", "Resource 1", "First") { ReadResourceResult(emptyList()) }
|
||||
server.addResource("test://res-2", "Resource 2", "Second") { ReadResourceResult(emptyList()) }
|
||||
|
||||
val resources = client.listResources().resources
|
||||
|
||||
resources shouldHaveSize 2
|
||||
resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://res-1", "test://res-2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listResources should expose resource metadata`() = runTest {
|
||||
server.addResource("test://my-res", "My Resource", "A detailed resource", "application/json") {
|
||||
ReadResourceResult(emptyList())
|
||||
}
|
||||
|
||||
val resource = client.listResources().resources.single()
|
||||
|
||||
assertSoftly(resource) {
|
||||
uri shouldBe "test://my-res"
|
||||
name shouldBe "My Resource"
|
||||
description shouldBe "A detailed resource"
|
||||
mimeType shouldBe "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listResources should expose resource without mimeType`() = runTest {
|
||||
server.addResources(
|
||||
listOf(
|
||||
RegisteredResource(Resource("test://no-mime", "No Mime")) {
|
||||
ReadResourceResult(emptyList())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
client.listResources().resources.single().mimeType shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listResources should reflect resource removal`() = runTest {
|
||||
server.addResource("test://keep", "Keep", "Kept") { ReadResourceResult(emptyList()) }
|
||||
server.addResource("test://drop", "Drop", "Dropped") { ReadResourceResult(emptyList()) }
|
||||
client.listResources().resources shouldHaveSize 2
|
||||
|
||||
server.removeResource("test://drop") shouldBe true
|
||||
|
||||
val resources = client.listResources().resources
|
||||
|
||||
resources shouldHaveSize 1
|
||||
resources.single().uri shouldBe "test://keep"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeResource should return false when resource not found`() = runTest {
|
||||
server.removeResource("test://ghost") shouldBe false
|
||||
}
|
||||
|
||||
// ── listResourceTemplates ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `listResourceTemplates should return empty list`() = runTest {
|
||||
client.listResourceTemplates(ListResourceTemplatesRequest()).resourceTemplates.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
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.LoggingLevel
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
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.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ServerHandlerNotificationTest : AbstractServerFeaturesTest() {
|
||||
|
||||
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(true),
|
||||
prompts = ServerCapabilities.Prompts(true),
|
||||
resources = ServerCapabilities.Resources(true, null),
|
||||
logging = JsonObject(emptyMap()),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `tool handler can send notification to client`() = runTest {
|
||||
val notificationReceived = CompletableDeferred<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) { notification ->
|
||||
notificationReceived.complete(notification)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
val notificationText = "Notification from tool"
|
||||
server.addTool("notify-tool", "A tool that notifies") {
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive(notificationText),
|
||||
),
|
||||
),
|
||||
)
|
||||
CallToolResult(listOf(TextContent("Tool executed")))
|
||||
}
|
||||
|
||||
val result = client.callTool(CallToolRequest(CallToolRequestParams(name = "notify-tool")))
|
||||
|
||||
(result.content[0] as TextContent).text shouldBe "Tool executed"
|
||||
val received = notificationReceived.await()
|
||||
received.params.level shouldBe LoggingLevel.Info
|
||||
received.params.data shouldBe JsonPrimitive(notificationText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prompt handler can send notification to client`() = runTest {
|
||||
val notificationReceived = CompletableDeferred<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) { notification ->
|
||||
notificationReceived.complete(notification)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
val notificationText = "Notification from prompt"
|
||||
server.addPrompt("notify-prompt", "A prompt that notifies") {
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive(notificationText),
|
||||
),
|
||||
),
|
||||
)
|
||||
GetPromptResult(messages = listOf(), description = "Prompt executed")
|
||||
}
|
||||
|
||||
val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams(name = "notify-prompt")))
|
||||
|
||||
result.description shouldBe "Prompt executed"
|
||||
val received = notificationReceived.await()
|
||||
received.params.level shouldBe LoggingLevel.Info
|
||||
received.params.data shouldBe JsonPrimitive(notificationText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resource handler can send notification to client`() = runTest {
|
||||
val notificationReceived = CompletableDeferred<LoggingMessageNotification>()
|
||||
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) { notification ->
|
||||
notificationReceived.complete(notification)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
val resourceUri = "test://notify-resource"
|
||||
val notificationText = "Notification from resource"
|
||||
server.addResource(resourceUri, "Notify Resource", "A resource that notifies") {
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive(notificationText),
|
||||
),
|
||||
),
|
||||
)
|
||||
ReadResourceResult(
|
||||
contents = listOf(TextResourceContents(uri = resourceUri, text = "Resource read")),
|
||||
)
|
||||
}
|
||||
|
||||
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = resourceUri)))
|
||||
|
||||
(result.contents[0] as TextResourceContents).text shouldBe "Resource read"
|
||||
val received = notificationReceived.await()
|
||||
received.params.level shouldBe LoggingLevel.Info
|
||||
received.params.data shouldBe JsonPrimitive(notificationText)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue