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:
Pavel Gorgulov 2026-06-23 22:14:59 +02:00 • committed by GitHub
commit c8a1103b16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 494 additions and 6 deletions

View file

@ -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"),

View file

@ -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"))

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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,

View file

@ -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)
}
}
}

View file

@ -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) }
}
}

View file

@ -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.
*

View file

@ -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.

View file

@ -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 */

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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(