diff --git a/Cargo.lock b/Cargo.lock index 7476dbd29..95e5b1224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5274,13 +5274,16 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "3.0.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "3875f7c471c2d709062bc2165d8dc883b021b44255bd00bb4d8025f5108178c8" dependencies = [ "async-trait", + "base64", + "bytes", "chrono", "futures", + "hmac 0.13.0", "http 1.4.2", "oauth2", "pastey", @@ -5291,6 +5294,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", + "sha2 0.11.0", "sse-stream", "thiserror 2.0.19", "tokio", @@ -5298,13 +5302,14 @@ dependencies = [ "tokio-util", "tracing", "url", + "uuid", ] [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "3.0.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "37a5ce7541aff2f3df9e8c3c48c7241800fa6fa68e2fbcba386c0d7dcdfc953c" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 55808172e..405bfb755 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ tracing-appender = "0.2" agent-client-protocol = "0.14.0" # MCP and API clients -rmcp = { version = "^1.7.0", default-features = false } +rmcp = { version = "=3.0.0-beta.2", default-features = false } async-openai = { version = "^0.41.0", features = ["byot", "chat-completion", "responses"] } reqwest = { version = "^0.13.4", default-features = false, features = ["json", "query", "rustls", "http2", "stream"] } oauth2 = "5.0" diff --git a/crates/acp-utils/src/lib.rs b/crates/acp-utils/src/lib.rs index 74d9d5ac5..27145c409 100644 --- a/crates/acp-utils/src/lib.rs +++ b/crates/acp-utils/src/lib.rs @@ -22,6 +22,6 @@ pub mod testing; // Re-export rmcp elicitation schema types so downstream crates (e.g. wisp) // don't need a direct rmcp dependency. pub use rmcp::model::{ - ConstTitle, CreateElicitationRequestParams, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchema, + ConstTitle, ElicitRequestParams, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchemaDefinition, SingleSelectEnumSchema, }; diff --git a/crates/acp-utils/src/notifications.rs b/crates/acp-utils/src/notifications.rs index 934289271..4ab031b92 100644 --- a/crates/acp-utils/src/notifications.rs +++ b/crates/acp-utils/src/notifications.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use agent_client_protocol::schema::AuthMethod; use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta}; -pub use rmcp::model::CreateElicitationRequestParams; +pub use rmcp::model::ElicitRequestParams; use serde::{Deserialize, Serialize, de::DeserializeOwned}; pub use mcp_utils::status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry}; @@ -99,7 +99,7 @@ pub struct AuthMethodsUpdatedParams { #[request(method = "_aether/elicitation", response = ElicitationResponse)] pub struct ElicitationParams { pub server_name: String, - pub request: CreateElicitationRequestParams, + pub request: ElicitRequestParams, } pub use rmcp::model::ElicitationAction; @@ -519,7 +519,7 @@ mod tests { let params = ElicitationParams { server_name: "github".to_string(), - request: CreateElicitationRequestParams::FormElicitationParams { + request: ElicitRequestParams::FormElicitationParams { meta: None, message: "Pick a color".to_string(), requested_schema: ElicitationSchema::builder() @@ -542,7 +542,7 @@ mod tests { fn elicitation_params_url_variant_has_mode_field() { let params = ElicitationParams { server_name: "github".to_string(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: "Authorize GitHub".to_string(), url: "https://github.com/login/oauth".to_string(), diff --git a/crates/acp-utils/src/testing.rs b/crates/acp-utils/src/testing.rs index b935f0e98..0caa292d5 100644 --- a/crates/acp-utils/src/testing.rs +++ b/crates/acp-utils/src/testing.rs @@ -16,7 +16,7 @@ use agent_client_protocol::schema::SessionNotification; use agent_client_protocol::{ self as acp, Agent, Builder, ByteStreams, Client, ConnectionTo, HandleDispatchFrom, NullRun, Responder, }; -use rmcp::model::{CreateElicitationRequestParams, ElicitationSchema}; +use rmcp::model::{ElicitRequestParams, ElicitationSchema}; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use tokio::io::DuplexStream; @@ -196,7 +196,7 @@ pub async fn test_connection() -> (ConnectionTo, TestPeer) { fn placeholder_params() -> ElicitationParams { ElicitationParams { server_name: String::new(), - request: CreateElicitationRequestParams::FormElicitationParams { + request: ElicitRequestParams::FormElicitationParams { meta: None, message: String::new(), requested_schema: ElicitationSchema::builder().build().expect("empty schema is valid"), diff --git a/crates/aether-auth/src/mcp/integration.rs b/crates/aether-auth/src/mcp/integration.rs index 7e249c8e6..3aa779fd3 100644 --- a/crates/aether-auth/src/mcp/integration.rs +++ b/crates/aether-auth/src/mcp/integration.rs @@ -51,8 +51,12 @@ pub async fn perform_oauth_flow( ); } - let metadata = manager.discover_metadata().await.map_err(rmcp_err("OAuth metadata discovery failed"))?; - manager.set_metadata(metadata); + // rmcp 3 resolves metadata through protected-resource metadata (RFC 9728), + // then authorization-server metadata, then a legacy-endpoint fallback. The + // returned metadata must be handed back via `set_metadata` before any client + // is configured. + let resolution = manager.resolve_metadata().await.map_err(rmcp_err("OAuth metadata discovery failed"))?; + manager.set_metadata(resolution.metadata); let scopes = manager.select_scopes(None, &[]); let scope_refs = scopes.iter().map(String::as_str).collect::>(); diff --git a/crates/aether-auth/tests/mcp_oauth.rs b/crates/aether-auth/tests/mcp_oauth.rs index cbce17485..9fde52e01 100644 --- a/crates/aether-auth/tests/mcp_oauth.rs +++ b/crates/aether-auth/tests/mcp_oauth.rs @@ -70,36 +70,61 @@ impl OAuthServer { captured_requests.lock().unwrap().push(request_line.clone()); let path = request_line.split_whitespace().nth(1).unwrap(); - let body = if path.contains("oauth-protected-resource") { - serde_json::json!({ - "resource": format!("{origin}/mcp"), - "authorization_servers": [&origin] - }) + let (status, reason, body) = if path.contains("oauth-protected-resource") { + ( + 200, + "OK", + serde_json::json!({ + "resource": format!("{origin}/mcp"), + "authorization_servers": [&origin] + }) + .to_string(), + ) } else if path == "/token" { - serde_json::json!({ - "access_token": "access-token", - "token_type": "Bearer", - "expires_in": 3600 - }) + ( + 200, + "OK", + serde_json::json!({ + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600 + }) + .to_string(), + ) } else if path == "/register" { - serde_json::json!({ - "client_id": "registered-client", - "redirect_uris": ["http://localhost:3118/"] - }) + ( + 200, + "OK", + serde_json::json!({ + "client_id": "registered-client", + "redirect_uris": ["http://localhost:3118/"] + }) + .to_string(), + ) + } else if path == "/mcp" { + // The MCP endpoint is a protected resource: an + // unauthenticated GET is rejected, which drives rmcp's + // RFC 9728 protected-resource-metadata discovery through + // the oauth-protected-resource well-known lookup. + (401, "Unauthorized", String::new()) } else { - serde_json::json!({ - "issuer": origin, - "authorization_endpoint": format!("{origin}/authorize"), - "token_endpoint": format!("{origin}/token"), - "registration_endpoint": format!("{origin}/register"), - "response_types_supported": ["code"], - "code_challenge_methods_supported": ["S256"], - "scopes_supported": ["openid"] - }) - } - .to_string(); + ( + 200, + "OK", + serde_json::json!({ + "issuer": origin, + "authorization_endpoint": format!("{origin}/authorize"), + "token_endpoint": format!("{origin}/token"), + "registration_endpoint": format!("{origin}/register"), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["openid"] + }) + .to_string(), + ) + }; let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); diff --git a/crates/aether-cli/Cargo.toml b/crates/aether-cli/Cargo.toml index d63cd47e3..b25b15d04 100644 --- a/crates/aether-cli/Cargo.toml +++ b/crates/aether-cli/Cargo.toml @@ -41,7 +41,7 @@ tui = { package = "aether-tui", path = "../tui", default-features = false, featu acp_utils = { package = "aether-acp-utils", path = "../acp-utils", features = ["server", "testing"], version = "0.3.33" } agent-client-protocol = { workspace = true } aether-lspd = { path = "../aether-lspd", version = "0.1.25" } -rmcp = { workspace = true, features = ["client", "elicitation", "server", "transport-streamable-http-client-reqwest"] } +rmcp = { workspace = true, features = ["client", "elicitation", "request-state", "server", "transport-streamable-http-client-reqwest"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "net", "fs", "time"] } tokio-util = { workspace = true, features = ["compat"] } diff --git a/crates/aether-cli/src/acp/fake_prompt_mcp.rs b/crates/aether-cli/src/acp/fake_prompt_mcp.rs index d476bf3d4..944bbe915 100644 --- a/crates/aether-cli/src/acp/fake_prompt_mcp.rs +++ b/crates/aether-cli/src/acp/fake_prompt_mcp.rs @@ -1,6 +1,6 @@ use rmcp::model::{ - GetPromptRequestParams, GetPromptResult, Implementation, ListPromptsResult, PaginatedRequestParams, - Prompt as McpPrompt, PromptMessage, PromptMessageRole, ServerCapabilities, ServerInfo, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, Implementation, ListPromptsResult, + PaginatedRequestParams, Prompt as McpPrompt, PromptMessage, Role, ServerCapabilities, ServerInfo, }; use rmcp::service::{DynService, RequestContext}; use rmcp::{ErrorData as McpError, RoleServer, ServerHandler}; @@ -33,18 +33,18 @@ impl ServerHandler for FakePromptMcp { _context: RequestContext, ) -> Result { let prompt = McpPrompt::new(&self.prompt_name, Some(format!("{} command", self.prompt_name)), None); - Ok(ListPromptsResult { prompts: vec![prompt], next_cursor: None, meta: None }) + Ok(ListPromptsResult::with_all_items(vec![prompt])) } async fn get_prompt( &self, request: GetPromptRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { if request.name.as_str() != self.prompt_name { return Err(McpError::invalid_params(format!("Prompt '{}' not found", request.name), None)); } - let messages = vec![PromptMessage::new_text(PromptMessageRole::User, format!("expanded {}", self.prompt_name))]; - Ok(GetPromptResult::new(messages)) + let messages = vec![PromptMessage::new_text(Role::User, format!("expanded {}", self.prompt_name))]; + Ok(GetPromptResult::new(messages).into()) } } diff --git a/crates/aether-cli/src/acp/session_actor.rs b/crates/aether-cli/src/acp/session_actor.rs index 2c81c6023..11d561666 100644 --- a/crates/aether-cli/src/acp/session_actor.rs +++ b/crates/aether-cli/src/acp/session_actor.rs @@ -11,7 +11,7 @@ use llm::catalog::LlmModel; use llm::parser::ModelProviderParser; use llm::{ChatMessage, ContentBlock, ProviderConnectionOverrides, ReasoningEffort}; use mcp_utils::client::{ElicitationRequest, McpClientEvent, McpServerStatusEntry, cancel_result}; -use rmcp::model::{CreateElicitationRequestParams, CreateElicitationResult}; +use rmcp::model::{ElicitRequestParams, ElicitResult}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{mpsc, watch}; @@ -592,7 +592,7 @@ async fn on_elicitation_request(connection: &ConnectionTo, elicitation: .map_err(|e| AcpServerError::protocol("_aether/elicitation", e)) { Ok(response) => { - let mut result = CreateElicitationResult::new(response.action); + let mut result = ElicitResult::new(response.action); result.content = response.content; result } @@ -617,7 +617,7 @@ fn spawn_elicitation_request(connection: &ConnectionTo, elicitation: Eli } } -fn build_elicitation_params(server_name: &str, request: &CreateElicitationRequestParams) -> ElicitationParams { +fn build_elicitation_params(server_name: &str, request: &ElicitRequestParams) -> ElicitationParams { ElicitationParams { server_name: server_name.to_string(), request: request.clone() } } @@ -787,7 +787,7 @@ mod tests { #[test] fn test_build_elicitation_params_from_form() { - let elicitation = CreateElicitationRequestParams::FormElicitationParams { + let elicitation = ElicitRequestParams::FormElicitationParams { meta: None, message: "Pick a color".to_string(), requested_schema: rmcp::model::ElicitationSchema::builder().required_bool("approved").build().unwrap(), @@ -796,18 +796,19 @@ mod tests { let params = build_elicitation_params("test-server", &elicitation); assert_eq!(params.server_name, "test-server"); match ¶ms.request { - CreateElicitationRequestParams::FormElicitationParams { message, requested_schema, .. } => { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. } => { assert_eq!(message, "Pick a color"); assert_eq!(requested_schema.properties.len(), 1); assert!(requested_schema.properties.contains_key("approved")); } - CreateElicitationRequestParams::UrlElicitationParams { .. } => panic!("Expected Form, got Url"), + ElicitRequestParams::UrlElicitationParams { .. } => panic!("Expected Form, got Url"), + _ => panic!("unexpected elicitation request variant"), } } #[test] fn test_build_elicitation_params_from_url() { - let elicitation = CreateElicitationRequestParams::UrlElicitationParams { + let elicitation = ElicitRequestParams::UrlElicitationParams { meta: None, message: "Authorize GitHub".to_string(), url: "https://github.com/login/oauth".to_string(), @@ -817,12 +818,13 @@ mod tests { let params = build_elicitation_params("github", &elicitation); assert_eq!(params.server_name, "github"); match ¶ms.request { - CreateElicitationRequestParams::UrlElicitationParams { message, url, elicitation_id, .. } => { + ElicitRequestParams::UrlElicitationParams { message, url, elicitation_id, .. } => { assert_eq!(message, "Authorize GitHub"); assert_eq!(url, "https://github.com/login/oauth"); assert_eq!(elicitation_id, "el-123"); } - CreateElicitationRequestParams::FormElicitationParams { .. } => panic!("Expected Url, got Form"), + ElicitRequestParams::FormElicitationParams { .. } => panic!("Expected Url, got Form"), + _ => panic!("unexpected elicitation request variant"), } } @@ -953,7 +955,7 @@ mod tests { let (tx, rx) = oneshot::channel(); let elicitation = ElicitationRequest { server_name: "test-server".to_string(), - request: CreateElicitationRequestParams::FormElicitationParams { + request: ElicitRequestParams::FormElicitationParams { meta: None, message: "Pick a color".to_string(), requested_schema: rmcp::model::ElicitationSchema::builder() @@ -985,7 +987,7 @@ mod tests { let (tx, rx) = oneshot::channel(); let elicitation = ElicitationRequest { server_name: "github".to_string(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: "Authorize".to_string(), url: "https://example.com/oauth".to_string(), @@ -1034,7 +1036,7 @@ mod tests { let (tx, rx) = oneshot::channel(); let elicitation = ElicitationRequest { server_name: "test-server".to_string(), - request: CreateElicitationRequestParams::FormElicitationParams { + request: ElicitRequestParams::FormElicitationParams { meta: None, message: "Pick a color".to_string(), requested_schema: rmcp::model::ElicitationSchema::builder() @@ -1067,7 +1069,7 @@ mod tests { let (tx, rx) = oneshot::channel(); let elicitation = ElicitationRequest { server_name: "test-server".to_string(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: "Authorize".to_string(), url: "https://example.com".to_string(), diff --git a/crates/aether-cli/src/slash_commands.rs b/crates/aether-cli/src/slash_commands.rs index 42d42f95e..d048c3c7d 100644 --- a/crates/aether-cli/src/slash_commands.rs +++ b/crates/aether-cli/src/slash_commands.rs @@ -1,6 +1,6 @@ use aether_core::mcp::run_mcp_task::McpCommand; use agent_client_protocol::schema::AvailableCommand; -use rmcp::model::{GetPromptResult, Prompt as McpPrompt, PromptMessageContent}; +use rmcp::model::{ContentBlock, GetPromptResult, Prompt as McpPrompt}; use std::collections::HashSet; use thiserror::Error; use tokio::sync::{mpsc, oneshot}; @@ -93,7 +93,7 @@ fn prompt_result_text(prompt_result: &GetPromptResult) -> Result Some(text.clone()), + ContentBlock::Text(text_content) => Some(text_content.text.clone()), _ => None, }) .ok_or(SlashCommandError::NoTextContent) diff --git a/crates/aether-core/src/mcp/run_mcp_task.rs b/crates/aether-core/src/mcp/run_mcp_task.rs index fa781d402..2499d29f5 100644 --- a/crates/aether-core/src/mcp/run_mcp_task.rs +++ b/crates/aether-core/src/mcp/run_mcp_task.rs @@ -8,8 +8,8 @@ use futures::stream::{self, StreamExt}; use llm::{ToolCallError, ToolCallRequest, ToolCallResult}; use rmcp::RoleClient; use rmcp::model::{ - CallToolRequestParams, CreateElicitationRequestParams, ErrorCode, GetPromptResult, ProgressNotificationParam, - Prompt, + CallToolRequestParams, ElicitRequestParams, ElicitationAction, ErrorCode, GetPromptResult, InputRequest, + InputRequiredResult, InputResponses, ProgressNotificationParam, Prompt, }; use rmcp::service::RunningService; use std::collections::HashSet; @@ -19,6 +19,16 @@ use tokio::select; use tokio::sync::mpsc; use tokio::sync::oneshot; +/// MCP error code signalling that a tool call requires URL elicitation before it +/// can proceed. rmcp 3 no longer exposes this as a named constant, so the raw +/// code (`-32042`) is kept here. +const URL_ELICITATION_REQUIRED_ERROR_CODE: ErrorCode = ErrorCode(-32042); + +/// Maximum number of MRTR (multi round-trip request) input rounds before +/// abandoning a tool call, preventing a misbehaving remote server from keeping +/// a request alive indefinitely. +const MRTR_MAX_ROUNDS: usize = 5; + /// Events emitted during tool execution lifecycle #[derive(Debug)] pub enum ToolExecutionEvent { @@ -174,58 +184,137 @@ async fn execute_mcp_call( use rmcp::model::{ClientRequest::CallToolRequest, Request, ServerResult}; use rmcp::service::PeerRequestOptions; - let handle = client - .send_cancellable_request(CallToolRequest(Request::new(params)), { - let mut opts = PeerRequestOptions::default(); - opts.timeout = Some(timeout); - opts - }) - .await - .map_err(|e| ToolCallError::from_request(request, format!("Failed to send tool request: {e}")))?; - - let progress_subscriber = client.service().progress_dispatcher.subscribe(handle.progress_token.clone()).await; - - let progress_stream = progress_subscriber - .map(move |progress| Either::Left(ToolExecutionEvent::Progress { tool_id: tool_call_id.clone(), progress })); + let mut params = params; + for round in 0..MRTR_MAX_ROUNDS { + let tool_call_id = tool_call_id.clone(); + let handle = client + .send_cancellable_request(CallToolRequest(Request::new(params.clone())), { + let mut opts = PeerRequestOptions::default(); + opts.timeout = Some(timeout); + opts + }) + .await + .map_err(|e| ToolCallError::from_request(request, format!("Failed to send tool request: {e}")))?; + + let progress_subscriber = client.service().progress_dispatcher.subscribe(handle.progress_token.clone()).await; + + let progress_stream = progress_subscriber.map(move |progress| { + Either::Left(ToolExecutionEvent::Progress { tool_id: tool_call_id.clone(), progress }) + }); - let result_stream = stream::once(handle.await_response()).map(Either::Right); - let combined_stream = stream::select(progress_stream, result_stream); - tokio::pin!(combined_stream); + let result_stream = stream::once(handle.await_response()).map(Either::Right); + let combined_stream = stream::select(progress_stream, result_stream); + tokio::pin!(combined_stream); - let server_result = loop { - match combined_stream.next().await { - Some(Either::Left(progress_event)) => { - let _ = event_tx.send(progress_event).await; - } - Some(Either::Right(result)) => { - break match result { - Ok(server_result) => server_result, - Err(e) => { - if let rmcp::service::ServiceError::McpError(ref error_data) = e - && error_data.code == ErrorCode::URL_ELICITATION_REQUIRED - { - return Err(handle_url_elicitation_required(&client, request, error_data).await); + let server_result = loop { + match combined_stream.next().await { + Some(Either::Left(progress_event)) => { + let _ = event_tx.send(progress_event).await; + } + Some(Either::Right(result)) => { + break match result { + Ok(server_result) => server_result, + Err(e) => { + if let rmcp::service::ServiceError::McpError(ref error_data) = e + && error_data.code == URL_ELICITATION_REQUIRED_ERROR_CODE + { + return Err(handle_url_elicitation_required(&client, request, error_data).await); + } + return Err(ToolCallError::from_request(request, format!("Tool execution failed: {e}"))); } - return Err(ToolCallError::from_request(request, format!("Tool execution failed: {e}"))); - } - }; + }; + } + None => { + return Err(ToolCallError::from_request(request, "Stream ended without result")); + } + } + }; + + match server_result { + ServerResult::CallToolResult(mcp_result) => return mcp_result_to_tool_call_result(request, mcp_result), + // MRTR (2026-07-28 spec): the server needs client-side input before + // the tool call can complete. Fulfil the requested inputs through + // the existing elicitation channel and retry the original call. + ServerResult::InputRequiredResult(input_required) => { + let responses = fulfil_mrtr_input_requests(&client, request, &input_required).await?; + params.input_responses = Some(responses); + params.request_state = input_required.request_state.clone(); + tracing::info!(round, "Retrying tool call after MRTR input required"); } - None => { - return Err(ToolCallError::from_request(request, "Stream ended without result")); + other => { + return Err(ToolCallError::from_request( + request, + format!("Unexpected response type from MCP server: {other:?}"), + )); } } - }; + } - let ServerResult::CallToolResult(mcp_result) = server_result else { - return Err(ToolCallError::from_request(request, "Unexpected response type from MCP server")); + Err(ToolCallError::from_request( + request, + format!("Server requested input more than {MRTR_MAX_ROUNDS} times; aborting to avoid an infinite loop"), + )) +} + +/// Fulfil the server-initiated requests carried by an MRTR `InputRequiredResult`. +/// +/// Only elicitation requests are supported — they reuse the same consent +/// channel (`McpClient::dispatch_elicitation`) as direct `create_elicitation` +/// calls and the `-32042` URL-elicitation path. Sampling and roots requests are +/// not supported and abort the call. A declined or cancelled elicitation also +/// aborts. The resulting responses are echoed back with the server's opaque +/// `requestState` on the next `tools/call` round. +async fn fulfil_mrtr_input_requests( + client: &Arc>, + request: &ToolCallRequest, + input_required: &InputRequiredResult, +) -> Result { + let server_name = client.service().server_name().to_string(); + let Some(input_requests) = input_required.input_requests.as_ref() else { + // The server only echoed request state (no new input to collect); retry. + return Ok(InputResponses::new()); }; - mcp_result_to_tool_call_result(request, mcp_result) + let mut responses = InputResponses::new(); + for (key, input_request) in input_requests { + let InputRequest::Elicitation(elicit) = input_request else { + return Err(ToolCallError::from_request( + request, + format!( + "Server '{server_name}' requested an input type that is not supported \ + (only elicitation is supported)" + ), + )); + }; + + let result = client.service().dispatch_elicitation(elicit.params.clone()).await; + match result.action { + ElicitationAction::Decline => { + return Err(ToolCallError::from_request( + request, + format!("Required input for server '{server_name}' was declined"), + )); + } + ElicitationAction::Cancel => { + return Err(ToolCallError::from_request( + request, + format!("Required input for server '{server_name}' was cancelled"), + )); + } + _ => { + let value = serde_json::to_value(&result).map_err(|e| { + ToolCallError::from_request(request, format!("Failed to serialize MRTR response: {e}")) + })?; + responses.insert(key.clone(), value); + } + } + } + Ok(responses) } #[derive(serde::Deserialize)] struct UrlElicitationRequiredData { - elicitations: Vec, + elicitations: Vec, } #[derive(Debug)] @@ -247,7 +336,7 @@ impl std::fmt::Display for UrlElicitationRequiredParseError { fn parse_required_url_elicitations( error_data: &rmcp::model::ErrorData, -) -> Result, UrlElicitationRequiredParseError> { +) -> Result, UrlElicitationRequiredParseError> { let data = error_data.data.as_ref().ok_or(UrlElicitationRequiredParseError::MissingData)?; let parsed: UrlElicitationRequiredData = serde_json::from_value(data.clone()).map_err(UrlElicitationRequiredParseError::InvalidData)?; @@ -255,7 +344,7 @@ fn parse_required_url_elicitations( let url_elicitations = parsed .elicitations .into_iter() - .filter(|elicitation| matches!(elicitation, CreateElicitationRequestParams::UrlElicitationParams { .. })) + .filter(|elicitation| matches!(elicitation, ElicitRequestParams::UrlElicitationParams { .. })) .collect::>(); if url_elicitations.is_empty() { @@ -310,6 +399,9 @@ async fn handle_url_elicitation_required( rmcp::model::ElicitationAction::Accept => { tracing::info!("User accepted URL elicitation for server '{server_name}'"); } + // `ElicitationAction` is non-exhaustive in rmcp 3; treat any future + // action as accepted so the browser flow is not blocked. + _ => {} } } @@ -342,14 +434,14 @@ mod tests { assert_eq!(parsed.elicitations.len(), 1); assert!(matches!( &parsed.elicitations[0], - CreateElicitationRequestParams::UrlElicitationParams { elicitation_id, .. } if elicitation_id == "el-1" + ElicitRequestParams::UrlElicitationParams { elicitation_id, .. } if elicitation_id == "el-1" )); } #[test] fn parse_required_url_elicitations_filters_to_url_only() { let error_data = rmcp::model::ErrorData { - code: rmcp::model::ErrorCode::URL_ELICITATION_REQUIRED, + code: URL_ELICITATION_REQUIRED_ERROR_CODE, message: "URL elicitation required".into(), data: Some(serde_json::json!({ "elicitations": [ @@ -372,7 +464,7 @@ mod tests { assert_eq!(result.len(), 1); assert!(matches!( &result[0], - CreateElicitationRequestParams::UrlElicitationParams { elicitation_id, .. } if elicitation_id == "el-1" + ElicitRequestParams::UrlElicitationParams { elicitation_id, .. } if elicitation_id == "el-1" )); } } diff --git a/crates/aether-core/src/mcp/tool_bridge.rs b/crates/aether-core/src/mcp/tool_bridge.rs index 7dfd6b2f2..f51fc08f0 100644 --- a/crates/aether-core/src/mcp/tool_bridge.rs +++ b/crates/aether-core/src/mcp/tool_bridge.rs @@ -100,7 +100,7 @@ fn maybe_spillover(tool_id: &str, result: String, max_bytes: usize, dir: &Path) fn extract_result_and_meta( structured_content: Option, - content: &[rmcp::model::Content], + content: &[rmcp::model::ContentBlock], ) -> (serde_json::Value, Option) { if let Some(mut val) = structured_content { let result_meta = extract_result_meta(&mut val); @@ -140,7 +140,7 @@ fn extract_result_meta(value: &mut serde_json::Value) -> Option mod tests { use super::*; use mcp_utils::display_meta::PlanMetaStatus; - use rmcp::model::{CallToolResult as McpCallToolResult, Content}; + use rmcp::model::{CallToolResult as McpCallToolResult, ContentBlock}; use serde::Serialize; use serde_json::json; @@ -167,7 +167,7 @@ mod tests { "_meta": { "display": { "title": "Read file", "value": "file.rs, 50 lines" } } }); let mut mcp = McpCallToolResult::structured(structured); - mcp.content = vec![Content::text("plain text fallback")]; + mcp.content = vec![ContentBlock::text("plain text fallback")]; let (result, meta) = mcp_result_to_tool_call_result(&req(), mcp).unwrap(); assert!(!result.result.contains("_meta")); @@ -237,7 +237,7 @@ mod tests { #[test] fn test_tool_call_result_falls_back_to_content() { - let mcp = McpCallToolResult::success(vec![Content::text("plain text result")]); + let mcp = McpCallToolResult::success(vec![ContentBlock::text("plain text result")]); let (result, meta) = mcp_result_to_tool_call_result(&req(), mcp).unwrap(); assert!(result.result.contains("plain text result")); assert!(meta.is_none()); @@ -314,7 +314,7 @@ mod tests { #[test] fn test_tool_call_result_handles_error() { - let mcp = McpCallToolResult::error(vec![Content::text("Error: file not found")]); + let mcp = McpCallToolResult::error(vec![ContentBlock::text("Error: file not found")]); let err = mcp_result_to_tool_call_result(&req(), mcp).unwrap_err(); assert!(err.error.contains("file not found")); } diff --git a/crates/aether-core/src/testing/agent_event_builder.rs b/crates/aether-core/src/testing/agent_event_builder.rs index 8ead91f56..d2e6f08ef 100644 --- a/crates/aether-core/src/testing/agent_event_builder.rs +++ b/crates/aether-core/src/testing/agent_event_builder.rs @@ -62,9 +62,7 @@ impl AgentEventBuilder { ) -> Self { let request_json = serde_json::to_string(request).expect("Failed to serialize request"); - let error_result = format!( - "Tool execution error: Annotated {{ raw: Text(RawTextContent {{ text: \"{error_message}\", meta: None }}), annotations: None }}" - ); + let error_result = format!("Tool execution error: {:?}", rmcp::model::ContentBlock::text(error_message)); self.push_tool_call_start(tool_call_id, name); self.push_tool_call_chunk(tool_call_id, &request_json); diff --git a/crates/aether-core/src/testing/fake_mcp.rs b/crates/aether-core/src/testing/fake_mcp.rs index fe839a16d..a4c098a5d 100644 --- a/crates/aether-core/src/testing/fake_mcp.rs +++ b/crates/aether-core/src/testing/fake_mcp.rs @@ -1,7 +1,7 @@ use rmcp::{ ErrorData as McpError, Json, RoleServer, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, Content, Implementation, ServerCapabilities, ServerInfo}, + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, service::DynService, tool, tool_handler, tool_router, }; @@ -154,13 +154,13 @@ impl FakeMcpServer { let Parameters(DivideNumbersRequest { a, b }) = request; if b == 0 { - return Ok(CallToolResult::error(vec![Content::text("Division by zero")])); + return Ok(CallToolResult::error(vec![ContentBlock::text("Division by zero")])); } let result = DivideNumbersResult { quotient: a / b }; let result_json = serde_json::to_string(&result).unwrap(); - Ok(CallToolResult::success(vec![Content::text(result_json)])) + Ok(CallToolResult::success(vec![ContentBlock::text(result_json)])) } #[tool(description = "A tool that sleeps for a specified duration (for testing timeouts)")] diff --git a/crates/aether-core/tests/mcp.rs b/crates/aether-core/tests/mcp.rs index 180034048..8781b0e1d 100644 --- a/crates/aether-core/tests/mcp.rs +++ b/crates/aether-core/tests/mcp.rs @@ -1,6 +1,7 @@ mod mcp { mod config_parser_tests; mod instructions_tests; + mod mrtr_tests; mod oauth_tests; mod tool_proxy_tests; mod url_elicitation_tests; diff --git a/crates/aether-core/tests/mcp/mrtr_tests.rs b/crates/aether-core/tests/mcp/mrtr_tests.rs new file mode 100644 index 000000000..511d44783 --- /dev/null +++ b/crates/aether-core/tests/mcp/mrtr_tests.rs @@ -0,0 +1,236 @@ +//! Integration tests for the client-side MRTR (multi round-trip request, +//! 2026-07-28 spec) handling in `run_mcp_task`. +//! +//! A remote server responds to `tools/call` with `InputRequiredResult` +//! (carrying an elicitation `inputRequest` and an opaque `requestState`). +//! The client surfaces the elicitation through its existing consent channel, +//! then retries the original call with the echoed `requestState` plus the +//! collected `inputResponses`, finally receiving a normal `CallToolResult`. + +use aether_core::mcp::mcp; +use aether_core::mcp::run_mcp_task::{McpCommand, ToolExecutionEvent}; +use mcp_utils::client::{McpServer, McpTransport}; +use rmcp::{ + RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequest, ElicitRequestParams, + ElicitationSchema, Implementation, InputRequest, InputRequests, InputRequiredResult, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + }, + service::{DynService, RequestContext}, +}; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; +use tokio::sync::mpsc; + +const REQUEST_STATE: &str = "opaque-server-state-123"; + +/// Fake server that demands an elicitation on the first `tools/call` and +/// completes once the client retries with `inputResponses`. +#[derive(Clone)] +struct MrtrServer { + request_state: String, + calls: Arc, +} + +impl MrtrServer { + fn new() -> Self { + Self { request_state: REQUEST_STATE.to_string(), calls: Arc::new(AtomicUsize::new(0)) } + } + + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn into_dyn(self) -> Box> { + Box::new(self) + } +} + +impl ServerHandler for MrtrServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("mrtr-server", "0.1.0")) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let schema = serde_json::from_value(json!({"type": "object", "properties": {}})).unwrap(); + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "needs_input", + "Returns InputRequiredResult", + Arc::new(schema), + )])) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + + // Retry: the client echoed requestState + inputResponses. + if request.input_responses.is_some() && request.request_state.as_deref() == Some(REQUEST_STATE) { + return Ok(CallToolResult::success(vec![ContentBlock::text("done after input")]).into()); + } + + // First call: request an elicitation before completing. + let schema = ElicitationSchema::builder().required_string("value").build().unwrap(); + let elicitation = ElicitRequest::new(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Pick a value".to_string(), + requested_schema: schema, + }); + let mut input_requests = InputRequests::new(); + input_requests.insert("elicit-1".to_string(), InputRequest::Elicitation(elicitation)); + Ok(InputRequiredResult::new(Some(input_requests), Some(self.request_state.clone())).into()) + } +} + +fn call_tool_request() -> llm::ToolCallRequest { + llm::ToolCallRequest { + id: "mrtr-1".to_string(), + name: "mrtr_server__needs_input".to_string(), + arguments: "{}".to_string(), + } +} + +async fn drain_until_complete( + event_rx: &mut mpsc::Receiver, +) -> (Result, Option) { + while let Some(event) = event_rx.recv().await { + if let ToolExecutionEvent::Complete { result, result_meta, .. } = event { + return (result, result_meta); + } + } + panic!("event stream ended without Complete"); +} + +async fn call_needs_input(command_tx: &mpsc::Sender) -> Result { + let (event_tx, mut event_rx) = mpsc::channel(10); + command_tx + .send(McpCommand::ExecuteTool { request: call_tool_request(), timeout: Duration::from_secs(10), tx: event_tx }) + .await + .unwrap(); + drain_until_complete(&mut event_rx).await.0 +} + +/// Spawn the manager with `server`, scripting an `Accept` response carrying +/// `content` to the first elicitation that arrives. Returns the command sender +/// and the task that captures the surfaced elicitation. +async fn spawn_with_scripted_elicitation( + server: MrtrServer, + content: serde_json::Value, +) -> (mpsc::Sender, tokio::task::JoinHandle>, MrtrServer) { + let config = McpServer::new("mrtr_server", McpTransport::InMemory { server: server.clone().into_dyn() }, false); + + let mut spawn = mcp("/workspace").with_servers(vec![config]).spawn().await.unwrap(); + let _snapshot = spawn.block_until_ready().await.expect("bootstrap completes"); + let command_tx = spawn.command_tx; + let mut event_rx = spawn.event_rx; + + let script = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + if let mcp_utils::client::McpClientEvent::Elicitation(req) = event { + let captured = (req.server_name.clone(), req.request.clone()); + let _ = req + .response_sender + .send(rmcp::model::ElicitResult::new(rmcp::model::ElicitationAction::Accept).with_content(content)); + return Some(captured); + } + } + None + }); + + (command_tx, script, server) +} + +#[tokio::test] +async fn input_required_result_surfaces_elicitation_and_retries_to_completion() { + let (command_tx, script_handle, server) = + spawn_with_scripted_elicitation(MrtrServer::new(), json!({"value": "green"})).await; + + let result = call_needs_input(&command_tx).await.expect("retry should complete the tool call"); + assert!(result.result.contains("done after input"), "result should contain final content: {}", result.result); + + // The server saw the InputRequired round and the echoed-state retry round. + assert_eq!(server.call_count(), 2, "server should be called exactly twice (input required, then complete)"); + + let (server_name, request) = script_handle.await.unwrap().expect("elicitation was never surfaced"); + assert_eq!(server_name, "mrtr_server"); + assert!(matches!(request, ElicitRequestParams::FormElicitationParams { .. }), "expected a form elicitation"); +} + +/// A server that never completes — it keeps returning `InputRequiredResult` — +/// must hit the client-side retry cap instead of looping forever. +#[derive(Clone)] +struct AlwaysInputRequiredServer; + +impl ServerHandler for AlwaysInputRequiredServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("always-mrtr", "0.1.0")) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let schema = serde_json::from_value(json!({"type": "object", "properties": {}})).unwrap(); + Ok(ListToolsResult::with_all_items(vec![Tool::new("needs_input", "Always InputRequired", Arc::new(schema))])) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let schema = ElicitationSchema::builder().required_string("value").build().unwrap(); + let elicitation = ElicitRequest::new(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Pick a value".to_string(), + requested_schema: schema, + }); + let mut input_requests = InputRequests::new(); + input_requests.insert("elicit-1".to_string(), InputRequest::Elicitation(elicitation)); + Ok(InputRequiredResult::new(Some(input_requests), Some(REQUEST_STATE.to_string())).into()) + } +} + +#[tokio::test] +async fn repeated_input_required_hits_retry_cap() { + let config = + McpServer::new("mrtr_server", McpTransport::InMemory { server: Box::new(AlwaysInputRequiredServer) }, false); + + let mut spawn = mcp("/workspace").with_servers(vec![config]).spawn().await.unwrap(); + let _snapshot = spawn.block_until_ready().await.expect("bootstrap completes"); + let mut event_rx = spawn.event_rx; + + // Script the elicitation so the client keeps retrying until the cap. + let script = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + if let mcp_utils::client::McpClientEvent::Elicitation(req) = event { + let _ = req.response_sender.send( + rmcp::model::ElicitResult::new(rmcp::model::ElicitationAction::Accept) + .with_content(json!({"value": "x"})), + ); + } + } + }); + + let result = call_needs_input(&spawn.command_tx).await; + drop(script); + let err = result.expect_err("repeated input required should hit the retry cap"); + assert!( + err.error.contains("more than") && err.error.contains("times"), + "error should mention the retry cap: {}", + err.error + ); +} diff --git a/crates/aether-core/tests/mcp/url_elicitation_tests.rs b/crates/aether-core/tests/mcp/url_elicitation_tests.rs index 8c35a37e6..231a05efa 100644 --- a/crates/aether-core/tests/mcp/url_elicitation_tests.rs +++ b/crates/aether-core/tests/mcp/url_elicitation_tests.rs @@ -4,7 +4,7 @@ use mcp_utils::client::{McpServer, McpTransport}; use rmcp::{ RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResult, CreateElicitationRequestParams, ElicitationAction, ErrorCode, ErrorData, + CallToolRequestParams, CallToolResponse, ElicitRequestParams, ElicitationAction, ErrorCode, ErrorData, Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }, service::{DynService, RequestContext}, @@ -14,6 +14,10 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; +/// MCP error code for `URL_ELICITATION_REQUIRED`. rmcp 3 no longer exposes this as +/// a named constant, so the raw code (`-32042`) is kept here. +const URL_ELICITATION_REQUIRED: ErrorCode = ErrorCode(-32042); + /// Fake MCP server whose single tool always fails with `-32042` /// `URL_ELICITATION_REQUIRED`, carrying one URL elicitation request in `data`. #[derive(Clone, Default)] @@ -63,18 +67,18 @@ impl ServerHandler for UrlElicitationRequiredServer { "properties": {} })) .unwrap(); - Ok(ListToolsResult { - tools: vec![Tool::new("needs_browser", "Always returns URL_ELICITATION_REQUIRED", Arc::new(input_schema))], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "needs_browser", + "Always returns URL_ELICITATION_REQUIRED", + Arc::new(input_schema), + )])) } async fn call_tool( &self, _request: CallToolRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { let data = json!({ "elicitations": [ { @@ -85,7 +89,7 @@ impl ServerHandler for UrlElicitationRequiredServer { } ] }); - Err(ErrorData::new(ErrorCode::URL_ELICITATION_REQUIRED, "browser interaction required", Some(data))) + Err(ErrorData::new(URL_ELICITATION_REQUIRED, "browser interaction required", Some(data))) } } @@ -105,27 +109,19 @@ impl ServerHandler for MalformedUrlElicitationRequiredServer { "properties": {} })) .unwrap(); - Ok(ListToolsResult { - tools: vec![Tool::new( - "needs_browser", - "Returns malformed URL_ELICITATION_REQUIRED data", - Arc::new(input_schema), - )], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "needs_browser", + "Returns malformed URL_ELICITATION_REQUIRED data", + Arc::new(input_schema), + )])) } async fn call_tool( &self, _request: CallToolRequestParams, _context: RequestContext, - ) -> Result { - Err(ErrorData::new( - ErrorCode::URL_ELICITATION_REQUIRED, - "browser interaction required", - Some(self.data.clone()), - )) + ) -> Result { + Err(ErrorData::new(URL_ELICITATION_REQUIRED, "browser interaction required", Some(self.data.clone()))) } } @@ -156,7 +152,7 @@ fn call_tool_request() -> llm::ToolCallRequest { /// manager. Includes the server name and the raw request params for assertion. struct CapturedElicitation { server_name: String, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, } /// Spawn an MCP manager with one fake server that always returns @@ -179,11 +175,7 @@ async fn spawn_scripted( while let Some(event) = event_rx.recv().await { if let mcp_utils::client::McpClientEvent::Elicitation(req) = event { let captured = CapturedElicitation { server_name: req.server_name, request: req.request }; - let _ = req.response_sender.send(rmcp::model::CreateElicitationResult { - action: user_action, - content: None, - meta: Option::default(), - }); + let _ = req.response_sender.send(rmcp::model::ElicitResult::new(user_action)); return Some(captured); } } @@ -236,13 +228,14 @@ async fn url_elicitation_required_accept_returns_retry_needed_error_without_url( let captured = script_handle.await.unwrap().expect("elicitation was never dispatched"); assert_eq!(captured.server_name, "browser_server"); match captured.request { - CreateElicitationRequestParams::UrlElicitationParams { url: req_url, elicitation_id, .. } => { + ElicitRequestParams::UrlElicitationParams { url: req_url, elicitation_id, .. } => { assert_eq!(req_url, url); assert_eq!(elicitation_id, "el-42"); } - CreateElicitationRequestParams::FormElicitationParams { .. } => { + ElicitRequestParams::FormElicitationParams { .. } => { panic!("expected UrlElicitationParams, got FormElicitationParams") } + _ => panic!("unexpected elicitation request variant"), } } diff --git a/crates/mcp-servers/src/coding/mod.rs b/crates/mcp-servers/src/coding/mod.rs index d53b4af31..ca199c636 100644 --- a/crates/mcp-servers/src/coding/mod.rs +++ b/crates/mcp-servers/src/coding/mod.rs @@ -7,7 +7,7 @@ use rmcp::{ wrapper::{Json, Parameters}, }, model::{ - CreateElicitationRequestParams, ElicitationSchema, EnumSchema, Implementation, ProgressNotificationParam, + ElicitRequestParams, ElicitationSchema, EnumSchema, Implementation, ProgressNotificationParam, ServerCapabilities, ServerInfo, }, service::RequestContext, @@ -168,15 +168,7 @@ async fn notify_preview(context: &RequestContext, meta: ToolDisplayM if let Some(token) = context.meta.get_progress_token() { let result_meta = ToolResultMeta::from(meta); let message = serde_json::to_string(&result_meta).unwrap_or_default(); - let _ = context - .peer - .notify_progress(ProgressNotificationParam { - progress_token: token, - progress: 0.0, - total: None, - message: Some(message), - }) - .await; + let _ = context.peer.notify_progress(ProgressNotificationParam::new(token, 0.0).with_message(message)).await; } } @@ -321,7 +313,7 @@ When using tools that take file paths, always use absolute paths from: let message = format!("Allow {tool_name}: {description}?"); let result = context .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message, requested_schema: ElicitationSchema::builder() @@ -667,12 +659,7 @@ When using tools that take file paths, always use absolute paths from: if let Some(token) = progress_token { let message = format!("Search rate limited; retrying in {} seconds.", delay.as_secs()); let _ = peer - .notify_progress(ProgressNotificationParam { - progress_token: token, - progress: 0.0, - total: None, - message: Some(message), - }) + .notify_progress(ProgressNotificationParam::new(token, 0.0).with_message(message)) .await; } } diff --git a/crates/mcp-servers/src/plan/server.rs b/crates/mcp-servers/src/plan/server.rs index d19653dec..8192a773e 100644 --- a/crates/mcp-servers/src/plan/server.rs +++ b/crates/mcp-servers/src/plan/server.rs @@ -10,9 +10,9 @@ use rmcp::{ wrapper::{Json, Parameters}, }, model::{ - CreateElicitationRequestParams, ElicitationAction, ElicitationSchema, EnumSchema, GetPromptRequestParams, - GetPromptResult, Implementation, ListPromptsResult, Meta, PaginatedRequestParams, Prompt, PromptArgument, - PromptMessage, PromptMessageRole, ServerCapabilities, ServerInfo, + ElicitRequestParams, ElicitationAction, ElicitationSchema, EnumSchema, GetPromptRequestParams, + GetPromptResponse, GetPromptResult, Implementation, ListPromptsResult, MetaObject, PaginatedRequestParams, + Prompt, PromptArgument, PromptMessage, RequestMetaObject, Role, ServerCapabilities, ServerInfo, }, service::RequestContext, tool, tool_handler, tool_router, @@ -202,10 +202,10 @@ impl PlanMcp { Ok(Json(SubmitPlanOutput { approved: false, feedback })) } - fn build_elicitation_form(plan: &Plan) -> Result { + fn build_elicitation_form(plan: &Plan) -> Result { let meta = PlanReviewElicitationMeta::new(&plan.path, &plan.content) .to_json() - .map(Meta) + .map(|map| RequestMetaObject(MetaObject(map))) .map_err(|e| format!("failed to serialize plan review metadata: {e}"))?; let approve = PlanReviewDecision::Approve.as_str(); @@ -216,7 +216,7 @@ impl PlanMcp { .map_err(|e| format!("failed to build decision schema: {e}"))? .build(); - Ok(CreateElicitationRequestParams::FormElicitationParams { + Ok(ElicitRequestParams::FormElicitationParams { meta: Some(meta), message: format!("Approve plan {}? Review the markdown and choose approve or deny.", plan.path.display()), requested_schema: ElicitationSchema::builder() @@ -251,14 +251,14 @@ impl ServerHandler for PlanMcp { ]), ); - Ok(ListPromptsResult { prompts: vec![prompt], next_cursor: None, meta: None }) + Ok(ListPromptsResult::with_all_items(vec![prompt])) } async fn get_prompt( &self, request: GetPromptRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { if request.name.as_str() != PROMPT_NAME { return Err(McpError::invalid_params(format!("Prompt '{}' not found", request.name), None)); } @@ -273,8 +273,8 @@ impl ServerHandler for PlanMcp { }); let content = substitute_parameters(&prompt, &arguments); - let messages = vec![PromptMessage::new_text(PromptMessageRole::User, content)]; - Ok(GetPromptResult::new(messages).with_description("Enter plan mode.".to_string())) + let messages = vec![PromptMessage::new_text(Role::User, content)]; + Ok(GetPromptResult::new(messages).with_description("Enter plan mode.".to_string()).into()) } } diff --git a/crates/mcp-servers/src/skills/server.rs b/crates/mcp-servers/src/skills/server.rs index e90ef3753..2c372fb05 100644 --- a/crates/mcp-servers/src/skills/server.rs +++ b/crates/mcp-servers/src/skills/server.rs @@ -6,8 +6,8 @@ use rmcp::{ wrapper::{Json, Parameters}, }, model::{ - GetPromptRequestParams, GetPromptResult, Implementation, ListPromptsResult, PaginatedRequestParams, Prompt, - PromptArgument, PromptMessage, PromptMessageRole, ServerCapabilities, ServerInfo, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, Implementation, ListPromptsResult, + PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, Role, ServerCapabilities, ServerInfo, }, service::RequestContext, tool, tool_handler, tool_router, @@ -281,14 +281,14 @@ impl ServerHandler for SkillsMcp { }) .collect(); - Ok(ListPromptsResult { prompts, next_cursor: None, meta: None }) + Ok(ListPromptsResult::with_all_items(prompts)) } async fn get_prompt( &self, request: GetPromptRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { let catalog = self.catalog.read().await; let spec = catalog .slash_commands() @@ -308,9 +308,9 @@ impl ServerHandler for SkillsMcp { let expander = ShellExpander::new(); let cwd = WorkspacePaths::new(self.root_dir.clone()).root().to_path_buf(); let content = expander.expand(&content, &cwd).await; - let messages = vec![PromptMessage::new_text(PromptMessageRole::User, content)]; + let messages = vec![PromptMessage::new_text(Role::User, content)]; - Ok(GetPromptResult::new(messages).with_description(spec.description.clone())) + Ok(GetPromptResult::new(messages).with_description(spec.description.clone()).into()) } } diff --git a/crates/mcp-servers/src/subagents/server.rs b/crates/mcp-servers/src/subagents/server.rs index eb9ccae08..e27ccd736 100644 --- a/crates/mcp-servers/src/subagents/server.rs +++ b/crates/mcp-servers/src/subagents/server.rs @@ -166,14 +166,12 @@ impl SubAgentsMcp { let progress_data_str = serde_json::to_string(&progress_payload).unwrap_or_default(); tokio::spawn(async move { + #[allow(clippy::cast_precision_loss)] + let progress = counter as f64; let _ = peer - .notify_progress(ProgressNotificationParam { - progress_token: token, - #[allow(clippy::cast_precision_loss)] - progress: counter as f64, - total: None, - message: Some(progress_data_str), - }) + .notify_progress( + ProgressNotificationParam::new(token, progress).with_message(progress_data_str), + ) .await; }); } diff --git a/crates/mcp-servers/src/survey/server.rs b/crates/mcp-servers/src/survey/server.rs index 9dcbcb53f..94b8a98b6 100644 --- a/crates/mcp-servers/src/survey/server.rs +++ b/crates/mcp-servers/src/survey/server.rs @@ -5,8 +5,7 @@ use rmcp::{ wrapper::{Json, Parameters}, }, model::{ - CreateElicitationRequestParams, ElicitationAction, ElicitationSchema, Implementation, ServerCapabilities, - ServerInfo, + ElicitRequestParams, ElicitationAction, ElicitationSchema, Implementation, ServerCapabilities, ServerInfo, }, service::RequestContext, tool, tool_handler, tool_router, @@ -88,7 +87,7 @@ impl SurveyMcp { let schema = parse_schema(args.schema).map_err(|e| e.to_string())?; let result = context .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: args.message, requested_schema: schema, diff --git a/crates/mcp-servers/tests/plan_mcp.rs b/crates/mcp-servers/tests/plan_mcp.rs index e6ff5815b..d6f72e531 100644 --- a/crates/mcp-servers/tests/plan_mcp.rs +++ b/crates/mcp-servers/tests/plan_mcp.rs @@ -5,7 +5,7 @@ use mcp_servers::file_ops::FileEdit; use mcp_servers::plan::{EditPlanInput, SubmitPlanInput, WritePlanInput}; use mcp_servers::{DEFAULT_PLAN_PROMPT, PlanMcp}; use mcp_utils::client::{McpClient, McpClientEvent}; -use rmcp::model::{CreateElicitationRequestParams, CreateElicitationResult, ElicitationAction, GetPromptRequestParams}; +use rmcp::model::{ElicitRequestParams, ElicitResult, ElicitationAction, GetPromptRequestParams}; use serde_json::json; use std::fs; use tempfile::TempDir; @@ -35,8 +35,8 @@ async fn submit_plan_raw(mcp: &TestClient, plan_name: &str) fn respond_to_elicitation_request( mut event_rx: mpsc::Receiver, - response: CreateElicitationResult, -) -> tokio::task::JoinHandle> { + response: ElicitResult, +) -> tokio::task::JoinHandle> { tokio::spawn(async move { while let Some(event) = event_rx.recv().await { if let McpClientEvent::Elicitation(req) = event { @@ -63,11 +63,7 @@ async fn submit_plan_attaches_plan_review_metadata_and_preserves_schema() -> Tes let task_handle = respond_to_elicitation_request( event_rx, - CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(json!({ "decision": "approve" })), - meta: None, - }, + ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "decision": "approve" })), ); let mcp = TestClient::start_with(|| PlanMcp::new().with_plans_dir(temp_dir.path().to_path_buf()), client).await?; @@ -77,8 +73,7 @@ async fn submit_plan_attaches_plan_review_metadata_and_preserves_schema() -> Tes let plan_path = temp_dir.path().join("example-plan.md"); let elicitation_request = task_handle.await?.expect("expected elicitation request"); - let CreateElicitationRequestParams::FormElicitationParams { meta, requested_schema, .. } = elicitation_request - else { + let ElicitRequestParams::FormElicitationParams { meta, requested_schema, .. } = elicitation_request else { panic!("submit_plan should issue form elicitation request"); }; @@ -252,7 +247,7 @@ async fn get_prompt_falls_back_when_configured_file_missing() { fn extract_user_text(message: &rmcp::model::PromptMessage) -> String { match &message.content { - rmcp::model::PromptMessageContent::Text { text } => text.clone(), + rmcp::model::ContentBlock::Text(text_content) => text_content.text.clone(), other => panic!("expected text content, got {other:?}"), } } diff --git a/crates/mcp-servers/tests/stdio_transport.rs b/crates/mcp-servers/tests/stdio_transport.rs index c8f33e62b..6c839b91a 100644 --- a/crates/mcp-servers/tests/stdio_transport.rs +++ b/crates/mcp-servers/tests/stdio_transport.rs @@ -16,7 +16,7 @@ fn tool_names(tools: &[rmcp::model::Tool]) -> Vec<&str> { tools.iter().map(|t| t.name.as_ref()).collect() } -fn extract_text(content: &rmcp::model::Content) -> &str { +fn extract_text(content: &rmcp::model::ContentBlock) -> &str { content.as_text().expect("expected text content").text.as_str() } diff --git a/crates/mcp-utils/Cargo.toml b/crates/mcp-utils/Cargo.toml index 3e64ff44c..1f5910071 100644 --- a/crates/mcp-utils/Cargo.toml +++ b/crates/mcp-utils/Cargo.toml @@ -39,7 +39,7 @@ client = [ futures = { workspace = true } serde = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "sync", "net", "time"] } -rmcp = { workspace = true, features = ["client", "server"] } +rmcp = { workspace = true, features = ["client", "request-state", "server"] } tracing = { workspace = true } utils = { package = "aether-utils", path = "../utils", version = "0.2.11" } schemars = { workspace = true } diff --git a/crates/mcp-utils/src/client/connection.rs b/crates/mcp-utils/src/client/connection.rs index 30d297bb4..88b4e05bb 100644 --- a/crates/mcp-utils/src/client/connection.rs +++ b/crates/mcp-utils/src/client/connection.rs @@ -7,12 +7,12 @@ use crate::{client::OAuthHandlerContext, transport::create_in_memory_transport}; use aether_auth::{OAuthCredentialStorage, create_auth_manager_from_store, perform_oauth_flow}; use llm::ToolAnnotations; use rmcp::{ - RoleClient, RoleServer, ServiceExt, - model::{ClientInfo, Tool as RmcpTool}, - serve_client, + ClientCacheConfig, ClientLifecycleMode, RoleClient, RoleServer, ServiceExt, + model::{ClientInfo, ProtocolVersion, Tool as RmcpTool}, + serve_client_with_lifecycle, service::{DynService, RunningService}, transport::{ - StreamableHttpClientTransport, TokioChildProcess, auth::AuthClient, + IntoTransport, StreamableHttpClientTransport, TokioChildProcess, auth::AuthClient, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -104,7 +104,7 @@ impl McpServerConnection { mcp_client: McpClient, ) -> Result { let transport = StreamableHttpClientTransport::with_client(auth_client, config); - let client = serve_client(mcp_client, transport) + let client = serve_client_modern(mcp_client, transport) .await .map_err(|e| McpError::ConnectionFailed(format!("reconnect failed for '{name}': {e}")))?; Ok(Self::from_parts(client, None)) @@ -215,7 +215,7 @@ async fn connect_stdio( }; let stderr_task = stderr.map(|stderr| spawn_stderr_logger(server_name.to_string(), stderr)); - match serve_client(mcp_client, proc).await { + match serve_client_modern(mcp_client, proc).await { Ok(client) => McpConnectOutcome::Connected { conn: McpServerConnection::from_parts(client, stderr_task), reauth_config: None, @@ -290,10 +290,10 @@ async fn connect_http( tracing::debug!("Using OAuth for server '{name}'"); let auth_client = AuthClient::new(reqwest::Client::default(), auth_manager); let transport = StreamableHttpClientTransport::with_client(auth_client, config.transport.clone()); - serve_client(mcp_client, transport).await.map_err(conn_err) + serve_client_modern(mcp_client, transport).await.map_err(conn_err) } else { let transport = StreamableHttpClientTransport::from_config(config.transport.clone()); - serve_client(mcp_client, transport).await.map_err(conn_err) + serve_client_modern(mcp_client, transport).await.map_err(conn_err) }; match result { @@ -341,9 +341,37 @@ async fn serve_in_memory( } }); - let client = serve_client(mcp_client, client_transport) + let client = serve_client_modern(mcp_client, client_transport) .await .map_err(|e| McpError::ConnectionFailed(format!("Failed to connect to in-memory server '{label}': {e}")))?; Ok((client, server_handle)) } + +/// Start an MCP client using rmcp's modern lifecycle: it probes +/// `server/discover` (negotiating the `2026-07-28` protocol, which unlocks MRTR +/// and other stateless features when the server supports it) and transparently +/// falls back to the legacy `initialize` handshake for older servers. A +/// server-advertised TTL-honoring response cache is enabled so repeated +/// `tools/list` / `resources/list` / `prompts/list` calls within the server's +/// `ttlMs` are served from cache instead of re-fetched. +async fn serve_client_modern( + mcp_client: McpClient, + transport: T, +) -> std::result::Result, rmcp::service::ClientInitializeError> +where + T: IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + let client = serve_client_with_lifecycle( + mcp_client, + transport, + ClientLifecycleMode::Auto { + preferred_versions: vec![ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25], + legacy_version: None, + }, + ) + .await?; + client.peer().set_response_cache_config(ClientCacheConfig::default()).await; + Ok(client) +} diff --git a/crates/mcp-utils/src/client/manager.rs b/crates/mcp-utils/src/client/manager.rs index d182dbbbb..e233155ff 100644 --- a/crates/mcp-utils/src/client/manager.rs +++ b/crates/mcp-utils/src/client/manager.rs @@ -16,8 +16,8 @@ use futures::future::join_all; use rmcp::{ RoleClient, model::{ - CallToolRequestParams, ClientCapabilities, ClientInfo, CreateElicitationRequestParams, CreateElicitationResult, - ElicitationAction, FormElicitationCapability, Implementation, Tool as RmcpTool, UrlElicitationCapability, + CallToolRequestParams, ClientCapabilities, ClientInfo, ElicitRequestParams, ElicitResult, ElicitationAction, + FormElicitationCapability, Implementation, Tool as RmcpTool, UrlElicitationCapability, }, service::RunningService, }; @@ -49,8 +49,8 @@ pub struct OAuthHandlerContext { #[derive(Debug)] pub struct ElicitationRequest { pub server_name: String, - pub request: CreateElicitationRequestParams, - pub response_sender: oneshot::Sender, + pub request: ElicitRequestParams, + pub response_sender: oneshot::Sender, } #[derive(Debug, Clone)] diff --git a/crates/mcp-utils/src/client/mcp_client.rs b/crates/mcp-utils/src/client/mcp_client.rs index 63670c74e..516661cf7 100644 --- a/crates/mcp-utils/src/client/mcp_client.rs +++ b/crates/mcp-utils/src/client/mcp_client.rs @@ -3,8 +3,8 @@ use rmcp::{ ClientHandler, RoleClient, handler::client::progress::ProgressDispatcher, model::{ - ClientInfo, CreateElicitationRequestParams, CreateElicitationResult, ElicitationAction, - ElicitationResponseNotificationParam, ErrorData, ProgressNotificationParam, + ClientInfo, CustomNotification, ElicitRequestParams, ElicitResult, ElicitationAction, ErrorData, + ProgressNotificationParam, }, service::{NotificationContext, RequestContext}, }; @@ -33,7 +33,7 @@ impl McpClient { /// /// Used by both the `create_elicitation` handler and the `-32042` /// `URL_ELICITATION_REQUIRED` error path to ensure the same user-facing flow. - pub async fn dispatch_elicitation(&self, request: CreateElicitationRequestParams) -> CreateElicitationResult { + pub async fn dispatch_elicitation(&self, request: ElicitRequestParams) -> ElicitResult { let (response_tx, response_rx) = oneshot::channel(); let elicitation_request = ElicitationRequest { server_name: self.server_name.clone(), request, response_sender: response_tx }; @@ -59,8 +59,8 @@ impl McpClient { } } -pub fn cancel_result() -> CreateElicitationResult { - CreateElicitationResult { action: ElicitationAction::Cancel, content: None, meta: Option::default() } +pub fn cancel_result() -> ElicitResult { + ElicitResult::new(ElicitationAction::Cancel) } impl ClientHandler for McpClient { @@ -74,21 +74,38 @@ impl ClientHandler for McpClient { async fn create_elicitation( &self, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { Ok(self.dispatch_elicitation(request).await) } - async fn on_url_elicitation_notification_complete( + // rmcp 3 routes `notifications/elicitation/response` (the URL-elicitation + // completion signal) through the generic custom-notification channel + // instead of a dedicated handler. Extract the `elicitationId` and forward it + // through the shared event channel so the host can mark the flow complete. + async fn on_custom_notification( &self, - params: ElicitationResponseNotificationParam, + notification: CustomNotification, _context: NotificationContext, ) { - self.forward_url_elicitation_complete(params.elicitation_id).await; + if notification.method.as_str() == URL_ELICITATION_RESPONSE_NOTIFICATION_METHOD + && let Some(id) = extract_elicitation_id(notification.params.as_ref()) + { + self.forward_url_elicitation_complete(id).await; + } } } +const URL_ELICITATION_RESPONSE_NOTIFICATION_METHOD: &str = "notifications/elicitation/response"; + +fn extract_elicitation_id(params: Option<&serde_json::Value>) -> Option { + params + .and_then(|value| value.get("elicitationId").or_else(|| value.get("elicitation_id"))) + .and_then(|id| id.as_str()) + .map(str::to_owned) +} + #[cfg(test)] mod tests { use super::*; @@ -122,7 +139,7 @@ mod tests { let (event_tx, _) = mpsc::channel(1); let client = make_client(event_tx); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "test".to_string(), requested_schema: ElicitationSchema::new(BTreeMap::new()), @@ -138,7 +155,7 @@ mod tests { let (event_tx, mut event_rx) = mpsc::channel(1); let client = make_client(event_tx); - let request = CreateElicitationRequestParams::FormElicitationParams { + let request = ElicitRequestParams::FormElicitationParams { meta: None, message: "test".to_string(), requested_schema: ElicitationSchema::new(BTreeMap::new()), @@ -162,7 +179,7 @@ mod tests { let (event_tx, mut event_rx) = mpsc::channel(1); let client = make_client(event_tx); - let request = CreateElicitationRequestParams::UrlElicitationParams { + let request = ElicitRequestParams::UrlElicitationParams { meta: None, message: "Auth".to_string(), url: "https://example.com/auth".to_string(), @@ -173,11 +190,7 @@ mod tests { let event = event_rx.recv().await.unwrap(); let elicitation = unwrap_elicitation(event); assert_eq!(elicitation.server_name, "test-server"); - let _ = elicitation.response_sender.send(CreateElicitationResult { - action: ElicitationAction::Accept, - content: None, - meta: Option::default(), - }); + let _ = elicitation.response_sender.send(ElicitResult::new(ElicitationAction::Accept)); }); let result = client.dispatch_elicitation(request).await; diff --git a/crates/mcp-utils/src/client/oauth_handler.rs b/crates/mcp-utils/src/client/oauth_handler.rs index 3a843272a..568531cad 100644 --- a/crates/mcp-utils/src/client/oauth_handler.rs +++ b/crates/mcp-utils/src/client/oauth_handler.rs @@ -1,7 +1,7 @@ use crate::client::manager::{ElicitationRequest, McpClientEvent, OAuthHandlerContext, UrlElicitationCompleteParams}; use aether_auth::{OAuthCallback, OAuthError, OAuthHandler, accept_oauth_callback}; use futures::future::BoxFuture; -use rmcp::model::{CreateElicitationRequestParams, ElicitationAction}; +use rmcp::model::{ElicitRequestParams, ElicitationAction}; use std::num::NonZeroU16; use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot}; @@ -46,7 +46,7 @@ impl OAuthHandler for ElicitingOAuthHandler { let (response_sender, response_rx) = oneshot::channel(); let request = ElicitationRequest { server_name: self.server_name.clone(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: "Open this URL to authorize MCP server access.".to_string(), url: auth_url, diff --git a/crates/wisp/src/components/app/mod.rs b/crates/wisp/src/components/app/mod.rs index b645a6e0f..21c17d20b 100644 --- a/crates/wisp/src/components/app/mod.rs +++ b/crates/wisp/src/components/app/mod.rs @@ -28,9 +28,7 @@ use crate::workspace_status::WorkspaceStatus; use acp_utils::client::{AcpEvent, AcpPromptHandle}; use acp_utils::config_meta::SelectOptionMeta; use acp_utils::config_option_id::ConfigOptionId; -use acp_utils::notifications::{ - AetherCapabilities, CreateElicitationRequestParams, ElicitationAction, ElicitationResponse, -}; +use acp_utils::notifications::{AetherCapabilities, ElicitRequestParams, ElicitationAction, ElicitationResponse}; use agent_client_protocol::Responder; use agent_client_protocol::schema::{self as acp, SessionId}; use attachments::build_attachment_blocks; @@ -739,12 +737,12 @@ impl Component for App { } } -fn plan_review_meta_from_request(request: &CreateElicitationRequestParams) -> Option { +fn plan_review_meta_from_request(request: &ElicitRequestParams) -> Option { match request { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } => { - PlanReviewElicitationMeta::parse(meta.as_ref().map(|meta| &meta.0)) + ElicitRequestParams::FormElicitationParams { meta, .. } => { + PlanReviewElicitationMeta::parse(meta.as_ref().map(|request_meta| &request_meta.0.0)) } - CreateElicitationRequestParams::UrlElicitationParams { .. } => None, + _ => None, } } @@ -908,7 +906,7 @@ mod tests { acp_utils::notifications::ElicitationParams { server_name: "plan-server".to_string(), - request: acp_utils::notifications::CreateElicitationRequestParams::FormElicitationParams { + request: acp_utils::notifications::ElicitRequestParams::FormElicitationParams { meta: Some( serde_json::from_value(serde_json::Value::Object(meta)) .expect("deserialize plan review metadata into rmcp meta"), diff --git a/crates/wisp/src/components/conversation_screen.rs b/crates/wisp/src/components/conversation_screen.rs index 6302cf7ea..c80a3a93c 100644 --- a/crates/wisp/src/components/conversation_screen.rs +++ b/crates/wisp/src/components/conversation_screen.rs @@ -10,7 +10,7 @@ use crate::components::session_picker::{SessionEntry, SessionPicker, SessionPick use crate::components::tool_call_statuses::{PromptTermination, ToolCallStatuses}; use crate::components::workspace_picker::{WorkspacePicker, WorkspacePickerMessage}; use crate::keybindings::Keybindings; -use acp_utils::CreateElicitationRequestParams; +use acp_utils::ElicitRequestParams; use acp_utils::notifications::{ AetherCapabilities, ElicitationResponse, PromptSearchParams, PromptSearchResponse, SessionPreviewResponse, WorkspaceEntry, WorkspaceMoveTarget, @@ -309,7 +309,7 @@ impl ConversationScreen { params: acp_utils::notifications::ElicitationParams, responder: Responder, ) { - if let CreateElicitationRequestParams::UrlElicitationParams { elicitation_id, .. } = ¶ms.request { + if let ElicitRequestParams::UrlElicitationParams { elicitation_id, .. } = ¶ms.request { self.pending_url_elicitations.insert((params.server_name.clone(), elicitation_id.clone())); } self.active_modal = Some(Modal::Elicitation(ElicitationForm::from_params(params, responder))); diff --git a/crates/wisp/src/components/elicitation_form.rs b/crates/wisp/src/components/elicitation_form.rs index ed15c6132..855008713 100644 --- a/crates/wisp/src/components/elicitation_form.rs +++ b/crates/wisp/src/components/elicitation_form.rs @@ -1,9 +1,8 @@ use acp_utils::notifications::{ - CreateElicitationRequestParams, ElicitationAction, ElicitationParams, ElicitationResponse, - UrlElicitationCompleteParams, + ElicitRequestParams, ElicitationAction, ElicitationParams, ElicitationResponse, UrlElicitationCompleteParams, }; use acp_utils::{ - ConstTitle, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchema, SingleSelectEnumSchema, + ConstTitle, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchemaDefinition, SingleSelectEnumSchema, }; use agent_client_protocol::Responder; use std::io::Write; @@ -212,13 +211,16 @@ impl ElicitationForm { U: Fn(&str) -> Result<(), UrlHandlerError> + Send + Sync + 'static, { let ui = match params.request { - CreateElicitationRequestParams::FormElicitationParams { message, requested_schema, .. } => { + ElicitRequestParams::FormElicitationParams { message, requested_schema, .. } => { let fields = parse_schema(&requested_schema); ElicitationUi::Form(Form::new(message, fields)) } - CreateElicitationRequestParams::UrlElicitationParams { message, url, elicitation_id, .. } => { + ElicitRequestParams::UrlElicitationParams { message, url, elicitation_id, .. } => { ElicitationUi::Url(UrlPrompt::new(params.server_name, elicitation_id, message, url)) } + // `ElicitRequestParams` is non-exhaustive; surface an unknown request + // type as an empty form the user can dismiss. + _ => ElicitationUi::Form(Form::new("Unsupported elicitation request".to_string(), Vec::new())), }; Self { ui, @@ -410,13 +412,15 @@ fn parse_schema(schema: &ElicitationSchema) -> Vec { .collect() } -fn parse_field_kind(prop: &PrimitiveSchema) -> FormFieldKind { +fn parse_field_kind(prop: &PrimitiveSchemaDefinition) -> FormFieldKind { match prop { - PrimitiveSchema::Boolean(b) => FormFieldKind::Boolean(Checkbox::new(b.default.unwrap_or(false))), - PrimitiveSchema::Integer(_) => FormFieldKind::Number(NumberField::new(String::new(), true)), - PrimitiveSchema::Number(_) => FormFieldKind::Number(NumberField::new(String::new(), false)), - PrimitiveSchema::String(_) => FormFieldKind::Text(TextField::new(String::new())), - PrimitiveSchema::Enum(e) => parse_enum_field(e), + PrimitiveSchemaDefinition::Boolean(b) => FormFieldKind::Boolean(Checkbox::new(b.default.unwrap_or(false))), + PrimitiveSchemaDefinition::Integer(_) => FormFieldKind::Number(NumberField::new(String::new(), true)), + PrimitiveSchemaDefinition::Number(_) => FormFieldKind::Number(NumberField::new(String::new(), false)), + PrimitiveSchemaDefinition::Enum(e) => parse_enum_field(e), + // `PrimitiveSchemaDefinition` is non-exhaustive; fall back to a free-text + // field for `String` and any future primitive kind. + _ => FormFieldKind::Text(TextField::new(String::new())), } } @@ -435,6 +439,7 @@ fn parse_enum_field(e: &EnumSchema) -> FormFieldKind { t.default.as_ref().and_then(|d| options.iter().position(|o| o.value == *d)).unwrap_or(0); FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx)) } + _ => FormFieldKind::SingleSelect(RadioSelect::new(Vec::new(), 0)), }, EnumSchema::Multi(m) => match m { MultiSelectEnumSchema::Untitled(u) => { @@ -449,29 +454,32 @@ fn parse_enum_field(e: &EnumSchema) -> FormFieldKind { let selected: Vec = options.iter().map(|o| defaults.contains(&o.value)).collect(); FormFieldKind::MultiSelect(MultiSelect::new(options, selected)) } + _ => FormFieldKind::MultiSelect(MultiSelect::new(Vec::new(), Vec::new())), }, EnumSchema::Legacy(l) => { let options = options_from_strings(&l.enum_); FormFieldKind::SingleSelect(RadioSelect::new(options, 0)) } + _ => FormFieldKind::SingleSelect(RadioSelect::new(Vec::new(), 0)), } } -fn extract_metadata(prop: &PrimitiveSchema) -> (Option, Option) { +fn extract_metadata(prop: &PrimitiveSchemaDefinition) -> (Option, Option) { match prop { - PrimitiveSchema::String(s) => { + PrimitiveSchemaDefinition::String(s) => { (s.title.as_ref().map(ToString::to_string), s.description.as_ref().map(ToString::to_string)) } - PrimitiveSchema::Number(n) => { + PrimitiveSchemaDefinition::Number(n) => { (n.title.as_ref().map(ToString::to_string), n.description.as_ref().map(ToString::to_string)) } - PrimitiveSchema::Integer(i) => { + PrimitiveSchemaDefinition::Integer(i) => { (i.title.as_ref().map(ToString::to_string), i.description.as_ref().map(ToString::to_string)) } - PrimitiveSchema::Boolean(b) => { + PrimitiveSchemaDefinition::Boolean(b) => { (b.title.as_ref().map(ToString::to_string), b.description.as_ref().map(ToString::to_string)) } - PrimitiveSchema::Enum(e) => extract_enum_metadata(e), + PrimitiveSchemaDefinition::Enum(e) => extract_enum_metadata(e), + _ => (None, None), } } @@ -484,6 +492,7 @@ fn extract_enum_metadata(e: &EnumSchema) -> (Option, Option) { SingleSelectEnumSchema::Titled(t) => { (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string)) } + _ => (None, None), }, EnumSchema::Multi(m) => match m { MultiSelectEnumSchema::Untitled(u) => { @@ -492,10 +501,12 @@ fn extract_enum_metadata(e: &EnumSchema) -> (Option, Option) { MultiSelectEnumSchema::Titled(t) => { (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string)) } + _ => (None, None), }, EnumSchema::Legacy(l) => { (l.title.as_ref().map(ToString::to_string), l.description.as_ref().map(ToString::to_string)) } + _ => (None, None), } } diff --git a/crates/wisp/src/test_helpers.rs b/crates/wisp/src/test_helpers.rs index 1d5ce5d5e..a5aaf8a36 100644 --- a/crates/wisp/src/test_helpers.rs +++ b/crates/wisp/src/test_helpers.rs @@ -1,6 +1,6 @@ use crate::settings::WISP_HOME_ENV_MUTEX; use acp_utils::ElicitationSchema; -use acp_utils::notifications::{CreateElicitationRequestParams, ElicitationParams}; +use acp_utils::notifications::{ElicitRequestParams, ElicitationParams}; use std::path::Path; use tui::{Event, KeyCode, KeyEvent, KeyModifiers}; @@ -27,11 +27,7 @@ pub fn elicitation_params( ) -> ElicitationParams { ElicitationParams { server_name: server.into(), - request: CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: message.into(), - requested_schema, - }, + request: ElicitRequestParams::FormElicitationParams { meta: None, message: message.into(), requested_schema }, } } @@ -51,7 +47,7 @@ pub fn url_elicitation_params_with_message( ) -> ElicitationParams { ElicitationParams { server_name: server.into(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: message.into(), url: url.into(), diff --git a/crates/wisp/tests/app_tests/mcp_oauth_elicitation_tests.rs b/crates/wisp/tests/app_tests/mcp_oauth_elicitation_tests.rs index 8ee344376..3d378680a 100644 --- a/crates/wisp/tests/app_tests/mcp_oauth_elicitation_tests.rs +++ b/crates/wisp/tests/app_tests/mcp_oauth_elicitation_tests.rs @@ -1,7 +1,7 @@ use super::common::*; use acp_utils::{ notifications::{ - CreateElicitationRequestParams, ElicitationAction, ElicitationParams, McpNotification, McpServerAuthCapability, + ElicitRequestParams, ElicitationAction, ElicitationParams, McpNotification, McpServerAuthCapability, McpServerStatus, McpServerStatusEntry, UrlElicitationCompleteParams, }, testing::test_connection, @@ -99,7 +99,7 @@ fn url_elicitation_params( ) -> ElicitationParams { ElicitationParams { server_name: server_name.into(), - request: CreateElicitationRequestParams::UrlElicitationParams { + request: ElicitRequestParams::UrlElicitationParams { meta: None, message: message.into(), url: url.into(),