Skip to content

Commit 85113e7

Browse files
committed
fix(gateway): improve meta-tool DX for ACL, schema batch, and max_bytes
list_all_tools now exposes invokable vs server_available with counts; get_tool_schema accepts array and JSON-encoded array forms and reports missing names; invoke filter applies max_bytes to JSON arrays without max_rows. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 6884742 commit 85113e7

3 files changed

Lines changed: 278 additions & 30 deletions

File tree

crates/mcpmux-gateway/src/services/meta_tools/invoke.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,11 +378,11 @@ fn shape_array(items: Vec<Value>, filter: &InvokeResultFilter, data_key: &str) -
378378
let filtered_items = apply_fields_filter(items, filter);
379379

380380
let Some(max_rows) = filter.max_rows else {
381-
return Value::Array(filtered_items);
381+
return enforce_byte_limit(Value::Array(filtered_items), filter);
382382
};
383383

384384
if total <= max_rows {
385-
return Value::Array(filtered_items);
385+
return enforce_byte_limit(Value::Array(filtered_items), filter);
386386
}
387387

388388
let sample_size = if filter.is_summary() {
@@ -662,6 +662,20 @@ mod tests {
662662
assert_eq!(filter.max_bytes, None);
663663
}
664664

665+
#[test]
666+
fn max_bytes_only_truncates_top_level_json_array() {
667+
let items: Vec<Value> = (0..50)
668+
.map(|i| json!({ "id": i, "label": format!("row-{i}-padding") }))
669+
.collect();
670+
let filter = InvokeResultFilter {
671+
max_bytes: Some(512),
672+
..Default::default()
673+
};
674+
let shaped = shape_json_value(Value::Array(items), &filter);
675+
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
676+
assert!(shaped.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 512);
677+
}
678+
665679
#[test]
666680
fn plain_text_byte_trunc_includes_metadata() {
667681
let text = "x".repeat(100);

crates/mcpmux-gateway/src/services/meta_tools/tools.rs

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,11 @@ impl MetaTool for ListAllToolsTool {
111111
}
112112

113113
fn description(&self) -> &'static str {
114-
"List every tool installed in the caller's resolved Space, without \
115-
the current FeatureSet filter applied. Use this to see what the \
116-
workspace could expose before composing a custom FeatureSet. \
117-
Returns an array of {server_id, qualified_name, description, available}."
114+
"Operator/diagnostic: list every tool installed in the caller's resolved \
115+
Space (ignores FeatureSet filter on the roster). Each entry includes \
116+
server_available (seen on the connected server) and invokable (callable \
117+
via mcpmux_invoke_tool with current grants). Agents should prefer \
118+
mcpmux_search_tools for discovery — only invokable tools can be invoked."
118119
}
119120

120121
fn input_schema(&self) -> Value {
@@ -130,8 +131,26 @@ impl MetaTool for ListAllToolsTool {
130131
}
131132

132133
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
134+
let resolved = caller_resolution(&call).await?;
133135
let space_id = caller_space_id(&call).await?;
134136
let server_filter = call.args.get("server_id").and_then(|v| v.as_str());
137+
138+
let invokable = call
139+
.ctx
140+
.feature_service
141+
.get_invokable_tools_for_grants(
142+
&space_id.to_string(),
143+
&resolved.feature_set_ids,
144+
call.session_id,
145+
)
146+
.await
147+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
148+
let invokable_names: HashSet<String> = invokable
149+
.iter()
150+
.filter(|f| f.feature_type == FeatureType::Tool)
151+
.map(|f| f.qualified_name())
152+
.collect();
153+
135154
let features = call
136155
.ctx
137156
.server_feature_repo
@@ -142,15 +161,26 @@ impl MetaTool for ListAllToolsTool {
142161
.filter(|f| f.feature_type == FeatureType::Tool)
143162
.filter(|f| server_filter.is_none_or(|sid| f.server_id == sid))
144163
.map(|f| {
164+
let qualified_name = f.qualified_name();
145165
json!({
146166
"server_id": f.server_id,
147-
"qualified_name": f.qualified_name(),
167+
"qualified_name": qualified_name,
148168
"description": f.description,
149-
"available": f.is_available,
169+
"server_available": f.is_available,
170+
"invokable": invokable_names.contains(&qualified_name),
150171
})
151172
})
152173
.collect();
153-
Ok(text_result(json!({ "tools": tools })))
174+
let total_invokable = tools
175+
.iter()
176+
.filter(|t| t.get("invokable") == Some(&json!(true)))
177+
.count();
178+
Ok(text_result(json!({
179+
"tools": tools,
180+
"total_installed": tools.len(),
181+
"total_invokable": total_invokable,
182+
"hint": "Use mcpmux_search_tools for agent discovery. Only invokable tools can be invoked with current FeatureSet grants.",
183+
})))
154184
}
155185
}
156186

@@ -435,6 +465,47 @@ impl MetaTool for SearchToolsTool {
435465
// mcpmux_get_tool_schema — read
436466
// ---------------------------------------------------------------------------
437467

468+
/// Parse the `tools` argument from `mcpmux_get_tool_schema` call args.
469+
///
470+
/// Accepts a qualified name string, a string array, or a JSON-encoded array
471+
/// string (common when agents double-serialize through MCP clients).
472+
fn parse_tool_schema_names(value: Option<&Value>) -> Result<Vec<String>, MetaToolError> {
473+
let Some(value) = value else {
474+
return Err(MetaToolError::InvalidArgument(
475+
"missing or invalid `tools` — expected string or string array".into(),
476+
));
477+
};
478+
479+
match value {
480+
Value::String(s) => {
481+
if let Ok(Value::Array(arr)) = serde_json::from_str(s) {
482+
return names_from_json_array(&arr);
483+
}
484+
Ok(vec![s.clone()])
485+
}
486+
Value::Array(arr) => names_from_json_array(arr),
487+
_ => Err(MetaToolError::InvalidArgument(
488+
"missing or invalid `tools` — expected string or string array".into(),
489+
)),
490+
}
491+
}
492+
493+
/// Collect non-empty qualified tool names from a JSON string array.
494+
fn names_from_json_array(arr: &[Value]) -> Result<Vec<String>, MetaToolError> {
495+
let names: Vec<String> = arr
496+
.iter()
497+
.filter_map(|v| v.as_str().map(str::trim))
498+
.filter(|s| !s.is_empty())
499+
.map(str::to_string)
500+
.collect();
501+
if names.is_empty() {
502+
return Err(MetaToolError::InvalidArgument(
503+
"`tools` must contain at least one qualified name".into(),
504+
));
505+
}
506+
Ok(names)
507+
}
508+
438509
pub struct GetToolSchemaTool;
439510

440511
#[async_trait]
@@ -445,8 +516,10 @@ impl MetaTool for GetToolSchemaTool {
445516

446517
fn description(&self) -> &'static str {
447518
"Load input schemas for one or more qualified tool names before \
448-
invoking via mcpmux_invoke_tool. Pass tools as a string or array. \
449-
Set compact: true to omit descriptions."
519+
invoking via mcpmux_invoke_tool. Pass tools as a single qualified \
520+
name string or a string array (e.g. [\"github_list_issues\"]). \
521+
Set compact: true to omit descriptions. Tools must be invokable \
522+
with current grants — use mcpmux_search_tools to discover names."
450523
}
451524

452525
fn input_schema(&self) -> Value {
@@ -469,24 +542,7 @@ impl MetaTool for GetToolSchemaTool {
469542
let resolved = caller_resolution(&call).await?;
470543
let space_id = caller_space_id(&call).await?;
471544

472-
let tool_names: Vec<String> = match call.args.get("tools") {
473-
Some(Value::String(s)) => vec![s.clone()],
474-
Some(Value::Array(arr)) => arr
475-
.iter()
476-
.filter_map(|v| v.as_str().map(String::from))
477-
.collect(),
478-
_ => {
479-
return Err(MetaToolError::InvalidArgument(
480-
"missing or invalid `tools` — expected string or string array".into(),
481-
));
482-
}
483-
};
484-
485-
if tool_names.is_empty() {
486-
return Err(MetaToolError::InvalidArgument(
487-
"`tools` must contain at least one qualified name".into(),
488-
));
489-
}
545+
let tool_names = parse_tool_schema_names(call.args.get("tools"))?;
490546

491547
let compact = call
492548
.args
@@ -518,7 +574,32 @@ impl MetaTool for GetToolSchemaTool {
518574
compact,
519575
);
520576

521-
Ok(text_result(json!({ "schemas": schemas })))
577+
let found_names: HashSet<String> = schemas
578+
.iter()
579+
.filter_map(|s| {
580+
s.get("qualified_name")
581+
.and_then(|v| v.as_str())
582+
.map(str::to_string)
583+
})
584+
.collect();
585+
let missing: Vec<&String> = tool_names
586+
.iter()
587+
.filter(|name| !found_names.contains(*name))
588+
.collect();
589+
590+
if missing.is_empty() {
591+
return Ok(text_result(json!({ "schemas": schemas })));
592+
}
593+
594+
let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect();
595+
Ok(text_result(json!({
596+
"schemas": schemas,
597+
"missing": missing_list,
598+
"message": format!(
599+
"{} tool(s) not invokable or unknown with current grants → use mcpmux_search_tools to discover allowed names",
600+
missing.len()
601+
),
602+
})))
522603
}
523604
}
524605

tests/rust/tests/integration/meta_gateway_invoke.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,3 +645,156 @@ async fn direct_backend_call_gate_allows_surfaced_only() {
645645
assert!(is_surfaced("github_list_issues"));
646646
assert!(!is_surfaced("github_get_me"));
647647
}
648+
649+
#[tokio::test(flavor = "multi_thread")]
650+
async fn list_all_tools_marks_invokable_against_acl() {
651+
let f = Fixture::new().await;
652+
653+
let list_issues = f
654+
.server_feature_repo
655+
.list_for_space(&f.space_id.to_string())
656+
.await
657+
.unwrap()
658+
.into_iter()
659+
.find(|feat| feat.feature_name == "list_issues")
660+
.unwrap();
661+
662+
let mut create_issue = ServerFeature::tool(f.space_id, "github", "create_issue");
663+
create_issue.description = Some("Create an issue".into());
664+
f.server_feature_repo.upsert(&create_issue).await.unwrap();
665+
666+
let mut partial_fs = FeatureSet::new_custom("Partial GitHub", f.space_id.to_string());
667+
partial_fs.members.push(FeatureSetMember {
668+
id: Uuid::new_v4().to_string(),
669+
feature_set_id: partial_fs.id.clone(),
670+
member_type: MemberType::Feature,
671+
member_id: list_issues.id.to_string(),
672+
mode: MemberMode::Include,
673+
surfaced: false,
674+
});
675+
f.feature_set_repo.create(&partial_fs).await.unwrap();
676+
f.grant_feature_set(&partial_fs.id).await;
677+
f.session_overrides.enable(&f.session_id, "github");
678+
679+
let result = f
680+
.call("mcpmux_list_all_tools", json!({ "server_id": "github" }))
681+
.await;
682+
let body = Fixture::result_json(&result);
683+
assert_eq!(body.get("total_installed").and_then(|v| v.as_u64()), Some(2));
684+
assert_eq!(body.get("total_invokable").and_then(|v| v.as_u64()), Some(1));
685+
686+
let tools = body.get("tools").unwrap().as_array().unwrap();
687+
let list_row = tools
688+
.iter()
689+
.find(|t| t.get("qualified_name") == Some(&json!("github_list_issues")))
690+
.expect("list_issues in catalog");
691+
let create_row = tools
692+
.iter()
693+
.find(|t| t.get("qualified_name") == Some(&json!("github_create_issue")))
694+
.expect("create_issue in catalog");
695+
assert_eq!(list_row.get("invokable"), Some(&json!(true)));
696+
assert_eq!(create_row.get("invokable"), Some(&json!(false)));
697+
assert_eq!(list_row.get("server_available"), Some(&json!(true)));
698+
}
699+
700+
#[tokio::test(flavor = "multi_thread")]
701+
async fn get_tool_schema_accepts_string_array() {
702+
let f = Fixture::new().await;
703+
f.grant_github_feature_set().await;
704+
f.session_overrides.enable(&f.session_id, "github");
705+
706+
let result = f
707+
.call(
708+
"mcpmux_get_tool_schema",
709+
json!({ "tools": ["github_list_issues"] }),
710+
)
711+
.await;
712+
let body = Fixture::result_json(&result);
713+
let schemas = body.get("schemas").unwrap().as_array().unwrap();
714+
assert_eq!(schemas.len(), 1);
715+
assert_eq!(
716+
schemas[0].get("qualified_name"),
717+
Some(&json!("github_list_issues"))
718+
);
719+
assert!(body.get("missing").is_none());
720+
}
721+
722+
#[tokio::test(flavor = "multi_thread")]
723+
async fn get_tool_schema_accepts_json_encoded_array_string() {
724+
let f = Fixture::new().await;
725+
f.grant_github_feature_set().await;
726+
f.session_overrides.enable(&f.session_id, "github");
727+
728+
let result = f
729+
.call(
730+
"mcpmux_get_tool_schema",
731+
json!({ "tools": "[\"github_list_issues\"]" }),
732+
)
733+
.await;
734+
let body = Fixture::result_json(&result);
735+
let schemas = body.get("schemas").unwrap().as_array().unwrap();
736+
assert_eq!(schemas.len(), 1);
737+
assert_eq!(
738+
schemas[0].get("qualified_name"),
739+
Some(&json!("github_list_issues"))
740+
);
741+
}
742+
743+
#[tokio::test(flavor = "multi_thread")]
744+
async fn get_tool_schema_reports_missing_tools() {
745+
let f = Fixture::new().await;
746+
f.grant_github_feature_set().await;
747+
f.session_overrides.enable(&f.session_id, "github");
748+
749+
let result = f
750+
.call(
751+
"mcpmux_get_tool_schema",
752+
json!({ "tools": ["github_list_issues", "github_create_issue"] }),
753+
)
754+
.await;
755+
let body = Fixture::result_json(&result);
756+
let schemas = body.get("schemas").unwrap().as_array().unwrap();
757+
assert_eq!(schemas.len(), 1);
758+
let missing = body.get("missing").unwrap().as_array().unwrap();
759+
assert_eq!(missing, &[json!("github_create_issue")]);
760+
assert!(body.get("message").and_then(|m| m.as_str()).is_some());
761+
}
762+
763+
#[tokio::test(flavor = "multi_thread")]
764+
async fn invoke_max_bytes_truncates_json_array_without_max_rows() {
765+
let rows: Vec<Value> = (0..40)
766+
.map(|i| json!({ "id": i, "label": format!("row-{i}-padding-value") }))
767+
.collect();
768+
let payload = json!({ "items": rows });
769+
let backend_result = ToolCallResult {
770+
content: vec![json!({
771+
"type": "text",
772+
"text": payload.to_string(),
773+
})],
774+
structured_content: None,
775+
is_error: false,
776+
};
777+
let invoke_backend = CannedInvokeBackend::new()
778+
.with_response("github_list_issues", backend_result)
779+
.into_arc();
780+
781+
let f = Fixture::with_invoke_backend(Some(invoke_backend)).await;
782+
f.grant_github_feature_set().await;
783+
f.session_overrides.enable(&f.session_id, "github");
784+
785+
let result = f
786+
.call(
787+
"mcpmux_invoke_tool",
788+
json!({
789+
"server_id": "github",
790+
"tool": "list_issues",
791+
"args": { "owner": "mcpmux", "repo": "mcp-mux" },
792+
"filter": { "max_bytes": 512 }
793+
}),
794+
)
795+
.await;
796+
797+
assert!(!result.is_error.unwrap_or(true));
798+
let body = Fixture::result_json(&result);
799+
assert_eq!(body.get("truncated"), Some(&json!(true)));
800+
}

0 commit comments

Comments
 (0)