Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,7 @@ class ClientTest {
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = EmptyJsonObject,
elicitation = ClientCapabilities.Elicitation(),
),
),
)
Expand Down Expand Up @@ -1270,7 +1270,7 @@ class ClientTest {
): Pair<Client, ServerSession> = kotlinx.coroutines.coroutineScope {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(capabilities = ClientCapabilities(elicitation = EmptyJsonObject)),
ClientOptions(capabilities = ClientCapabilities(elicitation = ClientCapabilities.Elicitation())),
)
client.setElicitationHandler(handler)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ public open class Client(private val clientInfo: Implementation, options: Client
"prompts" -> caps?.prompts != null
"resources" -> caps?.resources != null
"tools" -> caps?.tools != null
"tasks" -> caps?.tasks != null
else -> true
}

Expand Down Expand Up @@ -267,6 +268,12 @@ public open class Client(private val clientInfo: Implementation, options: Client
}
}

Method.Defined.TasksGet,
Method.Defined.TasksResult,
Method.Defined.TasksList,
Method.Defined.TasksCancel,
-> assertTasksCapabilityForMethod(method)
Comment on lines +271 to +275
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tasks methods were added to assertCapabilityForMethod, but assertRequestHandlerCapability still has no gating for tasks/* handlers. Since tasks requests can be received by either side, this allows registering handlers for tasks/get|result|list|cancel without advertising ClientCapabilities.tasks (and without checking list/cancel sub-capabilities). Consider adding corresponding tasks/* cases in assertRequestHandlerCapability that validate capabilities.tasks and, for list/cancel, capabilities.tasks.list/capabilities.tasks.cancel.

Copilot uses AI. Check for mistakes.

Comment on lines +271 to +276
Method.Defined.Initialize, Method.Defined.Ping -> {
// No specific capability required
}
Expand All @@ -277,6 +284,24 @@ public open class Client(private val clientInfo: Implementation, options: Client
}
}

private fun assertTasksCapabilityForMethod(method: Method) {
val tasks = serverCapabilities?.tasks
?: error("Server does not support tasks (required for $method)")
when (method) {
Method.Defined.TasksList -> checkNotNull(tasks.list) {
"Server does not support listing tasks (required for $method)"
}

Method.Defined.TasksCancel -> checkNotNull(tasks.cancel) {
"Server does not support cancelling tasks (required for $method)"
}

else -> {
// TasksGet, TasksResult: base tasks capability suffices.
}
}
}

override fun assertNotificationCapability(method: Method) {
when (method) {
Method.Defined.NotificationsRootsListChanged -> {
Expand All @@ -285,6 +310,12 @@ public open class Client(private val clientInfo: Implementation, options: Client
}
}

Method.Defined.NotificationsTasksStatus -> {
checkNotNull(capabilities.tasks) {
"Client does not support tasks (required for $method)"
}
}

Method.Defined.NotificationsInitialized,
Method.Defined.NotificationsCancelled,
Method.Defined.NotificationsProgress,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package io.modelcontextprotocol.kotlin.sdk.client

import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFailsWith

/**
* Tests for the protected helper [Client.assertCapability].
*
* `assertCapability(capability, method)` is `protected`, so this suite uses
* a small subclass [TestClient] that re-exports it via [TestClient.exposedAssertCapability].
* Server capabilities are seeded by completing the initialization handshake through
* [CapabilitiesTransport], which replays a configurable [ServerCapabilities] in its
* `initialize` response.
*/
class ClientAssertCapabilityTest {

@Test
fun `assertCapability tasks throws when server has no tasks capability`() = runTest {
val client = newTestClient(serverCapabilities = ServerCapabilities())
val ex = assertFailsWith<IllegalStateException> {
client.exposedAssertCapability("tasks", "tasks/list")
}
ex.message.orEmpty() shouldContain "Server does not support tasks"
}

@Test
fun `TasksGet throws when server has no tasks capability`() = runTest {
val client = newTestClient(serverCapabilities = ServerCapabilities())
val ex = assertFailsWith<IllegalStateException> {
client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet)
}
ex.message.orEmpty() shouldContain "Server does not support tasks"
}

@Test
fun `TasksGet does not throw when server declared tasks`() = runTest {
val client = newTestClient(
serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()),
)
client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet)
}

@Test
fun `TasksList throws when server tasks list is null`() = runTest {
val client = newTestClient(
serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()),
)
val ex = assertFailsWith<IllegalStateException> {
client.exposedAssertCapabilityForMethod(Method.Defined.TasksList)
}
ex.message.orEmpty() shouldContain "Server does not support listing tasks"
}

@Test
fun `TasksCancel throws when server tasks cancel is null`() = runTest {
val client = newTestClient(
serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()),
)
val ex = assertFailsWith<IllegalStateException> {
client.exposedAssertCapabilityForMethod(Method.Defined.TasksCancel)
}
ex.message.orEmpty() shouldContain "Server does not support cancelling tasks"
}

@Test
fun `NotificationsTasksStatus throws when client has no tasks capability`() = runTest {
val client = newTestClient(clientCapabilities = ClientCapabilities())
val ex = assertFailsWith<IllegalStateException> {
client.exposedAssertNotificationCapability(Method.Defined.NotificationsTasksStatus)
}
ex.message.orEmpty() shouldContain "Client does not support tasks"
}

private suspend fun newTestClient(
serverCapabilities: ServerCapabilities = ServerCapabilities(),
clientCapabilities: ClientCapabilities = ClientCapabilities(),
): TestClient {
val client = TestClient(
Implementation("test-client", "1.0.0"),
ClientOptions(capabilities = clientCapabilities),
)
val transport = CapabilitiesTransport(serverCapabilities)
client.connect(transport)
return client
}

/**
* Test-only [Client] subclass that exposes the protected
* [Client.assertCapability], [Client.assertCapabilityForMethod], and
* [Client.assertNotificationCapability] helpers to the test code.
*/
private class TestClient(clientInfo: Implementation, options: ClientOptions = ClientOptions()) :
Client(clientInfo, options) {
fun exposedAssertCapability(capability: String, method: String): Unit = assertCapability(capability, method)
fun exposedAssertCapabilityForMethod(method: Method): Unit = assertCapabilityForMethod(method)
fun exposedAssertNotificationCapability(method: Method): Unit = assertNotificationCapability(method)
}

/**
* Minimal in-memory [Transport] that responds to `initialize` with a configurable
* [ServerCapabilities]. Other JSON-RPC traffic (e.g. the `notifications/initialized`
* sent by [Client.connect]) is silently consumed.
*/
private class CapabilitiesTransport(private val serverCapabilities: ServerCapabilities) : Transport {
private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null
private var onCloseBlock: (() -> Unit)? = null

override suspend fun start() = Unit

override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
if (message is JSONRPCRequest && message.method == "initialize") {
onMessageBlock?.invoke(
JSONRPCResponse(
id = message.id,
result = InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = serverCapabilities,
serverInfo = Implementation("mock-server", "1.0.0"),
),
),
)
}
}

override suspend fun close() {
onCloseBlock?.invoke()
}

override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
onMessageBlock = block
}

override fun onClose(block: () -> Unit) {
onCloseBlock = block
}

override fun onError(block: (Throwable) -> Unit) = Unit
}
}
Loading
Loading