feat(mcp): elicitation support (#5965)

This commit is contained in:
Alex Hancock
2025-12-05 21:29:59 -05:00
committed by GitHub
parent 1db40709bb
commit 06155ce0ca
24 changed files with 992 additions and 22 deletions
@@ -0,0 +1,99 @@
use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Mutex, RwLock};
use tokio::time::timeout;
use tracing::warn;
use uuid::Uuid;
use crate::conversation::message::{Message, MessageContent};
struct PendingRequest {
response_tx: Option<tokio::sync::oneshot::Sender<Value>>,
}
pub struct ActionRequiredManager {
pending: Arc<RwLock<HashMap<String, Arc<Mutex<PendingRequest>>>>>,
request_tx: mpsc::UnboundedSender<Message>,
pub request_rx: Mutex<mpsc::UnboundedReceiver<Message>>,
}
impl ActionRequiredManager {
fn new() -> Self {
let (request_tx, request_rx) = mpsc::unbounded_channel();
Self {
pending: Arc::new(RwLock::new(HashMap::new())),
request_tx,
request_rx: Mutex::new(request_rx),
}
}
pub fn global() -> &'static Self {
static INSTANCE: once_cell::sync::Lazy<ActionRequiredManager> =
once_cell::sync::Lazy::new(ActionRequiredManager::new);
&INSTANCE
}
pub async fn request_and_wait(
&self,
message: String,
schema: Value,
timeout_duration: Duration,
) -> Result<Value> {
let id = Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
let pending_request = PendingRequest {
response_tx: Some(tx),
};
self.pending
.write()
.await
.insert(id.clone(), Arc::new(Mutex::new(pending_request)));
let action_required_message = Message::assistant().with_content(
MessageContent::action_required_elicitation(id.clone(), message, schema),
);
if let Err(e) = self.request_tx.send(action_required_message) {
warn!("Failed to send action required message: {}", e);
}
let result = match timeout(timeout_duration, rx).await {
Ok(Ok(user_data)) => Ok(user_data),
Ok(Err(_)) => {
warn!("Response channel closed for request: {}", id);
Err(anyhow::anyhow!("Response channel closed"))
}
Err(_) => {
warn!("Timeout waiting for response: {}", id);
Err(anyhow::anyhow!("Timeout waiting for user response"))
}
};
self.pending.write().await.remove(&id);
result
}
pub async fn submit_response(&self, request_id: String, user_data: Value) -> Result<()> {
let pending_arc = {
let pending = self.pending.read().await;
pending
.get(&request_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
};
let mut pending = pending_arc.lock().await;
if let Some(tx) = pending.response_tx.take() {
if tx.send(user_data).is_err() {
warn!("Failed to send response through oneshot channel");
}
}
Ok(())
}
}
+55 -9
View File
@@ -8,6 +8,10 @@ use futures::stream::BoxStream;
use futures::{stream, FutureExt, Stream, StreamExt, TryStreamExt};
use uuid::Uuid;
use super::final_output_tool::FinalOutputTool;
use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::action_required_manager::ActionRequiredManager;
use crate::agents::extension::{ExtensionConfig, ExtensionError, ExtensionResult, ToolInfo};
use crate::agents::extension_manager::{get_parameter_names, ExtensionManager};
use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
@@ -25,6 +29,7 @@ use crate::agents::subagent_execution_tool::subagent_execute_task_tool::{
self, SUBAGENT_EXECUTE_TASK_TOOL_NAME,
};
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
use crate::agents::subagent_task_config::TaskConfig;
use crate::agents::tool_route_manager::ToolRouteManager;
use crate::agents::tool_router_index_manager::ToolRouterIndexManager;
use crate::agents::types::SessionConfig;
@@ -33,6 +38,9 @@ use crate::config::{get_enabled_extensions, Config, GooseMode};
use crate::context_mgmt::{
check_if_compaction_needed, compact_messages, DEFAULT_COMPACTION_THRESHOLD,
};
use crate::conversation::message::{
ActionRequiredData, Message, MessageContent, SystemNotificationType, ToolRequest,
};
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use crate::mcp_utils::ToolResult;
use crate::permission::permission_inspector::PermissionInspector;
@@ -41,7 +49,10 @@ use crate::permission::PermissionConfirmation;
use crate::providers::base::Provider;
use crate::providers::errors::ProviderError;
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
use crate::scheduler_trait::SchedulerTrait;
use crate::security::security_inspector::SecurityInspector;
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
use crate::session::{Session, SessionManager};
use crate::tool_inspection::ToolInspectionManager;
use crate::tool_monitor::RepetitionInspector;
use crate::utils::is_token_cancelled;
@@ -55,15 +66,6 @@ use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, instrument, warn};
use super::final_output_tool::FinalOutputTool;
use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::agents::subagent_task_config::TaskConfig;
use crate::conversation::message::{Message, SystemNotificationType, ToolRequest};
use crate::scheduler_trait::SchedulerTrait;
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
use crate::session::{Session, SessionManager};
const DEFAULT_MAX_TURNS: u32 = 1000;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
pub const MANUAL_COMPACT_TRIGGERS: &[&str] =
@@ -241,6 +243,17 @@ impl Agent {
| RetryResult::SuccessChecksPassed => Ok(false),
}
}
async fn drain_elicitation_messages(session_id: &str) -> Vec<Message> {
let mut messages = Vec::new();
let mut elicitation_rx = ActionRequiredManager::global().request_rx.lock().await;
while let Ok(elicitation_message) = elicitation_rx.try_recv() {
if let Err(e) = SessionManager::add_message(session_id, &elicitation_message).await {
warn!("Failed to save elicitation message to session: {}", e);
}
messages.push(elicitation_message);
}
messages
}
async fn prepare_reply_context(
&self,
@@ -784,6 +797,29 @@ impl Agent {
session_config: SessionConfig,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
for content in &user_message.content {
if let MessageContent::ActionRequired(action_required) = content {
if let ActionRequiredData::ElicitationResponse { id, user_data } =
&action_required.data
{
if let Err(e) = ActionRequiredManager::global()
.submit_response(id.clone(), user_data.clone())
.await
{
let error_text = format!("Failed to submit elicitation response: {}", e);
error!(error_text);
return Ok(Box::pin(stream::once(async {
Ok(AgentEvent::Message(
Message::assistant().with_text(error_text),
))
})));
}
SessionManager::add_message(&session_config.id, &user_message).await?;
return Ok(Box::pin(futures::stream::empty()));
}
}
}
let message_text = user_message.as_concat_text();
let is_manual_compact = MANUAL_COMPACT_TRIGGERS.contains(&message_text.trim());
@@ -1133,6 +1169,11 @@ impl Agent {
if is_token_cancelled(&cancel_token) {
break;
}
for msg in Self::drain_elicitation_messages(&session_config.id).await {
yield AgentEvent::Message(msg);
}
match item {
ToolStreamItem::Result(output) => {
if enable_extension_request_ids.contains(&request_id)
@@ -1151,6 +1192,11 @@ impl Agent {
}
}
// check for remaining elicitation messages after all tools complete
for msg in Self::drain_elicitation_messages(&session_config.id).await {
yield AgentEvent::Message(msg);
}
if all_install_successful && !enable_extension_request_ids.is_empty() {
if let Err(e) = self.save_extension_state(&session_config).await {
warn!("Failed to save extension state after runtime changes: {}", e);
+42 -2
View File
@@ -1,6 +1,10 @@
use crate::action_required_manager::ActionRequiredManager;
use crate::agents::types::SharedProvider;
use crate::session_context::SESSION_ID_HEADER;
use rmcp::model::{Content, ErrorCode, JsonObject};
use rmcp::model::{
Content, CreateElicitationRequestParam, CreateElicitationResult, ElicitationAction, ErrorCode,
JsonObject,
};
/// MCP client implementation for Goose
use rmcp::{
model::{
@@ -218,10 +222,46 @@ impl ClientHandler for GooseClient {
})
}
async fn create_elicitation(
&self,
request: CreateElicitationRequestParam,
_context: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, ErrorData> {
let schema_value = serde_json::to_value(&request.requested_schema).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to serialize elicitation schema: {}", e),
None,
)
})?;
ActionRequiredManager::global()
.request_and_wait(
request.message.clone(),
schema_value,
Duration::from_secs(300),
)
.await
.map(|user_data| CreateElicitationResult {
action: ElicitationAction::Accept,
content: Some(user_data),
})
.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Elicitation request timed out or failed: {}", e),
None,
)
})
}
fn get_info(&self) -> ClientInfo {
ClientInfo {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ClientCapabilities::builder().enable_sampling().build(),
capabilities: ClientCapabilities::builder()
.enable_sampling()
.enable_elicitation()
.build(),
client_info: Implementation {
name: "goose".to_string(),
version: std::env::var("GOOSE_MCP_CLIENT_VERSION")
+8 -5
View File
@@ -1,4 +1,4 @@
use crate::conversation::message::MessageMetadata;
use crate::conversation::message::{ActionRequiredData, MessageMetadata};
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::{merge_consecutive_messages, Conversation};
use crate::prompt_template::render_global_file;
@@ -376,12 +376,15 @@ fn format_message_for_compacting(msg: &Message) -> String {
format!("tool_confirmation_request: {}", req.tool_name)
}
MessageContent::ActionRequired(action) => match &action.data {
crate::conversation::message::ActionRequiredData::ToolConfirmation {
tool_name,
..
} => {
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
format!("action_required(tool_confirmation): {}", tool_name)
}
ActionRequiredData::Elicitation { message, .. } => {
format!("action_required(elicitation): {}", message)
}
ActionRequiredData::ElicitationResponse { id, .. } => {
format!("action_required(elicitation_response): {}", id)
}
},
MessageContent::FrontendToolRequest(req) => {
if let Ok(call) = &req.tool_call {
+41
View File
@@ -111,6 +111,15 @@ pub enum ActionRequiredData {
arguments: JsonObject,
prompt: Option<String>,
},
Elicitation {
id: String,
message: String,
requested_schema: serde_json::Value,
},
ElicitationResponse {
id: String,
user_data: serde_json::Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
@@ -192,6 +201,12 @@ impl fmt::Display for MessageContent {
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
write!(f, "[ActionRequired: ToolConfirmation for {}]", tool_name)
}
ActionRequiredData::Elicitation { message, .. } => {
write!(f, "[ActionRequired: Elicitation - {}]", message)
}
ActionRequiredData::ElicitationResponse { id, .. } => {
write!(f, "[ActionRequired: ElicitationResponse for {}]", id)
}
},
MessageContent::FrontendToolRequest(r) => match &r.tool_call {
Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name),
@@ -274,6 +289,32 @@ impl MessageContent {
})
}
pub fn action_required_elicitation<S: Into<String>>(
id: S,
message: String,
requested_schema: serde_json::Value,
) -> Self {
MessageContent::ActionRequired(ActionRequired {
data: ActionRequiredData::Elicitation {
id: id.into(),
message,
requested_schema,
},
})
}
pub fn action_required_elicitation_response<S: Into<String>>(
id: S,
user_data: serde_json::Value,
) -> Self {
MessageContent::ActionRequired(ActionRequired {
data: ActionRequiredData::ElicitationResponse {
id: id.into(),
user_data,
},
})
}
pub fn thinking<S1: Into<String>, S2: Into<String>>(thinking: S1, signature: S2) -> Self {
MessageContent::Thinking(ThinkingContent {
thinking: thinking.into(),
+1
View File
@@ -1,3 +1,4 @@
pub mod action_required_manager;
pub mod agents;
pub mod config;
pub mod context_mgmt;