diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 9c6b2be5fe..392a00e3db 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -14,6 +14,7 @@ use goose::agents::{Container, ExtensionLoadResult}; use goose::goose_apps::{fetch_mcp_apps, GooseApp, McpAppCache}; use base64::Engine; +use goose::agents::reply_parts::is_tool_visible_to_app; use goose::agents::ExtensionConfig; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, GooseMode}; @@ -1047,6 +1048,18 @@ async fn call_tool( .get_agent_for_route(payload.session_id.clone()) .await?; + // Check app-side visibility: reject calls to tools that exclude "app" + let tools = agent.list_tools(&payload.session_id, None).await; + if let Some(tool) = tools.iter().find(|t| *t.name == payload.name) { + if !is_tool_visible_to_app(tool) { + warn!( + tool = %payload.name, + "Rejected app call to model-only tool" + ); + return Err(StatusCode::FORBIDDEN); + } + } + let arguments = match payload.arguments { Value::Object(map) => Some(map), _ => None, diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 36c179fed3..ee31d401e1 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1920,14 +1920,17 @@ impl Agent { .build(); let recipe_prompt = prompt_manager.get_recipe_prompt().await; - let tools = self + let tools: Vec<_> = self .extension_manager .get_prefixed_tools(session_id, None) .await .map_err(|e| { tracing::error!("Failed to get tools for recipe creation: {}", e); e - })?; + })? + .into_iter() + .filter(super::reply_parts::is_tool_visible_to_model) + .collect(); messages.push(Message::user().with_text(recipe_prompt)); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index eba1f01937..1b41a74318 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -12,7 +12,7 @@ pub mod moim; pub mod platform_extensions; pub mod platform_tools; pub mod prompt_manager; -mod reply_parts; +pub mod reply_parts; pub mod retry; mod schedule_tool; pub mod subagent_execution_tool; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 7046a729f4..7091acde14 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -201,6 +201,10 @@ impl Agent { .collect(); } + // Filter out tools not visible to the model per MCP Apps visibility spec. + // Tools with `_meta.ui.visibility` that doesn't include "model" are app-only. + tools.retain(is_tool_visible_to_model); + // Stable tool ordering is important for multi session prompt caching. tools.sort_by(|a, b| a.name.cmp(&b.name)); @@ -470,6 +474,48 @@ impl Agent { } } +/// Check whether a tool should be callable by an app based on MCP Apps visibility metadata. +/// +/// Per the MCP Apps spec (2026-01-26), if `_meta.ui.visibility` is present and does not +/// include `"app"`, the tool is model-only and must not be callable by app UIs. +/// If the field is absent, the tool defaults to visible to both model and app. +pub fn is_tool_visible_to_app(tool: &Tool) -> bool { + let Some(meta) = &tool.meta else { + return true; + }; + let Some(ui) = meta.0.get("ui") else { + return true; + }; + let Some(visibility) = ui.get("visibility") else { + return true; + }; + let Some(arr) = visibility.as_array() else { + return true; + }; + arr.iter().any(|v| v.as_str() == Some("app")) +} + +/// Check whether a tool should be visible to the model based on MCP Apps visibility metadata. +/// +/// Per the MCP Apps spec (2026-01-26), tools may declare `_meta.ui.visibility` as an array +/// of `"model"` and/or `"app"`. If the field is absent, the tool defaults to visible to both. +/// If present and does not include `"model"`, the tool is app-only and must not be sent to the LLM. +pub fn is_tool_visible_to_model(tool: &Tool) -> bool { + let Some(meta) = &tool.meta else { + return true; + }; + let Some(ui) = meta.0.get("ui") else { + return true; + }; + let Some(visibility) = ui.get("visibility") else { + return true; + }; + let Some(arr) = visibility.as_array() else { + return true; + }; + arr.iter().any(|v| v.as_str() == Some("model")) +} + #[cfg(test)] mod tests { use super::*; @@ -614,4 +660,97 @@ mod tests { "Error should have been propagated, not silently ignored" ); } + + fn make_tool_with_meta(meta_json: Option) -> Tool { + let mut tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" })); + if let Some(v) = meta_json { + let obj = v.as_object().unwrap().clone(); + tool = tool.with_meta(rmcp::model::Meta(obj)); + } + tool + } + + #[test] + fn test_tool_visible_when_no_meta() { + let tool = make_tool_with_meta(None); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_visible_when_meta_has_no_ui() { + let tool = make_tool_with_meta(Some(serde_json::json!({"other": "stuff"}))); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_visible_when_ui_has_no_visibility() { + let tool = make_tool_with_meta(Some( + serde_json::json!({"ui": {"resourceUri": "ui://foo/bar"}}), + )); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_visible_when_visibility_includes_model() { + let tool = make_tool_with_meta(Some( + serde_json::json!({"ui": {"visibility": ["model", "app"]}}), + )); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_visible_when_visibility_is_model_only() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": ["model"]}}))); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_hidden_when_visibility_is_app_only() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": ["app"]}}))); + assert!(!is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_hidden_when_visibility_is_empty() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": []}}))); + assert!(!is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_tool_visible_when_visibility_is_not_array() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": "model"}}))); + assert!(is_tool_visible_to_model(&tool)); + } + + #[test] + fn test_app_visible_when_no_meta() { + let tool = make_tool_with_meta(None); + assert!(is_tool_visible_to_app(&tool)); + } + + #[test] + fn test_app_visible_when_visibility_includes_app() { + let tool = make_tool_with_meta(Some( + serde_json::json!({"ui": {"visibility": ["model", "app"]}}), + )); + assert!(is_tool_visible_to_app(&tool)); + } + + #[test] + fn test_app_visible_when_visibility_is_app_only() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": ["app"]}}))); + assert!(is_tool_visible_to_app(&tool)); + } + + #[test] + fn test_app_hidden_when_visibility_is_model_only() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": ["model"]}}))); + assert!(!is_tool_visible_to_app(&tool)); + } + + #[test] + fn test_app_hidden_when_visibility_is_empty() { + let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": []}}))); + assert!(!is_tool_visible_to_app(&tool)); + } } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index ade7d0b226..1bbf2d0c33 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -231,7 +231,12 @@ async fn build_subagent_prompt( session_id: &str, system_instructions: String, ) -> Result { - let tools = agent.list_tools(session_id, None).await; + let tools: Vec<_> = agent + .list_tools(session_id, None) + .await + .into_iter() + .filter(super::reply_parts::is_tool_visible_to_model) + .collect(); render_template( "subagent_system.md", &SubagentPromptContext {