Skip to content

Commit 3bc4dc9

Browse files
committed
test(gateway): cover invoke filter shaping with unit and e2e tests
Add InvokeToolBackend for pluggable routing, expand filter unit tests, and prove mcpmux_invoke_tool applies filters via CannedInvokeBackend. Reconcile Phase B planning docs after manual github_list_issues pass. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 01b60e3 commit 3bc4dc9

14 files changed

Lines changed: 417 additions & 38 deletions

File tree

crates/mcpmux-gateway/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,17 @@ pub use pool::{
7070
ServerState,
7171
ServiceFactory,
7272
TokenService,
73+
ToolCallResult,
7374
TransportConnectResult,
7475
TransportFactory,
7576
TransportType,
7677
};
7778

7879
// Services module
79-
pub use services::{EventEmitter, GrantService, PrefixCacheService, SessionOverrideRegistry};
80+
pub use services::{
81+
EventEmitter, GrantService, InvokeToolBackend, PrefixCacheService, SessionOverrideRegistry,
82+
routing_as_invoke_backend,
83+
};
8084

8185
// MCP module (rmcp-based implementation)
8286
pub use mcp::McpMuxGatewayHandler;

crates/mcpmux-gateway/src/pool/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ pub use connection::{ConnectionResult, ConnectionService};
4444
pub use features::{CachedFeatures, FeatureService};
4545
pub use routing::{
4646
format_direct_call_redirect, format_invoke_permission_denied, format_server_inactive_error,
47-
RoutedPrompt, RoutedResource, RoutedTool, RoutingService,
47+
RoutedPrompt, RoutedResource, RoutedTool, RoutingService, ToolCallResult,
4848
};
4949
pub use service::{InstalledServerInfo, PoolService, PoolStats, ReconnectResult};
5050
pub use token::TokenService;

crates/mcpmux-gateway/src/pool/routing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub struct RoutedResource {
4848
}
4949

5050
/// Result of a tool call
51-
#[derive(Debug)]
51+
#[derive(Debug, Clone)]
5252
pub struct ToolCallResult {
5353
pub content: Vec<Value>,
5454
pub structured_content: Option<Value>,

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ impl ServiceContainer {
134134
deps.installed_server_repo.clone(),
135135
feature_set_resolver.clone(),
136136
pool_services.feature_service.clone(),
137-
Some(pool_services.routing_service.clone()),
137+
Some(meta_tools::routing_as_invoke_backend(
138+
pool_services.routing_service.clone(),
139+
)),
138140
session_roots.clone(),
139141
session_overrides.clone(),
140142
approval_broker.clone(),

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

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ impl MetaTool for InvokeToolTool {
131131
"format": {
132132
"type": "string",
133133
"enum": ["summary", "full"],
134-
"description": "summary keeps metadata plus a bounded sample; full returns truncated rows"
134+
"description": "When max_rows is set: summary caps the sample at min(max_rows, 5); full returns up to max_rows rows. Ignored when max_rows is omitted."
135135
}
136136
}
137137
}
@@ -246,12 +246,12 @@ impl MetaTool for InvokeToolTool {
246246
)));
247247
}
248248

249-
let routing = call
249+
let backend = call
250250
.ctx
251-
.routing_service
251+
.invoke_backend
252252
.as_ref()
253253
.ok_or_else(|| MetaToolError::Internal("invoke routing not configured".into()))?;
254-
match routing
254+
match backend
255255
.call_tool(
256256
space_id,
257257
&resolved.feature_set_ids,
@@ -473,6 +473,18 @@ fn invoke_error(message: String) -> CallToolResult {
473473
mod tests {
474474
use super::*;
475475

476+
fn issue_rows(count: usize) -> Vec<Value> {
477+
(0..count)
478+
.map(|i| {
479+
json!({
480+
"id": i,
481+
"title": format!("issue-{i}"),
482+
"body": format!("body-{i}")
483+
})
484+
})
485+
.collect()
486+
}
487+
476488
#[test]
477489
fn no_filter_passes_through_large_array() {
478490
let items: Vec<Value> = (0..100).map(|i| json!({ "id": i, "name": format!("n{i}") })).collect();
@@ -481,8 +493,8 @@ mod tests {
481493
}
482494

483495
#[test]
484-
fn explicit_max_rows_truncates() {
485-
let items: Vec<Value> = (0..20).map(|i| json!({ "id": i })).collect();
496+
fn explicit_max_rows_truncates_top_level_array() {
497+
let items: Vec<Value> = issue_rows(20);
486498
let filter = InvokeResultFilter {
487499
max_rows: Some(3),
488500
..Default::default()
@@ -491,6 +503,70 @@ mod tests {
491503
assert_eq!(shaped.get("returned"), Some(&json!(3)));
492504
assert_eq!(shaped.get("total"), Some(&json!(20)));
493505
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
506+
let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap();
507+
assert_eq!(sample.len(), 3);
508+
}
509+
510+
#[test]
511+
fn explicit_max_rows_truncates_nested_issues_key() {
512+
let issues = issue_rows(20);
513+
let filter = InvokeResultFilter {
514+
max_rows: Some(3),
515+
..Default::default()
516+
};
517+
let shaped = shape_json_value(json!({ "issues": issues }), &filter);
518+
assert_eq!(shaped.get("returned"), Some(&json!(3)));
519+
assert_eq!(shaped.get("total"), Some(&json!(20)));
520+
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
521+
let sample = shaped.get("issues").and_then(|v| v.as_array()).unwrap();
522+
assert_eq!(sample.len(), 3);
523+
}
524+
525+
#[test]
526+
fn json_in_text_block_truncates_with_metadata() {
527+
let rows: Vec<Value> = (0..80).map(|i| json!({ "n": i })).collect();
528+
let content = vec![json!({
529+
"type": "text",
530+
"text": json!({ "results": rows }).to_string(),
531+
})];
532+
let filter = parse_invoke_filter(Some(&json!({ "max_rows": 10 }))).unwrap();
533+
534+
let (shaped_content, _) = apply_invoke_result_filter(content, None, &filter);
535+
let text = shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap();
536+
let parsed: Value = serde_json::from_str(text).unwrap();
537+
538+
assert_eq!(parsed.get("returned"), Some(&json!(10)));
539+
assert_eq!(parsed.get("total"), Some(&json!(80)));
540+
assert_eq!(parsed.get("truncated"), Some(&json!(true)));
541+
}
542+
543+
#[test]
544+
fn structured_content_and_text_both_shaped() {
545+
let items = issue_rows(20);
546+
let structured = json!({ "items": items });
547+
let content = vec![json!({
548+
"type": "text",
549+
"text": structured.to_string(),
550+
})];
551+
let filter = InvokeResultFilter {
552+
max_rows: Some(5),
553+
fields: Some(vec!["id".into(), "title".into()]),
554+
..Default::default()
555+
};
556+
557+
let (shaped_content, shaped_structured) =
558+
apply_invoke_result_filter(content, Some(structured), &filter);
559+
560+
let parsed_text: Value =
561+
serde_json::from_str(shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap())
562+
.unwrap();
563+
assert_eq!(parsed_text.get("returned"), Some(&json!(5)));
564+
assert_eq!(parsed_text.get("total"), Some(&json!(20)));
565+
566+
let shaped = shaped_structured.unwrap();
567+
let structured_sample = shaped.get("items").and_then(|v| v.as_array()).unwrap();
568+
assert_eq!(structured_sample.len(), 5);
569+
assert_eq!(structured_sample[0], json!({ "id": 0, "title": "issue-0" }));
494570
}
495571

496572
#[test]
@@ -509,6 +585,83 @@ mod tests {
509585
assert_eq!(kept[1], json!({ "id": 2, "name": "b" }));
510586
}
511587

588+
#[test]
589+
fn max_rows_and_fields_together() {
590+
let items: Vec<Value> = (0..30)
591+
.map(|i| json!({ "id": i, "label": format!("row-{i}") }))
592+
.collect();
593+
let filter = parse_invoke_filter(Some(&json!({ "max_rows": 5, "fields": ["id"] }))).unwrap();
594+
let shaped = shape_json_value(Value::Array(items), &filter);
595+
596+
assert_eq!(shaped.get("returned"), Some(&json!(5)));
597+
assert_eq!(shaped.get("total"), Some(&json!(30)));
598+
assert_eq!(shaped.get("truncated"), Some(&json!(true)));
599+
let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap();
600+
assert_eq!(sample.len(), 5);
601+
assert_eq!(sample[0], json!({ "id": 0 }));
602+
}
603+
604+
#[test]
605+
fn summary_format_no_op_when_max_rows_at_most_five() {
606+
let items = issue_rows(20);
607+
let filter = InvokeResultFilter {
608+
max_rows: Some(3),
609+
format: Some("summary".into()),
610+
..Default::default()
611+
};
612+
let shaped = shape_json_value(Value::Array(items), &filter);
613+
assert_eq!(shaped.get("returned"), Some(&json!(3)));
614+
}
615+
616+
#[test]
617+
fn summary_format_caps_sample_at_five() {
618+
let items = issue_rows(20);
619+
let filter = InvokeResultFilter {
620+
max_rows: Some(10),
621+
format: Some("summary".into()),
622+
..Default::default()
623+
};
624+
let shaped = shape_json_value(Value::Array(items), &filter);
625+
assert_eq!(shaped.get("returned"), Some(&json!(5)));
626+
assert_eq!(shaped.get("total"), Some(&json!(20)));
627+
}
628+
629+
#[test]
630+
fn full_format_returns_up_to_max_rows() {
631+
let items = issue_rows(20);
632+
let filter = InvokeResultFilter {
633+
max_rows: Some(10),
634+
format: Some("full".into()),
635+
..Default::default()
636+
};
637+
let shaped = shape_json_value(Value::Array(items), &filter);
638+
assert_eq!(shaped.get("returned"), Some(&json!(10)));
639+
let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap();
640+
assert_eq!(sample.len(), 10);
641+
}
642+
643+
#[test]
644+
fn parse_invoke_filter_ignores_invalid_types() {
645+
let filter = parse_invoke_filter(Some(&json!({
646+
"max_rows": "not-a-number",
647+
"max_bytes": true,
648+
"fields": "id",
649+
"format": 123
650+
})))
651+
.unwrap();
652+
assert_eq!(filter.max_rows, None);
653+
assert_eq!(filter.max_bytes, None);
654+
assert_eq!(filter.fields, None);
655+
assert_eq!(filter.format, None);
656+
}
657+
658+
#[test]
659+
fn parse_invoke_filter_accepts_partial_objects() {
660+
let filter = parse_invoke_filter(Some(&json!({ "max_rows": 3 }))).unwrap();
661+
assert_eq!(filter.max_rows, Some(3));
662+
assert_eq!(filter.max_bytes, None);
663+
}
664+
512665
#[test]
513666
fn plain_text_byte_trunc_includes_metadata() {
514667
let text = "x".repeat(100);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! Pluggable backend for `mcpmux_invoke_tool` routing.
2+
3+
use std::sync::Arc;
4+
5+
use anyhow::Result;
6+
use async_trait::async_trait;
7+
use serde_json::Value;
8+
use uuid::Uuid;
9+
10+
use crate::pool::{RoutingService, ToolCallResult};
11+
12+
/// Dispatches permission-checked tool calls to a backend MCP server.
13+
#[async_trait]
14+
pub trait InvokeToolBackend: Send + Sync {
15+
/// Invoke a qualified backend tool and return raw MCP content.
16+
async fn call_tool(
17+
&self,
18+
space_id: Uuid,
19+
feature_set_ids: &[String],
20+
session_id: Option<&str>,
21+
qualified_name: &str,
22+
arguments: Value,
23+
) -> Result<ToolCallResult>;
24+
}
25+
26+
#[async_trait]
27+
impl InvokeToolBackend for RoutingService {
28+
async fn call_tool(
29+
&self,
30+
space_id: Uuid,
31+
feature_set_ids: &[String],
32+
session_id: Option<&str>,
33+
qualified_name: &str,
34+
arguments: Value,
35+
) -> Result<ToolCallResult> {
36+
RoutingService::call_tool(
37+
self,
38+
space_id,
39+
feature_set_ids,
40+
session_id,
41+
qualified_name,
42+
arguments,
43+
)
44+
.await
45+
}
46+
}
47+
48+
/// Wrap a [`RoutingService`] as an [`InvokeToolBackend`] trait object.
49+
pub fn routing_as_invoke_backend(routing: Arc<RoutingService>) -> Arc<dyn InvokeToolBackend> {
50+
routing
51+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
pub mod approval;
2424
pub mod diff;
2525
pub mod invoke;
26+
pub mod invoke_backend;
2627
mod registry;
2728
mod tools;
2829
mod workspace_server;
@@ -32,6 +33,7 @@ pub use approval::{
3233
ApprovalScope,
3334
};
3435
pub use diff::ToolDiff;
36+
pub use invoke_backend::{routing_as_invoke_backend, InvokeToolBackend};
3537
pub use registry::{
3638
MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY,
3739
SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY,
@@ -62,7 +64,7 @@ pub fn build_default_registry(
6264
installed_server_repo: std::sync::Arc<dyn mcpmux_core::InstalledServerRepository>,
6365
resolver: std::sync::Arc<crate::services::FeatureSetResolverService>,
6466
feature_service: std::sync::Arc<crate::pool::FeatureService>,
65-
routing_service: Option<std::sync::Arc<crate::pool::RoutingService>>,
67+
invoke_backend: Option<std::sync::Arc<dyn invoke_backend::InvokeToolBackend>>,
6668
session_roots: std::sync::Arc<crate::services::SessionRootsRegistry>,
6769
session_overrides: std::sync::Arc<crate::services::SessionOverrideRegistry>,
6870
approval_broker: std::sync::Arc<ApprovalBroker>,
@@ -80,7 +82,7 @@ pub fn build_default_registry(
8082
installed_server_repo,
8183
resolver,
8284
feature_service,
83-
routing_service,
85+
invoke_backend,
8486
tool_discovery,
8587
session_roots,
8688
session_overrides,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ use thiserror::Error;
1818
use tokio::sync::broadcast;
1919

2020
use super::approval::ApprovalBroker;
21-
use crate::pool::{FeatureService, RoutingService};
21+
use super::invoke_backend::InvokeToolBackend;
22+
use crate::pool::FeatureService;
2223
use crate::services::{
2324
FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry, ToolDiscoveryService,
2425
};
@@ -47,7 +48,7 @@ pub struct MetaToolContext {
4748
pub resolver: Arc<FeatureSetResolverService>,
4849
pub feature_service: Arc<FeatureService>,
4950
/// Backend invoke path — required for `mcpmux_invoke_tool`.
50-
pub routing_service: Option<Arc<RoutingService>>,
51+
pub invoke_backend: Option<Arc<dyn InvokeToolBackend>>,
5152
pub tool_discovery: Arc<ToolDiscoveryService>,
5253
pub session_roots: Arc<SessionRootsRegistry>,
5354
pub session_overrides: Arc<SessionOverrideRegistry>,

crates/mcpmux-gateway/src/services/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ pub use event_emitter::EventEmitter;
2424
pub use feature_set_resolver::{FeatureSetResolverService, ResolutionSource, ResolvedFeatureSet};
2525
pub use grant_service::GrantService;
2626
pub use meta_tools::{
27-
is_meta_tool, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher,
28-
ApprovalRequest, ApprovalScope, MetaToolRegistry, MCPMUX_PREFIX,
27+
is_meta_tool, routing_as_invoke_backend, ApprovalBroker, ApprovalDecision, ApprovalPayload,
28+
ApprovalPublisher, ApprovalRequest, ApprovalScope, InvokeToolBackend, MetaToolRegistry,
29+
MCPMUX_PREFIX,
2930
};
3031
pub use notification_emitter::NotificationEmitter;
3132
pub use prefix_cache::PrefixCacheService;

0 commit comments

Comments
 (0)