Skip to content

Commit 22d92c3

Browse files
committed
fix(gateway): allow direct call_tool for surfaced backend tools
Probe workspace roots before call_tool routing so surfaced tools resolve the same binding ACL as tools/list. Document checkbox vs Surface in the FeatureSet editor and record Phase C manual QA pass. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 3bc4dc9 commit 22d92c3

7 files changed

Lines changed: 174 additions & 29 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Create isolated Spaces — each with their own servers, credentials, and permiss
109109

110110
### Control What Each Client Can Do
111111

112-
Not every AI client should have the same power. Create Feature Sets — permission bundles that control exactly which tools a client can **invoke** (search + invoke ACL), plus optional per-tool **Surface in client** promotion for one-hop hot paths. Build a "Read Only" set for cautious workflows, a "React Development" set with just GitHub and Filesystem, or a "Full Stack Dev" set with everything. Assign them per-client so each tool only goes where you want it.
112+
Not every AI client should have the same power. Create Feature Sets — permission bundles that control exactly which tools a client can **invoke** (search + invoke ACL). In the editor, the **checkbox** includes a tool in that ACL; the **Surface** button (optional, per row) promotes an included tool into the client's `tools/list` for one-hop hot paths. Build a "Read Only" set for cautious workflows, a "React Development" set with just GitHub and Filesystem, or a "Full Stack Dev" set with everything. Assign them per-client so each tool only goes where you want it.
113113

114114
![Feature Sets — granular per-server tool selection](docs/screenshots/featureset-detail.png)
115115

apps/desktop/src/features/featuresets/FeatureSetPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
693693
${isSurfaced
694694
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
695695
: 'text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))]'}`}
696-
title="Surface in client tools/list"
696+
title="Promote this included tool into the client tools/list for direct one-hop calls. The checkbox only grants invoke access via search + mcpmux_invoke_tool—it does not add the tool to tools/list. Use sparingly; each surfaced tool adds its schema to the client context."
697697
data-testid={`surface-toggle-${feature.id}`}
698698
>
699699
<Monitor className="h-3.5 w-3.5" />

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -779,13 +779,21 @@ impl ServerHandler for McpMuxGatewayHandler {
779779
};
780780
}
781781

782+
self.ensure_roots_probed(
783+
&context.peer,
784+
session_id,
785+
&oauth_ctx.client_id,
786+
)
787+
.await;
788+
782789
// Resolve routing — the binding's target space is authoritative,
783790
// which may differ from oauth_ctx.space_id.
784791
let (space_id, feature_set_ids) = self
785792
.resolve_routing(session_id, &oauth_ctx.client_id)
786793
.await?;
787794

788-
// Hard cut: reject direct backend tool calls — agents must use mcpmux_invoke_tool.
795+
// Hard cut: non-surfaced backend tools must use mcpmux_invoke_tool.
796+
// Surfaced tools stay in tools/list for one-hop calls.
789797
let space_id_str = space_id.to_string();
790798
if let Ok(Some((server_id, actual_tool_name))) = self
791799
.services
@@ -794,18 +802,38 @@ impl ServerHandler for McpMuxGatewayHandler {
794802
.find_server_for_qualified_tool(&space_id_str, &params.name)
795803
.await
796804
{
797-
let message = crate::pool::format_direct_call_redirect(
798-
&params.name,
799-
&server_id,
800-
&actual_tool_name,
801-
);
802-
return Ok(CallToolResult::error(vec![Content::text(
803-
serde_json::json!({
804-
"error": "use_invoke_tool",
805-
"message": message,
806-
})
807-
.to_string(),
808-
)]));
805+
let advertised = self
806+
.services
807+
.pool_services
808+
.feature_service
809+
.get_advertised_tools_for_grants(
810+
&space_id_str,
811+
&feature_set_ids,
812+
session_id,
813+
)
814+
.await
815+
.map_err(|e| {
816+
McpError::internal_error(format!("Failed to get advertised tools: {}", e), None)
817+
})?;
818+
819+
let is_surfaced = advertised
820+
.iter()
821+
.any(|feature| feature.qualified_name() == params.name.as_ref());
822+
823+
if !is_surfaced {
824+
let message = crate::pool::format_direct_call_redirect(
825+
&params.name,
826+
&server_id,
827+
&actual_tool_name,
828+
);
829+
return Ok(CallToolResult::error(vec![Content::text(
830+
serde_json::json!({
831+
"error": "use_invoke_tool",
832+
"message": message,
833+
})
834+
.to_string(),
835+
)]));
836+
}
809837
}
810838

811839
// Call tool via routing service (handles auth and routing)

docs/guide/feature-sets.mdx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
---
22
title: FeatureSets — Permission Control
3-
description: FeatureSets control which MCP tools, resources, and prompts each AI client can access in McpMux. Create role-based permissions, domain bundles, or read-only views.
3+
description: FeatureSets control which MCP tools AI clients can invoke and optionally promote into tools/list. Create role-based permissions, domain bundles, or read-only views.
44
---
55

6-
FeatureSets are permission bundles that control what MCP capabilities (tools, resources, and prompts) each AI client can access. They let you grant fine-grained permissions per client, per Space.
6+
FeatureSets are permission bundles that control what MCP capabilities each AI client can use in a Space. For **tools**, they act as an **invoke ACL**: they define what agents can reach through `mcpmux_search_tools` and `mcpmux_invoke_tool`. They do **not** dump every permitted tool into the client's tool list by default — that keeps context windows lean.
7+
8+
Resources and prompts still follow the classic grant model (included members are exposed when the client lists them).
79

810
## Why FeatureSets
911

@@ -50,6 +52,26 @@ Exclude rules always win over include rules. This means you can create a permiss
5052
1. Include the **GitHub — All** ServerAll FeatureSet
5153
2. Exclude `delete_repository`, `delete_branch`, `delete_file`
5254

55+
## Included vs Surface (FeatureSet editor)
56+
57+
When you edit a custom FeatureSet, each tool row has two independent controls:
58+
59+
| Control | What it does | Client effect |
60+
| ------- | ------------ | --------------- |
61+
| **Checkbox** (left) | **Include** the tool in this FeatureSet's invoke ACL | Tool is **invokable** via `mcpmux_search_tools``mcpmux_get_tool_schema``mcpmux_invoke_tool`. It does **not** appear in the client's `tools/list`. |
62+
| **Surface** button (right, monitor icon) | **Promote** an already-included tool into `tools/list` | Tool appears alongside the ~12 `mcpmux_*` meta tools. The agent can call it **directly** (one hop) instead of going through `mcpmux_invoke_tool`. |
63+
64+
**Rules:**
65+
66+
- **Surface only appears when the checkbox is on.** You cannot surface a tool you have not included.
67+
- **Default is checkbox on, Surface off.** Most backend tools stay off the client tool list; agents discover them through search + invoke.
68+
- **Use Surface sparingly.** Each promoted tool adds its full schema to the client context window. Reserve it for hot paths you call constantly (e.g. one GitHub read tool).
69+
- **The server header toggle** (Enable All / Disable All) bulk-selects checkboxes for that server — it is **not** the Surface control.
70+
71+
**Example:** A "GitHub read-only" FeatureSet might include `list_issues` and `get_me` (both checked), with **Surface on** only for `list_issues`. Cursor shows `github_list_issues` in its tool list; `get_me` stays invoke-only.
72+
73+
Connected clients always see the fixed `mcpmux_*` meta surface regardless of FeatureSet membership. See [Self-management meta tools](#self-management-meta-tools) below.
74+
5375
## Composition
5476

5577
FeatureSets can **contain other FeatureSets**. This lets you build hierarchical permission structures:
@@ -95,15 +117,32 @@ FeatureSets can **contain other FeatureSets**. This lets you build hierarchical
95117
1. Go to the **FeatureSets** page
96118
2. Click **Create FeatureSet**
97119
3. Give it a name and optional description
98-
4. Add members — select features or other FeatureSets
99-
5. Set each member to include or exclude mode
120+
4. Under **Included Features**, check the tools/resources/prompts to allow (invoke ACL for tools)
121+
5. Optionally click **Surface** on individual included tools you want promoted into client `tools/list`
122+
6. Save — if a connected MCP client is open, reload its tools after changing Surface toggles
123+
124+
You can also nest FeatureSets (include another FeatureSet as a member) and set each member to include or exclude mode.
100125

101126
### Assigning to Clients
102127

103128
FeatureSets are assigned to clients per Space. Go to the **Clients** page, select a client, and manage its FeatureSet grants for each Space.
104129

105130
A client's effective permissions are the combination of all its granted FeatureSets, with exclude rules taking priority.
106131

132+
Workspace **bindings** attach FeatureSets to folder roots so the invoke ACL follows the project you have open. Client grants stack additional FeatureSets on top.
133+
134+
### Self-management meta tools
135+
136+
McpMux exposes a built-in `mcpmux_*` namespace (~12 tools) for server toggles, search, schema load, and invoke. FeatureSets control the **backend** pool those meta tools can reach; they do not replace the meta tools themselves.
137+
138+
Typical agent flow for a non-surfaced backend tool:
139+
140+
1. `mcpmux_search_tools` — find tools allowed by the active FeatureSet
141+
2. `mcpmux_get_tool_schema` — read parameter names before calling
142+
3. `mcpmux_invoke_tool` — run the backend tool
143+
144+
See the [Gateway](/docs/gateway/) doc for how bindings, session enable/disable, and FeatureSet members compose at request time.
145+
107146
## Next Steps
108147

109148
- [Set up Clients](/docs/clients/) and assign FeatureSets per Space

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@ One-session checklist for validating Phases A–C (search → schema → invoke,
1515
- [x] Confirm McpMux endpoint: `http://localhost:45818/mcp`
1616
- [x] Have at least one OAuth server (GitHub) **installed and connected**`QA: meta-gateway invoke` FeatureSet bound in UI (May 25)
1717
- [x] Workspace binding with GWorkspace (or target server) configured in UI — **not** via agent `mcpmux_bind_current_workspace`
18-
- [ ] Optional for Phase C tests: create a FeatureSet with 1–2 GitHub tools, bind to workspace; leave surfaced off until test 8
18+
- [x] Optional for Phase C tests: create a FeatureSet with 1–2 GitHub tools, bind to workspace; leave surfaced off until test 8 — `QA: meta-gateway invoke` (`list_issues` + `get_me`, surfaced off, bound May 25)
19+
20+
**FeatureSet editor controls (tests 8–9):**
21+
22+
| Control | Role in QA |
23+
| ------- | ---------- |
24+
| **Checkbox** | Include tool in invoke ACL → search + `mcpmux_invoke_tool` |
25+
| **Surface** button | Promote included tool into client `tools/list` → direct one-hop call (test 9 only) |
26+
| **Server header toggle** | Bulk include/exclude — not Surface |
27+
28+
After any Surface change: **Cursor → MCP → Reload tools**.
1929

2030
**Tester:** Cursor agent (Composer)
2131
**Date:** May 25, 2026
@@ -200,7 +210,7 @@ Confirm results are scoped to that server_id only.
200210

201211
## 8. FeatureSet ACL — partial tool set (Phase C)
202212

203-
**Setup:** FeatureSet with 1–2 GitHub tools included, bound to workspace, surfaced **off**.
213+
**Setup:** FeatureSet with 1–2 GitHub tools **checked** (included), bound to workspace, **Surface off** on all rows.
204214

205215
**Prompt:**
206216

@@ -214,15 +224,15 @@ I bound a FeatureSet that only allows specific GitHub tools.
214224

215225
| Check | Pass | Fail | Notes |
216226
| ----- | ---- | ---- | ----- |
217-
| Search only finds allowed tools | || |
218-
| Invoke denied for disallowed tool | || |
219-
| Invoke succeeds for allowed tool | || |
227+
| Search only finds allowed tools | || `query: "github"` + empty query → 2 hits: `github_get_me`, `github_list_issues` only (not 41) |
228+
| Invoke denied for disallowed tool | || `create_issue``tool 'github_create_issue' is not invokable with current grants` |
229+
| Invoke succeeds for allowed tool | || `list_issues` (3 open issues) + `get_me` (`crimsonsunset`) both succeeded |
220230

221231
---
222232

223233
## 9. Surfaced tool promotion (Phase C)
224234

225-
**Setup:** In FeatureSet editor, toggle **Surface in client** on one included tool. Reload MCP tools.
235+
**Setup:** In FeatureSet editor, leave **`list_issues` checked** and click **Surface** (blue) on that row only; leave other included tools checked but Surface off. Save, then **Cursor → MCP → Reload tools**.
226236

227237
**Prompt:**
228238

@@ -234,9 +244,9 @@ I bound a FeatureSet that only allows specific GitHub tools.
234244

235245
| Check | Pass | Fail | Notes |
236246
| ----- | ---- | ---- | ----- |
237-
| Surfaced tool appears in client tool list | || |
238-
| Surfaced tool callable without invoke wrapper | || |
239-
| Non-surfaced backend still requires invoke | || |
247+
| Surfaced tool appears in client tool list | || After Cursor MCP reload: 10 `mcpmux_*` + `github_list_issues` only; `github_get_me` not listed |
248+
| Surfaced tool callable without invoke wrapper | || Direct `github_list_issues` → 2 open issues (no `use_invoke_tool` redirect) after handler fix + binding reload May 25 |
249+
| Non-surfaced backend still requires invoke | || `get_me` absent from tools/list; `mcpmux_invoke_tool``crimsonsunset` OK |
240250

241251
---
242252

@@ -296,12 +306,13 @@ Rules: McpMux meta tools only, read schemas before invoke, note truncation if an
296306
| ---- | ------ |
297307
| Phase A — meta invoke core | ☑ Pass ☐ Fail |
298308
| Phase B — result shaping | ☑ Pass ☐ Fail |
299-
| Phase C — ACL + surfaced | Pass ☐ Fail ☐ Skipped |
309+
| Phase C — ACL + surfaced | Pass ☐ Fail ☐ Skipped |
300310
| Overall | ☐ Ship ☐ Block |
301311

302312
**Blockers / issues filed:**
303313

304314
```
305315
- section 6 JSON rows: manual pass May 25 after binding QA FeatureSet — github_list_issues filter verified live
306316
- beeper 401 on get_accounts/search_chats — auth expired; not blocking meta-gateway QA
317+
- test 9: surfaced direct one-hop + invoke-only non-surfaced — pass May 25 live (`github_list_issues` direct → 2 issues; `get_me` via invoke only)
307318
```

docs/planning/meta-gateway-invoke.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ Prompts and resources: unchanged — still materialized per grants. Invoke model
246246
- [x] FeatureSet member model: tools invokable by default when server in set; optional `surfaced: true` promotes into `tools/list`
247247
- [x] Search + invoke respect FeatureSet member filter (not just server-all)
248248
- [x] Workspaces UI: per-tool "Surface in client" toggle in FeatureSet editor (`FeatureSetPanel.tsx`)
249+
- **Checkbox** = invoke ACL member (search + `mcpmux_invoke_tool`)
250+
- **Surface button** = promote that included tool into client `tools/list` for direct one-hop calls
251+
- User-facing explainer: [`docs/guide/feature-sets.mdx`](../guide/feature-sets.mdx#included-vs-surface-featureset-editor)
249252
- [ ] Update `mcpmux_create_feature_set` to accept optional `surfaced_tools[]` (UI path done; meta-tool arg deferred)
250253
- [x] Integration tests: binding with partial tool set → search only finds allowed tools; surfaced tool appears in `tools/list`
251254

tests/rust/tests/integration/meta_gateway_invoke.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,3 +581,67 @@ async fn surfaced_tool_appears_in_advertised_set() {
581581
assert_eq!(advertised[0].feature_name, "list_issues");
582582
assert_eq!(advertised[0].qualified_name(), "github_list_issues");
583583
}
584+
585+
#[tokio::test(flavor = "multi_thread")]
586+
async fn direct_backend_call_gate_allows_surfaced_only() {
587+
let f = Fixture::new().await;
588+
589+
let features = f
590+
.server_feature_repo
591+
.list_for_space(&f.space_id.to_string())
592+
.await
593+
.unwrap();
594+
let list_issues = features
595+
.iter()
596+
.find(|feat| feat.feature_name == "list_issues")
597+
.unwrap();
598+
599+
let mut get_me = ServerFeature::tool(f.space_id, "github", "get_me");
600+
get_me.description = Some("Get authenticated GitHub user".into());
601+
f.server_feature_repo.upsert(&get_me).await.unwrap();
602+
603+
let mut mixed_fs = FeatureSet::new_custom("Mixed Surfaced GitHub", f.space_id.to_string());
604+
mixed_fs.members.push(FeatureSetMember {
605+
id: Uuid::new_v4().to_string(),
606+
feature_set_id: mixed_fs.id.clone(),
607+
member_type: MemberType::Feature,
608+
member_id: list_issues.id.to_string(),
609+
mode: MemberMode::Include,
610+
surfaced: true,
611+
});
612+
mixed_fs.members.push(FeatureSetMember {
613+
id: Uuid::new_v4().to_string(),
614+
feature_set_id: mixed_fs.id.clone(),
615+
member_type: MemberType::Feature,
616+
member_id: get_me.id.to_string(),
617+
mode: MemberMode::Include,
618+
surfaced: false,
619+
});
620+
f.feature_set_repo.create(&mixed_fs).await.unwrap();
621+
f.grant_feature_set(&mixed_fs.id).await;
622+
f.session_overrides.enable(&f.session_id, "github");
623+
624+
let fs_ids = vec![mixed_fs.id.clone()];
625+
let invokable = f
626+
.feature_service
627+
.get_invokable_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id))
628+
.await
629+
.unwrap();
630+
let advertised = f
631+
.feature_service
632+
.get_advertised_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id))
633+
.await
634+
.unwrap();
635+
636+
assert_eq!(invokable.len(), 2);
637+
assert_eq!(advertised.len(), 1);
638+
assert_eq!(advertised[0].qualified_name(), "github_list_issues");
639+
640+
let is_surfaced = |qualified_name: &str| {
641+
advertised
642+
.iter()
643+
.any(|feature| feature.qualified_name() == qualified_name)
644+
};
645+
assert!(is_surfaced("github_list_issues"));
646+
assert!(!is_surfaced("github_get_me"));
647+
}

0 commit comments

Comments
 (0)