Add support for URL mode elicitation (#1021)
This commit is contained in:
parent
c447973972
commit
ae17ba0b4b
16 changed files with 1241 additions and 92 deletions
|
|
@ -104,7 +104,19 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||
|
||||
_options.Capabilities ??= new();
|
||||
_options.Capabilities.Elicitation ??= new();
|
||||
if (_options.Capabilities.Elicitation is null)
|
||||
{
|
||||
// Default to supporting only form mode if not explicitly configured
|
||||
_options.Capabilities.Elicitation = new()
|
||||
{
|
||||
Form = new(),
|
||||
};
|
||||
}
|
||||
else if (_options.Capabilities.Elicitation.Form is null && _options.Capabilities.Elicitation.Url is null)
|
||||
{
|
||||
// If ElicitationCapability is set but both modes are null, default to form mode for backward compatibility
|
||||
_options.Capabilities.Elicitation.Form = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,10 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportShutdownFailed(Name, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionCts.Dispose();
|
||||
|
|
@ -268,15 +272,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _options.Endpoint);
|
||||
CopyAdditionalHeaders(deleteRequest.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
|
||||
|
||||
try
|
||||
{
|
||||
// Do not validate we get a successful status code, because server support for the DELETE request is optional
|
||||
(await _httpClient.SendAsync(deleteRequest, message: null, CancellationToken.None).ConfigureAwait(false)).Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogTransportShutdownFailed(Name, ex);
|
||||
}
|
||||
// Do not validate we get a successful status code, because server support for the DELETE request is optional
|
||||
(await _httpClient.SendAsync(deleteRequest, message: null, CancellationToken.None).ConfigureAwait(false)).Dispose();
|
||||
}
|
||||
|
||||
private void LogJsonException(JsonException ex, string data)
|
||||
|
|
|
|||
|
|
@ -56,4 +56,20 @@ public enum McpErrorCode
|
|||
/// This error is used when the endpoint encounters an unexpected condition that prevents it from fulfilling the request.
|
||||
/// </remarks>
|
||||
InternalError = -32603,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that URL-mode elicitation is required to complete the requested operation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This error is returned when a server operation requires additional user input through URL-mode elicitation
|
||||
/// before it can proceed. The error data must include the `data.elicitations` payload describing the pending
|
||||
/// elicitation(s) for the client to present to the user.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common scenarios include OAuth authorization and other out-of-band flows that cannot be completed inside
|
||||
/// the MCP client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
UrlElicitationRequired = -32042,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(CancelledNotificationParams))]
|
||||
[JsonSerializable(typeof(InitializedNotificationParams))]
|
||||
[JsonSerializable(typeof(LoggingMessageNotificationParams))]
|
||||
[JsonSerializable(typeof(ElicitationCompleteNotificationParams))]
|
||||
[JsonSerializable(typeof(ProgressNotificationParams))]
|
||||
[JsonSerializable(typeof(PromptListChangedNotificationParams))]
|
||||
[JsonSerializable(typeof(ResourceListChangedNotificationParams))]
|
||||
|
|
@ -117,6 +118,7 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(CreateMessageResult))]
|
||||
[JsonSerializable(typeof(ElicitRequestParams))]
|
||||
[JsonSerializable(typeof(ElicitResult))]
|
||||
[JsonSerializable(typeof(UrlElicitationRequiredErrorData))]
|
||||
[JsonSerializable(typeof(EmptyResult))]
|
||||
[JsonSerializable(typeof(GetPromptRequestParams))]
|
||||
[JsonSerializable(typeof(GetPromptResult))]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ namespace ModelContextProtocol;
|
|||
/// to be included, a different exception type should be used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class McpProtocolException : McpException
|
||||
public class McpProtocolException : McpException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpProtocolException"/> class.
|
||||
|
|
|
|||
|
|
@ -181,23 +181,31 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
LogRequestHandlerException(EndpointName, request.Method, ex);
|
||||
|
||||
JsonRpcErrorDetail detail = ex is McpProtocolException mcpProtocolException ?
|
||||
new()
|
||||
JsonRpcErrorDetail detail = ex switch
|
||||
{
|
||||
UrlElicitationRequiredException urlException => new()
|
||||
{
|
||||
Code = (int)urlException.ErrorCode,
|
||||
Message = urlException.Message,
|
||||
Data = urlException.CreateErrorDataNode(),
|
||||
},
|
||||
McpProtocolException mcpProtocolException => new()
|
||||
{
|
||||
Code = (int)mcpProtocolException.ErrorCode,
|
||||
Message = mcpProtocolException.Message,
|
||||
Data = ConvertExceptionData(mcpProtocolException.Data),
|
||||
} : ex is McpException mcpException ?
|
||||
new()
|
||||
},
|
||||
McpException mcpException => new()
|
||||
{
|
||||
Code = (int)McpErrorCode.InternalError,
|
||||
Message = mcpException.Message,
|
||||
} :
|
||||
new()
|
||||
},
|
||||
_ => new()
|
||||
{
|
||||
Code = (int)McpErrorCode.InternalError,
|
||||
Message = "An error occurred.",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
var errorMessage = new JsonRpcError
|
||||
{
|
||||
|
|
@ -453,40 +461,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
if (response is JsonRpcError error)
|
||||
{
|
||||
LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code);
|
||||
var exception = new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code);
|
||||
|
||||
// Populate exception.Data with the error data if present.
|
||||
// When deserializing JSON, Data will be a JsonElement.
|
||||
// We extract primitive values (strings, numbers, bools) for broader compatibility,
|
||||
// as JsonElement is not [Serializable] and cannot be stored in Exception.Data on .NET Framework.
|
||||
if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in jsonElement.EnumerateObject())
|
||||
{
|
||||
object? value = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
#if NET
|
||||
// Objects and arrays are stored as JsonElement on .NET Core only
|
||||
_ => property.Value,
|
||||
#else
|
||||
// Skip objects/arrays on .NET Framework as JsonElement is not serializable
|
||||
_ => (object?)null,
|
||||
#endif
|
||||
};
|
||||
|
||||
if (value is not null || property.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
exception.Data[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw exception;
|
||||
throw CreateRemoteProtocolException(error);
|
||||
}
|
||||
|
||||
if (response is JsonRpcResponse success)
|
||||
|
|
@ -803,6 +778,56 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
return null;
|
||||
}
|
||||
|
||||
private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error)
|
||||
{
|
||||
string formattedMessage = $"Request failed (remote): {error.Error.Message}";
|
||||
var errorCode = (McpErrorCode)error.Error.Code;
|
||||
|
||||
McpProtocolException exception;
|
||||
if (errorCode == McpErrorCode.UrlElicitationRequired &&
|
||||
UrlElicitationRequiredException.TryCreateFromError(formattedMessage, error.Error, out var urlException))
|
||||
{
|
||||
exception = urlException;
|
||||
}
|
||||
else
|
||||
{
|
||||
exception = new McpProtocolException(formattedMessage, errorCode);
|
||||
}
|
||||
|
||||
// Populate exception.Data with the error data if present.
|
||||
// When deserializing JSON, Data will be a JsonElement.
|
||||
// We extract primitive values (strings, numbers, bools) for broader compatibility,
|
||||
// as JsonElement is not [Serializable] and cannot be stored in Exception.Data on .NET Framework.
|
||||
if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in jsonElement.EnumerateObject())
|
||||
{
|
||||
object? value = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
#if NET
|
||||
// Objects and arrays are stored as JsonElement on .NET Core only
|
||||
_ => property.Value,
|
||||
#else
|
||||
// Skip objects/arrays on .NET Framework as JsonElement is not serializable
|
||||
_ => (object?)null,
|
||||
#endif
|
||||
};
|
||||
|
||||
if (value is not null || property.Value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
exception.Data[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exception;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the <see cref="Exception.Data"/> dictionary to a serializable <see cref="Dictionary{TKey, TValue}"/>.
|
||||
/// Returns null if the data dictionary is empty or contains no string keys with serializable values.
|
||||
|
|
|
|||
|
|
@ -9,17 +9,79 @@ namespace ModelContextProtocol.Protocol;
|
|||
/// <summary>
|
||||
/// Represents a message issued from the server to elicit additional information from the user via the client.
|
||||
/// </summary>
|
||||
public sealed class ElicitRequestParams
|
||||
public sealed class ElicitRequestParams : RequestParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the elicitation mode: "form" for in-band data collection or "url" for out-of-band URL navigation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>form</b>: Client collects structured data via a form interface. Data is exposed to the client.</description></item>
|
||||
/// <item><description><b>url</b>: Client navigates user to a URL for out-of-band interaction. Sensitive data is not exposed to the client.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[JsonPropertyName("mode")]
|
||||
[field: MaybeNull]
|
||||
public string Mode
|
||||
{
|
||||
get => field ??= "form";
|
||||
set
|
||||
{
|
||||
if (value is not ("form" or "url"))
|
||||
{
|
||||
throw new ArgumentException("Mode must be 'form' or 'url'.", nameof(value));
|
||||
}
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a unique identifier for this elicitation request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Used to track and correlate the elicitation across multiple messages, especially for out-of-band flows
|
||||
/// that may complete asynchronously.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Required for url mode elicitation to enable progress tracking and completion detection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonPropertyName("elicitationId")]
|
||||
public string? ElicitationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL to navigate to for out-of-band elicitation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Required when <see cref="Mode"/> is "url". The client should prompt the user for consent
|
||||
/// and then navigate to this URL in a user-agent (browser) where the user completes
|
||||
/// the required interaction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// URLs must not appear in any other field of the elicitation request for security reasons.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message to present to the user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For form mode, this describes what information is being requested.
|
||||
/// For url mode, this explains why the user needs to navigate to the URL.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested schema.
|
||||
/// Gets or sets the requested schema for form mode elicitation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only applicable when <see cref="Mode"/> is "form".
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Possible values are <see cref="StringSchema"/>, <see cref="NumberSchema"/>, <see cref="BooleanSchema"/>,
|
||||
/// <see cref="UntitledSingleSelectEnumSchema"/>, <see cref="TitledSingleSelectEnumSchema"/>,
|
||||
|
|
@ -27,14 +89,9 @@ public sealed class ElicitRequestParams
|
|||
/// and <see cref="LegacyTitledEnumSchema"/> (deprecated).
|
||||
/// </value>
|
||||
[JsonPropertyName("requestedSchema")]
|
||||
[field: MaybeNull]
|
||||
public RequestSchema RequestedSchema
|
||||
{
|
||||
get => field ??= new RequestSchema();
|
||||
set => field = value;
|
||||
}
|
||||
public RequestSchema? RequestedSchema { get; set; }
|
||||
|
||||
/// <summary>Represents a request schema used in an elicitation request.</summary>
|
||||
/// <summary>Represents a request schema used in a form mode elicitation request.</summary>
|
||||
public class RequestSchema
|
||||
{
|
||||
/// <summary>Gets the type of the schema.</summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
|
|
@ -10,17 +11,117 @@ namespace ModelContextProtocol.Protocol;
|
|||
/// <remarks>
|
||||
/// <para>
|
||||
/// This capability enables the MCP client to respond to elicitation requests from an MCP server.
|
||||
/// Clients must support at least one elicitation mode: form (in-band) or url (out-of-band via URL).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When this capability is enabled, an MCP server can request the client to provide additional information
|
||||
/// during interactions. The client must set a <see cref="McpClientHandlers.ElicitationHandler"/> to process these requests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class is intentionally empty as the Model Context Protocol specification does not
|
||||
/// currently define additional properties for sampling capabilities. Future versions of the
|
||||
/// specification may extend this capability with additional configuration options.
|
||||
/// Two modes of elicitation are supported:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>form</b>: In-band elicitation where data is collected via a form and exposed to the client</description></item>
|
||||
/// <item><description><b>url</b>: URL mode (out-of-band) elicitation via navigation where sensitive data is not exposed to the client</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(Converter))]
|
||||
public sealed class ElicitationCapability
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets or sets the form mode elicitation (in-band) capability, indicating support for in-band elicitation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When present, indicates the client supports form mode elicitation where structured data
|
||||
/// is collected through a form interface and returned to the server.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("form")]
|
||||
public FormElicitationCapability? Form { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL mode (out-of-band) elicitation capability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When present, indicates the client supports URL mode elicitation for secure out-of-band
|
||||
/// interactions such as OAuth flows, payments, or collecting sensitive credentials.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("url")]
|
||||
public UrlElicitationCapability? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provides a converter that normalizes blank capability objects to imply form support.
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public sealed class Converter : JsonConverter<ElicitationCapability>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ElicitationCapability? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new JsonException("elicitation capability must be an object.");
|
||||
}
|
||||
|
||||
var capability = new ElicitationCapability();
|
||||
bool hasForm = false;
|
||||
bool hasUrl = false;
|
||||
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.NameEquals("form"))
|
||||
{
|
||||
capability.Form = property.Value.ValueKind == JsonValueKind.Null
|
||||
? null
|
||||
: capability.Form ?? new FormElicitationCapability();
|
||||
hasForm = true;
|
||||
}
|
||||
else if (property.NameEquals("url"))
|
||||
{
|
||||
capability.Url = property.Value.ValueKind == JsonValueKind.Null
|
||||
? null
|
||||
: capability.Url ?? new UrlElicitationCapability();
|
||||
hasUrl = capability.Url is not null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasForm && !hasUrl)
|
||||
{
|
||||
capability.Form = new FormElicitationCapability();
|
||||
}
|
||||
|
||||
return capability;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, ElicitationCapability value, JsonSerializerOptions options)
|
||||
{
|
||||
Throw.IfNull(writer);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
bool writeForm = value.Form is not null || value.Url is null;
|
||||
if (writeForm)
|
||||
{
|
||||
writer.WritePropertyName("form");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (value.Url is not null)
|
||||
{
|
||||
writer.WritePropertyName("url");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters used with a <see cref="NotificationMethods.ElicitationCompleteNotification"/>
|
||||
/// notification emitted after a URL-mode elicitation finishes out-of-band.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The payload references the original elicitation by ID so that clients can resume deferred
|
||||
/// requests or update pending UI once the external flow completes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ElicitationCompleteNotificationParams : NotificationParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the elicitation that completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This matches <see cref="ElicitRequestParams.ElicitationId"/> from the originating request and allows
|
||||
/// clients to correlate the completion notification with previously issued prompts.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("elicitationId")]
|
||||
public required string ElicitationId { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the capability for form-based (in-band) elicitation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This capability enables the client to collect structured data from users through form interfaces
|
||||
/// where the data is visible to and processed by the client before being sent to the server.
|
||||
/// </remarks>
|
||||
public sealed class FormElicitationCapability;
|
||||
|
|
@ -81,6 +81,15 @@ public static class NotificationMethods
|
|||
/// </remarks>
|
||||
public const string LoggingMessageNotification = "notifications/message";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the notification sent by the server when a URL-mode elicitation flow completes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification references the original elicitation by ID, allowing clients to retry blocked requests
|
||||
/// or update their UI state once the out-of-band interaction finishes.
|
||||
/// </remarks>
|
||||
public const string ElicitationCompleteNotification = "notifications/elicitation/complete";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the notification sent from the client to the server after initialization has finished.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ public static class RequestMethods
|
|||
public const string ResourcesTemplatesList = "resources/templates/list";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to request <see cref="NotificationMethods.ResourceUpdatedNotification"/>
|
||||
/// The name of the request method sent from the client to request <see cref="NotificationMethods.ResourceUpdatedNotification"/>
|
||||
/// notifications from the server whenever a particular resource changes.
|
||||
/// </summary>
|
||||
public const string ResourcesSubscribe = "resources/subscribe";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to request unsubscribing from <see cref="NotificationMethods.ResourceUpdatedNotification"/>
|
||||
/// The name of the request method sent from the client to request unsubscribing from <see cref="NotificationMethods.ResourceUpdatedNotification"/>
|
||||
/// notifications from the server.
|
||||
/// </summary>
|
||||
public const string ResourcesUnsubscribe = "resources/unsubscribe";
|
||||
|
|
@ -78,7 +78,7 @@ public static class RequestMethods
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used to provide autocompletion-like functionality for arguments in a resource reference or a prompt template.
|
||||
/// The client provides a reference (resource or prompt), argument name, and partial value, and the server
|
||||
/// The client provides a reference (resource or prompt), argument name, and partial value, and the server
|
||||
/// responds with matching completion options.
|
||||
/// </remarks>
|
||||
public const string CompletionComplete = "completion/complete";
|
||||
|
|
@ -94,11 +94,21 @@ public static class RequestMethods
|
|||
public const string SamplingCreateMessage = "sampling/createMessage";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to the server to elicit additional information from the user via the client.
|
||||
/// The name of the request method sent from the server to elicit additional information from the user via the client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This request is used when the server needs more information from the client to proceed with a task or interaction.
|
||||
/// Servers can request structured data from users, with optional JSON schemas to validate responses.
|
||||
/// Servers can request structured data from users, with optional JSON schemas to validate responses (form mode),
|
||||
/// or request URL mode (out-of-band) user interaction via navigation for sensitive operations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two modes are supported:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>form</b>: In-band elicitation where structured data is collected and returned to the server</description></item>
|
||||
/// <item><description><b>url</b>: URL mode (out-of-band) elicitation for sensitive operations like OAuth or payments</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string ElicitationCreate = "elicitation/create";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the capability for URL mode (out-of-band) elicitation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This capability enables secure out-of-band interactions where the user is directed to a URL
|
||||
/// (typically opened in a browser) to complete sensitive operations like OAuth authorization,
|
||||
/// payments, or credential entry.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unlike form mode, sensitive data in URL mode is never exposed to the MCP client, providing
|
||||
/// better security for sensitive interactions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UrlElicitationCapability;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the payload for the <c>URL_ELICITATION_REQUIRED</c> JSON-RPC error.
|
||||
/// </summary>
|
||||
public sealed class UrlElicitationRequiredErrorData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the elicitations that must be completed before retrying the original request.
|
||||
/// </summary>
|
||||
[JsonPropertyName("elicitations")]
|
||||
public required IReadOnlyList<ElicitRequestParams> Elicitations { get; init; }
|
||||
}
|
||||
|
|
@ -240,7 +240,8 @@ public abstract partial class McpServer : McpSession
|
|||
public ValueTask<ElicitResult> ElicitAsync(
|
||||
ElicitRequestParams request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfElicitationUnsupported();
|
||||
Throw.IfNull(request);
|
||||
ThrowIfElicitationUnsupported(request);
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.ElicitationCreate,
|
||||
|
|
@ -268,8 +269,6 @@ public abstract partial class McpServer : McpSession
|
|||
JsonSerializerOptions? serializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfElicitationUnsupported();
|
||||
|
||||
serializerOptions ??= McpJsonUtilities.DefaultOptions;
|
||||
serializerOptions.MakeReadOnly();
|
||||
|
||||
|
|
@ -287,6 +286,8 @@ public abstract partial class McpServer : McpSession
|
|||
RequestedSchema = schema,
|
||||
};
|
||||
|
||||
ThrowIfElicitationUnsupported(request);
|
||||
|
||||
var raw = await ElicitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!raw.IsAccepted || raw.Content is null)
|
||||
|
|
@ -453,16 +454,47 @@ public abstract partial class McpServer : McpSession
|
|||
}
|
||||
}
|
||||
|
||||
private void ThrowIfElicitationUnsupported()
|
||||
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
||||
{
|
||||
if (ClientCapabilities?.Elicitation is null)
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
var elicitationCapability = ClientCapabilities.Elicitation;
|
||||
if (elicitationCapability is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support elicitation requests.");
|
||||
}
|
||||
|
||||
if (string.Equals(request.Mode, "form", StringComparison.Ordinal) && elicitationCapability.Form is null)
|
||||
{
|
||||
if (request.RequestedSchema is null)
|
||||
{
|
||||
throw new InvalidOperationException("Elicitation is not supported in stateless mode.");
|
||||
throw new ArgumentException("Form mode elicitation requests require a requested schema.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support elicitation requests.");
|
||||
if (elicitationCapability.Form is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support form mode elicitation requests.");
|
||||
}
|
||||
}
|
||||
else if (string.Equals(request.Mode, "url", StringComparison.Ordinal))
|
||||
{
|
||||
if (request.Url is null)
|
||||
{
|
||||
throw new ArgumentException("URL mode elicitation requests require a URL.");
|
||||
}
|
||||
|
||||
if (request.ElicitationId is null)
|
||||
{
|
||||
throw new ArgumentException("URL mode elicitation requests require an elicitation ID.");
|
||||
}
|
||||
|
||||
if (elicitationCapability.Url is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support URL mode elicitation requests.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
125
src/ModelContextProtocol.Core/UrlElicitationRequiredException.cs
Normal file
125
src/ModelContextProtocol.Core/UrlElicitationRequiredException.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an exception used to indicate that URL-mode elicitation must be completed before the request can proceed.
|
||||
/// </summary>
|
||||
public sealed class UrlElicitationRequiredException : McpProtocolException
|
||||
{
|
||||
private readonly IReadOnlyList<ElicitRequestParams> _elicitations;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UrlElicitationRequiredException"/> class with the specified message and pending elicitations.
|
||||
/// </summary>
|
||||
/// <param name="message">A description of why the elicitation is required.</param>
|
||||
/// <param name="elicitations">One or more URL-mode elicitation requests that must complete before retrying the original request.</param>
|
||||
public UrlElicitationRequiredException(string message, IEnumerable<ElicitRequestParams> elicitations)
|
||||
: base(message, McpErrorCode.UrlElicitationRequired)
|
||||
{
|
||||
Throw.IfNull(elicitations);
|
||||
_elicitations = Validate(elicitations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of pending URL-mode elicitation requests that must be completed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ElicitRequestParams> Elicitations => _elicitations;
|
||||
|
||||
internal JsonNode CreateErrorDataNode()
|
||||
{
|
||||
var payload = new UrlElicitationRequiredErrorData
|
||||
{
|
||||
Elicitations = _elicitations,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToNode(
|
||||
payload,
|
||||
McpJsonUtilities.JsonContext.Default.UrlElicitationRequiredErrorData)!;
|
||||
}
|
||||
|
||||
internal static bool TryCreateFromError(
|
||||
string formattedMessage,
|
||||
JsonRpcErrorDetail detail,
|
||||
[NotNullWhen(true)] out UrlElicitationRequiredException? exception)
|
||||
{
|
||||
exception = null;
|
||||
|
||||
if (detail.Data is not JsonElement dataElement)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseElicitations(dataElement, out var elicitations))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
exception = new UrlElicitationRequiredException(formattedMessage, elicitations);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseElicitations(JsonElement dataElement, out IReadOnlyList<ElicitRequestParams> elicitations)
|
||||
{
|
||||
elicitations = Array.Empty<ElicitRequestParams>();
|
||||
|
||||
if (dataElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.UrlElicitationRequiredErrorData);
|
||||
if (payload?.Elicitations is not { Count: > 0 } elicitationsFromPayload)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var elicitation in elicitationsFromPayload)
|
||||
{
|
||||
if (!IsValidUrlElicitation(elicitation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
elicitations = elicitationsFromPayload;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ElicitRequestParams> Validate(IEnumerable<ElicitRequestParams> elicitations)
|
||||
{
|
||||
var list = new List<ElicitRequestParams>();
|
||||
foreach (var elicitation in elicitations)
|
||||
{
|
||||
Throw.IfNull(elicitation);
|
||||
|
||||
if (!IsValidUrlElicitation(elicitation))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Elicitations must be URL-mode requests that include an elicitationId, message, and url.",
|
||||
nameof(elicitations));
|
||||
}
|
||||
|
||||
list.Add(elicitation);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one elicitation must be provided.", nameof(elicitations));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool IsValidUrlElicitation(ElicitRequestParams elicitation)
|
||||
{
|
||||
return string.Equals(elicitation.Mode, "url", StringComparison.Ordinal) &&
|
||||
elicitation.Url is not null &&
|
||||
elicitation.ElicitationId is not null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue