Skip to content

Commit 433e7bd

Browse files
committed
fix(gateway): make invoke result truncation opt-in via filter
Remove default row/byte shaping when filter is omitted so backends return payloads as-is. Explicit filter still bounds JSON arrays and plain text with returned/total/truncated metadata. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent c505b07 commit 433e7bd

4 files changed

Lines changed: 112 additions & 105 deletions

File tree

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

Lines changed: 63 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ use crate::pool::{format_invoke_permission_denied, format_server_inactive_error}
1010
use crate::services::tool_discovery::ToolDiscoveryService;
1111
use mcpmux_core::FeatureType;
1212

13-
/// Default row cap for smart truncation when `filter` is omitted.
14-
pub const DEFAULT_MAX_ROWS: usize = 50;
15-
16-
/// Default byte cap for smart truncation when `filter` is omitted.
17-
pub const DEFAULT_MAX_BYTES: usize = 65_536;
18-
1913
/// Object keys that commonly hold large list payloads from backend tools.
2014
const HEAVY_ARRAY_KEYS: &[&str] = &[
2115
"items", "data", "results", "rows", "records", "issues", "entries", "values", "list",
@@ -61,16 +55,6 @@ pub fn parse_invoke_filter(value: Option<&Value>) -> Option<InvokeResultFilter>
6155
}
6256

6357
impl InvokeResultFilter {
64-
fn effective_max_rows(&self, use_defaults: bool) -> Option<usize> {
65-
self.max_rows
66-
.or_else(|| use_defaults.then_some(DEFAULT_MAX_ROWS))
67-
}
68-
69-
fn effective_max_bytes(&self, use_defaults: bool) -> Option<usize> {
70-
self.max_bytes
71-
.or_else(|| use_defaults.then_some(DEFAULT_MAX_BYTES))
72-
}
73-
7458
fn is_summary(&self) -> bool {
7559
self.format.as_deref() == Some("summary")
7660
}
@@ -80,14 +64,12 @@ impl InvokeResultFilter {
8064
pub fn apply_invoke_result_filter(
8165
content: Vec<Value>,
8266
structured_content: Option<Value>,
83-
filter: Option<&InvokeResultFilter>,
84-
use_defaults: bool,
67+
filter: &InvokeResultFilter,
8568
) -> (Vec<Value>, Option<Value>) {
86-
let filter = filter.cloned().unwrap_or_default();
87-
let shaped_structured = structured_content.map(|value| shape_json_value(value, &filter, use_defaults));
69+
let shaped_structured = structured_content.map(|value| shape_json_value(value, filter));
8870
let shaped_content = content
8971
.into_iter()
90-
.map(|block| shape_content_block(block, &filter, use_defaults))
72+
.map(|block| shape_content_block(block, filter))
9173
.collect();
9274
(shaped_content, shaped_structured)
9375
}
@@ -105,8 +87,8 @@ impl MetaTool for InvokeToolTool {
10587
"Invoke a backend MCP tool by server_id and tool name. Requires the \
10688
server to be active (binding or session enable) and the tool to be \
10789
in the current permission set. Use mcpmux_search_tools and \
108-
mcpmux_get_tool_schema before calling. Large list payloads are \
109-
truncated by default; pass an optional filter object to control shaping."
90+
mcpmux_get_tool_schema before calling. Pass an optional filter object \
91+
to bound large payloads; omit filter to return the backend response as-is."
11092
}
11193

11294
fn input_schema(&self) -> Value {
@@ -129,7 +111,7 @@ impl MetaTool for InvokeToolTool {
129111
},
130112
"filter": {
131113
"type": "object",
132-
"description": "Optional result shaping. When omitted, large arrays are truncated with returned/total/truncated metadata.",
114+
"description": "Optional result shaping (max_rows, max_bytes, fields, format). Omit to return the backend response as-is.",
133115
"properties": {
134116
"max_rows": {
135117
"type": "integer",
@@ -176,7 +158,6 @@ impl MetaTool for InvokeToolTool {
176158
.to_string();
177159
let args = call.args.get("args").cloned().unwrap_or_else(|| json!({}));
178160
let filter = parse_invoke_filter(call.args.get("filter"));
179-
let use_defaults = call.args.get("filter").is_none();
180161

181162
let resolved = caller_resolution(&call).await?;
182163
let space_id = caller_space_id(&call).await?;
@@ -292,12 +273,11 @@ impl MetaTool for InvokeToolTool {
292273
return Ok(mcp_result);
293274
}
294275

295-
let (content, structured_content) = apply_invoke_result_filter(
296-
result.content,
297-
result.structured_content,
298-
filter.as_ref(),
299-
use_defaults,
300-
);
276+
let (content, structured_content) = if let Some(ref filter) = filter {
277+
apply_invoke_result_filter(result.content, result.structured_content, filter)
278+
} else {
279+
(result.content, result.structured_content)
280+
};
301281
let parsed_content: Vec<Content> = content
302282
.into_iter()
303283
.filter_map(|v| serde_json::from_value(v).ok())
@@ -312,72 +292,69 @@ impl MetaTool for InvokeToolTool {
312292
}
313293

314294
/// Shape one MCP content block (typically `{ "type": "text", "text": "..." }`).
315-
fn shape_content_block(block: Value, filter: &InvokeResultFilter, use_defaults: bool) -> Value {
295+
fn shape_content_block(block: Value, filter: &InvokeResultFilter) -> Value {
316296
let Some(text) = block.get("text").and_then(|v| v.as_str()) else {
317297
return block;
318298
};
319299

320300
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
321-
let shaped = shape_json_value(parsed, filter, use_defaults);
301+
let shaped = shape_json_value(parsed, filter);
322302
return json!({
323303
"type": "text",
324304
"text": shaped.to_string(),
325305
});
326306
}
327307

328-
let max_bytes = filter.effective_max_bytes(use_defaults);
329-
let Some(max_bytes) = max_bytes else {
308+
let Some(max_bytes) = filter.max_bytes else {
330309
return block;
331310
};
332311
if text.len() <= max_bytes {
333312
return block;
334313
}
335314

336-
let mut truncated = text[..max_bytes].to_string();
337-
truncated.push_str("\n...[truncated]");
315+
let envelope = byte_truncation_envelope(text, max_bytes);
338316
json!({
339317
"type": "text",
340-
"text": truncated,
318+
"text": envelope.to_string(),
341319
})
342320
}
343321

344-
/// Shape a JSON value, applying smart truncation for large arrays when enabled.
345-
pub fn shape_json_value(value: Value, filter: &InvokeResultFilter, use_defaults: bool) -> Value {
322+
/// Shape a JSON value, applying truncation when explicit filter limits are set.
323+
pub fn shape_json_value(value: Value, filter: &InvokeResultFilter) -> Value {
346324
match value {
347-
Value::Array(items) => shape_array(items, filter, use_defaults, "items"),
348-
Value::Object(map) => shape_object(map, filter, use_defaults),
349-
other => enforce_byte_limit(other, filter, use_defaults),
325+
Value::Array(items) => shape_array(items, filter, "items"),
326+
Value::Object(map) => shape_object(map, filter),
327+
other => enforce_byte_limit(other, filter),
350328
}
351329
}
352330

353-
fn shape_object(map: Map<String, Value>, filter: &InvokeResultFilter, use_defaults: bool) -> Value {
331+
fn shape_object(map: Map<String, Value>, filter: &InvokeResultFilter) -> Value {
354332
for key in HEAVY_ARRAY_KEYS {
355333
if let Some(Value::Array(items)) = map.get(*key).cloned() {
356-
if should_truncate(items.len(), filter, use_defaults) {
357-
return shape_object_with_truncated_array(map, key, items, filter, use_defaults);
334+
if should_truncate(items.len(), filter) {
335+
return shape_object_with_truncated_array(map, key, items, filter);
358336
}
359337
}
360338
}
361339

362340
for (key, value) in &map {
363341
if let Value::Array(items) = value {
364-
if should_truncate(items.len(), filter, use_defaults) {
365-
return shape_object_with_truncated_array(map.clone(), key, items.clone(), filter, use_defaults);
342+
if should_truncate(items.len(), filter) {
343+
return shape_object_with_truncated_array(map.clone(), key, items.clone(), filter);
366344
}
367345
}
368346
}
369347

370-
enforce_byte_limit(Value::Object(map), filter, use_defaults)
348+
enforce_byte_limit(Value::Object(map), filter)
371349
}
372350

373351
fn shape_object_with_truncated_array(
374352
mut map: Map<String, Value>,
375353
array_key: &str,
376354
items: Vec<Value>,
377355
filter: &InvokeResultFilter,
378-
use_defaults: bool,
379356
) -> Value {
380-
let shaped_array = shape_array(items, filter, use_defaults, array_key);
357+
let shaped_array = shape_array(items, filter, array_key);
381358
if let Value::Object(truncation) = &shaped_array {
382359
if truncation.get("truncated") == Some(&Value::Bool(true)) {
383360
for (meta_key, meta_value) in truncation {
@@ -388,25 +365,19 @@ fn shape_object_with_truncated_array(
388365
if let Some(data) = truncation.get(array_key) {
389366
map.insert(array_key.to_string(), data.clone());
390367
}
391-
return enforce_byte_limit(Value::Object(map), filter, use_defaults);
368+
return enforce_byte_limit(Value::Object(map), filter);
392369
}
393370
}
394371

395372
map.insert(array_key.to_string(), shaped_array);
396-
enforce_byte_limit(Value::Object(map), filter, use_defaults)
373+
enforce_byte_limit(Value::Object(map), filter)
397374
}
398375

399-
fn shape_array(
400-
items: Vec<Value>,
401-
filter: &InvokeResultFilter,
402-
use_defaults: bool,
403-
data_key: &str,
404-
) -> Value {
376+
fn shape_array(items: Vec<Value>, filter: &InvokeResultFilter, data_key: &str) -> Value {
405377
let total = items.len();
406378
let filtered_items = apply_fields_filter(items, filter);
407-
let max_rows = filter.effective_max_rows(use_defaults);
408379

409-
let Some(max_rows) = max_rows else {
380+
let Some(max_rows) = filter.max_rows else {
410381
return Value::Array(filtered_items);
411382
};
412383

@@ -455,15 +426,15 @@ fn pick_fields(value: Value, fields: &[String]) -> Value {
455426
Value::Object(picked)
456427
}
457428

458-
fn should_truncate(length: usize, filter: &InvokeResultFilter, use_defaults: bool) -> bool {
459-
match filter.effective_max_rows(use_defaults) {
429+
fn should_truncate(length: usize, filter: &InvokeResultFilter) -> bool {
430+
match filter.max_rows {
460431
Some(max_rows) => length > max_rows,
461432
None => false,
462433
}
463434
}
464435

465-
fn enforce_byte_limit(value: Value, filter: &InvokeResultFilter, use_defaults: bool) -> Value {
466-
let Some(max_bytes) = filter.effective_max_bytes(use_defaults) else {
436+
fn enforce_byte_limit(value: Value, filter: &InvokeResultFilter) -> Value {
437+
let Some(max_bytes) = filter.max_bytes else {
467438
return value;
468439
};
469440

@@ -472,8 +443,13 @@ fn enforce_byte_limit(value: Value, filter: &InvokeResultFilter, use_defaults: b
472443
return value;
473444
}
474445

475-
let total_bytes = serialized.len();
476-
let mut truncated = serialized;
446+
byte_truncation_envelope(&serialized, max_bytes)
447+
}
448+
449+
/// Build a `{ returned, total, truncated, text }` envelope for byte-capped plain text or JSON.
450+
fn byte_truncation_envelope(text: &str, max_bytes: usize) -> Value {
451+
let total_bytes = text.len();
452+
let mut truncated = text.to_string();
477453
truncated.truncate(max_bytes);
478454
truncated.push_str("...[truncated]");
479455
json!({
@@ -498,22 +474,20 @@ mod tests {
498474
use super::*;
499475

500476
#[test]
501-
fn default_truncates_large_top_level_array() {
477+
fn no_filter_passes_through_large_array() {
502478
let items: Vec<Value> = (0..100).map(|i| json!({ "id": i, "name": format!("n{i}") })).collect();
503-
let shaped = shape_json_value(Value::Array(items), &InvokeResultFilter::default(), true);
504-
assert_eq!(shaped.get("returned"), Some(&json!(50)));
505-
assert_eq!(shaped.get("total"), Some(&json!(100)));
506-
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
479+
let shaped = shape_json_value(Value::Array(items.clone()), &InvokeResultFilter::default());
480+
assert_eq!(shaped, Value::Array(items));
507481
}
508482

509483
#[test]
510-
fn explicit_max_rows_overrides_default() {
484+
fn explicit_max_rows_truncates() {
511485
let items: Vec<Value> = (0..20).map(|i| json!({ "id": i })).collect();
512486
let filter = InvokeResultFilter {
513487
max_rows: Some(3),
514488
..Default::default()
515489
};
516-
let shaped = shape_json_value(Value::Array(items), &filter, false);
490+
let shaped = shape_json_value(Value::Array(items), &filter);
517491
assert_eq!(shaped.get("returned"), Some(&json!(3)));
518492
assert_eq!(shaped.get("total"), Some(&json!(20)));
519493
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
@@ -529,9 +503,23 @@ mod tests {
529503
fields: Some(vec!["id".into(), "name".into()]),
530504
..Default::default()
531505
};
532-
let shaped = shape_json_value(Value::Array(items), &filter, false);
506+
let shaped = shape_json_value(Value::Array(items), &filter);
533507
let kept = shaped.as_array().unwrap();
534508
assert_eq!(kept[0], json!({ "id": 1, "name": "a" }));
535509
assert_eq!(kept[1], json!({ "id": 2, "name": "b" }));
536510
}
511+
512+
#[test]
513+
fn plain_text_byte_trunc_includes_metadata() {
514+
let text = "x".repeat(100);
515+
let filter = InvokeResultFilter {
516+
max_bytes: Some(50),
517+
..Default::default()
518+
};
519+
let block = json!({ "type": "text", "text": text });
520+
let shaped = shape_content_block(block, &filter);
521+
let parsed: Value = serde_json::from_str(shaped.get("text").unwrap().as_str().unwrap()).unwrap();
522+
assert_eq!(parsed.get("truncated"), Some(&json!(true)));
523+
assert_eq!(parsed.get("total"), Some(&json!(100)));
524+
}
537525
}

docs/planning/meta-gateway-invoke-qa.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,44 +129,49 @@ What did compact strip?
129129

130130
| Check | Pass | Fail | Notes |
131131
| ----- | ---- | ---- | ----- |
132-
| Search empty / no github matches when disabled | || |
133-
| Meta tool count unchanged across enable/disable | || |
132+
| Search empty / no github matches when disabled | || `total: 0`, `tools: []` after session disable |
133+
| Meta tool count unchanged across enable/disable | || 10 `mcpmux_*` before and after |
134134

135135
---
136136

137-
## 5. Default truncation (Phase B)
137+
## 5. Pass-through without filter (Phase B)
138138

139-
**Setup:** Enable a heavy server — `posthog-personal`, `firebase-dev`, or GWorkspace clone.
139+
**Setup:** GWorkspace Personal bound (`taylorwilsdon.google-workspace-mcp-uvx`) or any heavy server in FeatureSet ACL.
140140

141141
**Prompt:**
142142

143143
```
144-
Enable [heavy server]. Find a list/analytics tool via search, read schema, invoke WITHOUT filter.
144+
Find a list tool via search (e.g. GWorkspace list_drive_items), read schema, invoke WITHOUT filter.
145145
146-
Show whether response includes { returned, total, truncated: true } or similar metadata.
147-
Paste payload size estimate (rough char count is fine).
146+
Confirm the full backend response is returned with no { returned, total, truncated } metadata.
147+
Paste rough char count.
148148
```
149149

150150
| Check | Pass | Fail | Notes |
151151
| ----- | ---- | ---- | ----- |
152-
| Large array auto-truncated ||| Default ~50 rows / 64KB |
153-
| Truncation metadata present ||| |
152+
| Full backend response returned ||| |
153+
| No truncation metadata without filter ||| Design: opt-in filter only (May 25) |
154154

155155
---
156156

157157
## 6. Explicit filter (Phase B)
158158

159+
**Setup:** Same tool as test 5, or GitHub `list_issues` for JSON row truncation.
160+
159161
**Prompt:**
160162

161163
```
162-
Same tool as test 5. Invoke with filter: { "max_rows": 3, "format": "summary" }
164+
Invoke with filter: { "max_rows": 3, "format": "summary" }
165+
166+
For plain-text tools (GWorkspace), also try filter: { "max_bytes": 4096 }.
163167
164-
Then again with fields projection if the tool returns objects with id/name/title fields.
168+
Then fields projection if the tool returns JSON objects with id/name/title fields.
165169
```
166170

167171
| Check | Pass | Fail | Notes |
168172
| ----- | ---- | ---- | ----- |
169-
| `max_rows: 3` honored ||| |
173+
| `max_rows: 3` honored (JSON tools) ||| |
174+
| `max_bytes` honored with metadata (plain text) ||| |
170175
| `format: summary` applied ||| |
171176
| `fields` projection limits keys per row (if tested) ||| |
172177

@@ -279,7 +284,7 @@ Rules: McpMux meta tools only, read schemas before invoke, note truncation if an
279284
- [ ] Enable server expands `tools/list` beyond meta + surfaced
280285
- [ ] Search returns tools from inactive or unbound servers
281286
- [ ] Invoke succeeds for tools outside FeatureSet ACL
282-
- [ ] Large list invoke returns unbounded payload with no truncation metadata
287+
- [ ] Invoke with explicit filter fails to truncate or return metadata
283288
- [ ] Opaque errors (no enable/invoke redirect hints)
284289

285290
---

0 commit comments

Comments
 (0)