feat: sep-1036 add URL Mode Elicitation (#843)
closes #410 ## How Has This Been Tested? integration and unit tests ## Breaking Changes none ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] 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
d21728ecca
commit
c8a1103b16
15 changed files with 494 additions and 6 deletions
|
|
@ -14,7 +14,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
|
||||
|
|
@ -46,6 +49,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
|
|
@ -1251,6 +1255,189 @@ class ClientTest {
|
|||
client.close()
|
||||
}
|
||||
|
||||
// ── URL-mode elicitation (SEP-1036) ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should handle URL mode elicitation end-to-end`() = runTest {
|
||||
val client = Client(
|
||||
Implementation(name = "test client", version = "1.0"),
|
||||
ClientOptions(
|
||||
capabilities = ClientCapabilities(
|
||||
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val elicitationId = "550e8400-e29b-41d4-a716-446655440000"
|
||||
val url = "https://oauth.example.com/authorize"
|
||||
|
||||
client.setElicitationHandler { request ->
|
||||
val params = assertIs<ElicitRequestURLParams>(request.params)
|
||||
assertEquals(elicitationId, params.elicitationId)
|
||||
assertEquals(url, params.url)
|
||||
ElicitResult(action = ElicitResult.Action.Accept)
|
||||
}
|
||||
|
||||
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
|
||||
val server = Server(
|
||||
serverInfo = Implementation(name = "test server", version = "1.0"),
|
||||
options = ServerOptions(capabilities = ServerCapabilities()),
|
||||
)
|
||||
|
||||
val serverSessionResult = CompletableDeferred<ServerSession>()
|
||||
listOf(
|
||||
launch { client.connect(clientTransport) },
|
||||
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
|
||||
).joinAll()
|
||||
val serverSession = serverSessionResult.await()
|
||||
|
||||
val result = serverSession.createElicitation(
|
||||
message = "Authorize access to continue",
|
||||
elicitationId = elicitationId,
|
||||
url = url,
|
||||
)
|
||||
|
||||
assertEquals(ElicitResult.Action.Accept, result.action)
|
||||
assertNull(result.content)
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject URL mode elicitation when client supports only form mode`() = runTest {
|
||||
val (client, serverSession) = setupElicitationPair {
|
||||
ElicitResult(action = ElicitResult.Action.Accept)
|
||||
}
|
||||
|
||||
val exception = assertFailsWith<IllegalArgumentException> {
|
||||
serverSession.createElicitation(
|
||||
message = "Authorize",
|
||||
elicitationId = "id-1",
|
||||
url = "https://example.com/auth",
|
||||
)
|
||||
}
|
||||
assertTrue(exception.message!!.contains("elicitation.url"))
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deliver elicitation complete notification to client`() = runTest {
|
||||
val received = CompletableDeferred<ElicitationCompleteNotification>()
|
||||
val client = Client(
|
||||
Implementation(name = "test client", version = "1.0"),
|
||||
ClientOptions(
|
||||
capabilities = ClientCapabilities(
|
||||
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
|
||||
),
|
||||
),
|
||||
)
|
||||
client.setElicitationCompleteHandler { received.complete(it) }
|
||||
|
||||
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
|
||||
val server = Server(
|
||||
serverInfo = Implementation(name = "test server", version = "1.0"),
|
||||
options = ServerOptions(capabilities = ServerCapabilities()),
|
||||
)
|
||||
|
||||
val serverSessionResult = CompletableDeferred<ServerSession>()
|
||||
listOf(
|
||||
launch { client.connect(clientTransport) },
|
||||
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
|
||||
).joinAll()
|
||||
val serverSession = serverSessionResult.await()
|
||||
|
||||
val elicitationId = "complete-id-1"
|
||||
serverSession.sendElicitationComplete(
|
||||
ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = elicitationId)),
|
||||
)
|
||||
|
||||
val notification = received.await()
|
||||
assertEquals(elicitationId, notification.params.elicitationId)
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject elicitation complete when client supports only form mode`() = runTest {
|
||||
val (client, serverSession) = setupElicitationPair {
|
||||
ElicitResult(action = ElicitResult.Action.Accept)
|
||||
}
|
||||
|
||||
val exception = assertFailsWith<IllegalArgumentException> {
|
||||
serverSession.sendElicitationComplete(
|
||||
ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "id-1")),
|
||||
)
|
||||
}
|
||||
assertTrue(exception.message!!.contains("elicitation.url"))
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setElicitationCompleteHandler should require url capability`() = runTest {
|
||||
val client = Client(
|
||||
Implementation(name = "test client", version = "1.0"),
|
||||
ClientOptions(
|
||||
capabilities = ClientCapabilities(
|
||||
elicitation = ClientCapabilities.Elicitation(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
client.setElicitationCompleteHandler { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should surface URL elicitation required error to client as typed exception`() = runTest {
|
||||
val client = Client(
|
||||
Implementation(name = "test client", version = "1.0"),
|
||||
ClientOptions(
|
||||
capabilities = ClientCapabilities(
|
||||
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val elicitationId = "auth-required-1"
|
||||
val url = "https://oauth.example.com/authorize"
|
||||
|
||||
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
|
||||
val server = Server(
|
||||
serverInfo = Implementation(name = "test server", version = "1.0"),
|
||||
options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(true))),
|
||||
)
|
||||
server.addTool("needs-auth", "Requires URL elicitation") {
|
||||
throw UrlElicitationRequiredException(
|
||||
listOf(
|
||||
ElicitRequestURLParams(
|
||||
message = "Authorize to continue",
|
||||
elicitationId = elicitationId,
|
||||
url = url,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val serverSessionResult = CompletableDeferred<ServerSession>()
|
||||
listOf(
|
||||
launch { client.connect(clientTransport) },
|
||||
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
|
||||
).joinAll()
|
||||
serverSessionResult.await()
|
||||
|
||||
val exception = assertFailsWith<UrlElicitationRequiredException> {
|
||||
client.callTool(name = "needs-auth", arguments = emptyMap())
|
||||
}
|
||||
val elicitation = exception.elicitations.single()
|
||||
assertEquals(elicitationId, elicitation.elicitationId)
|
||||
assertEquals(url, elicitation.url)
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema(
|
||||
properties = mapOf(
|
||||
"name" to StringSchema(description = "User name", default = "John Doe"),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest
|
||||
|
|
@ -61,6 +62,7 @@ class ClientConnectionTest : AbstractServerFeaturesTest() {
|
|||
|
||||
override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities(
|
||||
roots = ClientCapabilities.Roots(listChanged = true),
|
||||
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
|
||||
)
|
||||
|
||||
private val sampleRoots = listOf(Root("file:///project", "Project Root"))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp
|
|||
public final fun removeRoot (Ljava/lang/String;)Z
|
||||
public final fun removeRoots (Ljava/util/List;)I
|
||||
public final fun sendRootsListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public final fun setElicitationCompleteHandler (Lkotlin/jvm/functions/Function1;)V
|
||||
public final fun setElicitationHandler (Lkotlin/jvm/functions/Function1;)V
|
||||
public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public static synthetic fun setLoggingLevel$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
|
|
@ -63,6 +64,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.toJson
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.getAndUpdate
|
||||
|
|
@ -70,6 +72,7 @@ import kotlinx.atomicfu.update
|
|||
import kotlinx.collections.immutable.minus
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -643,9 +646,18 @@ public open class Client(private val clientInfo: Implementation, options: Client
|
|||
/**
|
||||
* Sets the elicitation handler.
|
||||
*
|
||||
* When the handler returns [ElicitResult.Action.Accept], any properties missing from
|
||||
* The handler receives both form-mode ([ElicitRequestFormParams]) and URL-mode
|
||||
* ([io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams]) requests;
|
||||
* branch on `request.params` to tell them apart. For URL mode,
|
||||
* the host application must obtain explicit user consent and display the target domain before
|
||||
* navigating — the SDK never opens or validates the URL — and should return
|
||||
* [ElicitResult.Action.Decline] or [ElicitResult.Action.Cancel] when it cannot or will not proceed.
|
||||
* A URL-mode [ElicitResult.Action.Accept] only signals consent; the outcome arrives out-of-band via
|
||||
* [setElicitationCompleteHandler].
|
||||
*
|
||||
* When a form-mode handler returns [ElicitResult.Action.Accept], any properties missing from
|
||||
* [ElicitResult.content] are automatically populated with default values defined in the
|
||||
* elicitation schema.
|
||||
* elicitation schema. URL-mode responses carry no content.
|
||||
*
|
||||
* @param handler The elicitation handler.
|
||||
* @throws IllegalStateException if the client does not support elicitation.
|
||||
|
|
@ -663,6 +675,35 @@ public open class Client(private val clientInfo: Implementation, options: Client
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the handler invoked when the server reports that a URL-mode elicitation has completed.
|
||||
*
|
||||
* The handler is called for every `notifications/elicitation/complete` notification. Because the
|
||||
* server only sends this for an out-of-band (URL-mode) interaction, the client must support url-mode
|
||||
* elicitation. The client is responsible for correlating the notification's `elicitationId` with a
|
||||
* pending elicitation, ignoring unknown or already-completed identifiers, and providing a manual way
|
||||
* to continue if a notification never arrives.
|
||||
*
|
||||
* @param handler Invoked with each completion notification.
|
||||
* @throws IllegalStateException if the client does not support url-mode elicitation.
|
||||
*/
|
||||
public fun setElicitationCompleteHandler(handler: (ElicitationCompleteNotification) -> Unit) {
|
||||
check(capabilities.elicitation.supportsUrl) {
|
||||
logger.error {
|
||||
"Failed to set elicitation-complete handler: client does not support url-mode elicitation"
|
||||
}
|
||||
"Client does not support url-mode elicitation."
|
||||
}
|
||||
logger.info { "Setting the elicitation-complete handler" }
|
||||
|
||||
setNotificationHandler<ElicitationCompleteNotification>(
|
||||
Method.Defined.NotificationsElicitationComplete,
|
||||
) { notification ->
|
||||
handler(notification)
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal Handlers ---
|
||||
|
||||
private fun applyElicitationDefaults(request: ElicitRequest, result: ElicitResult): ElicitResult {
|
||||
|
|
|
|||
|
|
@ -610,6 +610,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotificatio
|
|||
public final fun serializer ()Lkotlinx/serialization/KSerializer;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesKt {
|
||||
public static final fun getSupportsUrl (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)Z
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities {
|
||||
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion;
|
||||
public fun <init> ()V
|
||||
|
|
@ -3128,7 +3132,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Logging_dslKt {
|
|||
public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/types/McpDsl : java/lang/annotation/Annotation {
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
|
||||
public class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
|
||||
public fun <init> (I)V
|
||||
public fun <init> (ILjava/lang/String;)V
|
||||
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
|
||||
|
|
@ -3744,6 +3748,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode {
|
|||
public static final field PARSE_ERROR I
|
||||
public static final field REQUEST_TIMEOUT I
|
||||
public static final field RESOURCE_NOT_FOUND I
|
||||
public static final field URL_ELICITATION_REQUIRED I
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest {
|
||||
|
|
@ -5814,6 +5819,39 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelect
|
|||
public final fun serializer ()Lkotlinx/serialization/KSerializer;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData {
|
||||
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion;
|
||||
public fun <init> (Ljava/util/List;)V
|
||||
public final fun component1 ()Ljava/util/List;
|
||||
public final fun copy (Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;
|
||||
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;
|
||||
public fun equals (Ljava/lang/Object;)Z
|
||||
public final fun getElicitations ()Ljava/util/List;
|
||||
public fun hashCode ()I
|
||||
public fun toString ()Ljava/lang/String;
|
||||
}
|
||||
|
||||
public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
|
||||
public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer;
|
||||
public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
|
||||
public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;
|
||||
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
|
||||
public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
|
||||
public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;)V
|
||||
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
|
||||
public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion {
|
||||
public final fun serializer ()Lkotlinx/serialization/KSerializer;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException : io/modelcontextprotocol/kotlin/sdk/types/McpException {
|
||||
public fun <init> (Ljava/util/List;Ljava/lang/String;)V
|
||||
public synthetic fun <init> (Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
|
||||
public final fun getElicitations ()Ljava/util/List;
|
||||
}
|
||||
|
||||
public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/WithMeta {
|
||||
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta$Companion;
|
||||
public abstract fun getMeta ()Lkotlinx/serialization/json/JsonObject;
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
|
|||
handler(response, null)
|
||||
} else {
|
||||
checkNotNull(error)
|
||||
val mcpException = McpException(
|
||||
val mcpException = McpException.fromError(
|
||||
code = error.error.code,
|
||||
message = error.error.message,
|
||||
data = error.error.data,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,26 @@ import kotlin.jvm.JvmOverloads
|
|||
* @property data optional additional error payload as a JSON element
|
||||
* @param cause the original cause
|
||||
*/
|
||||
public class McpException @JvmOverloads public constructor(
|
||||
public open class McpException @JvmOverloads public constructor(
|
||||
public val code: Int,
|
||||
message: String = "MCP error $code",
|
||||
public val data: JsonElement? = null,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause)
|
||||
) : Exception(message, cause) {
|
||||
internal companion object {
|
||||
/**
|
||||
* Reconstructs the most specific [McpException] subtype for a JSON-RPC error.
|
||||
*
|
||||
* Recognizes [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] errors and returns a
|
||||
* [UrlElicitationRequiredException] when [data] carries valid URL-mode elicitations;
|
||||
* otherwise returns a plain [McpException]. Never throws — a malformed payload simply
|
||||
* degrades to a plain [McpException].
|
||||
*/
|
||||
fun fromError(code: Int, message: String, data: JsonElement?): McpException {
|
||||
if (code == RPCError.ErrorCode.URL_ELICITATION_REQUIRED && data != null) {
|
||||
UrlElicitationRequiredException.fromDataOrNull(message, data)?.let { return it }
|
||||
}
|
||||
return McpException(code = code, message = message, data = data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.types
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
|
||||
/**
|
||||
* Payload carried by the [data][RPCError.data] of a [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] error.
|
||||
*
|
||||
* @property elicitations The URL-mode elicitations that must be completed before the original request
|
||||
* can succeed. Each entry is equivalent to an `elicitation/create` request.
|
||||
*/
|
||||
@Serializable
|
||||
public data class UrlElicitationRequiredData(val elicitations: List<ElicitRequestURLParams>)
|
||||
|
||||
/**
|
||||
* Signals that one or more URL-mode elicitations must be completed before a request can be processed.
|
||||
*
|
||||
* Serialized over the wire as a JSON-RPC error with code [RPCError.ErrorCode.URL_ELICITATION_REQUIRED]
|
||||
* and [data][RPCError.data] holding the [elicitations]. A server throws this from a request handler to
|
||||
* tell the client that a URL-mode elicitation is required; the client receives it from any request as a
|
||||
* typed exception, equivalent to receiving an `elicitation/create` request.
|
||||
*
|
||||
* The client is responsible for obtaining explicit user consent and surfacing each [ElicitRequestURLParams]
|
||||
* before navigation — the SDK never opens or validates URLs.
|
||||
*
|
||||
* Example client-side handling:
|
||||
* ```kotlin
|
||||
* try {
|
||||
* client.callTool(name = "create_repo", arguments = args)
|
||||
* } catch (e: UrlElicitationRequiredException) {
|
||||
* for (elicitation in e.elicitations) {
|
||||
* // Show elicitation.message and the target domain, then ask the user for consent.
|
||||
* if (userConsents(elicitation.url)) openInBrowser(elicitation.url)
|
||||
* // Await completion via Client.setElicitationCompleteHandler, then optionally retry the call.
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @property elicitations The required URL-mode elicitations (always non-empty).
|
||||
*/
|
||||
public class UrlElicitationRequiredException(
|
||||
public val elicitations: List<ElicitRequestURLParams>,
|
||||
message: String = defaultMessage(elicitations),
|
||||
) : McpException(
|
||||
code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED,
|
||||
message = message,
|
||||
data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(elicitations)),
|
||||
) {
|
||||
internal companion object {
|
||||
private fun defaultMessage(elicitations: List<ElicitRequestURLParams>): String =
|
||||
if (elicitations.size > 1) "URL elicitations are required" else "URL elicitation is required"
|
||||
|
||||
/**
|
||||
* Reconstructs a [UrlElicitationRequiredException] from JSON-RPC error [data], or `null` when the
|
||||
* payload is absent, malformed, or carries no elicitations. Never throws.
|
||||
*/
|
||||
fun fromDataOrNull(message: String, data: JsonElement): UrlElicitationRequiredException? =
|
||||
runCatching { McpJson.decodeFromJsonElement<UrlElicitationRequiredData>(data).elicitations }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { UrlElicitationRequiredException(it, message) }
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +189,15 @@ public data class ClientCapabilities(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client supports url-mode elicitation (out-of-band interaction via URL navigation).
|
||||
*
|
||||
* `false` when the client declared no elicitation capability at all, or only form mode (an empty
|
||||
* [ClientCapabilities.Elicitation] is treated as form mode only).
|
||||
*/
|
||||
public val ClientCapabilities.Elicitation?.supportsUrl: Boolean
|
||||
get() = this?.url != null
|
||||
|
||||
/**
|
||||
* Capabilities that a server may support.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ public data class ElicitRequestFormParams(
|
|||
* Directs the user to an external URL for out-of-band interactions (e.g., OAuth flows,
|
||||
* payment processing, or entering sensitive credentials) that must not pass through the MCP client.
|
||||
*
|
||||
* Handling the [url] is the responsibility of the host application: it should require HTTPS, clearly
|
||||
* display the target domain, obtain explicit user consent, and open the URL in a secure browser context.
|
||||
* The SDK neither opens, fetches, nor validates the URL. URLs must only appear in URL-mode requests —
|
||||
* never render a URL from a form-mode request as a clickable link.
|
||||
*
|
||||
* @property message The message explaining why the interaction is needed.
|
||||
* @property elicitationId A unique identifier for this elicitation. The client MUST treat
|
||||
* this ID as an opaque value.
|
||||
|
|
|
|||
|
|
@ -269,6 +269,13 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle
|
|||
/** Resource not found */
|
||||
public const val RESOURCE_NOT_FOUND: Int = -32002
|
||||
|
||||
/**
|
||||
* A URL-mode elicitation must be completed before the request can be processed.
|
||||
* The error [data][RPCError.data] carries the required URL-mode elicitations
|
||||
* (see [UrlElicitationRequiredException]).
|
||||
*/
|
||||
public const val URL_ELICITATION_REQUIRED: Int = -32042
|
||||
|
||||
// Standard JSON-RPC 2.0 error codes
|
||||
|
||||
/** Invalid JSON was received */
|
||||
|
|
|
|||
|
|
@ -923,4 +923,24 @@ class CapabilitiesTest {
|
|||
assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.sampling?.createMessage)
|
||||
assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.elicitation?.create)
|
||||
}
|
||||
|
||||
// ── Elicitation supportsUrl predicate ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `supportsUrl is false when no elicitation capability is declared`() {
|
||||
val elicitation: ClientCapabilities.Elicitation? = null
|
||||
elicitation.supportsUrl shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supportsUrl is false for an empty form-only capability`() {
|
||||
ClientCapabilities.Elicitation().supportsUrl shouldBe false
|
||||
ClientCapabilities.Elicitation(form = EmptyJsonObject).supportsUrl shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supportsUrl is true when url mode is declared`() {
|
||||
ClientCapabilities.Elicitation(url = EmptyJsonObject).supportsUrl shouldBe true
|
||||
ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject).supportsUrl shouldBe true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
|
|||
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
|
@ -273,4 +274,86 @@ class ElicitationTest {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── URLElicitationRequired error (-32042) ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `UrlElicitationRequiredException carries code and serialized elicitations`() {
|
||||
val elicitation = ElicitRequestURLParams(
|
||||
message = "Authorize access to continue",
|
||||
elicitationId = "550e8400-e29b-41d4-a716-446655440000",
|
||||
url = "https://oauth.example.com/authorize",
|
||||
)
|
||||
val exception = UrlElicitationRequiredException(listOf(elicitation))
|
||||
|
||||
exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED
|
||||
exception.elicitations shouldBe listOf(elicitation)
|
||||
|
||||
val data = exception.data.shouldNotBeNull()
|
||||
McpJson.encodeToString(data) shouldEqualJson """
|
||||
{
|
||||
"elicitations": [
|
||||
{
|
||||
"message": "Authorize access to continue",
|
||||
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://oauth.example.com/authorize",
|
||||
"mode": "url"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromError reconstructs typed exception for -32042 with valid data`() {
|
||||
val data = McpJson.encodeToJsonElement(
|
||||
UrlElicitationRequiredData(
|
||||
listOf(ElicitRequestURLParams(message = "m", elicitationId = "id-1", url = "https://example.com/a")),
|
||||
),
|
||||
)
|
||||
|
||||
val exception = McpException.fromError(
|
||||
code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED,
|
||||
message = "needs auth",
|
||||
data = data,
|
||||
)
|
||||
|
||||
val typed = exception.shouldBeInstanceOf<UrlElicitationRequiredException>()
|
||||
typed.message shouldBe "needs auth"
|
||||
typed.elicitations.single().elicitationId shouldBe "id-1"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromError returns plain McpException for unrelated code`() {
|
||||
val exception = McpException.fromError(
|
||||
code = RPCError.ErrorCode.INVALID_PARAMS,
|
||||
message = "bad params",
|
||||
data = null,
|
||||
)
|
||||
(exception is UrlElicitationRequiredException) shouldBe false
|
||||
exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromError degrades to plain McpException for -32042 with malformed data`() {
|
||||
val malformed = buildJsonObject { put("unexpected", "shape") }
|
||||
|
||||
val exception = McpException.fromError(
|
||||
code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED,
|
||||
message = "x",
|
||||
data = malformed,
|
||||
)
|
||||
|
||||
(exception is UrlElicitationRequiredException) shouldBe false
|
||||
exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromError degrades to plain McpException when elicitations empty`() {
|
||||
val data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(emptyList()))
|
||||
|
||||
val exception = McpException.fromError(RPCError.ErrorCode.URL_ELICITATION_REQUIRED, "x", data)
|
||||
|
||||
(exception is UrlElicitationRequiredException) shouldBe false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
|
|
@ -238,6 +239,11 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client
|
|||
|
||||
override suspend fun createElicitation(request: ElicitRequest, options: RequestOptions?): ElicitResult {
|
||||
val params = request.params
|
||||
if (params is ElicitRequestURLParams) {
|
||||
require(session.clientCapabilities?.elicitation.supportsUrl) {
|
||||
"Client did not advertise elicitation.url capability; cannot send a URL-mode elicitation."
|
||||
}
|
||||
}
|
||||
logger.debug {
|
||||
when (params) {
|
||||
is ElicitRequestFormParams ->
|
||||
|
|
@ -299,6 +305,9 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client
|
|||
}
|
||||
|
||||
override suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) {
|
||||
require(session.clientCapabilities?.elicitation.supportsUrl) {
|
||||
"Client did not advertise elicitation.url capability; cannot send an elicitation completion notification."
|
||||
}
|
||||
logger.debug { "Sending elicitation complete notification for: ${notification.params.elicitationId}" }
|
||||
notification(notification)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException
|
||||
import io.modelcontextprotocol.kotlin.sdk.utils.MatchResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.utils.PathSegmentTemplateMatcher
|
||||
import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher
|
||||
|
|
@ -651,6 +652,9 @@ public open class Server(
|
|||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: UrlElicitationRequiredException) {
|
||||
// Surface a required URL-mode elicitation as a JSON-RPC error (-32042)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error executing tool ${requestParams.name}" }
|
||||
CallToolResult(
|
||||
|
|
|
|||
Loading…
Reference in a new issue