diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index bc1fc7ee..3902e27c 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -9,13 +9,19 @@ uid: elicitation The **elicitation** feature allows servers to request additional information from users during interactions. This enables more dynamic and interactive AI experiences, making it easier to gather necessary context before executing tasks. +The protocol supports two modes of elicitation: +- **Form (In-Band)**: The server requests structured data (strings, numbers, booleans, enums) which the client collects via a form interface and returns to the server. +- **URL Mode**: The server provides a URL for the user to visit (e.g., for OAuth, payments, or sensitive data entry). The interaction happens outside the MCP client. + ### Server Support for Elicitation -Servers request structured data from users with the extension method on . +Servers request information from users with the extension method on . The C# SDK registers an instance of with the dependency injection container, so tools can simply add a parameter of type to their method signature to access it. -The MCP Server must specify the schema of each input value it is requesting from the user. +#### Form Mode Elicitation (In-Band) + +For form-based elicitation, the MCP Server must specify the schema of each input value it is requesting from the user. Primitive types (string, number, boolean) and enum types are supported for elicitation requests. The schema may include a description to help the user understand what is being requested. @@ -33,18 +39,56 @@ The following example demonstrates how a server could request a boolean response [!code-csharp[](samples/server/Tools/InteractiveTools.cs?name=snippet_GuessTheNumber)] -### Client Support for Elicitation +#### URL Mode Elicitation (Out-of-Band) -Elicitation is an optional feature so clients declare their support for it in their capabilities as part of the `initialize` request. In the MCP C# SDK, this is done by configuring an in the : +For URL mode elicitation, the server provides a URL that the user must visit to complete an action. This is useful for scenarios like OAuth flows, payment processing, or collecting sensitive credentials that should not be exposed to the MCP client. -[!code-csharp[](samples/client/Program.cs?name=snippet_McpInitialize)] +To request a URL mode interaction, set the `Mode` to "url" and provide a `Url` and `ElicitationId` in the `ElicitRequestParams`. -The ElicitationHandler is an asynchronous method that will be called when the server requests additional information. -The ElicitationHandler must request input from the user and return the data in a format that matches the requested schema. -This will be highly dependent on the client application and how it interacts with the user. +```csharp +var elicitationId = Guid.NewGuid().ToString(); +var result = await server.ElicitAsync( + new ElicitRequestParams + { + Mode = "url", + ElicitationId = elicitationId, + Url = $"https://auth.example.com/oauth/authorize?state={elicitationId}", + Message = "Please authorize access to your account by logging in through your browser." + }, + cancellationToken); +``` + +### Client Support for Elicitation -If the user provides the requested information, the ElicitationHandler should return an with the action set to "accept" and the content containing the user's input. -If the user does not provide the requested information, the ElicitationHandler should return an [ with the action set to "reject" and no content. +Elicitation is an optional feature so clients declare their support for it in their capabilities as part of the `initialize` request. Clients can support `Form` (in-band), `Url` (out-of-band), or both. + +In the MCP C# SDK, this is done by configuring the capabilities and an in the : + +```csharp +var options = new McpClientOptions +{ + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Form = new FormElicitationCapability(), + Url = new UrlElicitationCapability() + } + }, + Handlers = new McpClientHandlers + { + ElicitationHandler = HandleElicitationAsync + } +}; +``` + +The `ElicitationHandler` is an asynchronous method that will be called when the server requests additional information. The handler should check the `Mode` of the request: + +- **Form Mode**: Present the form defined by `RequestedSchema` to the user. Return the user's input in the `Content` of the result. +- **URL Mode**: Present the `Message` and `Url` to the user. Ask for consent to open the URL. If the user consents, open the URL and return `Action="accept"`. If the user declines, return `Action="decline"`. + +If the user provides the requested information (or consents to URL mode), the ElicitationHandler should return an with the action set to "accept". +If the user does not provide the requested information, the ElicitationHandler should return an with the action set to "reject" (or "decline" / "cancel"). Below is an example of how a console application might handle elicitation requests. Here's an example implementation: diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 3a289d13..368b03e2 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -80,7 +80,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not cancellationToken), McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult); - + _options.Capabilities ??= new(); _options.Capabilities.Sampling ??= new(); } @@ -106,7 +106,19 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not 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(); + } } } diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 11d03ebd..7839f023 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -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. /// InternalError = -32603, + + /// + /// Indicates that URL-mode elicitation is required to complete the requested operation. + /// + /// + /// + /// 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. + /// + /// + /// Common scenarios include OAuth authorization and other out-of-band flows that cannot be completed inside + /// the MCP client. + /// + /// + UrlElicitationRequired = -32042, } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 3d08bd82..99c487cd 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -16,7 +16,7 @@ public static partial class McpJsonUtilities /// /// /// - /// For Native AOT or applications disabling , this instance + /// For Native AOT or applications disabling , this instance /// includes source generated contracts for all common exchange types contained in the ModelContextProtocol library. /// /// @@ -88,7 +88,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString)] - + // JSON-RPC [JsonSerializable(typeof(JsonRpcMessage))] [JsonSerializable(typeof(JsonRpcMessage[]))] @@ -101,6 +101,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [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 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(CreateMessageResult))] [JsonSerializable(typeof(ElicitRequestParams))] [JsonSerializable(typeof(ElicitResult))] + [JsonSerializable(typeof(UrlElicitationRequiredErrorData))] [JsonSerializable(typeof(EmptyResult))] [JsonSerializable(typeof(GetPromptRequestParams))] [JsonSerializable(typeof(GetPromptResult))] diff --git a/src/ModelContextProtocol.Core/McpProtocolException.cs b/src/ModelContextProtocol.Core/McpProtocolException.cs index c6e6ba0f..8a44848f 100644 --- a/src/ModelContextProtocol.Core/McpProtocolException.cs +++ b/src/ModelContextProtocol.Core/McpProtocolException.cs @@ -16,12 +16,12 @@ namespace ModelContextProtocol; /// . /// /// -/// or from a may be +/// or from a may be /// propagated to the remote endpoint; sensitive information should not be included. If sensitive details need /// to be included, a different exception type should be used. /// /// -public sealed class McpProtocolException : McpException +public class McpProtocolException : McpException { /// /// Initializes a new instance of the class. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index fcd7980d..b743d02a 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -181,23 +181,30 @@ ex is OperationCanceledException && { 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, - } : 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 { @@ -452,7 +459,7 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc if (response is JsonRpcError error) { LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code); - throw new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code); + throw CreateRemoteProtocolException(error); } if (response is JsonRpcResponse success) @@ -769,6 +776,20 @@ private static TimeSpan GetElapsed(long startingTimestamp) => return null; } + private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error) + { + string formattedMessage = $"Request failed (remote): {error.Error.Message}"; + var errorCode = (McpErrorCode)error.Error.Code; + + if (errorCode == McpErrorCode.UrlElicitationRequired && + UrlElicitationRequiredException.TryCreateFromError(formattedMessage, error.Error, out var urlException)) + { + return urlException; + } + + return new McpProtocolException(formattedMessage, errorCode); + } + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")] private partial void LogEndpointMessageProcessingCanceled(string endpointName); diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 74d0fd8a..b2e8aa5e 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -9,32 +9,87 @@ namespace ModelContextProtocol.Protocol; /// /// Represents a message issued from the server to elicit additional information from the user via the client. /// -public sealed class ElicitRequestParams +public sealed class ElicitRequestParams : RequestParams { + /// + /// Gets or sets the elicitation mode: "form" for in-band data collection or "url" for out-of-band URL navigation. + /// + /// + /// + /// form: Client collects structured data via a form interface. Data is exposed to the client. + /// url: Client navigates user to a URL for out-of-band interaction. Sensitive data is not exposed to the client. + /// + /// + [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; + } + } + + /// + /// Gets or sets a unique identifier for this elicitation request. + /// + /// + /// + /// Used to track and correlate the elicitation across multiple messages, especially for out-of-band flows + /// that may complete asynchronously. + /// + /// + /// Required for url mode elicitation to enable progress tracking and completion detection. + /// + /// + [JsonPropertyName("elicitationId")] + public string? ElicitationId { get; set; } + + /// + /// Gets or sets the URL to navigate to for out-of-band elicitation. + /// + /// + /// + /// Required when 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. + /// + /// + /// URLs must not appear in any other field of the elicitation request for security reasons. + /// + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + /// /// Gets or sets the message to present to the user. /// + /// + /// For form mode, this describes what information is being requested. + /// For url mode, this explains why the user needs to navigate to the URL. + /// [JsonPropertyName("message")] public required string Message { get; set; } /// - /// Gets or sets the requested schema. + /// Gets or sets the requested schema for form mode elicitation. /// /// + /// Only applicable when is "form". /// May be one of , , , /// , , /// , , /// or (deprecated). /// [JsonPropertyName("requestedSchema")] - [field: MaybeNull] - public RequestSchema RequestedSchema - { - get => field ??= new RequestSchema(); - set => field = value; - } + public RequestSchema? RequestedSchema { get; set; } - /// Represents a request schema used in an elicitation request. + /// Represents a request schema used in a form mode elicitation request. public class RequestSchema { /// Gets the type of the schema. @@ -61,7 +116,7 @@ public IDictionary Properties } /// - /// Represents restricted subset of JSON Schema: + /// Represents restricted subset of JSON Schema: /// , , , /// , , /// , , diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs b/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs index 9d46bcc4..d2aa0e9e 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Text.Json; using System.Text.Json.Serialization; using ModelContextProtocol.Client; @@ -10,19 +11,43 @@ namespace ModelContextProtocol.Protocol; /// /// /// 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). /// /// /// When this capability is enabled, an MCP server can request the client to provide additional information /// during interactions. The client must set a to process these requests. /// /// -/// 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: +/// +/// form: In-band elicitation where data is collected via a form and exposed to the client +/// url: URL mode (out-of-band) elicitation via navigation where sensitive data is not exposed to the client +/// /// /// +[JsonConverter(typeof(Converter))] public sealed class ElicitationCapability { + /// + /// Gets or sets the form mode elicitation (in-band) capability, indicating support for in-band elicitation. + /// + /// + /// When present, indicates the client supports form mode elicitation where structured data + /// is collected through a form interface and returned to the server. + /// + [JsonPropertyName("form")] + public FormElicitationCapability? Form { get; set; } + + /// + /// Gets or sets the URL mode (out-of-band) elicitation capability. + /// + /// + /// When present, indicates the client supports URL mode elicitation for secure out-of-band + /// interactions such as OAuth flows, payments, or collecting sensitive credentials. + /// + [JsonPropertyName("url")] + public UrlElicitationCapability? Url { get; set; } + /// /// Gets or sets the handler for processing requests. /// @@ -40,4 +65,81 @@ public sealed class ElicitationCapability [Obsolete($"Use {nameof(McpClientOptions.Handlers.ElicitationHandler)} instead. This member will be removed in a subsequent release.")] // See: https://github.com/modelcontextprotocol/csharp-sdk/issues/774 [EditorBrowsable(EditorBrowsableState.Never)] public Func>? ElicitationHandler { get; set; } -} \ No newline at end of file + + /// + /// Provides a converter that normalizes blank capability objects to imply form support. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + 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; + } + + /// + 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(); + } + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitationCompleteNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitationCompleteNotificationParams.cs new file mode 100644 index 00000000..86d0a88a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ElicitationCompleteNotificationParams.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a +/// notification emitted after a URL-mode elicitation finishes out-of-band. +/// +/// +/// +/// The payload references the original elicitation by ID so that clients can resume deferred +/// requests or update pending UI once the external flow completes. +/// +/// +public sealed class ElicitationCompleteNotificationParams : NotificationParams +{ + /// + /// Gets or sets the unique identifier of the elicitation that completed. + /// + /// + /// This matches from the originating request and allows + /// clients to correlate the completion notification with previously issued prompts. + /// + [JsonPropertyName("elicitationId")] + public required string ElicitationId { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/FormElicitationCapability.cs b/src/ModelContextProtocol.Core/Protocol/FormElicitationCapability.cs new file mode 100644 index 00000000..e3a2d154 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/FormElicitationCapability.cs @@ -0,0 +1,10 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the capability for form-based (in-band) elicitation. +/// +/// +/// 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. +/// +public sealed class FormElicitationCapability; diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 30b7d68a..1a83cfaa 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -10,8 +10,8 @@ public static class NotificationMethods /// /// /// This notification informs clients that the set of available tools has been modified. - /// Changes may include tools being added, removed, or updated. Upon receiving this - /// notification, clients may refresh their tool list by calling the appropriate + /// Changes may include tools being added, removed, or updated. Upon receiving this + /// notification, clients may refresh their tool list by calling the appropriate /// method to get the updated list of tools. /// public const string ToolListChangedNotification = "notifications/tools/list_changed"; @@ -21,8 +21,8 @@ public static class NotificationMethods /// /// /// This notification informs clients that the set of available prompts has been modified. - /// Changes may include prompts being added, removed, or updated. Upon receiving this - /// notification, clients may refresh their prompt list by calling the appropriate + /// Changes may include prompts being added, removed, or updated. Upon receiving this + /// notification, clients may refresh their prompt list by calling the appropriate /// method to get the updated list of prompts. /// public const string PromptListChangedNotification = "notifications/prompts/list_changed"; @@ -32,8 +32,8 @@ public static class NotificationMethods /// /// /// This notification informs clients that the set of available resources has been modified. - /// Changes may include resources being added, removed, or updated. Upon receiving this - /// notification, clients may refresh their resource list by calling the appropriate + /// Changes may include resources being added, removed, or updated. Upon receiving this + /// notification, clients may refresh their resource list by calling the appropriate /// method to get the updated list of resources. /// public const string ResourceListChangedNotification = "notifications/resources/list_changed"; @@ -52,13 +52,13 @@ public static class NotificationMethods /// /// /// - /// This notification informs the server that the client's "roots" have changed. - /// Roots define the boundaries of where servers can operate within the filesystem, - /// allowing them to understand which directories and files they have access to. Servers + /// This notification informs the server that the client's "roots" have changed. + /// Roots define the boundaries of where servers can operate within the filesystem, + /// allowing them to understand which directories and files they have access to. Servers /// can request the list of roots from supporting clients and receive notifications when that list changes. /// /// - /// After receiving this notification, servers may refresh their knowledge of roots by calling the appropriate + /// After receiving this notification, servers may refresh their knowledge of roots by calling the appropriate /// method to get the updated list of roots from the client. /// /// @@ -75,19 +75,28 @@ public static class NotificationMethods /// /// /// The minimum logging level that triggers notifications can be controlled by clients using the - /// request. If no level has been set by a client, + /// request. If no level has been set by a client, /// the server may determine which messages to send based on its own configuration. /// /// public const string LoggingMessageNotification = "notifications/message"; + /// + /// The name of the notification sent by the server when a URL-mode elicitation flow completes. + /// + /// + /// 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. + /// + public const string ElicitationCompleteNotification = "notifications/elicitation/complete"; + /// /// The name of the notification sent from the client to the server after initialization has finished. /// /// /// - /// This notification is sent by the client after it has received and processed the server's response to the - /// request. It signals that the client is ready to begin normal operation + /// This notification is sent by the client after it has received and processed the server's response to the + /// request. It signals that the client is ready to begin normal operation /// and that the initialization phase is complete. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index 2081a604..8fbc2386 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -41,13 +41,13 @@ public static class RequestMethods public const string ResourcesTemplatesList = "resources/templates/list"; /// - /// The name of the request method sent from the client to request + /// The name of the request method sent from the client to request /// notifications from the server whenever a particular resource changes. /// public const string ResourcesSubscribe = "resources/subscribe"; /// - /// The name of the request method sent from the client to request unsubscribing from + /// The name of the request method sent from the client to request unsubscribing from /// notifications from the server. /// public const string ResourcesUnsubscribe = "resources/unsubscribe"; @@ -78,7 +78,7 @@ public static class RequestMethods /// /// /// 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. /// public const string CompletionComplete = "completion/complete"; @@ -94,11 +94,21 @@ public static class RequestMethods public const string SamplingCreateMessage = "sampling/createMessage"; /// - /// 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. /// /// + /// /// 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. + /// + /// + /// Two modes are supported: + /// + /// form: In-band elicitation where structured data is collected and returned to the server + /// url: URL mode (out-of-band) elicitation for sensitive operations like OAuth or payments + /// + /// /// public const string ElicitationCreate = "elicitation/create"; diff --git a/src/ModelContextProtocol.Core/Protocol/UrlElicitationCapability.cs b/src/ModelContextProtocol.Core/Protocol/UrlElicitationCapability.cs new file mode 100644 index 00000000..9a96ffe0 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UrlElicitationCapability.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the capability for URL mode (out-of-band) elicitation. +/// +/// +/// +/// 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. +/// +/// +/// Unlike form mode, sensitive data in URL mode is never exposed to the MCP client, providing +/// better security for sensitive interactions. +/// +/// +public sealed class UrlElicitationCapability; diff --git a/src/ModelContextProtocol.Core/Protocol/UrlElicitationRequiredErrorData.cs b/src/ModelContextProtocol.Core/Protocol/UrlElicitationRequiredErrorData.cs new file mode 100644 index 00000000..81424b90 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UrlElicitationRequiredErrorData.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the URL_ELICITATION_REQUIRED JSON-RPC error. +/// +public sealed class UrlElicitationRequiredErrorData +{ + /// + /// Gets or sets the elicitations that must be completed before retrying the original request. + /// + [JsonPropertyName("elicitations")] + public required IReadOnlyList Elicitations { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 3b0eadf3..2c576f95 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -243,7 +243,8 @@ public ValueTask RequestRootsAsync( public ValueTask ElicitAsync( ElicitRequestParams request, CancellationToken cancellationToken = default) { - ThrowIfElicitationUnsupported(); + Throw.IfNull(request); + ThrowIfElicitationUnsupported(request); return SendRequestAsync( RequestMethods.ElicitationCreate, @@ -271,8 +272,6 @@ public async ValueTask> ElicitAsync( JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) { - ThrowIfElicitationUnsupported(); - serializerOptions ??= McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); @@ -290,6 +289,8 @@ public async ValueTask> ElicitAsync( RequestedSchema = schema, }; + ThrowIfElicitationUnsupported(request); + var raw = await ElicitAsync(request, cancellationToken).ConfigureAwait(false); if (!raw.IsAccepted || raw.Content is null) @@ -456,16 +457,47 @@ private void ThrowIfRootsUnsupported() } } - 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."); + } } } diff --git a/src/ModelContextProtocol.Core/UrlElicitationRequiredException.cs b/src/ModelContextProtocol.Core/UrlElicitationRequiredException.cs new file mode 100644 index 00000000..622c1def --- /dev/null +++ b/src/ModelContextProtocol.Core/UrlElicitationRequiredException.cs @@ -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; + +/// +/// Represents an exception used to indicate that URL-mode elicitation must be completed before the request can proceed. +/// +public sealed class UrlElicitationRequiredException : McpProtocolException +{ + private readonly IReadOnlyList _elicitations; + + /// + /// Initializes a new instance of the class with the specified message and pending elicitations. + /// + /// A description of why the elicitation is required. + /// One or more URL-mode elicitation requests that must complete before retrying the original request. + public UrlElicitationRequiredException(string message, IEnumerable elicitations) + : base(message, McpErrorCode.UrlElicitationRequired) + { + Throw.IfNull(elicitations); + _elicitations = Validate(elicitations); + } + + /// + /// Gets the collection of pending URL-mode elicitation requests that must be completed. + /// + public IReadOnlyList 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 elicitations) + { + elicitations = Array.Empty(); + + 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 Validate(IEnumerable elicitations) + { + var list = new List(); + 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; + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs index c29f7d03..5d426dea 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationDefaultValuesTests.cs @@ -315,20 +315,21 @@ public void RequestSchema_WithAllDefaultTypes_Serializes_Correctly() // Assert Assert.NotNull(deserialized); + Assert.NotNull(deserialized.RequestedSchema); Assert.Equal(5, deserialized.RequestedSchema.Properties.Count); - + var nameSchema = Assert.IsType(deserialized.RequestedSchema.Properties["name"]); Assert.Equal("John Doe", nameSchema.Default); - + var ageSchema = Assert.IsType(deserialized.RequestedSchema.Properties["age"]); Assert.Equal(30, ageSchema.Default); - + var scoreSchema = Assert.IsType(deserialized.RequestedSchema.Properties["score"]); Assert.Equal(85.5, scoreSchema.Default); - + var activeSchema = Assert.IsType(deserialized.RequestedSchema.Properties["active"]); Assert.True(activeSchema.Default); - + // EnumSchema without enumNames deserializes as UntitledSingleSelectEnumSchema var statusSchema = Assert.IsType(deserialized.RequestedSchema.Properties["status"]); Assert.Equal("active", statusSchema.Default); diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs index 92db42f7..90ee4d0c 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs @@ -23,6 +23,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer var result = await request.Server.ElicitAsync( new() { + Mode = "form", Message = "Please provide more information.", RequestedSchema = new() { @@ -75,7 +76,9 @@ public async Task Can_Elicit_Information() ElicitationHandler = async (request, cancellationtoken) => { Assert.NotNull(request); + Assert.Equal("form", request.Mode); Assert.Equal("Please provide more information.", request.Message); + Assert.NotNull(request.RequestedSchema); Assert.Equal(4, request.RequestedSchema.Properties.Count); foreach (var entry in request.RequestedSchema.Properties) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index ab0a5d4f..cdfa29ca 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -125,6 +125,7 @@ public async Task Can_Elicit_Typed_Information() Assert.NotNull(request); Assert.Equal("Please provide more information.", request.Message); + Assert.NotNull(request.RequestedSchema); Assert.Equal(6, request.RequestedSchema.Properties.Count); foreach (var entry in request.RequestedSchema.Properties) @@ -219,6 +220,7 @@ public async Task Elicit_Typed_Respects_NamingPolicy() Assert.Equal("Please provide more information.", request.Message); // Expect camelCase names based on serializer options + Assert.NotNull(request.RequestedSchema); Assert.Contains("firstName", request.RequestedSchema.Properties.Keys); Assert.Contains("zipCode", request.RequestedSchema.Properties.Keys); Assert.Contains("isAdmin", request.RequestedSchema.Properties.Keys); @@ -312,7 +314,6 @@ public async Task Elicit_Typed_With_NonObject_Generic_Type_Throws() } [JsonConverter(typeof(JsonStringEnumConverter))] - public enum SampleRole { User, @@ -327,7 +328,7 @@ public sealed class SampleForm public SampleRole Role { get; set; } public double Score { get; set; } - + public DateTime Created { get; set; } } @@ -400,6 +401,7 @@ public async Task Elicit_Typed_With_Defaults_Maps_To_Schema_Defaults() Assert.NotNull(request); Assert.Equal("Please provide information.", request.Message); + Assert.NotNull(request.RequestedSchema); Assert.Equal(5, request.RequestedSchema.Properties.Count); // Verify that default values from the type are mapped to the schema diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs new file mode 100644 index 00000000..ce03c4a3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -0,0 +1,521 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests.Configuration; + +public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithCallToolHandler(async (request, cancellationToken) => + { + Assert.NotNull(request.Params); + + if (request.Params.Name == "TestUrlElicitation") + { + var result = await request.Server.ElicitAsync(new() + { + Mode = "url", + ElicitationId = "test-elicitation-id", + Url = $"https://auth.example.com/oauth/authorize?state=test-elicitation-id", + Message = "Please authorize access to your account by logging in through your browser.", + }, + cancellationToken); + + // For URL mode, we expect the client to accept (consent to navigate) + // The actual OAuth flow happens out-of-band + Assert.Equal("accept", result.Action); + + await request.Server.SendNotificationAsync( + NotificationMethods.ElicitationCompleteNotification, + new ElicitationCompleteNotificationParams + { + ElicitationId = "test-elicitation-id", + }, + McpJsonUtilities.DefaultOptions, + cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "authorization-pending:test-elicitation-id" }], + }; + } + else if (request.Params.Name == "TestUrlElicitationDecline") + { + var elicitationId = Guid.NewGuid().ToString(); + var result = await request.Server.ElicitAsync(new() + { + Mode = "url", + ElicitationId = "elicitation-declined-id", + Url = $"https://payment.example.com/pay?transaction=elicitation-declined-id", + Message = "Please complete payment in your browser.", + }, + cancellationToken); + + // User declined + Assert.Equal("decline", result.Action); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "payment-declined" }], + }; + } + else if (request.Params.Name == "TestUrlElicitationCancel") + { + var elicitationId = Guid.NewGuid().ToString(); + var result = await request.Server.ElicitAsync(new() + { + Mode = "url", + ElicitationId = "elicitation-canceled-id", + Url = $"https://verify.example.com/verify?id=elicitation-canceled-id", + Message = "Please verify your identity.", + }, + cancellationToken); + + // User canceled (dismissed without explicit choice) + Assert.Equal("cancel", result.Action); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "verification-canceled" }], + }; + } + else if (request.Params.Name == "TestUrlElicitationRequired") + { + throw new UrlElicitationRequiredException( + "Authorization is required to continue.", + new[] + { + new ElicitRequestParams + { + Mode = "url", + ElicitationId = "elicitation-required-id", + Url = "https://auth.example.com/connect?elicitationId=elicitation-required-id", + Message = "Authorization is required to access Example Co.", + } + }); + } + else if (request.Params.Name == "TestUrlElicitationMissingId") + { + try + { + await request.Server.ElicitAsync(new() + { + Mode = "url", + Url = "https://missing-id.example.com/oauth", + Message = "URL elicitation without ID should fail.", + }, + cancellationToken); + } + catch (ArgumentException ex) + { + throw new McpException(ex.Message); + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "missing-id-succeeded" }], + }; + } + else if (request.Params.Name == "ProbeUrlCapability") + { + try + { + await request.Server.ElicitAsync(new() + { + Mode = "url", + ElicitationId = Guid.NewGuid().ToString(), + Url = "https://probe.example.com/oauth", + Message = "Capability probe for url mode.", + }, + cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = "url-allowed" }], + }; + } + catch (InvalidOperationException ex) + { + throw new McpException(ex.Message); + } + } + else if (request.Params.Name == "ProbeFormCapability") + { + try + { + var elicitationResult = await request.Server.ElicitAsync(new() + { + Message = "Capability probe for form mode.", + RequestedSchema = new(), + }, + cancellationToken: cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"form-allowed:{elicitationResult.Action}" }], + }; + } + catch (InvalidOperationException ex) + { + throw new McpException(ex.Message); + } + } + + Assert.Fail($"Unexpected tool name: {request.Params.Name}"); + return new CallToolResult { Content = [] }; + }); + } + + [Fact] + public async Task Can_Elicit_OutOfBand_With_Url() + { + string? capturedElicitationId = null; + string? capturedUrl = null; + string? capturedMessage = null; + var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + // Explicitly declare support for both modes + Elicitation = new ElicitationCapability + { + Form = new FormElicitationCapability(), + Url = new UrlElicitationCapability() + } + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + + // Verify this is an URL mode elicitation request + Assert.NotNull(request.Mode); + Assert.Equal("url", request.Mode); + + // Capture the request details + capturedElicitationId = request.ElicitationId; + capturedUrl = request.Url; + capturedMessage = request.Message; + + // Verify URL-specific fields + Assert.NotNull(request.ElicitationId); + Assert.NotNull(request.Url); + Assert.StartsWith("https://auth.example.com/oauth/authorize", request.Url); + Assert.Contains("state=", request.Url); + Assert.Equal("Please authorize access to your account by logging in through your browser.", request.Message); + + // Verify that RequestedSchema is null for URL mode + Assert.Null(request.RequestedSchema); + + // Simulate user consent to navigate to the URL + // In a real implementation, the client would: + // 1. Display a consent dialog showing the URL and message + // 2. Open the URL in the system browser + // 3. Return "accept" to indicate user consented + + // Return accept (user consented to navigate) + return new ElicitResult + { + Action = "accept", + // Note: No Content for URL mode - the actual interaction happens out-of-band + }; + } + } + }); + + await using var completionHandler = client.RegisterNotificationHandler( + NotificationMethods.ElicitationCompleteNotification, + async (notification, cancellationToken) => + { + var payload = notification.Params?.Deserialize(McpJsonUtilities.DefaultOptions); + if (payload is not null) + { + completionNotification.TrySetResult(payload.ElicitationId); + } + + await Task.CompletedTask; + }); + + var result = await client.CallToolAsync("TestUrlElicitation", cancellationToken: TestContext.Current.CancellationToken); + + // Verify the tool completed successfully + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.StartsWith("authorization-pending:", textContent.Text); + + // Verify we captured all the expected URL mode elicitation data + Assert.NotNull(capturedElicitationId); + Assert.NotNull(capturedUrl); + Assert.NotNull(capturedMessage); + Assert.Contains(capturedElicitationId, capturedUrl); + + var notifiedElicitationId = await completionNotification.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(capturedElicitationId, notifiedElicitationId); + } + + [Fact] + public async Task UrlElicitation_User_Can_Decline() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Url = new UrlElicitationCapability() + } + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.NotNull(request.Mode); + Assert.Equal("url", request.Mode); + Assert.Contains("payment.example.com", request.Url); + + // Simulate user declining to navigate to the URL + // This might happen if user doesn't trust the URL or doesn't want to proceed + return new ElicitResult + { + Action = "decline" + }; + } + } + }); + + var result = await client.CallToolAsync("TestUrlElicitationDecline", cancellationToken: TestContext.Current.CancellationToken); + + // Server should handle decline gracefully + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("payment-declined", textContent.Text); + } + + [Fact] + public async Task UrlElicitation_User_Can_Cancel() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Url = new UrlElicitationCapability() + } + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = async (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.NotNull(request.Mode); + Assert.Equal("url", request.Mode); + Assert.Contains("verify.example.com", request.Url); + + // Simulate user canceling (dismissing dialog without explicit choice) + return new ElicitResult + { + Action = "cancel" + }; + } + } + }); + + var result = await client.CallToolAsync("TestUrlElicitationCancel", cancellationToken: TestContext.Current.CancellationToken); + + // Server should handle cancellation + Assert.Single(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("verification-canceled", textContent.Text); + } + + [Fact] + public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers() + { + ElicitationHandler = (request, cancellationToken) => + { + throw new InvalidOperationException("URL handler should not be invoked in default mode."); + }, + } + }); + + var defaultCapability = AssertServerElicitationCapability(); + Assert.NotNull(defaultCapability.Form); + Assert.Null(defaultCapability.Url); + + var result = await client.CallToolAsync("ProbeUrlCapability", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("An error occurred invoking 'ProbeUrlCapability': Client does not support URL mode elicitation requests.", textContent.Text); + } + + [Fact] + public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers() + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "decline" }), + } + }); + + var capability = AssertServerElicitationCapability(); + Assert.NotNull(capability.Form); + Assert.Null(capability.Url); + + var result = await client.CallToolAsync("ProbeFormCapability", cancellationToken: TestContext.Current.CancellationToken); + + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("form-allowed:decline", textContent.Text); + } + + [Fact] + public async Task UrlElicitation_BlankCapability_Allows_Only_Form() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability(), + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "decline" }), + } + }); + + var capability = AssertServerElicitationCapability(); + Assert.NotNull(capability.Form); + Assert.Null(capability.Url); + + var urlResult = await client.CallToolAsync("ProbeUrlCapability", cancellationToken: TestContext.Current.CancellationToken); + Assert.True(urlResult.IsError); + var urlTextContent = Assert.IsType(urlResult.Content[0]); + Assert.Equal("An error occurred invoking 'ProbeUrlCapability': Client does not support URL mode elicitation requests.", urlTextContent.Text); + + var formResult = await client.CallToolAsync("ProbeFormCapability", cancellationToken: TestContext.Current.CancellationToken); + var textContent = Assert.IsType(formResult.Content[0]); + Assert.Equal("form-allowed:decline", textContent.Text); + } + + [Fact] + public async Task FormElicitation_UrlOnlyCapability_NotSupported() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Url = new UrlElicitationCapability(), + } + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = (request, cancellationToken) => + { + Assert.NotNull(request); + Assert.Equal("url", request.Mode); + return new ValueTask(new ElicitResult { Action = "decline" }); + }, + } + }); + + var capability = AssertServerElicitationCapability(); + Assert.Null(capability.Form); + Assert.NotNull(capability.Url); + + var urlResult = await client.CallToolAsync("ProbeUrlCapability", cancellationToken: TestContext.Current.CancellationToken); + var urlText = Assert.IsType(urlResult.Content[0]); + Assert.Equal("url-allowed", urlText.Text); + + var formResult = await client.CallToolAsync("ProbeFormCapability", cancellationToken: TestContext.Current.CancellationToken); + Assert.True(formResult.IsError); + var formText = Assert.IsType(formResult.Content[0]); + Assert.Equal("An error occurred invoking 'ProbeFormCapability': Client does not support form mode elicitation requests.", formText.Text); + } + + [Fact] + public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() + { + var elicitationHandlerCalled = false; + + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Url = new UrlElicitationCapability(), + } + }, + Handlers = new McpClientHandlers() + { + ElicitationHandler = (request, cancellationToken) => + { + elicitationHandlerCalled = true; + return new ValueTask(new ElicitResult()); + }, + } + }); + + var result = await client.CallToolAsync("TestUrlElicitationMissingId", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("An error occurred invoking 'TestUrlElicitationMissingId': URL mode elicitation requests require an elicitation ID.", textContent.Text); + Assert.False(elicitationHandlerCalled); + } + + [Fact] + public async Task UrlElicitationRequired_Exception_Propagates_To_Client() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability + { + Url = new UrlElicitationCapability(), + } + } + }); + + var exception = await Assert.ThrowsAsync( + async () => await client.CallToolAsync("TestUrlElicitationRequired", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.UrlElicitationRequired, exception.ErrorCode); + + var elicitation = Assert.Single(exception.Elicitations); + Assert.Equal("url", elicitation.Mode); + Assert.Equal("elicitation-required-id", elicitation.ElicitationId); + Assert.Equal("https://auth.example.com/connect?elicitationId=elicitation-required-id", elicitation.Url); + Assert.Equal("Authorization is required to access Example Co.", elicitation.Message); + } + + private ElicitationCapability AssertServerElicitationCapability() + { + var capabilities = Server.ClientCapabilities; + Assert.NotNull(capabilities); + Assert.NotNull(capabilities.Elicitation); + return capabilities.Elicitation; + } + + private sealed class TestForm + { + public required string Value { get; set; } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerExtensionsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerExtensionsTests.cs index 1ba3a514..e6a3f411 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerExtensionsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerExtensionsTests.cs @@ -198,7 +198,13 @@ public async Task ElicitAsync_Forwards_To_McpServer_SendRequestAsync() mockServer .Setup(s => s.ClientCapabilities) - .Returns(new ClientCapabilities() { Elicitation = new() }); + .Returns(new ClientCapabilities() + { + Elicitation = new() + { + Form = new() + } + }); mockServer .Setup(s => s.SendRequestAsync(It.IsAny(), It.IsAny())) @@ -209,7 +215,7 @@ public async Task ElicitAsync_Forwards_To_McpServer_SendRequestAsync() IMcpServer server = mockServer.Object; - var result = await server.ElicitAsync(new ElicitRequestParams { Message = "hi" }, TestContext.Current.CancellationToken); + var result = await server.ElicitAsync(new ElicitRequestParams { Message = "hi", RequestedSchema = new() }, TestContext.Current.CancellationToken); Assert.Equal("accept", result.Action); mockServer.Verify(s => s.SendRequestAsync(It.IsAny(), It.IsAny()), Times.Once); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index ab2537b6..4bbffb89 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -210,11 +210,17 @@ public async Task ElicitAsync_Should_SendRequest() // Arrange await using var transport = new TestServerTransport(); await using var server = McpServer.Create(transport, _options, LoggerFactory); - SetClientCapabilities(server, new ClientCapabilities { Elicitation = new ElicitationCapability() }); + SetClientCapabilities(server, new ClientCapabilities + { + Elicitation = new() + { + Form = new(), + }, + }); var runTask = server.RunAsync(TestContext.Current.CancellationToken); // Act - var result = await server.ElicitAsync(new ElicitRequestParams { Message = "" }, CancellationToken.None); + var result = await server.ElicitAsync(new ElicitRequestParams { Message = "", RequestedSchema = new() }, CancellationToken.None); // Assert Assert.NotNull(result);