Add support for elicitation (#138)

* feat(client): add support for elicitation

Fixes #137

* feat(client): add a convenience method to set the elicitation handler
This commit is contained in:
Didier Villevalois 2025-07-11 14:46:57 +02:00 • committed by GitHub
commit 675cc73c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 320 additions and 5 deletions

View file

@ -8,6 +8,8 @@ import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CompatibilityCallToolResult
import io.modelcontextprotocol.kotlin.sdk.CompleteRequest
import io.modelcontextprotocol.kotlin.sdk.CompleteResult
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
@ -262,6 +264,14 @@ public open class Client(
}
}
Method.Defined.ElicitationCreate -> {
if (capabilities.elicitation == null) {
throw IllegalStateException(
"Client does not support elicitation capability (required for $method)"
)
}
}
Method.Defined.Ping -> {
// No capability required
}
@ -571,6 +581,24 @@ public open class Client(
notification(RootsListChangedNotification())
}
/**
* Sets the elicitation handler.
*
* @param handler The elicitation handler.
* @throws IllegalStateException if the client does not support elicitation.
*/
public fun setElicitationHandler(handler: (CreateElicitationRequest) -> CreateElicitationResult) {
if (capabilities.elicitation == null) {
logger.error { "Failed to set elicitation handler: Client does not support elicitation" }
throw IllegalStateException("Client does not support elicitation.")
}
logger.info { "Setting the elicitation handler" }
setRequestHandler<CreateElicitationRequest>(Method.Defined.ElicitationCreate) { request, _ ->
handler(request)
}
}
// --- Internal Handlers ---
private suspend fun handleListRoots(): ListRootsResult {

View file

@ -4,6 +4,9 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest.RequestedSchema
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
@ -525,6 +528,15 @@ public open class Server(
return request<ListRootsResult>(ListRootsRequest(params), options)
}
public suspend fun createElicitation(
message: String,
requestedSchema: RequestedSchema,
options: RequestOptions? = null
): CreateElicitationResult {
logger.debug { "Creating elicitation with message: $message" }
return request(CreateElicitationRequest(message, requestedSchema), options)
}
/**
* Sends a logging message notification to the client.
*
@ -666,6 +678,12 @@ public open class Server(
}
}
"elicitation/create" -> {
if (clientCapabilities?.elicitation == null) {
throw IllegalStateException("Client does not support elicitation (required for ${method.value})")
}
}
"ping" -> {
// No specific capability required
}

View file

@ -98,7 +98,8 @@ public sealed interface Method {
LoggingSetLevel("logging/setLevel"),
SamplingCreateMessage("sampling/createMessage"),
CompletionComplete("completion/complete"),
RootsList("roots/list")
RootsList("roots/list"),
ElicitationCreate("elicitation/create"),
}
/**
@ -337,6 +338,10 @@ public data class ClientCapabilities(
* Present if the client supports listing roots.
*/
val roots: Roots? = null,
/**
* Present if the client supports elicitation.
*/
val elicitation: JsonObject? = null,
) {
@Serializable
public data class Roots(
@ -1544,6 +1549,48 @@ public class RootsListChangedNotification : ClientNotification {
override val method: Method = Method.Defined.NotificationsRootsListChanged
}
/**
* Sent from the server to create an elicitation from the client.
*/
@Serializable
public data class CreateElicitationRequest(
public val message: String,
public val requestedSchema: RequestedSchema,
override val _meta: JsonObject = EmptyJsonObject,
) : ServerRequest, WithMeta {
override val method: Method = Method.Defined.ElicitationCreate
@Serializable
public data class RequestedSchema(
val properties: JsonObject = EmptyJsonObject,
val required: List<String>? = null,
) {
@OptIn(ExperimentalSerializationApi::class)
@EncodeDefault
val type: String = "object"
}
}
/**
* The client's response to an elicitation/create request from the server.
*/
@Serializable
public data class CreateElicitationResult(
public val action: Action,
public val content: JsonObject? = null,
override val _meta: JsonObject = EmptyJsonObject,
) : ClientResult {
init {
require(action == Action.accept || content == null) {
"Content can only be provided for an 'accept' action"
}
}
@Serializable
public enum class Action { accept, decline, cancel }
}
/**
* Represents an error specific to the MCP protocol.
*

View file

@ -3,6 +3,8 @@ package client
import io.mockk.coEvery
import io.mockk.spyk
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
@ -42,6 +44,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertInstanceOf
import org.junit.jupiter.api.assertThrows
@ -796,4 +799,106 @@ class ClientTest {
"Notification should be sent when sendRootsListChanged is called"
)
}
@Test
fun `should reject server elicitation when elicitation capability is not supported`() = runTest {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities()
)
)
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(
capabilities = ServerCapabilities()
)
)
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) }
).joinAll()
// Verify that creating an elicitation throws an exception
val exception = assertThrows<IllegalStateException> {
server.createElicitation(
message = "Please provide your GitHub username",
requestedSchema = CreateElicitationRequest.RequestedSchema(
properties = buildJsonObject {
putJsonObject("name") {
put("type", "string")
}
},
required = listOf("name")
)
)
}
assertEquals(
"Client does not support elicitation (required for elicitation/create)",
exception.message
)
}
@Test
fun `should handle server elicitation`() = runTest {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = EmptyJsonObject,
)
)
)
val elicitationMessage = "Please provide your GitHub username"
val requestedSchema = CreateElicitationRequest.RequestedSchema(
properties = buildJsonObject {
putJsonObject("name") {
put("type", "string")
}
},
required = listOf("name")
)
val elicitationResultAction = CreateElicitationResult.Action.accept
val elicitationResultContent = buildJsonObject {
put("name", "octocat")
}
client.setElicitationHandler { request ->
assertEquals(elicitationMessage, request.message)
assertEquals(requestedSchema, request.requestedSchema)
CreateElicitationResult(
action = elicitationResultAction,
content = elicitationResultContent
)
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(
capabilities = ServerCapabilities()
)
)
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) }
).joinAll()
val result = server.createElicitation(
message = elicitationMessage,
requestedSchema = requestedSchema
)
assertEquals(elicitationResultAction, result.action)
assertEquals(elicitationResultContent, result.content)
}
}