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
8 changed files with 494 additions and 6 deletions

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