feat: filter tools by MCP Apps visibility metadata (#8022)

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
Andrew Harvard
2026-03-20 08:28:45 -04:00
committed by GitHub
parent e2fc8ddb80
commit b7615938bc
5 changed files with 164 additions and 4 deletions
+13
View File
@@ -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,
+5 -2
View File
@@ -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));
+1 -1
View File
@@ -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;
+139
View File
@@ -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<serde_json::Value>) -> 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));
}
}
+6 -1
View File
@@ -231,7 +231,12 @@ async fn build_subagent_prompt(
session_id: &str,
system_instructions: String,
) -> Result<String> {
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 {