mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user