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
8 changed files with 494 additions and 6 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue