diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index df72c3f8965f..5b120c25feac 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -537,6 +537,7 @@ mod tests { id: "test-id".to_string(), tool_call: Ok(tool_call), metadata: None, + tool_meta: None, }; let result = tool_request_to_markdown(&tool_request, true); @@ -561,6 +562,7 @@ mod tests { id: "test-id".to_string(), tool_call: Ok(tool_call), metadata: None, + tool_meta: None, }; let result = tool_request_to_markdown(&tool_request, true); @@ -701,6 +703,7 @@ mod tests { id: "shell-cat".to_string(), tool_call: Ok(tool_call), metadata: None, + tool_meta: None, }; let python_code = r#"#!/usr/bin/env python3 @@ -754,6 +757,7 @@ if __name__ == "__main__": id: "git-status".to_string(), tool_call: Ok(git_status_call), metadata: None, + tool_meta: None, }; let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs"; @@ -799,6 +803,7 @@ if __name__ == "__main__": id: "cargo-build".to_string(), tool_call: Ok(cargo_build_call), metadata: None, + tool_meta: None, }; let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose) @@ -850,6 +855,7 @@ warning: unused variable `x` id: "curl-api".to_string(), tool_call: Ok(curl_call), metadata: None, + tool_meta: None, }; let api_response = r#"{ @@ -905,6 +911,7 @@ warning: unused variable `x` id: "editor-write".to_string(), tool_call: Ok(editor_call), metadata: None, + tool_meta: None, }; let text_content = TextContent { @@ -952,6 +959,7 @@ warning: unused variable `x` id: "editor-view".to_string(), tool_call: Ok(editor_call), metadata: None, + tool_meta: None, }; let python_code = r#"import os @@ -1008,6 +1016,7 @@ def process_data(data: List[Dict]) -> List[Dict]: id: "shell-error".to_string(), tool_call: Ok(error_call), metadata: None, + tool_meta: None, }; let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory @@ -1050,6 +1059,7 @@ Command failed with exit code 2"#; id: "script-exec".to_string(), tool_call: Ok(script_call), metadata: None, + tool_meta: None, }; let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ] @@ -1103,6 +1113,7 @@ Command failed with exit code 2"#; id: "multi-cmd".to_string(), tool_call: Ok(multi_call), metadata: None, + tool_meta: None, }; let multi_output = r#"total 24 @@ -1154,6 +1165,7 @@ drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc id: "grep-search".to_string(), tool_call: Ok(grep_call), metadata: None, + tool_meta: None, }; let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result { @@ -1204,6 +1216,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul id: "json-test".to_string(), tool_call: Ok(tool_call), metadata: None, + tool_meta: None, }; let json_output = r#"{"status": "success", "data": {"count": 42}}"#; @@ -1245,6 +1258,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul id: "npm-install".to_string(), tool_call: Ok(npm_call), metadata: None, + tool_meta: None, }; let npm_output = r#"added 57 packages, and audited 58 packages in 3s diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 3f8786502ad6..70b871fa7048 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -12,6 +12,7 @@ use axum::{ }; use goose::config::PermissionManager; +use base64::Engine; use goose::agents::ExtensionConfig; use goose::config::{Config, GooseMode}; use goose::model::ModelConfig; @@ -94,9 +95,15 @@ pub struct ReadResourceRequest { uri: String, } -#[derive(Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ReadResourceResponse { - html: String, + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + text: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option>, } #[derive(Deserialize, utoipa::ToSchema)] @@ -587,13 +594,15 @@ async fn read_resource( State(state): State>, Json(payload): Json, ) -> Result, StatusCode> { + use rmcp::model::ResourceContents; + let agent = state .get_agent_for_route(payload.session_id.clone()) .await?; - let html = agent + let read_result = agent .extension_manager - .read_ui_resource( + .read_resource( &payload.uri, &payload.extension_name, CancellationToken::default(), @@ -601,7 +610,43 @@ async fn read_resource( .await .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(ReadResourceResponse { html })) + let content = read_result + .contents + .into_iter() + .next() + .ok_or(StatusCode::NOT_FOUND)?; + + let (uri, mime_type, text, meta) = match content { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + meta, + } => (uri, mime_type, text, meta), + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + meta, + } => { + let decoded = match base64::engine::general_purpose::STANDARD.decode(&blob) { + Ok(bytes) => { + String::from_utf8(bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + } + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + (uri, mime_type, decoded, meta) + } + }; + + let meta_map = meta.map(|m| m.0); + + Ok(Json(ReadResourceResponse { + uri, + mime_type, + text, + meta: meta_map, + })) } #[utoipa::path( @@ -649,7 +694,7 @@ async fn call_tool( content: result.content, structured_content: result.structured_content, is_error: result.is_error.unwrap_or(false), - _meta: None, + _meta: result.meta.and_then(|m| serde_json::to_value(m).ok()), })) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 2dd5503b8b67..1fb2165aca50 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1176,6 +1176,7 @@ impl Agent { request.id.clone(), request.tool_call.clone(), request.metadata.as_ref(), + request.tool_meta.clone(), ); messages_to_add.push(request_msg); let final_response = tool_response_messages[idx] diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 6de616a3a208..206b873c89cb 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -41,8 +41,8 @@ use crate::oauth::oauth_flow; use crate::prompt_template; use crate::subprocess::configure_command_no_window; use rmcp::model::{ - CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, RawContent, - Resource, ResourceContents, ServerInfo, Tool, + CallToolRequestParam, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource, + ResourceContents, ServerInfo, Tool, }; use rmcp::transport::auth::AuthClient; use schemars::_private::NoSerialize; @@ -713,14 +713,13 @@ impl ExtensionManager { input_schema: tool.input_schema, annotations: tool.annotations, output_schema: tool.output_schema, - icons: None, - title: None, - meta: None, + icons: tool.icons, + title: tool.title, + meta: tool.meta, }); } } - // Exit loop when there are no more pages if client_tools.next_cursor.is_none() { break; } @@ -773,7 +772,7 @@ impl ExtensionManager { } // Function that gets executed for read_resource tool - pub async fn read_resource( + pub async fn read_resource_tool( &self, params: Value, cancellation_token: CancellationToken, @@ -784,14 +783,17 @@ impl ExtensionManager { // If extension name is provided, we can just look it up if extension_name.is_some() { - let result = self - .read_resource_from_extension( - uri, - extension_name.unwrap(), - cancellation_token.clone(), - true, - ) + let read_result = self + .read_resource(uri, extension_name.unwrap(), cancellation_token.clone()) .await?; + + let mut result = Vec::new(); + for content in read_result.contents { + if let ResourceContents::TextResourceContents { text, .. } = content { + let content_str = format!("{}\n\n{}", uri, text); + result.push(Content::text(content_str)); + } + } return Ok(result); } @@ -804,16 +806,20 @@ impl ExtensionManager { let extension_names: Vec = self.extensions.lock().await.keys().cloned().collect(); for extension_name in extension_names { - let result = self - .read_resource_from_extension( - uri, - &extension_name, - cancellation_token.clone(), - true, - ) + let read_result = self + .read_resource(uri, &extension_name, cancellation_token.clone()) .await; - match result { - Ok(result) => return Ok(result), + match read_result { + Ok(read_result) => { + let mut result = Vec::new(); + for content in read_result.contents { + if let ResourceContents::TextResourceContents { text, .. } = content { + let content_str = format!("{}\n\n{}", uri, text); + result.push(Content::text(content_str)); + } + } + return Ok(result); + } Err(_) => continue, } } @@ -839,13 +845,12 @@ impl ExtensionManager { )) } - async fn read_resource_from_extension( + pub async fn read_resource( &self, uri: &str, extension_name: &str, cancellation_token: CancellationToken, - format_with_uri: bool, - ) -> Result, ErrorData> { + ) -> Result { let available_extensions = self .extensions .lock() @@ -865,7 +870,7 @@ impl ExtensionManager { .ok_or(ErrorData::new(ErrorCode::INVALID_PARAMS, error_msg, None))?; let client_guard = client.lock().await; - let read_result = client_guard + client_guard .read_resource(uri, cancellation_token) .await .map_err(|_| { @@ -874,21 +879,7 @@ impl ExtensionManager { format!("Could not read resource with uri: {}", uri), None, ) - })?; - - let mut result = Vec::new(); - for content in read_result.contents { - if let ResourceContents::TextResourceContents { text, .. } = content { - let content_str = if format_with_uri { - format!("{}\n\n{}", uri, text) - } else { - text - }; - result.push(Content::text(content_str)); - } - } - - Ok(result) + }) } pub async fn get_ui_resources(&self) -> Result, ErrorData> { @@ -925,31 +916,6 @@ impl ExtensionManager { Ok(ui_resources) } - pub async fn read_ui_resource( - &self, - uri: &str, - extension_name: &str, - cancellation_token: CancellationToken, - ) -> Result { - let contents = self - .read_resource_from_extension(uri, extension_name, cancellation_token, false) - .await?; - - contents - .into_iter() - .find_map(|c| match c.raw { - RawContent::Text(text_content) => Some(text_content.text), - _ => None, - }) - .ok_or_else(|| { - ErrorData::new( - ErrorCode::RESOURCE_NOT_FOUND, - format!("No text content in resource '{}'", uri), - None, - ) - }) - } - async fn list_resources_from_extension( &self, extension_name: &str, diff --git a/crates/goose/src/agents/extension_manager_extension.rs b/crates/goose/src/agents/extension_manager_extension.rs index c8304fbb25a3..49e09ed066aa 100644 --- a/crates/goose/src/agents/extension_manager_extension.rs +++ b/crates/goose/src/agents/extension_manager_extension.rs @@ -257,7 +257,7 @@ impl ExtensionManagerClient { .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); match extension_manager - .read_resource(params, tokio_util::sync::CancellationToken::default()) + .read_resource_tool(params, tokio_util::sync::CancellationToken::default()) .await { Ok(content) => Ok(content), diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index cd00a07d07c4..e98bcf145c2f 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -274,6 +274,10 @@ impl Agent { let schema_value = Value::Object(tool.input_schema.as_ref().clone()); tool_call.arguments = coerce_tool_arguments(tool_call.arguments.clone(), &schema_value); + + if let Some(ref meta) = tool.meta { + coerced_req.tool_meta = serde_json::to_value(meta).ok(); + } } } @@ -286,22 +290,29 @@ impl Agent { // Create a filtered message with frontend tool requests removed let mut filtered_content = Vec::new(); + let mut tool_request_index = 0; - // Process each content item one by one for content in &response.content { - let should_include = match content { - MessageContent::ToolRequest(req) => { - if let Ok(tool_call) = &req.tool_call { - !self.is_frontend_tool(&tool_call.name).await - } else { - true + match content { + MessageContent::ToolRequest(_) => { + if tool_request_index < tool_requests.len() { + let coerced_req = &tool_requests[tool_request_index]; + tool_request_index += 1; + + let should_include = if let Ok(tool_call) = &coerced_req.tool_call { + !self.is_frontend_tool(&tool_call.name).await + } else { + true + }; + + if should_include { + filtered_content.push(MessageContent::ToolRequest(coerced_req.clone())); + } } } - _ => true, - }; - - if should_include { - filtered_content.push(content.clone()); + _ => { + filtered_content.push(content.clone()); + } } } diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index dd10dde8f830..a257f7a1d75d 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -68,6 +68,9 @@ pub struct ToolRequest { #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Object)] pub metadata: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub tool_meta: Option, } impl ToolRequest { @@ -259,6 +262,7 @@ impl MessageContent { id: id.into(), tool_call, metadata: None, + tool_meta: None, }) } @@ -271,6 +275,7 @@ impl MessageContent { id: id.into(), tool_call, metadata: metadata.cloned(), + tool_meta: None, }) } @@ -667,10 +672,14 @@ impl Message { id: S, tool_call: ToolResult, metadata: Option<&ProviderMetadata>, + tool_meta: Option, ) -> Self { - self.with_content(MessageContent::tool_request_with_metadata( - id, tool_call, metadata, - )) + self.with_content(MessageContent::ToolRequest(ToolRequest { + id: id.into(), + tool_call, + metadata: metadata.cloned(), + tool_meta, + })) } /// Add a tool response to the message diff --git a/crates/goose/src/security/security_inspector.rs b/crates/goose/src/security/security_inspector.rs index 7b9ba6333bed..3fb601d0d0fb 100644 --- a/crates/goose/src/security/security_inspector.rs +++ b/crates/goose/src/security/security_inspector.rs @@ -114,6 +114,7 @@ mod tests { arguments: Some(object!({"command": "rm -rf /"})), }), metadata: None, + tool_meta: None, }]; let results = inspector.inspect(&tool_requests, &[]).await.unwrap(); diff --git a/crates/goose/src/tool_inspection.rs b/crates/goose/src/tool_inspection.rs index 277c83a0ba5d..bea409600f57 100644 --- a/crates/goose/src/tool_inspection.rs +++ b/crates/goose/src/tool_inspection.rs @@ -296,6 +296,7 @@ mod tests { arguments: Some(object!({})), }), metadata: None, + tool_meta: None, }; let permission_result = PermissionCheckResult { diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 0094986cda49..a5da5bf3d011 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4571,10 +4571,23 @@ "ReadResourceResponse": { "type": "object", "required": [ - "html" + "uri", + "text" ], "properties": { - "html": { + "_meta": { + "type": "object", + "additionalProperties": {}, + "nullable": true + }, + "mimeType": { + "type": "string", + "nullable": true + }, + "text": { + "type": "string" + }, + "uri": { "type": "string" } } @@ -5690,6 +5703,9 @@ "toolCall" ], "properties": { + "_meta": { + "type": "object" + }, "id": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index fb2745b028db..7a63aa388b76 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -686,7 +686,12 @@ export type ReadResourceRequest = { }; export type ReadResourceResponse = { - html: string; + _meta?: { + [key: string]: unknown; + } | null; + mimeType?: string | null; + text: string; + uri: string; }; export type Recipe = { @@ -1039,6 +1044,9 @@ export type ToolPermission = { }; export type ToolRequest = { + _meta?: { + [key: string]: unknown; + }; id: string; metadata?: { [key: string]: unknown; diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 194bafccdfcb..66c58074a414 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -151,6 +151,7 @@ export default function GooseMessage({ {toolRequests.map((toolRequest) => (
(null); + const [resourceCsp, setResourceCsp] = useState(null); + const [error, setError] = useState(null); const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT); - // Handle MCP requests from the guest app - const handleMcpRequest = useCallback( - async (method: string, params: unknown, id?: string | number): Promise => { - console.log(`[MCP App] Request: ${method}`, { params, id }); + useEffect(() => { + const fetchResource = async () => { + try { + const response = await readResource({ + body: { + session_id: sessionId, + uri: resourceUri, + extension_name: extensionName, + }, + }); + + if (response.data) { + const content = response.data; + + setResourceHtml(content.text); + + const meta = content._meta as { ui?: { csp?: CspMetadata } } | undefined; + setResourceCsp(meta?.ui?.csp || null); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load resource'); + } + }; + fetchResource(); + }, [resourceUri, extensionName, sessionId]); + + const handleMcpRequest = useCallback( + async ( + method: string, + params: Record = {}, + _id?: string | number + ): Promise => { switch (method) { - case 'ui/open-link': - if (params && typeof params === 'object' && 'url' in params) { - const { url } = params as { url: string }; - window.electron.openExternal(url).catch(console.error); - return { status: 'success', message: 'Link opened successfully' }; - } - throw new Error('Invalid params for ui/open-link'); - - case 'ui/message': - if (params && typeof params === 'object' && 'content' in params) { - const content = params.content as { type: string; text: string }; - if (!append) { - throw new Error('Message handler not available in this context'); - } - if (!content.text) { - throw new Error('Missing message text'); - } - append(content.text); - window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); - return { status: 'success', message: 'Message appended successfully' }; + case 'ui/open-link': { + const { url } = params as McpMethodParams['ui/open-link']; + await window.electron.openExternal(url); + return { + status: 'success', + message: 'Link opened successfully', + } satisfies McpMethodResponse['ui/open-link']; + } + + case 'ui/message': { + const { content } = params as McpMethodParams['ui/message']; + if (!append) { + throw new Error('Message handler not available in this context'); } - throw new Error('Invalid params for ui/message'); - - case 'notifications/message': - case 'tools/call': - case 'resources/list': - case 'resources/templates/list': - case 'resources/read': - case 'prompts/list': + append(content.text); + window.dispatchEvent(new CustomEvent('scroll-chat-to-bottom')); + return { + status: 'success', + message: 'Message appended successfully', + } satisfies McpMethodResponse['ui/message']; + } + + case 'tools/call': { + const { name, arguments: args } = params as McpMethodParams['tools/call']; + const fullToolName = `${extensionName}__${name}`; + const response = await callTool({ + body: { + session_id: sessionId, + name: fullToolName, + arguments: args || {}, + }, + }); + return { + content: response.data?.content || [], + isError: response.data?.is_error || false, + structuredContent: (response.data as Record)?.structured_content as + | Record + | undefined, + } satisfies McpMethodResponse['tools/call']; + } + + case 'resources/read': { + const { uri } = params as McpMethodParams['resources/read']; + const response = await readResource({ + body: { + session_id: sessionId, + uri, + extension_name: extensionName, + }, + }); + return { + contents: response.data ? [response.data] : [], + } satisfies McpMethodResponse['resources/read']; + } + + case 'notifications/message': { + const { level, logger, data } = params as McpMethodParams['notifications/message']; + console.log( + `[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`, + data + ); + return {} satisfies McpMethodResponse['notifications/message']; + } + case 'ping': - console.warn(`[MCP App] TODO: ${method} not yet implemented`); - throw new Error(`Method not implemented: ${method}`); + return {} satisfies McpMethodResponse['ping']; default: throw new Error(`Unknown method: ${method}`); } }, - [append] + [append, sessionId, extensionName] ); const handleSizeChanged = useCallback((height: number, _width?: number) => { @@ -84,9 +161,9 @@ export default function McpAppRenderer({ }, []); const { iframeRef, proxyUrl } = useSandboxBridge({ - resourceHtml: resource.text || '', - resourceCsp: resource._meta?.ui?.csp || null, - resourceUri: resource.uri, + resourceHtml: resourceHtml || '', + resourceCsp, + resourceUri, toolInput, toolInputPartial, toolResult, @@ -95,17 +172,26 @@ export default function McpAppRenderer({ onSizeChanged: handleSizeChanged, }); - if (!resource) { - return null; + if (error) { + return ( +
+
Failed to load MCP app: {error}
+
+ ); + } + + if (!resourceHtml) { + return ( +
+
+ Loading MCP app... +
+
+ ); } return ( -
+
{proxyUrl ? (