From 220543a9179c78c06bb5b0b46c073004145206d2 Mon Sep 17 00:00:00 2001 From: "Anna.Zhdan" Date: Thu, 19 Mar 2026 18:20:21 +0100 Subject: [PATCH 01/10] update elicitation mechanism to allow for elicitations outside of the session scope --- docs/protocol/draft/schema.mdx | 225 +++++++++++++++++---------------- docs/rfds/elicitation.mdx | 38 +++--- schema/meta.unstable.json | 4 +- schema/schema.unstable.json | 28 +++- src/bin/generate.rs | 4 +- src/client.rs | 22 ++-- src/elicitation.rs | 56 +++++--- src/rpc.rs | 4 +- 8 files changed, 207 insertions(+), 174 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index ebc6338c..b22b0576 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1483,87 +1483,22 @@ Clients are typically code editors (IDEs, text editors) that provide the interfa between users and AI agents. They manage the environment, handle user interactions, and control access to resources. - -### fs/read_text_file - -Reads content from a text file in the client's file system. - -Only available if the client advertises the `fs.readTextFile` capability. -Allows the agent to access file contents within the client's environment. - -See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) - -#### ReadTextFileRequest - -Request to read content from a text file. - -Only available if the client supports the `fs.readTextFile` capability. - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - Maximum number of lines to read. - - - Minimum: `0` - - - - Line number to start reading from (1-based). - - - Minimum: `0` - - - - Absolute path to the file to read. - -SessionId} required> - The session ID for this request. - - -#### ReadTextFileResponse - -Response containing the contents of a text file. - -**Type:** Object - -**Properties:** + +### elicitation/complete - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - - - - - -### fs/write_text_file +**UNSTABLE** -Writes content to a text file in the client's file system. +This capability is not part of the spec yet, and may be removed or changed at any point. -Only available if the client advertises the `fs.writeTextFile` capability. -Allows the agent to create or modify files within the client's environment. +Notification that a URL-based elicitation has completed. -See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) +#### ElicitationCompleteNotification -#### WriteTextFileRequest +**UNSTABLE** -Request to write content to a text file. +This capability is not part of the spec yet, and may be removed or changed at any point. -Only available if the client supports the `fs.writeTextFile` capability. +Notification sent by the agent when a URL-based elicitation is complete. **Type:** Object @@ -1577,35 +1512,12 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - - The text content to write to the file. - - - Absolute path to the file to write. - -SessionId} required> - The session ID for this request. - - -#### WriteTextFileResponse - -Response to `fs/write_text_file` - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - +ElicitationId} required> + The ID of the elicitation that completed. - -### session/elicitation + +### elicitation/create **UNSTABLE** @@ -1623,6 +1535,7 @@ Request from the agent to elicit structured user input. The agent sends this to the client to request information from the user, either via a form or by directing them to a URL. +Elicitations may be tied to a session/request, or sent without either scope. **Type:** Union @@ -1691,22 +1604,56 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte The user's action in response to the elicitation. - -### session/elicitation/complete + +### fs/read_text_file -**UNSTABLE** +Reads content from a text file in the client's file system. -This capability is not part of the spec yet, and may be removed or changed at any point. +Only available if the client advertises the `fs.readTextFile` capability. +Allows the agent to access file contents within the client's environment. -Notification that a URL-based elicitation has completed. +See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) -#### ElicitationCompleteNotification +#### ReadTextFileRequest -**UNSTABLE** +Request to read content from a text file. -This capability is not part of the spec yet, and may be removed or changed at any point. +Only available if the client supports the `fs.readTextFile` capability. -Notification sent by the agent when a URL-based elicitation is complete. +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + Maximum number of lines to read. + + - Minimum: `0` + + + + Line number to start reading from (1-based). + + - Minimum: `0` + + + + Absolute path to the file to read. + +SessionId} required> + The session ID for this request. + + +#### ReadTextFileResponse + +Response containing the contents of a text file. **Type:** Object @@ -1720,8 +1667,62 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) -ElicitationId} required> - The ID of the elicitation that completed. + + + + +### fs/write_text_file + +Writes content to a text file in the client's file system. + +Only available if the client advertises the `fs.writeTextFile` capability. +Allows the agent to create or modify files within the client's environment. + +See protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client) + +#### WriteTextFileRequest + +Request to write content to a text file. + +Only available if the client supports the `fs.writeTextFile` capability. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + The text content to write to the file. + + + Absolute path to the file to write. + +SessionId} required> + The session ID for this request. + + +#### WriteTextFileResponse + +Response to `fs/write_text_file` + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 0987663e..bfbae9a6 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -1,5 +1,5 @@ --- -title: "Elicitation: Structured User Input During Sessions" +title: "Elicitation: Structured User Input" --- - Author(s): [@yordis](https://github.com/yordis) @@ -7,7 +7,7 @@ title: "Elicitation: Structured User Input During Sessions" ## Elevator pitch -Add support for agents to request structured information from users during a session through a standardized elicitation mechanism, aligned with [MCP's elicitation feature](https://modelcontextprotocol.io/specification/draft/client/elicitation). This allows agents to ask follow-up questions, collect authentication credentials, gather preferences, and request required information without side-channel communication or ad-hoc client UI implementations. +Add support for agents to request structured information from users through a standardized elicitation mechanism, aligned with [MCP's elicitation feature](https://modelcontextprotocol.io/specification/draft/client/elicitation). This allows agents to ask follow-up questions, collect authentication credentials, gather preferences, and request required information without side-channel communication or ad-hoc client UI implementations. ## Status quo @@ -41,7 +41,7 @@ The mechanism would: - **Form mode** (in-band): Structured data collection via JSON Schema forms - **URL mode** (out-of-band): Browser-based flows for sensitive operations like OAuth (addressing PR #330 authentication pain points) -3. **Request/response pattern**: Agents send elicitation requests via a `session/elicitation` method and receive responses. The agent controls when to send requests and whether to wait for responses before proceeding. Unlike Session Config Options (which are persistent), elicitation requests are transient. +3. **Request/response pattern**: Agents send elicitation requests via an `elicitation/create` method and receive responses. The agent controls when to send requests and whether to wait for responses before proceeding. Unlike Session Config Options (which are persistent), elicitation requests are transient. 4. **Support client capability negotiation**: Clients declare elicitation support via a structured capability object that distinguishes between `form`-based and `url`-based elicitation (following MCP's capability model). This allows clients to support one or both modalities, enables agents to pass capabilities along to MCP servers, and handles graceful degradation when clients have limited elicitation support. @@ -70,12 +70,12 @@ Clients can: ### Alignment with MCP -This proposal follows MCP's draft elicitation specification. See [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/draft/client/elicitation) for detailed guidance. ACP uses the same JSON Schema constraint approach and capability model, adapted for our session/turn-based architecture. +This proposal follows MCP's draft elicitation specification. See [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/draft/client/elicitation) for detailed guidance. ACP uses the same JSON Schema constraint approach and capability model, adapted for ACP interactions. Key differences from MCP: -- MCP elicitation is tool-call-scoped; ACP elicitation is session-scoped -- ACP uses `session/elicitation` method; MCP uses `elicitation/create` +- MCP elicitation is tool-call-scoped; ACP elicitation may be request-scoped, session-scoped, or standalone +- ACP uses `elicitation/create` method (same as MCP) - ACP must integrate with existing Session Config Options (which also use schema constraints) ### Elicitation Request Structure @@ -346,7 +346,7 @@ Clients use this schema to generate appropriate input forms, validate user input ### Elicitation Request -The agent sends a `session/elicitation` request when it needs information from the user: +The agent sends an `elicitation/create` request when it needs information from the user: **Form mode example:** @@ -354,9 +354,10 @@ The agent sends a `session/elicitation` request when it needs information from t { "jsonrpc": "2.0", "id": 43, - "method": "session/elicitation", + "method": "elicitation/create", "params": { "sessionId": "...", + "requestId": 43, "mode": "form", "message": "How would you like me to approach this refactoring?", "requestedSchema": { @@ -385,9 +386,8 @@ The agent sends a `session/elicitation` request when it needs information from t { "jsonrpc": "2.0", "id": 44, - "method": "session/elicitation", + "method": "elicitation/create", "params": { - "sessionId": "...", "mode": "url", "elicitationId": "github-oauth-001", "url": "https://agent.example.com/connect?elicitationId=github-oauth-001", @@ -396,6 +396,8 @@ The agent sends a `session/elicitation` request when it needs information from t } ``` +`sessionId` and `requestId` are both optional. In general, elicitation is not required to be tied to any specific session or request. + The client presents the elicitation UI to the user. For form mode, the client generates appropriate input UI based on the JSON Schema. For URL mode, the client opens the URL in a secure browser context. ### User Response @@ -460,7 +462,7 @@ sequenceDiagram participant Agent Note over Agent: Agent initiates elicitation - Agent->>Client: session/elicitation (mode: form) + Agent->>Client: elicitation/create (mode: form) Note over User,Client: Present elicitation UI User-->>Client: Provide requested information @@ -481,7 +483,7 @@ sequenceDiagram participant Agent Note over Agent: Agent initiates elicitation - Agent->>Client: session/elicitation (mode: url) + Agent->>Client: elicitation/create (mode: url) Client->>User: Present consent to open URL User-->>Client: Provide consent @@ -491,7 +493,7 @@ sequenceDiagram Note over User,UserAgent: User interaction UserAgent-->>Agent: Interaction complete - Agent-->>Client: notifications/elicitation/complete (optional) + Agent-->>Client: elicitation/complete (optional) Note over Agent: Continue processing with new information ``` @@ -519,19 +521,19 @@ sequenceDiagram Note over User,UserAgent: User interaction UserAgent-->>Agent: Interaction complete - Agent-->>Client: notifications/elicitation/complete (optional) + Agent-->>Client: elicitation/complete (optional) Client->>Agent: Retry original request (optional) ``` ### Completion Notifications for URL Mode -Following MCP, agents MAY send a `notifications/elicitation/complete` notification when an out-of-band interaction started by URL mode elicitation is completed: +Following MCP, agents MAY send an `elicitation/complete` notification when an out-of-band interaction started by URL mode elicitation is completed: ```json { "jsonrpc": "2.0", - "method": "notifications/elicitation/complete", + "method": "elicitation/complete", "params": { "elicitationId": "github-oauth-001" } @@ -584,7 +586,7 @@ Agents MUST return standard JSON-RPC errors for common failure cases: Clients MUST return standard JSON-RPC errors for common failure cases: -- When the agent sends a `session/elicitation` request with a mode not declared in client capabilities: `-32602` (Invalid params) +- When the agent sends an `elicitation/create` request with a mode not declared in client capabilities: `-32602` (Invalid params) ### Client Capabilities @@ -725,7 +727,7 @@ From PR #330: URL-mode elicitation allows agents to request authentication witho 7. User authenticates and grants permission 8. OAuth provider redirects back to the agent's redirect_uri 9. Agent exchanges the authorization code for tokens and stores them bound to the user's identity -10. Agent sends a `notifications/elicitation/complete` notification to inform the client +10. Agent sends an `elicitation/complete` notification to inform the client **Key guarantees**: diff --git a/schema/meta.unstable.json b/schema/meta.unstable.json index 167bba2f..24b26a94 100644 --- a/schema/meta.unstable.json +++ b/schema/meta.unstable.json @@ -26,10 +26,10 @@ "session_set_model": "session/set_model" }, "clientMethods": { + "elicitation_complete": "elicitation/complete", + "elicitation_create": "elicitation/create", "fs_read_text_file": "fs/read_text_file", "fs_write_text_file": "fs/write_text_file", - "session_elicitation": "session/elicitation", - "session_elicitation_complete": "session/elicitation/complete", "session_request_permission": "session/request_permission", "session_update": "session/update", "terminal_create": "terminal/create", diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 0a6fb847..c885da18 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -2022,7 +2022,7 @@ }, "required": ["elicitationId"], "type": "object", - "x-method": "session/elicitation/complete", + "x-method": "elicitation/complete", "x-side": "client" }, "ElicitationContentValue": { @@ -2173,7 +2173,7 @@ ] }, "ElicitationRequest": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations may be tied to a session/request, or sent without either scope.", "discriminator": { "propertyName": "mode" }, @@ -2221,18 +2221,32 @@ "description": "A human-readable message describing what input is needed.", "type": "string" }, + "requestId": { + "anyOf": [ + { + "$ref": "#/$defs/RequestId" + }, + { + "type": "null" + } + ], + "description": "Optional request ID if this elicitation is tied to a specific request." + }, "sessionId": { - "allOf": [ + "anyOf": [ { "$ref": "#/$defs/SessionId" + }, + { + "type": "null" } ], - "description": "The session ID for this request." + "description": "Optional session ID if this elicitation is tied to a specific session." } }, - "required": ["sessionId", "message"], + "required": ["message"], "type": "object", - "x-method": "session/elicitation", + "x-method": "elicitation/create", "x-side": "client" }, "ElicitationResponse": { @@ -2254,7 +2268,7 @@ }, "required": ["action"], "type": "object", - "x-method": "session/elicitation", + "x-method": "elicitation/create", "x-side": "client" }, "ElicitationSchema": { diff --git a/src/bin/generate.rs b/src/bin/generate.rs index fd93616b..e45a7d6b 100644 --- a/src/bin/generate.rs +++ b/src/bin/generate.rs @@ -1061,9 +1061,9 @@ starting with '$/' it is free to ignore the notification." "terminal/wait_for_exit" => self.client.get("WaitForTerminalExitRequest").unwrap(), "terminal/kill" => self.client.get("KillTerminalRequest").unwrap(), #[cfg(feature = "unstable_elicitation")] - "session/elicitation" => self.client.get("ElicitationRequest").unwrap(), + "elicitation/create" => self.client.get("ElicitationRequest").unwrap(), #[cfg(feature = "unstable_elicitation")] - "session/elicitation/complete" => { + "elicitation/complete" => { self.client.get("ElicitationCompleteNotification").unwrap() } _ => panic!("Introduced a method? Add it here :)"), diff --git a/src/client.rs b/src/client.rs index 0a34cfe3..b5d0b693 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1746,12 +1746,12 @@ pub struct ClientMethodNames { pub terminal_wait_for_exit: &'static str, /// Method for killing a terminal. pub terminal_kill: &'static str, - /// Method for session elicitation. + /// Method for elicitation. #[cfg(feature = "unstable_elicitation")] - pub session_elicitation: &'static str, + pub elicitation_create: &'static str, /// Notification for elicitation completion. #[cfg(feature = "unstable_elicitation")] - pub session_elicitation_complete: &'static str, + pub elicitation_complete: &'static str, } /// Constant containing all client method names. @@ -1766,9 +1766,9 @@ pub const CLIENT_METHOD_NAMES: ClientMethodNames = ClientMethodNames { terminal_wait_for_exit: TERMINAL_WAIT_FOR_EXIT_METHOD_NAME, terminal_kill: TERMINAL_KILL_METHOD_NAME, #[cfg(feature = "unstable_elicitation")] - session_elicitation: SESSION_ELICITATION_METHOD_NAME, + elicitation_create: ELICITATION_CREATE_METHOD_NAME, #[cfg(feature = "unstable_elicitation")] - session_elicitation_complete: SESSION_ELICITATION_COMPLETE, + elicitation_complete: ELICITATION_COMPLETE, }; /// Notification name for session updates. @@ -1789,12 +1789,12 @@ pub(crate) const TERMINAL_RELEASE_METHOD_NAME: &str = "terminal/release"; pub(crate) const TERMINAL_WAIT_FOR_EXIT_METHOD_NAME: &str = "terminal/wait_for_exit"; /// Method for killing a terminal. pub(crate) const TERMINAL_KILL_METHOD_NAME: &str = "terminal/kill"; -/// Method name for session elicitation. +/// Method name for elicitation. #[cfg(feature = "unstable_elicitation")] -pub(crate) const SESSION_ELICITATION_METHOD_NAME: &str = "session/elicitation"; +pub(crate) const ELICITATION_CREATE_METHOD_NAME: &str = "elicitation/create"; /// Notification name for elicitation completion. #[cfg(feature = "unstable_elicitation")] -pub(crate) const SESSION_ELICITATION_COMPLETE: &str = "session/elicitation/complete"; +pub(crate) const ELICITATION_COMPLETE: &str = "elicitation/complete"; /// All possible requests that an agent can send to a client. /// @@ -1915,7 +1915,7 @@ impl AgentRequest { Self::WaitForTerminalExitRequest(_) => CLIENT_METHOD_NAMES.terminal_wait_for_exit, Self::KillTerminalRequest(_) => CLIENT_METHOD_NAMES.terminal_kill, #[cfg(feature = "unstable_elicitation")] - Self::ElicitationRequest(_) => CLIENT_METHOD_NAMES.session_elicitation, + Self::ElicitationRequest(_) => CLIENT_METHOD_NAMES.elicitation_create, Self::ExtMethodRequest(ext_request) => &ext_request.method, } } @@ -1993,9 +1993,7 @@ impl AgentNotification { match self { Self::SessionNotification(_) => CLIENT_METHOD_NAMES.session_update, #[cfg(feature = "unstable_elicitation")] - Self::ElicitationCompleteNotification(_) => { - CLIENT_METHOD_NAMES.session_elicitation_complete - } + Self::ElicitationCompleteNotification(_) => CLIENT_METHOD_NAMES.elicitation_complete, Self::ExtNotification(ext_notification) => &ext_notification.method, } } diff --git a/src/elicitation.rs b/src/elicitation.rs index 55b1d316..0beccedc 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -11,8 +11,8 @@ use derive_more::{Display, From}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::client::{SESSION_ELICITATION_COMPLETE, SESSION_ELICITATION_METHOD_NAME}; -use crate::{IntoOption, Meta, SessionId}; +use crate::client::{ELICITATION_COMPLETE, ELICITATION_CREATE_METHOD_NAME}; +use crate::{IntoOption, Meta, RequestId, SessionId}; /// **UNSTABLE** /// @@ -870,13 +870,18 @@ impl ElicitationUrlCapabilities { /// /// The agent sends this to the client to request information from the user, /// either via a form or by directing them to a URL. +/// Elicitations may be tied to a session/request, or sent without either scope. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] -#[schemars(extend("x-side" = "client", "x-method" = SESSION_ELICITATION_METHOD_NAME))] +#[schemars(extend("x-side" = "client", "x-method" = ELICITATION_CREATE_METHOD_NAME))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ElicitationRequest { - /// The session ID for this request. - pub session_id: SessionId, + /// Optional session ID if this elicitation is tied to a specific session. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Optional request ID if this elicitation is tied to a specific request. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, /// The elicitation mode and its mode-specific fields. #[serde(flatten)] pub mode: ElicitationMode, @@ -893,19 +898,28 @@ pub struct ElicitationRequest { impl ElicitationRequest { #[must_use] - pub fn new( - session_id: impl Into, - mode: ElicitationMode, - message: impl Into, - ) -> Self { + pub fn new(mode: ElicitationMode, message: impl Into) -> Self { Self { - session_id: session_id.into(), + session_id: None, + request_id: None, mode, message: message.into(), meta: None, } } + #[must_use] + pub fn session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } + + #[must_use] + pub fn request_id(mut self, request_id: impl Into) -> Self { + self.request_id = Some(request_id.into()); + self + } + /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -986,7 +1000,7 @@ impl ElicitationUrlMode { /// /// Response from the client to an elicitation request. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] -#[schemars(extend("x-side" = "client", "x-method" = SESSION_ELICITATION_METHOD_NAME))] +#[schemars(extend("x-side" = "client", "x-method" = ELICITATION_CREATE_METHOD_NAME))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ElicitationResponse { @@ -1139,7 +1153,7 @@ impl Default for ElicitationAcceptAction { /// /// Notification sent by the agent when a URL-based elicitation is complete. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[schemars(extend("x-side" = "client", "x-method" = SESSION_ELICITATION_COMPLETE))] +#[schemars(extend("x-side" = "client", "x-method" = ELICITATION_COMPLETE))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ElicitationCompleteNotification { @@ -1251,13 +1265,13 @@ mod tests { fn form_mode_request_serialization() { let schema = ElicitationSchema::new().string("name", true); let req = ElicitationRequest::new( - "sess_1", ElicitationMode::Form(ElicitationFormMode::new(schema)), "Please enter your name", ); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["sessionId"], "sess_1"); + assert!(json.get("sessionId").is_none()); + assert!(json.get("requestId").is_none()); assert_eq!(json["mode"], "form"); assert_eq!(json["message"], "Please enter your name"); assert!(json["requestedSchema"].is_object()); @@ -1268,7 +1282,8 @@ mod tests { ); let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, SessionId::new("sess_1")); + assert_eq!(roundtripped.session_id, None); + assert_eq!(roundtripped.request_id, None); assert_eq!(roundtripped.message, "Please enter your name"); assert!(matches!(roundtripped.mode, ElicitationMode::Form(_))); } @@ -1276,23 +1291,26 @@ mod tests { #[test] fn url_mode_request_serialization() { let req = ElicitationRequest::new( - "sess_2", ElicitationMode::Url(ElicitationUrlMode::new( "elic_1", "https://example.com/auth", )), "Please authenticate", - ); + ) + .session_id("sess_2") + .request_id(42i64); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["sessionId"], "sess_2"); + assert_eq!(json["requestId"], 42); assert_eq!(json["mode"], "url"); assert_eq!(json["elicitationId"], "elic_1"); assert_eq!(json["url"], "https://example.com/auth"); assert_eq!(json["message"], "Please authenticate"); let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, SessionId::new("sess_2")); + assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_2"))); + assert_eq!(roundtripped.request_id, Some(RequestId::Number(42))); assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } diff --git a/src/rpc.rs b/src/rpc.rs index 4df1d484..1a7462cd 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -216,7 +216,7 @@ impl Side for ClientSide { .map_err(Into::into) } #[cfg(feature = "unstable_elicitation")] - m if m == CLIENT_METHOD_NAMES.session_elicitation => serde_json::from_str(params.get()) + m if m == CLIENT_METHOD_NAMES.elicitation_create => serde_json::from_str(params.get()) .map(AgentRequest::ElicitationRequest) .map_err(Into::into), _ => { @@ -240,7 +240,7 @@ impl Side for ClientSide { .map(AgentNotification::SessionNotification) .map_err(Into::into), #[cfg(feature = "unstable_elicitation")] - m if m == CLIENT_METHOD_NAMES.session_elicitation_complete => { + m if m == CLIENT_METHOD_NAMES.elicitation_complete => { serde_json::from_str(params.get()) .map(AgentNotification::ElicitationCompleteNotification) .map_err(Into::into) From e2781303122bd2d90175e9bd8e06a91ccad211ad Mon Sep 17 00:00:00 2001 From: "Anna.Zhdan" Date: Fri, 27 Mar 2026 11:32:36 +0100 Subject: [PATCH 02/10] Add ability to provide toolCallId --- docs/rfds/elicitation.mdx | 13 ++++++++++--- src/elicitation.rs | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index bfbae9a6..dfa68b71 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -74,8 +74,9 @@ This proposal follows MCP's draft elicitation specification. See [MCP Elicitatio Key differences from MCP: -- MCP elicitation is tool-call-scoped; ACP elicitation may be request-scoped, session-scoped, or standalone +- MCP elicitation is tool-call-scoped; ACP elicitation may be tool-call-scoped, session-scoped, or request-scoped - ACP uses `elicitation/create` method (same as MCP) +- ACP includes an optional `toolCallId` field to support tool-call-scoped elicitations (e.g., when an agent receives an MCP elicitation during a tool call and needs to redirect it to the user) - ACP must integrate with existing Session Config Options (which also use schema constraints) ### Elicitation Request Structure @@ -357,7 +358,7 @@ The agent sends an `elicitation/create` request when it needs information from t "method": "elicitation/create", "params": { "sessionId": "...", - "requestId": 43, + "toolCallId": "tc_123", "mode": "form", "message": "How would you like me to approach this refactoring?", "requestedSchema": { @@ -396,7 +397,13 @@ The agent sends an `elicitation/create` request when it needs information from t } ``` -`sessionId` and `requestId` are both optional. In general, elicitation is not required to be tied to any specific session or request. +`sessionId`, `requestId`, and `toolCallId` are all optional. Elicitation supports three scoping variants: + +- **Session elicitation**: `sessionId` is set — tied to a specific session. +- **Tool call elicitation**: `sessionId` and `toolCallId` are both set — tied to a specific tool call within a session. This is useful when an agent receives an elicitation request from an MCP server during a tool call and needs to redirect it to the user. +- **Request elicitation**: `requestId` is set — tied to a specific JSON-RPC request outside of a session (e.g., auth/configuration phases before any session is started). + +An elicitation may also be sent without any of these fields, in which case it is standalone and not tied to any specific scope. The client presents the elicitation UI to the user. For form mode, the client generates appropriate input UI based on the JSON Schema. For URL mode, the client opens the URL in a secure browser context. diff --git a/src/elicitation.rs b/src/elicitation.rs index 0beccedc..33b5ab15 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -12,6 +12,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::client::{ELICITATION_COMPLETE, ELICITATION_CREATE_METHOD_NAME}; +use crate::tool_call::ToolCallId; use crate::{IntoOption, Meta, RequestId, SessionId}; /// **UNSTABLE** @@ -882,6 +883,10 @@ pub struct ElicitationRequest { /// Optional request ID if this elicitation is tied to a specific request. #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, + /// Optional tool call ID if this elicitation originated during a tool call. + /// When present, `session_id` should also be set. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, /// The elicitation mode and its mode-specific fields. #[serde(flatten)] pub mode: ElicitationMode, @@ -902,6 +907,7 @@ impl ElicitationRequest { Self { session_id: None, request_id: None, + tool_call_id: None, mode, message: message.into(), meta: None, @@ -920,6 +926,12 @@ impl ElicitationRequest { self } + #[must_use] + pub fn tool_call_id(mut self, tool_call_id: impl Into) -> Self { + self.tool_call_id = Some(tool_call_id.into()); + self + } + /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1272,6 +1284,7 @@ mod tests { let json = serde_json::to_value(&req).unwrap(); assert!(json.get("sessionId").is_none()); assert!(json.get("requestId").is_none()); + assert!(json.get("toolCallId").is_none()); assert_eq!(json["mode"], "form"); assert_eq!(json["message"], "Please enter your name"); assert!(json["requestedSchema"].is_object()); @@ -1284,6 +1297,7 @@ mod tests { let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.session_id, None); assert_eq!(roundtripped.request_id, None); + assert_eq!(roundtripped.tool_call_id, None); assert_eq!(roundtripped.message, "Please enter your name"); assert!(matches!(roundtripped.mode, ElicitationMode::Form(_))); } @@ -1298,11 +1312,13 @@ mod tests { "Please authenticate", ) .session_id("sess_2") - .request_id(42i64); + .request_id(42i64) + .tool_call_id("tc_1"); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["sessionId"], "sess_2"); assert_eq!(json["requestId"], 42); + assert_eq!(json["toolCallId"], "tc_1"); assert_eq!(json["mode"], "url"); assert_eq!(json["elicitationId"], "elic_1"); assert_eq!(json["url"], "https://example.com/auth"); @@ -1311,6 +1327,7 @@ mod tests { let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_2"))); assert_eq!(roundtripped.request_id, Some(RequestId::Number(42))); + assert_eq!(roundtripped.tool_call_id, Some(ToolCallId::new("tc_1"))); assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } From 573f988b92f55e36d3ec19420135a9c88d68c10c Mon Sep 17 00:00:00 2001 From: "Anna.Zhdan" Date: Fri, 27 Mar 2026 11:33:32 +0100 Subject: [PATCH 03/10] Add ability to provide toolCallId --- schema/schema.unstable.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index c885da18..57ed1ab2 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -2242,6 +2242,17 @@ } ], "description": "Optional session ID if this elicitation is tied to a specific session." + }, + "toolCallId": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "description": "Optional tool call ID if this elicitation originated during a tool call.\nWhen present, `session_id` should also be set." } }, "required": ["message"], From 42db22d8cac1139a76979c094be35070d9184486 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 8 Apr 2026 21:19:16 +0200 Subject: [PATCH 04/10] Cleanup (also fix nested action) --- docs/protocol/draft/schema.mdx | 174 ++++++++++------ docs/rfds/elicitation.mdx | 26 +++ schema/schema.unstable.json | 356 ++++++++++++++++----------------- src/bin/generate.rs | 81 +++++++- src/client.rs | 18 +- src/elicitation.rs | 91 +++++---- src/rpc.rs | 4 +- 7 files changed, 450 insertions(+), 300 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index b22b0576..d2c8fcaa 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1306,6 +1306,25 @@ Request parameters for setting a session configuration option. **Type:** Union +**Shared properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionConfigId} required> + The ID of the configuration option to set. + +SessionId} required> + The ID of the session to set the configuration option for. + + +**Variants:** + A boolean value (`type: "boolean"`). @@ -1492,7 +1511,7 @@ This capability is not part of the spec yet, and may be removed or changed at an Notification that a URL-based elicitation has completed. -#### ElicitationCompleteNotification +#### CompleteElicitationNotification **UNSTABLE** @@ -1525,7 +1544,7 @@ This capability is not part of the spec yet, and may be removed or changed at an Requests structured user input via a form or URL. -#### ElicitationRequest +#### CreateElicitationRequest **UNSTABLE** @@ -1535,10 +1554,37 @@ Request from the agent to elicit structured user input. The agent sends this to the client to request information from the user, either via a form or by directing them to a URL. -Elicitations may be tied to a session/request, or sent without either scope. +Elicitations may be tied to a session, request, or tool call, +or sent without a specific scope. **Type:** Union +**Shared properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + + + A human-readable message describing what input is needed. + +RequestId | null} > + Optional request ID if this elicitation is tied to a specific request. + +SessionId | null} > + Optional session ID if this elicitation is tied to a specific session. + +ToolCallId | null} > + Optional tool call ID if this elicitation is tied to a specific tool call. +When present, `sessionId` should also be set. + + +**Variants:** + Form-based elicitation where the client renders a form from the provided schema. @@ -1580,7 +1626,7 @@ URL-based elicitation where the client directs the user to a URL. -#### ElicitationResponse +#### CreateElicitationResponse **UNSTABLE** @@ -1588,9 +1634,9 @@ This capability is not part of the spec yet, and may be removed or changed at an Response from the client to an elicitation request. -**Type:** Object +**Type:** Union -**Properties:** +**Shared properties:** The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1600,8 +1646,46 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) -ElicitationAction} required> - The user's action in response to the elicitation. + +**Variants:** + + +The user accepted and provided content. + + + + + The discriminator value. Must be `"accept"`. + + + The user-provided content, if any, as an object matching the requested schema. + + + + + + +The user declined the elicitation. + + + + + The discriminator value. Must be `"decline"`. + + + + + + +The elicitation was cancelled. + + + + + The discriminator value. Must be `"cancel"`. + + + @@ -3176,55 +3260,6 @@ The user accepted the elicitation and provided content. The user-provided content, if any, as an object matching the requested schema. -## ElicitationAction - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -The user's action in response to an elicitation. - -**Type:** Union - - -The user accepted and provided content. - - - - - The discriminator value. Must be `"accept"`. - - - The user-provided content, if any, as an object matching the requested schema. - - - - - - -The user declined the elicitation. - - - - - The discriminator value. Must be `"decline"`. - - - - - - -The elicitation was cancelled. - - - - - The discriminator value. Must be `"cancel"`. - - - - - ## ElicitationCapabilities **UNSTABLE** @@ -5755,6 +5790,31 @@ A session configuration option selector and its current state. **Type:** Union +**Shared properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + + +SessionConfigOptionCategory | null} > + Optional semantic category for this option (UX only). + + + Optional description for the Client to display to the user. + +SessionConfigId} required> + Unique identifier for the configuration option. + + + Human-readable label for the option. + + +**Variants:** + Single-value selector (dropdown). diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index dfa68b71..073ef326 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -405,6 +405,32 @@ The agent sends an `elicitation/create` request when it needs information from t An elicitation may also be sent without any of these fields, in which case it is standalone and not tied to any specific scope. +**Request-scoped example:** + +```json +{ + "jsonrpc": "2.0", + "id": 45, + "method": "elicitation/create", + "params": { + "requestId": 12, + "mode": "form", + "message": "Please provide your API key to continue setup.", + "requestedSchema": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "title": "API Key", + "description": "Your API key for authentication" + } + }, + "required": ["apiKey"] + } + } +} +``` + The client presents the elicitation UI to the user. For form mode, the client generates appropriate input UI based on the JSON Schema. For URL mode, the client opens the URL in a secure browser context. ### User Response diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 57ed1ab2..00afac7d 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -149,11 +149,11 @@ { "allOf": [ { - "$ref": "#/$defs/ElicitationCompleteNotification" + "$ref": "#/$defs/CompleteElicitationNotification" } ], "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification that a URL-based elicitation has completed.", - "title": "ElicitationCompleteNotification" + "title": "CompleteElicitationNotification" }, { "allOf": [ @@ -264,11 +264,11 @@ { "allOf": [ { - "$ref": "#/$defs/ElicitationRequest" + "$ref": "#/$defs/CreateElicitationRequest" } ], "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequests structured user input via a form or URL.", - "title": "ElicitationRequest" + "title": "CreateElicitationRequest" }, { "allOf": [ @@ -1349,10 +1349,10 @@ { "allOf": [ { - "$ref": "#/$defs/ElicitationResponse" + "$ref": "#/$defs/CreateElicitationResponse" } ], - "title": "ElicitationResponse" + "title": "CreateElicitationResponse" }, { "allOf": [ @@ -1456,6 +1456,28 @@ "x-method": "session/close", "x-side": "agent" }, + "CompleteElicitationNotification": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.", + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "elicitationId": { + "allOf": [ + { + "$ref": "#/$defs/ElicitationId" + } + ], + "description": "The ID of the elicitation that completed." + } + }, + "required": ["elicitationId"], + "type": "object", + "x-method": "elicitation/complete", + "x-side": "client" + }, "ConfigOptionUpdate": { "description": "Session configuration options have been updated.", "properties": { @@ -1623,6 +1645,150 @@ "required": ["amount", "currency"], "type": "object" }, + "CreateElicitationRequest": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations may be tied to a session, request, or tool call,\nor sent without a specific scope.", + "discriminator": { + "propertyName": "mode" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ElicitationFormMode" + } + ], + "description": "Form-based elicitation where the client renders a form from the provided schema.", + "properties": { + "mode": { + "const": "form", + "type": "string" + } + }, + "required": ["mode"], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ElicitationUrlMode" + } + ], + "description": "URL-based elicitation where the client directs the user to a URL.", + "properties": { + "mode": { + "const": "url", + "type": "string" + } + }, + "required": ["mode"], + "type": "object" + } + ], + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + }, + "message": { + "description": "A human-readable message describing what input is needed.", + "type": "string" + }, + "requestId": { + "anyOf": [ + { + "$ref": "#/$defs/RequestId" + }, + { + "type": "null" + } + ], + "description": "Optional request ID if this elicitation is tied to a specific request." + }, + "sessionId": { + "anyOf": [ + { + "$ref": "#/$defs/SessionId" + }, + { + "type": "null" + } + ], + "description": "Optional session ID if this elicitation is tied to a specific session." + }, + "toolCallId": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "description": "Optional tool call ID if this elicitation is tied to a specific tool call.\nWhen present, `sessionId` should also be set." + } + }, + "required": ["message"], + "type": "object", + "x-method": "elicitation/create", + "x-side": "client" + }, + "CreateElicitationResponse": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.", + "discriminator": { + "propertyName": "action" + }, + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ElicitationAcceptAction" + } + ], + "description": "The user accepted and provided content.", + "properties": { + "action": { + "const": "accept", + "type": "string" + } + }, + "required": ["action"], + "type": "object" + }, + { + "description": "The user declined the elicitation.", + "properties": { + "action": { + "const": "decline", + "type": "string" + } + }, + "required": ["action"], + "type": "object" + }, + { + "description": "The elicitation was cancelled.", + "properties": { + "action": { + "const": "cancel", + "type": "string" + } + }, + "required": ["action"], + "type": "object" + } + ], + "properties": { + "_meta": { + "additionalProperties": true, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"] + } + }, + "type": "object", + "x-method": "elicitation/create", + "x-side": "client" + }, "CreateTerminalRequest": { "description": "Request to create a new terminal and execute a command.", "properties": { @@ -1924,52 +2090,6 @@ }, "type": "object" }, - "ElicitationAction": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.", - "discriminator": { - "propertyName": "action" - }, - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/$defs/ElicitationAcceptAction" - } - ], - "description": "The user accepted and provided content.", - "properties": { - "action": { - "const": "accept", - "type": "string" - } - }, - "required": ["action"], - "type": "object" - }, - { - "description": "The user declined the elicitation.", - "properties": { - "action": { - "const": "decline", - "type": "string" - } - }, - "required": ["action"], - "type": "object" - }, - { - "description": "The elicitation was cancelled.", - "properties": { - "action": { - "const": "cancel", - "type": "string" - } - }, - "required": ["action"], - "type": "object" - } - ] - }, "ElicitationCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.", "properties": { @@ -2003,28 +2123,6 @@ }, "type": "object" }, - "ElicitationCompleteNotification": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.", - "properties": { - "_meta": { - "additionalProperties": true, - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - "type": ["object", "null"] - }, - "elicitationId": { - "allOf": [ - { - "$ref": "#/$defs/ElicitationId" - } - ], - "description": "The ID of the elicitation that completed." - } - }, - "required": ["elicitationId"], - "type": "object", - "x-method": "elicitation/complete", - "x-side": "client" - }, "ElicitationContentValue": { "anyOf": [ { @@ -2172,116 +2270,6 @@ } ] }, - "ElicitationRequest": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations may be tied to a session/request, or sent without either scope.", - "discriminator": { - "propertyName": "mode" - }, - "oneOf": [ - { - "allOf": [ - { - "$ref": "#/$defs/ElicitationFormMode" - } - ], - "description": "Form-based elicitation where the client renders a form from the provided schema.", - "properties": { - "mode": { - "const": "form", - "type": "string" - } - }, - "required": ["mode"], - "type": "object" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ElicitationUrlMode" - } - ], - "description": "URL-based elicitation where the client directs the user to a URL.", - "properties": { - "mode": { - "const": "url", - "type": "string" - } - }, - "required": ["mode"], - "type": "object" - } - ], - "properties": { - "_meta": { - "additionalProperties": true, - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - "type": ["object", "null"] - }, - "message": { - "description": "A human-readable message describing what input is needed.", - "type": "string" - }, - "requestId": { - "anyOf": [ - { - "$ref": "#/$defs/RequestId" - }, - { - "type": "null" - } - ], - "description": "Optional request ID if this elicitation is tied to a specific request." - }, - "sessionId": { - "anyOf": [ - { - "$ref": "#/$defs/SessionId" - }, - { - "type": "null" - } - ], - "description": "Optional session ID if this elicitation is tied to a specific session." - }, - "toolCallId": { - "anyOf": [ - { - "$ref": "#/$defs/ToolCallId" - }, - { - "type": "null" - } - ], - "description": "Optional tool call ID if this elicitation originated during a tool call.\nWhen present, `session_id` should also be set." - } - }, - "required": ["message"], - "type": "object", - "x-method": "elicitation/create", - "x-side": "client" - }, - "ElicitationResponse": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.", - "properties": { - "_meta": { - "additionalProperties": true, - "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - "type": ["object", "null"] - }, - "action": { - "allOf": [ - { - "$ref": "#/$defs/ElicitationAction" - } - ], - "description": "The user's action in response to the elicitation." - } - }, - "required": ["action"], - "type": "object", - "x-method": "elicitation/create", - "x-side": "client" - }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", "properties": { diff --git a/src/bin/generate.rs b/src/bin/generate.rs index e45a7d6b..fdd1e648 100644 --- a/src/bin/generate.rs +++ b/src/bin/generate.rs @@ -339,6 +339,17 @@ starting with '$/' it is free to ignore the notification." writeln!(&mut self.output, "**Type:** Union").unwrap(); writeln!(&mut self.output).unwrap(); + if let Some(shared_props) = definition.get("properties").and_then(|v| v.as_object()) + && !shared_props.is_empty() + { + writeln!(&mut self.output, "**Shared properties:**").unwrap(); + writeln!(&mut self.output).unwrap(); + self.document_properties_as_fields(shared_props, definition, 0); + writeln!(&mut self.output).unwrap(); + writeln!(&mut self.output, "**Variants:**").unwrap(); + writeln!(&mut self.output).unwrap(); + } + let variants = definition .get("oneOf") .or_else(|| definition.get("anyOf")) @@ -1061,10 +1072,10 @@ starting with '$/' it is free to ignore the notification." "terminal/wait_for_exit" => self.client.get("WaitForTerminalExitRequest").unwrap(), "terminal/kill" => self.client.get("KillTerminalRequest").unwrap(), #[cfg(feature = "unstable_elicitation")] - "elicitation/create" => self.client.get("ElicitationRequest").unwrap(), + "elicitation/create" => self.client.get("CreateElicitationRequest").unwrap(), #[cfg(feature = "unstable_elicitation")] "elicitation/complete" => { - self.client.get("ElicitationCompleteNotification").unwrap() + self.client.get("CompleteElicitationNotification").unwrap() } _ => panic!("Introduced a method? Add it here :)"), } @@ -1190,4 +1201,70 @@ starting with '$/' it is free to ignore the notification." side_docs } + + #[cfg(test)] + mod tests { + use super::MarkdownGenerator; + use serde_json::json; + + #[test] + fn document_union_includes_shared_properties() { + let mut generator = MarkdownGenerator::new(); + let definition = json!({ + "description": "Example union.", + "properties": { + "message": { + "type": "string", + "description": "Shared message." + } + }, + "required": ["message"], + "oneOf": [ + { + "description": "First variant.", + "properties": { + "mode": { + "const": "form", + "type": "string" + } + }, + "required": ["mode"], + "type": "object" + }, + { + "description": "Second variant.", + "properties": { + "mode": { + "const": "url", + "type": "string" + } + }, + "required": ["mode"], + "type": "object" + } + ] + }); + + generator.document_type(4, "ExampleUnion", &definition); + + assert!(generator.output.contains("**Shared properties:**")); + assert!( + generator + .output + .contains("") + ); + assert!(generator.output.contains("Shared message.")); + assert!(generator.output.contains("**Variants:**")); + assert!( + generator + .output + .contains("") + ); + assert!( + generator + .output + .contains("") + ); + } + } } diff --git a/src/client.rs b/src/client.rs index b5d0b693..652cf4e5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "unstable_elicitation")] use crate::elicitation::{ - ElicitationCapabilities, ElicitationCompleteNotification, ElicitationRequest, - ElicitationResponse, + CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, + ElicitationCapabilities, }; use crate::{ ContentBlock, ExtNotification, ExtRequest, ExtResponse, IntoOption, Meta, Plan, @@ -1768,7 +1768,7 @@ pub const CLIENT_METHOD_NAMES: ClientMethodNames = ClientMethodNames { #[cfg(feature = "unstable_elicitation")] elicitation_create: ELICITATION_CREATE_METHOD_NAME, #[cfg(feature = "unstable_elicitation")] - elicitation_complete: ELICITATION_COMPLETE, + elicitation_complete: ELICITATION_COMPLETE_NOTIFICATION, }; /// Notification name for session updates. @@ -1794,7 +1794,7 @@ pub(crate) const TERMINAL_KILL_METHOD_NAME: &str = "terminal/kill"; pub(crate) const ELICITATION_CREATE_METHOD_NAME: &str = "elicitation/create"; /// Notification name for elicitation completion. #[cfg(feature = "unstable_elicitation")] -pub(crate) const ELICITATION_COMPLETE: &str = "elicitation/complete"; +pub(crate) const ELICITATION_COMPLETE_NOTIFICATION: &str = "elicitation/complete"; /// All possible requests that an agent can send to a client. /// @@ -1890,7 +1890,7 @@ pub enum AgentRequest { /// /// Requests structured user input via a form or URL. #[cfg(feature = "unstable_elicitation")] - ElicitationRequest(ElicitationRequest), + CreateElicitationRequest(CreateElicitationRequest), /// Handles extension method requests from the agent. /// /// Allows the Agent to send an arbitrary request that is not part of the ACP spec. @@ -1915,7 +1915,7 @@ impl AgentRequest { Self::WaitForTerminalExitRequest(_) => CLIENT_METHOD_NAMES.terminal_wait_for_exit, Self::KillTerminalRequest(_) => CLIENT_METHOD_NAMES.terminal_kill, #[cfg(feature = "unstable_elicitation")] - Self::ElicitationRequest(_) => CLIENT_METHOD_NAMES.elicitation_create, + Self::CreateElicitationRequest(_) => CLIENT_METHOD_NAMES.elicitation_create, Self::ExtMethodRequest(ext_request) => &ext_request.method, } } @@ -1941,7 +1941,7 @@ pub enum ClientResponse { WaitForTerminalExitResponse(WaitForTerminalExitResponse), KillTerminalResponse(#[serde(default)] KillTerminalResponse), #[cfg(feature = "unstable_elicitation")] - ElicitationResponse(ElicitationResponse), + CreateElicitationResponse(CreateElicitationResponse), ExtMethodResponse(ExtResponse), } @@ -1975,7 +1975,7 @@ pub enum AgentNotification { /// /// Notification that a URL-based elicitation has completed. #[cfg(feature = "unstable_elicitation")] - ElicitationCompleteNotification(ElicitationCompleteNotification), + CompleteElicitationNotification(CompleteElicitationNotification), /// Handles extension notifications from the agent. /// /// Allows the Agent to send an arbitrary notification that is not part of the ACP spec. @@ -1993,7 +1993,7 @@ impl AgentNotification { match self { Self::SessionNotification(_) => CLIENT_METHOD_NAMES.session_update, #[cfg(feature = "unstable_elicitation")] - Self::ElicitationCompleteNotification(_) => CLIENT_METHOD_NAMES.elicitation_complete, + Self::CompleteElicitationNotification(_) => CLIENT_METHOD_NAMES.elicitation_complete, Self::ExtNotification(ext_notification) => &ext_notification.method, } } diff --git a/src/elicitation.rs b/src/elicitation.rs index 33b5ab15..547f67f3 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -11,7 +11,7 @@ use derive_more::{Display, From}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::client::{ELICITATION_COMPLETE, ELICITATION_CREATE_METHOD_NAME}; +use crate::client::{ELICITATION_COMPLETE_NOTIFICATION, ELICITATION_CREATE_METHOD_NAME}; use crate::tool_call::ToolCallId; use crate::{IntoOption, Meta, RequestId, SessionId}; @@ -871,20 +871,21 @@ impl ElicitationUrlCapabilities { /// /// The agent sends this to the client to request information from the user, /// either via a form or by directing them to a URL. -/// Elicitations may be tied to a session/request, or sent without either scope. +/// Elicitations may be tied to a session, request, or tool call, +/// or sent without a specific scope. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[schemars(extend("x-side" = "client", "x-method" = ELICITATION_CREATE_METHOD_NAME))] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct ElicitationRequest { +pub struct CreateElicitationRequest { /// Optional session ID if this elicitation is tied to a specific session. #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Optional request ID if this elicitation is tied to a specific request. #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, - /// Optional tool call ID if this elicitation originated during a tool call. - /// When present, `session_id` should also be set. + /// Optional tool call ID if this elicitation is tied to a specific tool call. + /// When present, `sessionId` should also be set. #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, /// The elicitation mode and its mode-specific fields. @@ -901,7 +902,7 @@ pub struct ElicitationRequest { pub meta: Option, } -impl ElicitationRequest { +impl CreateElicitationRequest { #[must_use] pub fn new(mode: ElicitationMode, message: impl Into) -> Self { Self { @@ -1015,8 +1016,9 @@ impl ElicitationUrlMode { #[schemars(extend("x-side" = "client", "x-method" = ELICITATION_CREATE_METHOD_NAME))] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct ElicitationResponse { +pub struct CreateElicitationResponse { /// The user's action in response to the elicitation. + #[serde(flatten)] pub action: ElicitationAction, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at @@ -1027,7 +1029,7 @@ pub struct ElicitationResponse { pub meta: Option, } -impl ElicitationResponse { +impl CreateElicitationResponse { #[must_use] pub fn new(action: ElicitationAction) -> Self { Self { action, meta: None } @@ -1165,10 +1167,10 @@ impl Default for ElicitationAcceptAction { /// /// Notification sent by the agent when a URL-based elicitation is complete. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[schemars(extend("x-side" = "client", "x-method" = ELICITATION_COMPLETE))] +#[schemars(extend("x-side" = "client", "x-method" = ELICITATION_COMPLETE_NOTIFICATION))] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct ElicitationCompleteNotification { +pub struct CompleteElicitationNotification { /// The ID of the elicitation that completed. pub elicitation_id: ElicitationId, /// The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1180,7 +1182,7 @@ pub struct ElicitationCompleteNotification { pub meta: Option, } -impl ElicitationCompleteNotification { +impl CompleteElicitationNotification { #[must_use] pub fn new(elicitation_id: impl Into) -> Self { Self { @@ -1276,7 +1278,7 @@ mod tests { #[test] fn form_mode_request_serialization() { let schema = ElicitationSchema::new().string("name", true); - let req = ElicitationRequest::new( + let req = CreateElicitationRequest::new( ElicitationMode::Form(ElicitationFormMode::new(schema)), "Please enter your name", ); @@ -1294,7 +1296,7 @@ mod tests { "string" ); - let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.session_id, None); assert_eq!(roundtripped.request_id, None); assert_eq!(roundtripped.tool_call_id, None); @@ -1304,7 +1306,7 @@ mod tests { #[test] fn url_mode_request_serialization() { - let req = ElicitationRequest::new( + let req = CreateElicitationRequest::new( ElicitationMode::Url(ElicitationUrlMode::new( "elic_1", "https://example.com/auth", @@ -1324,7 +1326,7 @@ mod tests { assert_eq!(json["url"], "https://example.com/auth"); assert_eq!(json["message"], "Please authenticate"); - let roundtripped: ElicitationRequest = serde_json::from_value(json).unwrap(); + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_2"))); assert_eq!(roundtripped.request_id, Some(RequestId::Number(42))); assert_eq!(roundtripped.tool_call_id, Some(ToolCallId::new("tc_1"))); @@ -1333,7 +1335,7 @@ mod tests { #[test] fn response_accept_serialization() { - let resp = ElicitationResponse::new(ElicitationAction::Accept( + let resp = CreateElicitationResponse::new(ElicitationAction::Accept( ElicitationAcceptAction::new().content(BTreeMap::from([( "name".to_string(), ElicitationContentValue::from("Alice"), @@ -1341,10 +1343,10 @@ mod tests { )); let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["action"]["action"], "accept"); - assert_eq!(json["action"]["content"]["name"], "Alice"); + assert_eq!(json["action"], "accept"); + assert_eq!(json["content"]["name"], "Alice"); - let roundtripped: ElicitationResponse = serde_json::from_value(json).unwrap(); + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); assert!(matches!( roundtripped.action, ElicitationAction::Accept(ElicitationAcceptAction { @@ -1356,34 +1358,34 @@ mod tests { #[test] fn response_decline_serialization() { - let resp = ElicitationResponse::new(ElicitationAction::Decline); + let resp = CreateElicitationResponse::new(ElicitationAction::Decline); let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["action"]["action"], "decline"); + assert_eq!(json["action"], "decline"); - let roundtripped: ElicitationResponse = serde_json::from_value(json).unwrap(); + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); assert!(matches!(roundtripped.action, ElicitationAction::Decline)); } #[test] fn response_cancel_serialization() { - let resp = ElicitationResponse::new(ElicitationAction::Cancel); + let resp = CreateElicitationResponse::new(ElicitationAction::Cancel); let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["action"]["action"], "cancel"); + assert_eq!(json["action"], "cancel"); - let roundtripped: ElicitationResponse = serde_json::from_value(json).unwrap(); + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); assert!(matches!(roundtripped.action, ElicitationAction::Cancel)); } #[test] fn completion_notification_serialization() { - let notif = ElicitationCompleteNotification::new("elic_1"); + let notif = CompleteElicitationNotification::new("elic_1"); let json = serde_json::to_value(¬if).unwrap(); assert_eq!(json["elicitationId"], "elic_1"); - let roundtripped: ElicitationCompleteNotification = serde_json::from_value(json).unwrap(); + let roundtripped: CompleteElicitationNotification = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.elicitation_id, ElicitationId::new("elic_1")); } @@ -1677,11 +1679,9 @@ mod tests { #[test] fn response_accept_rejects_non_object_content() { - let err = serde_json::from_value::(json!({ - "action": { - "action": "accept", - "content": "Alice" - } + let err = serde_json::from_value::(json!({ + "action": "accept", + "content": "Alice" })) .unwrap_err(); @@ -1690,13 +1690,11 @@ mod tests { #[test] fn response_accept_rejects_nested_object_content() { - let err = serde_json::from_value::(json!({ - "action": { - "action": "accept", - "content": { - "profile": { - "name": "Alice" - } + let err = serde_json::from_value::(json!({ + "action": "accept", + "content": { + "profile": { + "name": "Alice" } } })) @@ -1707,7 +1705,7 @@ mod tests { #[test] fn response_accept_allows_primitive_and_string_array_content() { - let response = ElicitationResponse::new(ElicitationAction::Accept( + let response = CreateElicitationResponse::new(ElicitationAction::Accept( ElicitationAcceptAction::new().content(BTreeMap::from([ ("name".to_string(), ElicitationContentValue::from("Alice")), ("age".to_string(), ElicitationContentValue::from(30_i32)), @@ -1724,11 +1722,12 @@ mod tests { )); let json = serde_json::to_value(&response).unwrap(); - assert_eq!(json["action"]["content"]["name"], "Alice"); - assert_eq!(json["action"]["content"]["age"], 30); - assert_eq!(json["action"]["content"]["score"], 9.5); - assert_eq!(json["action"]["content"]["subscribed"], true); - assert_eq!(json["action"]["content"]["tags"][0], "rust"); - assert_eq!(json["action"]["content"]["tags"][1], "acp"); + assert_eq!(json["action"], "accept"); + assert_eq!(json["content"]["name"], "Alice"); + assert_eq!(json["content"]["age"], 30); + assert_eq!(json["content"]["score"], 9.5); + assert_eq!(json["content"]["subscribed"], true); + assert_eq!(json["content"]["tags"][0], "rust"); + assert_eq!(json["content"]["tags"][1], "acp"); } } diff --git a/src/rpc.rs b/src/rpc.rs index 1a7462cd..ae52d616 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -217,7 +217,7 @@ impl Side for ClientSide { } #[cfg(feature = "unstable_elicitation")] m if m == CLIENT_METHOD_NAMES.elicitation_create => serde_json::from_str(params.get()) - .map(AgentRequest::ElicitationRequest) + .map(AgentRequest::CreateElicitationRequest) .map_err(Into::into), _ => { if let Some(custom_method) = method.strip_prefix('_') { @@ -242,7 +242,7 @@ impl Side for ClientSide { #[cfg(feature = "unstable_elicitation")] m if m == CLIENT_METHOD_NAMES.elicitation_complete => { serde_json::from_str(params.get()) - .map(AgentNotification::ElicitationCompleteNotification) + .map(AgentNotification::CompleteElicitationNotification) .map_err(Into::into) } _ => { From 58569dbca0e524a53d3992cf05bbfaf3d1e2b972 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 8 Apr 2026 22:31:23 +0200 Subject: [PATCH 05/10] More clean up --- docs/protocol/draft/schema.mdx | 16 ------- docs/rfds/elicitation.mdx | 10 ++--- schema/schema.unstable.json | 25 +++-------- src/elicitation.rs | 78 ++++++++++++++++++++++++++++++++-- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index d2c8fcaa..d36d832b 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -3244,22 +3244,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte The file path being modified. -## ElicitationAcceptAction - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -The user accepted the elicitation and provided content. - -**Type:** Object - -**Properties:** - - - The user-provided content, if any, as an object matching the requested schema. - - ## ElicitationCapabilities **UNSTABLE** diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 073ef326..ed6bcd4d 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -415,17 +415,17 @@ An elicitation may also be sent without any of these fields, in which case it is "params": { "requestId": 12, "mode": "form", - "message": "Please provide your API key to continue setup.", + "message": "Please provide your workspace name to continue setup.", "requestedSchema": { "type": "object", "properties": { - "apiKey": { + "workspaceName": { "type": "string", - "title": "API Key", - "description": "Your API key for authentication" + "title": "Workspace Name", + "description": "The name of your workspace" } }, - "required": ["apiKey"] + "required": ["workspaceName"] } } } diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 00afac7d..78c594c5 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -1740,16 +1740,18 @@ }, "oneOf": [ { - "allOf": [ - { - "$ref": "#/$defs/ElicitationAcceptAction" - } - ], "description": "The user accepted and provided content.", "properties": { "action": { "const": "accept", "type": "string" + }, + "content": { + "additionalProperties": { + "$ref": "#/$defs/ElicitationContentValue" + }, + "description": "The user-provided content, if any, as an object matching the requested schema.", + "type": ["object", "null"] } }, "required": ["action"], @@ -2077,19 +2079,6 @@ "required": ["path", "newText"], "type": "object" }, - "ElicitationAcceptAction": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", - "properties": { - "content": { - "additionalProperties": { - "$ref": "#/$defs/ElicitationContentValue" - }, - "description": "The user-provided content, if any, as an object matching the requested schema.", - "type": ["object", "null"] - } - }, - "type": "object" - }, "ElicitationCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.", "properties": { diff --git a/src/elicitation.rs b/src/elicitation.rs index 547f67f3..59dced2f 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -927,8 +927,17 @@ impl CreateElicitationRequest { self } + /// Sets both `session_id` and `tool_call_id` for a tool-call-scoped elicitation. + /// + /// This is the recommended way to create a tool-call-scoped elicitation, + /// since `tool_call_id` should always be accompanied by a `session_id`. #[must_use] - pub fn tool_call_id(mut self, tool_call_id: impl Into) -> Self { + pub fn for_tool_call( + mut self, + session_id: impl Into, + tool_call_id: impl Into, + ) -> Self { + self.session_id = Some(session_id.into()); self.tool_call_id = Some(tool_call_id.into()); self } @@ -1054,7 +1063,7 @@ impl CreateElicitationResponse { /// The user's action in response to an elicitation. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "action", rename_all = "snake_case")] -#[schemars(extend("discriminator" = {"propertyName": "action"}))] +#[schemars(inline, extend("discriminator" = {"propertyName": "action"}))] #[non_exhaustive] pub enum ElicitationAction { /// The user accepted and provided content. @@ -1071,6 +1080,7 @@ pub enum ElicitationAction { /// /// The user accepted the elicitation and provided content. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[schemars(inline)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ElicitationAcceptAction { @@ -1315,7 +1325,7 @@ mod tests { ) .session_id("sess_2") .request_id(42i64) - .tool_call_id("tc_1"); + .for_tool_call("sess_2", "tc_1"); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["sessionId"], "sess_2"); @@ -1378,6 +1388,68 @@ mod tests { assert!(matches!(roundtripped.action, ElicitationAction::Cancel)); } + #[test] + fn session_only_request_serialization() { + let req = CreateElicitationRequest::new( + ElicitationMode::Form(ElicitationFormMode::new( + ElicitationSchema::new().string("name", true), + )), + "Enter your name", + ) + .session_id("sess_1"); + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sessionId"], "sess_1"); + assert!(json.get("requestId").is_none()); + assert!(json.get("toolCallId").is_none()); + + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); + assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_1"))); + assert_eq!(roundtripped.request_id, None); + assert_eq!(roundtripped.tool_call_id, None); + } + + #[test] + fn request_only_request_serialization() { + let req = CreateElicitationRequest::new( + ElicitationMode::Form(ElicitationFormMode::new( + ElicitationSchema::new().string("workspace", true), + )), + "Enter workspace name", + ) + .request_id(99i64); + + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("sessionId").is_none()); + assert_eq!(json["requestId"], 99); + assert!(json.get("toolCallId").is_none()); + + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); + assert_eq!(roundtripped.session_id, None); + assert_eq!(roundtripped.request_id, Some(RequestId::Number(99))); + assert_eq!(roundtripped.tool_call_id, None); + } + + #[test] + fn for_tool_call_sets_both_ids() { + let req = CreateElicitationRequest::new( + ElicitationMode::Form(ElicitationFormMode::new( + ElicitationSchema::new().string("name", true), + )), + "Enter name", + ) + .for_tool_call("sess_1", "tc_42"); + + assert_eq!(req.session_id, Some(SessionId::new("sess_1"))); + assert_eq!(req.tool_call_id, Some(ToolCallId::new("tc_42"))); + assert_eq!(req.request_id, None); + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sessionId"], "sess_1"); + assert_eq!(json["toolCallId"], "tc_42"); + assert!(json.get("requestId").is_none()); + } + #[test] fn completion_notification_serialization() { let notif = CompleteElicitationNotification::new("elic_1"); From 43d25f465241665a228482c67758d78968817cb2 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 8 Apr 2026 22:49:03 +0200 Subject: [PATCH 06/10] Clean up constructors --- src/elicitation.rs | 62 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/elicitation.rs b/src/elicitation.rs index 59dced2f..030bcbc9 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -915,22 +915,33 @@ impl CreateElicitationRequest { } } + /// Sets the session scope for this elicitation. + /// + /// Clears `request_id` and `tool_call_id` since scopes are mutually exclusive. + /// Use [`for_tool_call`](Self::for_tool_call) if you also need a `tool_call_id`. #[must_use] - pub fn session_id(mut self, session_id: impl Into) -> Self { + pub fn for_session(mut self, session_id: impl Into) -> Self { self.session_id = Some(session_id.into()); + self.request_id = None; + self.tool_call_id = None; self } + /// Sets the request scope for this elicitation. + /// + /// Clears `session_id` and `tool_call_id` since scopes are mutually exclusive. #[must_use] - pub fn request_id(mut self, request_id: impl Into) -> Self { + pub fn for_request(mut self, request_id: impl Into) -> Self { self.request_id = Some(request_id.into()); + self.session_id = None; + self.tool_call_id = None; self } - /// Sets both `session_id` and `tool_call_id` for a tool-call-scoped elicitation. + /// Sets the tool-call scope for this elicitation. /// - /// This is the recommended way to create a tool-call-scoped elicitation, - /// since `tool_call_id` should always be accompanied by a `session_id`. + /// Sets both `session_id` and `tool_call_id`, and clears `request_id`, + /// since scopes are mutually exclusive and `tool_call_id` requires a `session_id`. #[must_use] pub fn for_tool_call( mut self, @@ -939,6 +950,7 @@ impl CreateElicitationRequest { ) -> Self { self.session_id = Some(session_id.into()); self.tool_call_id = Some(tool_call_id.into()); + self.request_id = None; self } @@ -1323,13 +1335,11 @@ mod tests { )), "Please authenticate", ) - .session_id("sess_2") - .request_id(42i64) .for_tool_call("sess_2", "tc_1"); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["sessionId"], "sess_2"); - assert_eq!(json["requestId"], 42); + assert!(json.get("requestId").is_none()); assert_eq!(json["toolCallId"], "tc_1"); assert_eq!(json["mode"], "url"); assert_eq!(json["elicitationId"], "elic_1"); @@ -1338,7 +1348,7 @@ mod tests { let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_2"))); - assert_eq!(roundtripped.request_id, Some(RequestId::Number(42))); + assert_eq!(roundtripped.request_id, None); assert_eq!(roundtripped.tool_call_id, Some(ToolCallId::new("tc_1"))); assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } @@ -1396,7 +1406,7 @@ mod tests { )), "Enter your name", ) - .session_id("sess_1"); + .for_session("sess_1"); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["sessionId"], "sess_1"); @@ -1417,7 +1427,7 @@ mod tests { )), "Enter workspace name", ) - .request_id(99i64); + .for_request(99i64); let json = serde_json::to_value(&req).unwrap(); assert!(json.get("sessionId").is_none()); @@ -1450,6 +1460,36 @@ mod tests { assert!(json.get("requestId").is_none()); } + #[test] + fn scope_setters_are_mutually_exclusive() { + let base = || { + CreateElicitationRequest::new( + ElicitationMode::Form(ElicitationFormMode::new( + ElicitationSchema::new().string("name", true), + )), + "msg", + ) + }; + + // session_id clears request_id and tool_call_id + let req = base().for_request(1i64).for_session("s1"); + assert_eq!(req.session_id, Some(SessionId::new("s1"))); + assert_eq!(req.request_id, None); + assert_eq!(req.tool_call_id, None); + + // request_id clears session_id and tool_call_id + let req = base().for_tool_call("s1", "tc1").for_request(2i64); + assert_eq!(req.request_id, Some(RequestId::Number(2))); + assert_eq!(req.session_id, None); + assert_eq!(req.tool_call_id, None); + + // for_tool_call clears request_id + let req = base().for_request(3i64).for_tool_call("s2", "tc2"); + assert_eq!(req.session_id, Some(SessionId::new("s2"))); + assert_eq!(req.tool_call_id, Some(ToolCallId::new("tc2"))); + assert_eq!(req.request_id, None); + } + #[test] fn completion_notification_serialization() { let notif = CompleteElicitationNotification::new("elic_1"); From 3f658d2e53dbbf92674e66f09e67bbb08924abb0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 9 Apr 2026 16:32:31 +0200 Subject: [PATCH 07/10] Nest scope --- docs/protocol/draft/schema.mdx | 72 +++++++-- docs/rfds/elicitation.mdx | 19 ++- schema/schema.unstable.json | 76 ++++++--- src/bin/generate.rs | 48 ++++-- src/elicitation.rs | 272 +++++++++++++++++++-------------- 5 files changed, 322 insertions(+), 165 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index d36d832b..ed8d4fe4 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1572,15 +1572,9 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte A human-readable message describing what input is needed. -RequestId | null} > - Optional request ID if this elicitation is tied to a specific request. - -SessionId | null} > - Optional session ID if this elicitation is tied to a specific session. - -ToolCallId | null} > - Optional tool call ID if this elicitation is tied to a specific tool call. -When present, `sessionId` should also be set. +ElicitationScope | null} > + Optional scope for this elicitation. +When absent, the elicitation is standalone and not tied to any specific context. **Variants:** @@ -3555,6 +3549,66 @@ Type discriminator for elicitation schemas. Object schema type. +## ElicitationScope + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The scope of an elicitation request, determining what context it's tied to. + +**Type:** Union + + +Tied to a session, optionally to a specific tool call within that session. + +When `tool_call_id` is set, the elicitation is tied to a specific tool call. +This is useful when an agent receives an elicitation from an MCP server +during a tool call and needs to redirect it to the user. + + + +SessionId} + required +> + The session this elicitation is tied to. + + + + ToolCallId + + | null + + } +> + Optional tool call within the session. + + + + + + +Tied to a specific JSON-RPC request outside of a session +(e.g., during auth/configuration phases before any session is started). + + + +RequestId} + required +> + The request this elicitation is tied to. + + + + + ## ElicitationStringType Items definition for untitled multi-select enum properties. diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index ed6bcd4d..b476346c 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -357,8 +357,10 @@ The agent sends an `elicitation/create` request when it needs information from t "id": 43, "method": "elicitation/create", "params": { - "sessionId": "...", - "toolCallId": "tc_123", + "scope": { + "sessionId": "...", + "toolCallId": "tc_123" + }, "mode": "form", "message": "How would you like me to approach this refactoring?", "requestedSchema": { @@ -397,13 +399,12 @@ The agent sends an `elicitation/create` request when it needs information from t } ``` -`sessionId`, `requestId`, and `toolCallId` are all optional. Elicitation supports three scoping variants: +The optional `scope` field determines what context the elicitation is tied to. Elicitation supports two scoping variants: -- **Session elicitation**: `sessionId` is set — tied to a specific session. -- **Tool call elicitation**: `sessionId` and `toolCallId` are both set — tied to a specific tool call within a session. This is useful when an agent receives an elicitation request from an MCP server during a tool call and needs to redirect it to the user. -- **Request elicitation**: `requestId` is set — tied to a specific JSON-RPC request outside of a session (e.g., auth/configuration phases before any session is started). +- **Session scope**: `scope.sessionId` is set — tied to a specific session. Optionally includes `scope.toolCallId` when tied to a specific tool call within that session (e.g., when an agent receives an elicitation from an MCP server during a tool call and needs to redirect it to the user). +- **Request scope**: `scope.requestId` is set — tied to a specific JSON-RPC request outside of a session (e.g., auth/configuration phases before any session is started). -An elicitation may also be sent without any of these fields, in which case it is standalone and not tied to any specific scope. +When `scope` is omitted entirely, the elicitation is standalone and not tied to any specific context. **Request-scoped example:** @@ -413,7 +414,9 @@ An elicitation may also be sent without any of these fields, in which case it is "id": 45, "method": "elicitation/create", "params": { - "requestId": 12, + "scope": { + "requestId": 12 + }, "mode": "form", "message": "Please provide your workspace name to continue setup.", "requestedSchema": { diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 78c594c5..6bad2114 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -1694,38 +1694,16 @@ "description": "A human-readable message describing what input is needed.", "type": "string" }, - "requestId": { - "anyOf": [ - { - "$ref": "#/$defs/RequestId" - }, - { - "type": "null" - } - ], - "description": "Optional request ID if this elicitation is tied to a specific request." - }, - "sessionId": { - "anyOf": [ - { - "$ref": "#/$defs/SessionId" - }, - { - "type": "null" - } - ], - "description": "Optional session ID if this elicitation is tied to a specific session." - }, - "toolCallId": { + "scope": { "anyOf": [ { - "$ref": "#/$defs/ToolCallId" + "$ref": "#/$defs/ElicitationScope" }, { "type": "null" } ], - "description": "Optional tool call ID if this elicitation is tied to a specific tool call.\nWhen present, `sessionId` should also be set." + "description": "Optional scope for this elicitation.\nWhen absent, the elicitation is standalone and not tied to any specific context." } }, "required": ["message"], @@ -2307,6 +2285,54 @@ } ] }, + "ElicitationScope": { + "anyOf": [ + { + "description": "Tied to a session, optionally to a specific tool call within that session.\n\nWhen `tool_call_id` is set, the elicitation is tied to a specific tool call.\nThis is useful when an agent receives an elicitation from an MCP server\nduring a tool call and needs to redirect it to the user.", + "properties": { + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session this elicitation is tied to." + }, + "toolCallId": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "description": "Optional tool call within the session." + } + }, + "required": ["sessionId"], + "title": "Session", + "type": "object" + }, + { + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "properties": { + "requestId": { + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ], + "description": "The request this elicitation is tied to." + } + }, + "required": ["requestId"], + "title": "Request", + "type": "object" + } + ], + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe scope of an elicitation request, determining what context it's tied to." + }, "ElicitationStringType": { "description": "Items definition for untitled multi-select enum properties.", "oneOf": [ diff --git a/src/bin/generate.rs b/src/bin/generate.rs index fdd1e648..95ae8e05 100644 --- a/src/bin/generate.rs +++ b/src/bin/generate.rs @@ -339,15 +339,29 @@ starting with '$/' it is free to ignore the notification." writeln!(&mut self.output, "**Type:** Union").unwrap(); writeln!(&mut self.output).unwrap(); - if let Some(shared_props) = definition.get("properties").and_then(|v| v.as_object()) - && !shared_props.is_empty() - { - writeln!(&mut self.output, "**Shared properties:**").unwrap(); - writeln!(&mut self.output).unwrap(); - self.document_properties_as_fields(shared_props, definition, 0); - writeln!(&mut self.output).unwrap(); - writeln!(&mut self.output, "**Variants:**").unwrap(); - writeln!(&mut self.output).unwrap(); + let discriminator_prop = definition + .get("discriminator") + .and_then(|d| d.get("propertyName")) + .and_then(|p| p.as_str()); + + // Union types with top-level "properties" alongside "oneOf"/"anyOf" use them + // as shared properties that apply to all variants (e.g., _meta, message). + // The discriminator property (if any) is excluded since it's per-variant. + if let Some(shared_props) = definition.get("properties").and_then(|v| v.as_object()) { + let filtered_props: serde_json::Map = shared_props + .iter() + .filter(|(key, _)| Some(key.as_str()) != discriminator_prop) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + if !filtered_props.is_empty() { + writeln!(&mut self.output, "**Shared properties:**").unwrap(); + writeln!(&mut self.output).unwrap(); + self.document_properties_as_fields(&filtered_props, definition, 0); + writeln!(&mut self.output).unwrap(); + writeln!(&mut self.output, "**Variants:**").unwrap(); + writeln!(&mut self.output).unwrap(); + } } let variants = definition @@ -1212,13 +1226,20 @@ starting with '$/' it is free to ignore the notification." let mut generator = MarkdownGenerator::new(); let definition = json!({ "description": "Example union.", + "discriminator": { + "propertyName": "mode" + }, "properties": { "message": { "type": "string", "description": "Shared message." + }, + "mode": { + "type": "string", + "description": "The discriminator." } }, - "required": ["message"], + "required": ["message", "mode"], "oneOf": [ { "description": "First variant.", @@ -1263,7 +1284,12 @@ starting with '$/' it is free to ignore the notification." assert!( generator .output - .contains("") + .contains(""), + ); + let shared_section = generator.output.split("**Variants:**").next().unwrap_or(""); + assert!( + !shared_section.contains(", + }, + /// Tied to a specific JSON-RPC request outside of a session + /// (e.g., during auth/configuration phases before any session is started). + #[serde(rename_all = "camelCase")] + Request { + /// The request this elicitation is tied to. + request_id: RequestId, + }, +} + /// **UNSTABLE** /// /// This capability is not part of the spec yet, and may be removed or changed at any point. @@ -878,16 +909,10 @@ impl ElicitationUrlCapabilities { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct CreateElicitationRequest { - /// Optional session ID if this elicitation is tied to a specific session. - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - /// Optional request ID if this elicitation is tied to a specific request. + /// Optional scope for this elicitation. + /// When absent, the elicitation is standalone and not tied to any specific context. #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, - /// Optional tool call ID if this elicitation is tied to a specific tool call. - /// When present, `sessionId` should also be set. - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, + pub scope: Option, /// The elicitation mode and its mode-specific fields. #[serde(flatten)] pub mode: ElicitationMode, @@ -906,51 +931,18 @@ impl CreateElicitationRequest { #[must_use] pub fn new(mode: ElicitationMode, message: impl Into) -> Self { Self { - session_id: None, - request_id: None, - tool_call_id: None, + scope: None, mode, message: message.into(), meta: None, } } - /// Sets the session scope for this elicitation. - /// - /// Clears `request_id` and `tool_call_id` since scopes are mutually exclusive. - /// Use [`for_tool_call`](Self::for_tool_call) if you also need a `tool_call_id`. - #[must_use] - pub fn for_session(mut self, session_id: impl Into) -> Self { - self.session_id = Some(session_id.into()); - self.request_id = None; - self.tool_call_id = None; - self - } - - /// Sets the request scope for this elicitation. - /// - /// Clears `session_id` and `tool_call_id` since scopes are mutually exclusive. - #[must_use] - pub fn for_request(mut self, request_id: impl Into) -> Self { - self.request_id = Some(request_id.into()); - self.session_id = None; - self.tool_call_id = None; - self - } - - /// Sets the tool-call scope for this elicitation. - /// - /// Sets both `session_id` and `tool_call_id`, and clears `request_id`, - /// since scopes are mutually exclusive and `tool_call_id` requires a `session_id`. + /// Optional scope for this elicitation. + /// When absent, the elicitation is standalone and not tied to any specific context. #[must_use] - pub fn for_tool_call( - mut self, - session_id: impl Into, - tool_call_id: impl Into, - ) -> Self { - self.session_id = Some(session_id.into()); - self.tool_call_id = Some(tool_call_id.into()); - self.request_id = None; + pub fn scope(mut self, scope: impl IntoOption) -> Self { + self.scope = scope.into_option(); self } @@ -1306,9 +1298,7 @@ mod tests { ); let json = serde_json::to_value(&req).unwrap(); - assert!(json.get("sessionId").is_none()); - assert!(json.get("requestId").is_none()); - assert!(json.get("toolCallId").is_none()); + assert!(json.get("scope").is_none()); assert_eq!(json["mode"], "form"); assert_eq!(json["message"], "Please enter your name"); assert!(json["requestedSchema"].is_object()); @@ -1319,9 +1309,7 @@ mod tests { ); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, None); - assert_eq!(roundtripped.request_id, None); - assert_eq!(roundtripped.tool_call_id, None); + assert_eq!(roundtripped.scope, None); assert_eq!(roundtripped.message, "Please enter your name"); assert!(matches!(roundtripped.mode, ElicitationMode::Form(_))); } @@ -1335,21 +1323,27 @@ mod tests { )), "Please authenticate", ) - .for_tool_call("sess_2", "tc_1"); + .scope(ElicitationScope::Session { + session_id: SessionId::new("sess_2"), + tool_call_id: Some(ToolCallId::new("tc_1")), + }); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["sessionId"], "sess_2"); - assert!(json.get("requestId").is_none()); - assert_eq!(json["toolCallId"], "tc_1"); + assert_eq!(json["scope"]["sessionId"], "sess_2"); + assert_eq!(json["scope"]["toolCallId"], "tc_1"); assert_eq!(json["mode"], "url"); assert_eq!(json["elicitationId"], "elic_1"); assert_eq!(json["url"], "https://example.com/auth"); assert_eq!(json["message"], "Please authenticate"); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_2"))); - assert_eq!(roundtripped.request_id, None); - assert_eq!(roundtripped.tool_call_id, Some(ToolCallId::new("tc_1"))); + assert_eq!( + roundtripped.scope, + Some(ElicitationScope::Session { + session_id: SessionId::new("sess_2"), + tool_call_id: Some(ToolCallId::new("tc_1")), + }) + ); assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } @@ -1406,17 +1400,23 @@ mod tests { )), "Enter your name", ) - .for_session("sess_1"); + .scope(ElicitationScope::Session { + session_id: SessionId::new("sess_1"), + tool_call_id: None, + }); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["sessionId"], "sess_1"); - assert!(json.get("requestId").is_none()); - assert!(json.get("toolCallId").is_none()); + assert_eq!(json["scope"]["sessionId"], "sess_1"); + assert!(json["scope"].get("toolCallId").is_none()); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, Some(SessionId::new("sess_1"))); - assert_eq!(roundtripped.request_id, None); - assert_eq!(roundtripped.tool_call_id, None); + assert_eq!( + roundtripped.scope, + Some(ElicitationScope::Session { + session_id: SessionId::new("sess_1"), + tool_call_id: None, + }) + ); } #[test] @@ -1427,41 +1427,24 @@ mod tests { )), "Enter workspace name", ) - .for_request(99i64); + .scope(ElicitationScope::Request { + request_id: RequestId::Number(99), + }); let json = serde_json::to_value(&req).unwrap(); - assert!(json.get("sessionId").is_none()); - assert_eq!(json["requestId"], 99); - assert!(json.get("toolCallId").is_none()); + assert_eq!(json["scope"]["requestId"], 99); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.session_id, None); - assert_eq!(roundtripped.request_id, Some(RequestId::Number(99))); - assert_eq!(roundtripped.tool_call_id, None); - } - - #[test] - fn for_tool_call_sets_both_ids() { - let req = CreateElicitationRequest::new( - ElicitationMode::Form(ElicitationFormMode::new( - ElicitationSchema::new().string("name", true), - )), - "Enter name", - ) - .for_tool_call("sess_1", "tc_42"); - - assert_eq!(req.session_id, Some(SessionId::new("sess_1"))); - assert_eq!(req.tool_call_id, Some(ToolCallId::new("tc_42"))); - assert_eq!(req.request_id, None); - - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["sessionId"], "sess_1"); - assert_eq!(json["toolCallId"], "tc_42"); - assert!(json.get("requestId").is_none()); + assert_eq!( + roundtripped.scope, + Some(ElicitationScope::Request { + request_id: RequestId::Number(99), + }) + ); } #[test] - fn scope_setters_are_mutually_exclusive() { + fn scope_builder_sets_and_replaces() { let base = || { CreateElicitationRequest::new( ElicitationMode::Form(ElicitationFormMode::new( @@ -1471,23 +1454,88 @@ mod tests { ) }; - // session_id clears request_id and tool_call_id - let req = base().for_request(1i64).for_session("s1"); - assert_eq!(req.session_id, Some(SessionId::new("s1"))); - assert_eq!(req.request_id, None); - assert_eq!(req.tool_call_id, None); - - // request_id clears session_id and tool_call_id - let req = base().for_tool_call("s1", "tc1").for_request(2i64); - assert_eq!(req.request_id, Some(RequestId::Number(2))); - assert_eq!(req.session_id, None); - assert_eq!(req.tool_call_id, None); - - // for_tool_call clears request_id - let req = base().for_request(3i64).for_tool_call("s2", "tc2"); - assert_eq!(req.session_id, Some(SessionId::new("s2"))); - assert_eq!(req.tool_call_id, Some(ToolCallId::new("tc2"))); - assert_eq!(req.request_id, None); + // scope builder sets session scope + let req = base().scope(ElicitationScope::Session { + session_id: SessionId::new("s1"), + tool_call_id: None, + }); + assert_eq!( + req.scope, + Some(ElicitationScope::Session { + session_id: SessionId::new("s1"), + tool_call_id: None, + }) + ); + + // calling scope again replaces the previous value + let req = base() + .scope(ElicitationScope::Request { + request_id: RequestId::Number(1), + }) + .scope(ElicitationScope::Session { + session_id: SessionId::new("s2"), + tool_call_id: Some(ToolCallId::new("tc1")), + }); + assert_eq!( + req.scope, + Some(ElicitationScope::Session { + session_id: SessionId::new("s2"), + tool_call_id: Some(ToolCallId::new("tc1")), + }) + ); + } + + /// `ClientResponse` is `#[serde(untagged)]` with `WriteTextFileResponse` (which has + /// `#[serde(default)]`) listed first, so standalone deserialization is ambiguous. + /// In practice, the RPC layer selects the correct variant based on the originating + /// request method. These tests verify that serialization through `ClientResponse` + /// produces the correct flattened wire format and round-trips back via the + /// concrete `CreateElicitationResponse` type. + #[test] + fn client_response_serialization_accept() { + use crate::ClientResponse; + + let resp = ClientResponse::CreateElicitationResponse(CreateElicitationResponse::new( + ElicitationAction::Accept(ElicitationAcceptAction::new().content(BTreeMap::from([( + "name".to_string(), + ElicitationContentValue::from("Alice"), + )]))), + )); + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["action"], "accept"); + assert_eq!(json["content"]["name"], "Alice"); + + // Round-trip back through the concrete type + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped.action, ElicitationAction::Accept(_))); + } + + #[test] + fn client_response_serialization_decline() { + use crate::ClientResponse; + + let resp = ClientResponse::CreateElicitationResponse(CreateElicitationResponse::new( + ElicitationAction::Decline, + )); + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["action"], "decline"); + + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped.action, ElicitationAction::Decline)); + } + + #[test] + fn client_response_serialization_cancel() { + use crate::ClientResponse; + + let resp = ClientResponse::CreateElicitationResponse(CreateElicitationResponse::new( + ElicitationAction::Cancel, + )); + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["action"], "cancel"); + + let roundtripped: CreateElicitationResponse = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped.action, ElicitationAction::Cancel)); } #[test] From 75e6c2b6322ac66f9218a0d014fbee01732c50ba Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 9 Apr 2026 17:05:22 +0200 Subject: [PATCH 08/10] Flatten ids again --- docs/protocol/draft/schema.mdx | 119 +++++++++++++--------------- docs/rfds/elicitation.mdx | 19 ++--- schema/schema.unstable.json | 106 +++++++++++-------------- src/bin/generate.rs | 139 ++++++++++++++++++++++++++++++--- src/elicitation.rs | 132 +++++++++++-------------------- 5 files changed, 282 insertions(+), 233 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index ed8d4fe4..af328b13 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1340,7 +1340,7 @@ A boolean value (`type: "boolean"`). - + A `SessionConfigValueId` string value. This is the default when `type` is absent on the wire. Unknown `type` @@ -1554,8 +1554,7 @@ Request from the agent to elicit structured user input. The agent sends this to the client to request information from the user, either via a form or by directing them to a URL. -Elicitations may be tied to a session, request, or tool call, -or sent without a specific scope. +Elicitations are tied to a session (optionally a tool call) or a request. **Type:** Union @@ -1572,9 +1571,57 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte A human-readable message describing what input is needed. -ElicitationScope | null} > - Optional scope for this elicitation. -When absent, the elicitation is standalone and not tied to any specific context. + +**One of:** + + +Tied to a session, optionally to a specific tool call within that session. + +When `tool_call_id` is set, the elicitation is tied to a specific tool call. +This is useful when an agent receives an elicitation from an MCP server +during a tool call and needs to redirect it to the user. + + + +SessionId} + required +> + The session this elicitation is tied to. + + + + ToolCallId + + | null + + } +> + Optional tool call within the session. + + + + + + +Tied to a specific JSON-RPC request outside of a session +(e.g., during auth/configuration phases before any session is started). + + + +RequestId} + required +> + The request this elicitation is tied to. + + + **Variants:** @@ -3549,66 +3596,6 @@ Type discriminator for elicitation schemas. Object schema type. -## ElicitationScope - -**UNSTABLE** - -This capability is not part of the spec yet, and may be removed or changed at any point. - -The scope of an elicitation request, determining what context it's tied to. - -**Type:** Union - - -Tied to a session, optionally to a specific tool call within that session. - -When `tool_call_id` is set, the elicitation is tied to a specific tool call. -This is useful when an agent receives an elicitation from an MCP server -during a tool call and needs to redirect it to the user. - - - -SessionId} - required -> - The session this elicitation is tied to. - - - - ToolCallId - - | null - - } -> - Optional tool call within the session. - - - - - - -Tied to a specific JSON-RPC request outside of a session -(e.g., during auth/configuration phases before any session is started). - - - -RequestId} - required -> - The request this elicitation is tied to. - - - - - ## ElicitationStringType Items definition for untitled multi-select enum properties. diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index b476346c..ab69c108 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -357,10 +357,8 @@ The agent sends an `elicitation/create` request when it needs information from t "id": 43, "method": "elicitation/create", "params": { - "scope": { - "sessionId": "...", - "toolCallId": "tc_123" - }, + "sessionId": "...", + "toolCallId": "tc_123", "mode": "form", "message": "How would you like me to approach this refactoring?", "requestedSchema": { @@ -391,6 +389,7 @@ The agent sends an `elicitation/create` request when it needs information from t "id": 44, "method": "elicitation/create", "params": { + "requestId": 12, "mode": "url", "elicitationId": "github-oauth-001", "url": "https://agent.example.com/connect?elicitationId=github-oauth-001", @@ -399,12 +398,10 @@ The agent sends an `elicitation/create` request when it needs information from t } ``` -The optional `scope` field determines what context the elicitation is tied to. Elicitation supports two scoping variants: - -- **Session scope**: `scope.sessionId` is set — tied to a specific session. Optionally includes `scope.toolCallId` when tied to a specific tool call within that session (e.g., when an agent receives an elicitation from an MCP server during a tool call and needs to redirect it to the user). -- **Request scope**: `scope.requestId` is set — tied to a specific JSON-RPC request outside of a session (e.g., auth/configuration phases before any session is started). +The scope fields are flattened at the top level of the request. Elicitation supports two scoping variants: -When `scope` is omitted entirely, the elicitation is standalone and not tied to any specific context. +- **Session scope**: `sessionId` is set — tied to a specific session. Optionally includes `toolCallId` when tied to a specific tool call within that session (e.g., when an agent receives an elicitation from an MCP server during a tool call and needs to redirect it to the user). +- **Request scope**: `requestId` is set — tied to a specific JSON-RPC request outside of a session (e.g., auth/configuration phases before any session is started). **Request-scoped example:** @@ -414,9 +411,7 @@ When `scope` is omitted entirely, the elicitation is standalone and not tied to "id": 45, "method": "elicitation/create", "params": { - "scope": { - "requestId": 12 - }, + "requestId": 12, "mode": "form", "message": "Please provide your workspace name to continue setup.", "requestedSchema": { diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 6bad2114..40a92d78 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -1646,7 +1646,52 @@ "type": "object" }, "CreateElicitationRequest": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations may be tied to a session, request, or tool call,\nor sent without a specific scope.", + "anyOf": [ + { + "description": "Tied to a session, optionally to a specific tool call within that session.\n\nWhen `tool_call_id` is set, the elicitation is tied to a specific tool call.\nThis is useful when an agent receives an elicitation from an MCP server\nduring a tool call and needs to redirect it to the user.", + "properties": { + "sessionId": { + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ], + "description": "The session this elicitation is tied to." + }, + "toolCallId": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "description": "Optional tool call within the session." + } + }, + "required": ["sessionId"], + "title": "Session", + "type": "object" + }, + { + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "properties": { + "requestId": { + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ], + "description": "The request this elicitation is tied to." + } + }, + "required": ["requestId"], + "title": "Request", + "type": "object" + } + ], + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations are tied to a session (optionally a tool call) or a request.", "discriminator": { "propertyName": "mode" }, @@ -1693,17 +1738,6 @@ "message": { "description": "A human-readable message describing what input is needed.", "type": "string" - }, - "scope": { - "anyOf": [ - { - "$ref": "#/$defs/ElicitationScope" - }, - { - "type": "null" - } - ], - "description": "Optional scope for this elicitation.\nWhen absent, the elicitation is standalone and not tied to any specific context." } }, "required": ["message"], @@ -2285,54 +2319,6 @@ } ] }, - "ElicitationScope": { - "anyOf": [ - { - "description": "Tied to a session, optionally to a specific tool call within that session.\n\nWhen `tool_call_id` is set, the elicitation is tied to a specific tool call.\nThis is useful when an agent receives an elicitation from an MCP server\nduring a tool call and needs to redirect it to the user.", - "properties": { - "sessionId": { - "allOf": [ - { - "$ref": "#/$defs/SessionId" - } - ], - "description": "The session this elicitation is tied to." - }, - "toolCallId": { - "anyOf": [ - { - "$ref": "#/$defs/ToolCallId" - }, - { - "type": "null" - } - ], - "description": "Optional tool call within the session." - } - }, - "required": ["sessionId"], - "title": "Session", - "type": "object" - }, - { - "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", - "properties": { - "requestId": { - "allOf": [ - { - "$ref": "#/$defs/RequestId" - } - ], - "description": "The request this elicitation is tied to." - } - }, - "required": ["requestId"], - "title": "Request", - "type": "object" - } - ], - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe scope of an elicitation request, determining what context it's tied to." - }, "ElicitationStringType": { "description": "Items definition for untitled multi-select enum properties.", "oneOf": [ diff --git a/src/bin/generate.rs b/src/bin/generate.rs index 95ae8e05..85e2d850 100644 --- a/src/bin/generate.rs +++ b/src/bin/generate.rs @@ -344,32 +344,61 @@ starting with '$/' it is free to ignore the notification." .and_then(|d| d.get("propertyName")) .and_then(|p| p.as_str()); + let any_of = definition.get("anyOf").and_then(|v| v.as_array()); + let one_of = definition.get("oneOf").and_then(|v| v.as_array()); + // Union types with top-level "properties" alongside "oneOf"/"anyOf" use them // as shared properties that apply to all variants (e.g., _meta, message). // The discriminator property (if any) is excluded since it's per-variant. - if let Some(shared_props) = definition.get("properties").and_then(|v| v.as_object()) { + let has_shared_props = if let Some(shared_props) = + definition.get("properties").and_then(|v| v.as_object()) + { let filtered_props: serde_json::Map = shared_props .iter() .filter(|(key, _)| Some(key.as_str()) != discriminator_prop) .map(|(k, v)| (k.clone(), v.clone())) .collect(); - if !filtered_props.is_empty() { + if filtered_props.is_empty() { + false + } else { writeln!(&mut self.output, "**Shared properties:**").unwrap(); writeln!(&mut self.output).unwrap(); self.document_properties_as_fields(&filtered_props, definition, 0); writeln!(&mut self.output).unwrap(); - writeln!(&mut self.output, "**Variants:**").unwrap(); + true + } + } else { + false + }; + + // When both anyOf and oneOf exist (e.g. a flattened untagged enum + // alongside a flattened tagged enum), render the anyOf variants + // first since they represent additional constraints (like scope) + // before the main discriminated variants. + if let Some(variants) = any_of { + // Label when there are other sections around this one. + if has_shared_props || one_of.is_some() { + let label = if one_of.is_some() { + "**One of:**" + } else { + "**Variants:**" + }; + writeln!(&mut self.output, "{label}").unwrap(); writeln!(&mut self.output).unwrap(); } + for variant in variants { + self.document_variant_table_row(variant); + } + writeln!(&mut self.output).unwrap(); } - let variants = definition - .get("oneOf") - .or_else(|| definition.get("anyOf")) - .and_then(|v| v.as_array()); - - if let Some(variants) = variants { + if let Some(variants) = one_of { + // Label when there are other sections above. + if has_shared_props || any_of.is_some() { + writeln!(&mut self.output, "**Variants:**").unwrap(); + writeln!(&mut self.output).unwrap(); + } for variant in variants { self.document_variant_table_row(variant); } @@ -402,6 +431,8 @@ starting with '$/' it is free to ignore the notification." if let Some(const_val) = discriminator { write!(&mut self.output, "{const_val}").unwrap(); + } else if let Some(title) = variant.get("title").and_then(|t| t.as_str()) { + write!(&mut self.output, "{title}").unwrap(); } else { write!(&mut self.output, "Object").unwrap(); } @@ -1292,5 +1323,95 @@ starting with '$/' it is free to ignore the notification." "discriminator property 'mode' should not appear in shared properties" ); } + + #[test] + fn document_union_renders_both_any_of_and_one_of() { + let mut generator = MarkdownGenerator::new(); + let definition = json!({ + "description": "Request with scope and mode.", + "anyOf": [ + { + "description": "Session scope.", + "properties": { + "sessionId": { "type": "string" } + }, + "required": ["sessionId"], + "title": "Session", + "type": "object" + }, + { + "description": "Request scope.", + "properties": { + "requestId": { "type": "integer" } + }, + "required": ["requestId"], + "title": "Request", + "type": "object" + } + ], + "discriminator": { "propertyName": "mode" }, + "oneOf": [ + { + "description": "Form variant.", + "properties": { + "mode": { "const": "form", "type": "string" } + }, + "required": ["mode"], + "type": "object" + }, + { + "description": "URL variant.", + "properties": { + "mode": { "const": "url", "type": "string" } + }, + "required": ["mode"], + "type": "object" + } + ], + "properties": { + "message": { "type": "string", "description": "A message." } + }, + "required": ["message"], + "type": "object" + }); + + generator.document_type(4, "TestRequest", &definition); + + // Shared properties rendered + assert!(generator.output.contains("**Shared properties:**")); + assert!(generator.output.contains(""), + "should use title 'Session' not 'Object'" + ); + assert!( + generator + .output + .contains(""), + "should use title 'Request' not 'Object'" + ); + + // oneOf mode variants rendered under Variants + assert!(generator.output.contains("**Variants:**")); + assert!(generator.output.contains(", + /// The scope this elicitation is tied to. + #[serde(flatten)] + pub scope: ElicitationScope, /// The elicitation mode and its mode-specific fields. #[serde(flatten)] pub mode: ElicitationMode, @@ -929,23 +927,15 @@ pub struct CreateElicitationRequest { impl CreateElicitationRequest { #[must_use] - pub fn new(mode: ElicitationMode, message: impl Into) -> Self { + pub fn new(scope: ElicitationScope, mode: ElicitationMode, message: impl Into) -> Self { Self { - scope: None, + scope, mode, message: message.into(), meta: None, } } - /// Optional scope for this elicitation. - /// When absent, the elicitation is standalone and not tied to any specific context. - #[must_use] - pub fn scope(mut self, scope: impl IntoOption) -> Self { - self.scope = scope.into_option(); - self - } - /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1293,12 +1283,17 @@ mod tests { fn form_mode_request_serialization() { let schema = ElicitationSchema::new().string("name", true); let req = CreateElicitationRequest::new( + ElicitationScope::Session { + session_id: SessionId::new("sess_1"), + tool_call_id: None, + }, ElicitationMode::Form(ElicitationFormMode::new(schema)), "Please enter your name", ); let json = serde_json::to_value(&req).unwrap(); - assert!(json.get("scope").is_none()); + assert_eq!(json["sessionId"], "sess_1"); + assert!(json.get("toolCallId").is_none()); assert_eq!(json["mode"], "form"); assert_eq!(json["message"], "Please enter your name"); assert!(json["requestedSchema"].is_object()); @@ -1309,7 +1304,13 @@ mod tests { ); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); - assert_eq!(roundtripped.scope, None); + assert_eq!( + roundtripped.scope, + ElicitationScope::Session { + session_id: SessionId::new("sess_1"), + tool_call_id: None, + } + ); assert_eq!(roundtripped.message, "Please enter your name"); assert!(matches!(roundtripped.mode, ElicitationMode::Form(_))); } @@ -1317,20 +1318,20 @@ mod tests { #[test] fn url_mode_request_serialization() { let req = CreateElicitationRequest::new( + ElicitationScope::Session { + session_id: SessionId::new("sess_2"), + tool_call_id: Some(ToolCallId::new("tc_1")), + }, ElicitationMode::Url(ElicitationUrlMode::new( "elic_1", "https://example.com/auth", )), "Please authenticate", - ) - .scope(ElicitationScope::Session { - session_id: SessionId::new("sess_2"), - tool_call_id: Some(ToolCallId::new("tc_1")), - }); + ); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["scope"]["sessionId"], "sess_2"); - assert_eq!(json["scope"]["toolCallId"], "tc_1"); + assert_eq!(json["sessionId"], "sess_2"); + assert_eq!(json["toolCallId"], "tc_1"); assert_eq!(json["mode"], "url"); assert_eq!(json["elicitationId"], "elic_1"); assert_eq!(json["url"], "https://example.com/auth"); @@ -1339,10 +1340,10 @@ mod tests { let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!( roundtripped.scope, - Some(ElicitationScope::Session { + ElicitationScope::Session { session_id: SessionId::new("sess_2"), tool_call_id: Some(ToolCallId::new("tc_1")), - }) + } ); assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } @@ -1395,93 +1396,52 @@ mod tests { #[test] fn session_only_request_serialization() { let req = CreateElicitationRequest::new( + ElicitationScope::Session { + session_id: SessionId::new("sess_1"), + tool_call_id: None, + }, ElicitationMode::Form(ElicitationFormMode::new( ElicitationSchema::new().string("name", true), )), "Enter your name", - ) - .scope(ElicitationScope::Session { - session_id: SessionId::new("sess_1"), - tool_call_id: None, - }); + ); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["scope"]["sessionId"], "sess_1"); - assert!(json["scope"].get("toolCallId").is_none()); + assert_eq!(json["sessionId"], "sess_1"); + assert!(json.get("toolCallId").is_none()); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!( roundtripped.scope, - Some(ElicitationScope::Session { + ElicitationScope::Session { session_id: SessionId::new("sess_1"), tool_call_id: None, - }) + } ); } #[test] - fn request_only_request_serialization() { + fn request_scope_request_serialization() { let req = CreateElicitationRequest::new( + ElicitationScope::Request { + request_id: RequestId::Number(99), + }, ElicitationMode::Form(ElicitationFormMode::new( ElicitationSchema::new().string("workspace", true), )), "Enter workspace name", - ) - .scope(ElicitationScope::Request { - request_id: RequestId::Number(99), - }); + ); let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["scope"]["requestId"], 99); + assert_eq!(json["requestId"], 99); + assert!(json.get("sessionId").is_none()); let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); assert_eq!( roundtripped.scope, - Some(ElicitationScope::Request { + ElicitationScope::Request { request_id: RequestId::Number(99), - }) - ); - } - - #[test] - fn scope_builder_sets_and_replaces() { - let base = || { - CreateElicitationRequest::new( - ElicitationMode::Form(ElicitationFormMode::new( - ElicitationSchema::new().string("name", true), - )), - "msg", - ) - }; - - // scope builder sets session scope - let req = base().scope(ElicitationScope::Session { - session_id: SessionId::new("s1"), - tool_call_id: None, - }); - assert_eq!( - req.scope, - Some(ElicitationScope::Session { - session_id: SessionId::new("s1"), - tool_call_id: None, - }) - ); - - // calling scope again replaces the previous value - let req = base() - .scope(ElicitationScope::Request { - request_id: RequestId::Number(1), - }) - .scope(ElicitationScope::Session { - session_id: SessionId::new("s2"), - tool_call_id: Some(ToolCallId::new("tc1")), - }); - assert_eq!( - req.scope, - Some(ElicitationScope::Session { - session_id: SessionId::new("s2"), - tool_call_id: Some(ToolCallId::new("tc1")), - }) + } ); } From 171ea5e6215c5a3e4c2ba38737b8553a346e33d7 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 9 Apr 2026 17:15:24 +0200 Subject: [PATCH 09/10] Clean up docs --- docs/protocol/draft/schema.mdx | 4 +--- src/bin/generate.rs | 41 ++++++++++------------------------ src/elicitation.rs | 31 +++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index af328b13..a06b583b 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1572,7 +1572,7 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte A human-readable message describing what input is needed. -**One of:** +**Variants:** Tied to a session, optionally to a specific tool call within that session. @@ -1624,8 +1624,6 @@ Tied to a specific JSON-RPC request outside of a session -**Variants:** - Form-based elicitation where the client renders a form from the provided schema. diff --git a/src/bin/generate.rs b/src/bin/generate.rs index 85e2d850..b461ec6e 100644 --- a/src/bin/generate.rs +++ b/src/bin/generate.rs @@ -372,21 +372,15 @@ starting with '$/' it is free to ignore the notification." false }; - // When both anyOf and oneOf exist (e.g. a flattened untagged enum - // alongside a flattened tagged enum), render the anyOf variants - // first since they represent additional constraints (like scope) - // before the main discriminated variants. + // Print a single "Variants:" label before all variant groups when + // there is surrounding context that benefits from a separator + // (shared properties above, or multiple variant groups). + if has_shared_props || (any_of.is_some() && one_of.is_some()) { + writeln!(&mut self.output, "**Variants:**").unwrap(); + writeln!(&mut self.output).unwrap(); + } + if let Some(variants) = any_of { - // Label when there are other sections around this one. - if has_shared_props || one_of.is_some() { - let label = if one_of.is_some() { - "**One of:**" - } else { - "**Variants:**" - }; - writeln!(&mut self.output, "{label}").unwrap(); - writeln!(&mut self.output).unwrap(); - } for variant in variants { self.document_variant_table_row(variant); } @@ -394,11 +388,6 @@ starting with '$/' it is free to ignore the notification." } if let Some(variants) = one_of { - // Label when there are other sections above. - if has_shared_props || any_of.is_some() { - writeln!(&mut self.output, "**Variants:**").unwrap(); - writeln!(&mut self.output).unwrap(); - } for variant in variants { self.document_variant_table_row(variant); } @@ -1382,10 +1371,6 @@ starting with '$/' it is free to ignore the notification." assert!(generator.output.contains(" Date: Thu, 9 Apr 2026 17:19:34 +0200 Subject: [PATCH 10/10] Remove unneeded inline --- docs/protocol/draft/schema.mdx | 16 ++++++++++++++++ schema/schema.unstable.json | 25 ++++++++++++++++++------- src/elicitation.rs | 3 +-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index a06b583b..ca2eb5b3 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -3283,6 +3283,22 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/exte The file path being modified. +## ElicitationAcceptAction + +**UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +The user accepted the elicitation and provided content. + +**Type:** Object + +**Properties:** + + + The user-provided content, if any, as an object matching the requested schema. + + ## ElicitationCapabilities **UNSTABLE** diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index 40a92d78..2492de78 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -1752,18 +1752,16 @@ }, "oneOf": [ { + "allOf": [ + { + "$ref": "#/$defs/ElicitationAcceptAction" + } + ], "description": "The user accepted and provided content.", "properties": { "action": { "const": "accept", "type": "string" - }, - "content": { - "additionalProperties": { - "$ref": "#/$defs/ElicitationContentValue" - }, - "description": "The user-provided content, if any, as an object matching the requested schema.", - "type": ["object", "null"] } }, "required": ["action"], @@ -2091,6 +2089,19 @@ "required": ["path", "newText"], "type": "object" }, + "ElicitationAcceptAction": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", + "properties": { + "content": { + "additionalProperties": { + "$ref": "#/$defs/ElicitationContentValue" + }, + "description": "The user-provided content, if any, as an object matching the requested schema.", + "type": ["object", "null"] + } + }, + "type": "object" + }, "ElicitationCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.", "properties": { diff --git a/src/elicitation.rs b/src/elicitation.rs index 417ff496..4ce13b9c 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -1057,7 +1057,7 @@ impl CreateElicitationResponse { /// The user's action in response to an elicitation. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "action", rename_all = "snake_case")] -#[schemars(inline, extend("discriminator" = {"propertyName": "action"}))] +#[schemars(extend("discriminator" = {"propertyName": "action"}))] #[non_exhaustive] pub enum ElicitationAction { /// The user accepted and provided content. @@ -1074,7 +1074,6 @@ pub enum ElicitationAction { /// /// The user accepted the elicitation and provided content. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] -#[schemars(inline)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ElicitationAcceptAction {