Skip to content

Commit f2efc81

Browse files
committed
feat(gateway): Phase 2 — invocation gate + self-bind escape hatch
Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 472dca0 commit f2efc81

5 files changed

Lines changed: 78 additions & 24 deletions

File tree

crates/mcpmux-gateway/src/admin/command_bridge/read.rs

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -412,23 +412,12 @@ pub async fn get_workspace_effective_features(
412412
binding.space_id,
413413
binding.feature_set_ids,
414414
),
415-
None => {
416-
let sets = ctx
417-
.services
418-
.permission()
419-
.list_feature_sets_for_space(&default_space.id.to_string())
420-
.await?;
421-
let fallback = sets
422-
.into_iter()
423-
.find(|set| set.feature_set_type.as_str() == "starter")
424-
.ok_or_else(|| anyhow!("Default Space has no Starter FeatureSet"))?;
425-
(
426-
"unbound".to_string(),
427-
None,
428-
default_space.id,
429-
vec![fallback.id],
430-
)
431-
}
415+
None => (
416+
"unbound".to_string(),
417+
None,
418+
default_space.id,
419+
vec![],
420+
),
432421
};
433422

434423
let space = ctx

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ impl McpMuxGatewayHandler {
143143
resolved.source,
144144
crate::services::ResolutionSource::Deny
145145
| crate::services::ResolutionSource::SpaceDefault
146+
| crate::services::ResolutionSource::Unbound
146147
);
147148
if let (true, Some(sid), Some(space_id), Some(root)) = (
148149
should_prompt,
@@ -605,7 +606,7 @@ impl ServerHandler for McpMuxGatewayHandler {
605606
// normalized them on insert. Passing `Some(root)`
606607
// lets log_and_notify_resolution emit
607608
// `WorkspaceNeedsBinding` if the resolver ended
608-
// up at `source = Deny` (i.e. no binding yet).
609+
// up at `source = Deny | Unbound` (i.e. no binding yet).
609610
let root_for_prompt =
610611
session_roots.get(&session_id_for_task).and_then(|roots| {
611612
roots

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ pub const CORE_META_TOOLS: &[&str] = &[
8585
"mcpmux_get_tool_schema",
8686
"mcpmux_list_servers",
8787
"mcpmux_set_workspace_root",
88+
"mcpmux_bind_current_workspace",
8889
];
8990

9091
/// Convenience: is this tool name one of ours?

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ mod tests {
159159
);
160160
println!("{line}");
161161

162-
assert_eq!(CORE_META_TOOLS.len(), 5);
162+
assert_eq!(CORE_META_TOOLS.len(), 6);
163163
assert_eq!(ALL_REGISTERED_META_TOOL_NAMES.len(), 12);
164164
assert!(
165165
budget.core_claude_est < budget.full_claude_est,

tests/rust/tests/integration/meta_tools.rs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,7 +1923,6 @@ async fn registry_advertises_core_tools_read_only_in_list() {
19231923
}
19241924
for hidden in [
19251925
"mcpmux_list_feature_sets",
1926-
"mcpmux_bind_current_workspace",
19271926
"mcpmux_search_resources",
19281927
"mcpmux_read_resource",
19291928
"mcpmux_search_prompts",
@@ -1941,6 +1940,14 @@ async fn registry_advertises_core_tools_read_only_in_list() {
19411940
.as_ref()
19421941
.and_then(|a| a.destructive_hint)
19431942
.unwrap_or(false);
1943+
if tool.name.as_ref() == "mcpmux_bind_current_workspace" {
1944+
assert!(
1945+
destructive,
1946+
"bind must be annotated as a write tool: {:?}",
1947+
tool.name
1948+
);
1949+
continue;
1950+
}
19441951
assert!(
19451952
!destructive,
19461953
"advertised core tools must be read-only hints: {:?}",
@@ -1950,18 +1957,74 @@ async fn registry_advertises_core_tools_read_only_in_list() {
19501957
}
19511958

19521959
#[tokio::test(flavor = "multi_thread")]
1953-
async fn hidden_bind_tool_callable_but_not_advertised() {
1960+
async fn unbound_session_lists_bind_current_workspace() {
19541961
let f = Fixture::new().await;
1962+
let root = "/tmp/mcpmux-unbound-list-tools";
1963+
f.session_roots.set_roots_capable(&f.session_id, true);
1964+
f.session_roots.set(&f.session_id, [root]);
1965+
19551966
let advertised: Vec<_> = f
19561967
.registry
19571968
.list_as_tools()
19581969
.iter()
19591970
.map(|t| t.name.to_string())
19601971
.collect();
1961-
assert!(!advertised
1972+
assert!(
1973+
advertised.iter().any(|n| n == "mcpmux_bind_current_workspace"),
1974+
"Unbound sessions must advertise bind in tools/list: {advertised:?}"
1975+
);
1976+
}
1977+
1978+
#[tokio::test(flavor = "multi_thread")]
1979+
async fn unbound_session_invoke_tool_returns_bind_denial_hint() {
1980+
let f = Fixture::new().await;
1981+
let root = "/tmp/mcpmux-unbound-invoke-denial";
1982+
f.session_roots.set_roots_capable(&f.session_id, true);
1983+
f.session_roots.set(&f.session_id, [root]);
1984+
1985+
let result = f
1986+
.registry
1987+
.call(
1988+
"mcpmux_invoke_tool",
1989+
&f.client_id,
1990+
Some(&f.session_id),
1991+
json!({ "server_id": "github", "tool": "create_issue" }),
1992+
)
1993+
.await
1994+
.unwrap();
1995+
assert!(
1996+
Fixture::is_error(&result),
1997+
"backend invoke must be denied for Unbound sessions"
1998+
);
1999+
let body = Fixture::result_json(&result);
2000+
assert_eq!(
2001+
body.get("error").and_then(|v| v.as_str()),
2002+
Some("not_ready")
2003+
);
2004+
assert_eq!(
2005+
body.get("reason").and_then(|v| v.as_str()),
2006+
Some("inactive")
2007+
);
2008+
assert_eq!(
2009+
body.get("tool").and_then(|v| v.as_str()),
2010+
Some("mcpmux_bind_current_workspace")
2011+
);
2012+
}
2013+
2014+
#[tokio::test(flavor = "multi_thread")]
2015+
async fn hidden_list_feature_sets_callable_but_not_advertised() {
2016+
let f = Fixture::new().await;
2017+
let advertised: Vec<_> = f
2018+
.registry
2019+
.list_as_tools()
19622020
.iter()
1963-
.any(|n| n == "mcpmux_bind_current_workspace"));
1964-
assert!(f.registry.contains("mcpmux_bind_current_workspace"));
2021+
.map(|t| t.name.to_string())
2022+
.collect();
2023+
assert!(
2024+
!advertised.iter().any(|n| n == "mcpmux_list_feature_sets"),
2025+
"list_feature_sets stays off tools/list"
2026+
);
2027+
assert!(f.registry.contains("mcpmux_list_feature_sets"));
19652028

19662029
let result = f
19672030
.registry

0 commit comments

Comments
 (0)