feat!: capabilities for tasks, elicitation (#732)
closes #544 Replaces opaque `JsonObject?` modelling of `elicitation` with a typed `ClientCapabilities.Elicitation` (`form`, `url`), and adds the new `tasks` capability on both `ClientCapabilities` and `ServerCapabilities` (with `list`, `cancel`, per-request-type augmentation). Wires capability assertions for `tasks/*` methods and `notifications/tasks/status` into `Client` and `ServerSession` ## How Has This Been Tested? unit tests ## Breaking Changes yes now elicitation is a specific class in capabilities ## Types of changes - [ ] 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 - [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
This commit is contained in:
parent
664f51b9cc
commit
ebc53f0ded
2 changed files with 872 additions and 65 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
Method.Defined.Initialize, Method.Defined.Ping -> {
|
||||
// No specific capability required
|
||||
}
|
||||
|
|
@ -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 -> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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…
Reference in a new issue