Skip to content

Commit 270921d

Browse files
committed
feat(gateway): Phase C — FeatureSet invoke ACL + surfaced tools
Autonomous decisions: - Keep session-enable all-tools expansion only when no FeatureSet is bound — preserves Phase A bootstrap path while member filter is authoritative once bound - Add migration 019 surfaced column on feature_set_members — matches existing ALTER TABLE pattern, default 0 - Surface toggle UI only on selected tools in FeatureSetPanel — surfaced without include is meaningless Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 4d11112 commit 270921d

16 files changed

Lines changed: 360 additions & 72 deletions

File tree

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Lightweight and cross-platform — built in Rust with Tauri 2, McpMux uses minim
7777
}
7878
```
7979

80-
**3.** Done. Every tool from every server is available in every client, right now.
80+
**3.** Done. Connected clients see a small fixed meta-tool surface (~12 `mcpmux_*` tools). Backend tools are discovered via **`mcpmux_search_tools`****`mcpmux_get_tool_schema`****`mcpmux_invoke_tool`**, keeping context windows lean. Optionally surface individual hot-path tools into `tools/list` per FeatureSet.
8181

8282
McpMux routes calls to the right server, refreshes OAuth tokens automatically, and keeps credentials encrypted in your OS keychain — you never think about it again.
8383

@@ -109,28 +109,33 @@ 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, prompts, and resources a client can access. 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), 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.
113113

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

116116
### Self-Management Meta Tools (mcpmux_*)
117117

118-
Routing everything through one gateway endpoint means connected AI clients can see every backend tool at session start — even when the project only needs a handful. Workspace bindings pin stable per-folder toolsets, but they do not cover one-off needs ("enable Firebase for the next 15 minutes") or discovery-driven workflows where the LLM picks servers as it goes.
118+
Connected AI clients see a fixed ~12-tool meta surface instead of every backend tool definition. FeatureSets define what is **invokable**; optional **surfaced** tools (0–N per set) can be promoted into `tools/list` for one-hop hot paths. Workspace bindings pin stable per-folder toolsets; session enable/disable gates server activity without bloating context.
119119

120-
McpMux exposes a built-in `mcpmux_*` tool namespace so the LLM can introspect and reshape its own tool surface mid-conversation:
120+
McpMux exposes a built-in `mcpmux_*` tool namespace for search → schema → invoke workflows:
121121

122122
1. Call **`mcpmux_list_servers`** — server-level manifest with per-server status: `enabled_via_binding`, `enabled_via_session`, `disabled_via_session`, or `inactive`.
123-
2. Call **`mcpmux_enable_server`** or **`mcpmux_disable_server`** — toggle servers on or off. The gateway pushes `tools/list_changed` so the tool list refreshes without reconnecting.
124-
3. Use **`scope: "session"`** (default) for ephemeral overrides that die with the MCP session, or **`scope: "workspace"`** to persistently add/remove a server from the workspace binding (workspace writes always require approval).
123+
2. Call **`mcpmux_enable_server`** or **`mcpmux_disable_server`** — toggle servers on or off for the session or workspace.
124+
3. Call **`mcpmux_search_tools`** — find invokable tools by query (respects FeatureSet ACL).
125+
4. Call **`mcpmux_get_tool_schema`** — load parameter schemas before invoking.
126+
5. Call **`mcpmux_invoke_tool`** — invoke any permitted backend tool through one entry point.
125127

126128
| Tool | Type | Purpose |
127129
| ---- | ---- | ------- |
128-
| `mcpmux_list_all_tools` | read | Full tool roster in the resolved Space |
130+
| `mcpmux_list_all_tools` | read | Full tool roster in the resolved Space (diagnostic) |
129131
| `mcpmux_list_feature_sets` | read | FeatureSets available in the resolved Space |
130132
| `mcpmux_list_servers` | read | Server-level manifest with status |
133+
| `mcpmux_search_tools` | read | Search invokable tools with optional schema detail |
134+
| `mcpmux_get_tool_schema` | read | Load input schemas before invoke |
135+
| `mcpmux_invoke_tool` | read | Invoke a backend tool by server_id + tool name |
131136
| `mcpmux_enable_server` | write | Enable a server (session or workspace scope) |
132137
| `mcpmux_disable_server` | write | Disable a server (session or workspace scope) |
133-
| `mcpmux_create_feature_set` | write | Create a custom FeatureSet |
138+
| `mcpmux_create_feature_set` | write | Create a custom FeatureSet (optional `surfaced_tools[]`) |
134139
| `mcpmux_bind_current_workspace` | write | Bind the session's workspace root to FeatureSets |
135140

136141
In the desktop app: **Settings → Self-management tools** toggles the whole namespace and optional approval for session-scope overrides. **Workspaces → live folder inspector → Active session overrides** shows per-session enabled/disabled servers and lets you clear overrides with one click.

apps/desktop/src-tauri/src/commands/feature_set.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub struct FeatureSetMemberResponse {
2222
pub member_type: String,
2323
pub member_id: String,
2424
pub mode: String,
25+
pub surfaced: bool,
2526
}
2627

2728
impl From<&FeatureSetMember> for FeatureSetMemberResponse {
@@ -32,6 +33,7 @@ impl From<&FeatureSetMember> for FeatureSetMemberResponse {
3233
member_type: m.member_type.as_str().to_string(),
3334
member_id: m.member_id.clone(),
3435
mode: m.mode.as_str().to_string(),
36+
surfaced: m.surfaced,
3537
}
3638
}
3739
}
@@ -92,6 +94,7 @@ pub struct AddMemberInput {
9294
pub member_type: String, // "feature" or "feature_set"
9395
pub member_id: String,
9496
pub mode: Option<String>, // "include" or "exclude", defaults to "include"
97+
pub surfaced: Option<bool>,
9598
}
9699

97100
/// List all feature sets.
@@ -368,6 +371,7 @@ pub async fn add_feature_set_member(
368371
member_type,
369372
member_id: input.member_id,
370373
mode,
374+
surfaced: input.surfaced.unwrap_or(false),
371375
};
372376

373377
feature_set.members.push(member);
@@ -492,6 +496,7 @@ pub async fn set_feature_set_members(
492496
member_type,
493497
member_id: input.member_id,
494498
mode,
499+
surfaced: input.surfaced.unwrap_or(false),
495500
}
496501
})
497502
.collect();

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

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Star,
1919
Shield,
2020
Save,
21+
Monitor,
2122
} from 'lucide-react';
2223
import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
2324
import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets';
@@ -46,6 +47,7 @@ interface ServerGroup {
4647
export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpdate }: FeatureSetPanelProps) {
4748
const [allFeatures, setAllFeatures] = useState<ServerFeature[]>([]);
4849
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
50+
const [surfacedFeatureIds, setSurfacedFeatureIds] = useState<Set<string>>(new Set());
4951
const [searchQuery, setSearchQuery] = useState('');
5052
const [isLoading, setIsLoading] = useState(true);
5153
const [isSaving, setIsSaving] = useState(false);
@@ -93,13 +95,18 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
9395

9496
// Seed from the set's include-mode feature members.
9597
const currentIds = new Set<string>();
98+
const surfacedIds = new Set<string>();
9699
featureSet.members?.forEach((m) => {
97100
if (m.member_type === 'feature' && m.mode === 'include') {
98101
currentIds.add(m.member_id);
102+
if (m.surfaced) {
103+
surfacedIds.add(m.member_id);
104+
}
99105
}
100106
});
101107

102108
setSelectedFeatureIds(currentIds);
109+
setSurfacedFeatureIds(surfacedIds);
103110

104111
// Start with all servers collapsed
105112
setExpandedServers(new Set());
@@ -143,6 +150,28 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
143150
const toggleFeature = (featureId: string) => {
144151
if (!isConfigurable) return;
145152
setSelectedFeatureIds((prev) => {
153+
const next = new Set(prev);
154+
if (next.has(featureId)) {
155+
next.delete(featureId);
156+
setSurfacedFeatureIds((surfaced) => {
157+
const nextSurfaced = new Set(surfaced);
158+
nextSurfaced.delete(featureId);
159+
return nextSurfaced;
160+
});
161+
} else {
162+
next.add(featureId);
163+
}
164+
return next;
165+
});
166+
};
167+
168+
/**
169+
* Toggle whether an included tool is promoted into client tools/list.
170+
*/
171+
const toggleSurfaced = (featureId: string, event: React.MouseEvent) => {
172+
event.stopPropagation();
173+
if (!isConfigurable || !selectedFeatureIds.has(featureId)) return;
174+
setSurfacedFeatureIds((prev) => {
146175
const next = new Set(prev);
147176
if (next.has(featureId)) {
148177
next.delete(featureId);
@@ -230,6 +259,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
230259
member_type: 'feature' as const,
231260
member_id: id,
232261
mode: 'include' as const,
262+
surfaced: surfacedFeatureIds.has(id),
233263
}));
234264

235265
await setFeatureSetMembers(featureSet.id, members);
@@ -613,42 +643,64 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
613643
<div className="bg-[rgb(var(--background))] border-t border-[rgb(var(--border))]">
614644
{group.features.map((feature) => {
615645
const isSelected = isFeatureSelected(feature.id, feature);
616-
646+
const isSurfaced = surfacedFeatureIds.has(feature.id);
647+
const isTool = feature.feature_type === 'tool';
648+
617649
return (
618-
<button
650+
<div
619651
key={feature.id}
620-
onClick={() => toggleFeature(feature.id)}
621-
disabled={!isConfigurable}
622-
className={`w-full flex items-center gap-3 px-4 py-2.5 pl-12 text-left border-b border-[rgb(var(--border))] last:border-b-0 transition-colors
623-
${isConfigurable ? 'hover:bg-[rgb(var(--surface-hover))]' : 'cursor-default'}
652+
className={`flex items-center gap-2 border-b border-[rgb(var(--border))] last:border-b-0 transition-colors
624653
${isSelected ? 'bg-primary-50 dark:bg-primary-900/10' : ''}`}
625654
>
626-
<div className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors ${
627-
isSelected
628-
? 'bg-primary-500 border-primary-500'
629-
: 'border-[rgb(var(--border))] bg-white dark:bg-[rgb(var(--surface))]'
630-
}`}>
631-
{isSelected && <Check className="h-3 w-3 text-white" />}
632-
</div>
633-
634-
{getFeatureIcon(feature.feature_type)}
635-
636-
<div className="flex-1 min-w-0">
637-
<div className="flex items-center gap-2">
638-
<span className="font-medium text-sm truncate">
639-
{feature.display_name || feature.feature_name}
640-
</span>
641-
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getTypeColor(feature.feature_type)}`}>
642-
{feature.feature_type}
643-
</span>
655+
<button
656+
onClick={() => toggleFeature(feature.id)}
657+
disabled={!isConfigurable}
658+
className={`flex-1 flex items-center gap-3 px-4 py-2.5 pl-12 text-left transition-colors
659+
${isConfigurable ? 'hover:bg-[rgb(var(--surface-hover))]' : 'cursor-default'}`}
660+
>
661+
<div className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors ${
662+
isSelected
663+
? 'bg-primary-500 border-primary-500'
664+
: 'border-[rgb(var(--border))] bg-white dark:bg-[rgb(var(--surface))]'
665+
}`}>
666+
{isSelected && <Check className="h-3 w-3 text-white" />}
667+
</div>
668+
669+
{getFeatureIcon(feature.feature_type)}
670+
671+
<div className="flex-1 min-w-0">
672+
<div className="flex items-center gap-2">
673+
<span className="font-medium text-sm truncate">
674+
{feature.display_name || feature.feature_name}
675+
</span>
676+
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getTypeColor(feature.feature_type)}`}>
677+
{feature.feature_type}
678+
</span>
679+
</div>
680+
{feature.description && (
681+
<p className="text-xs text-[rgb(var(--muted))] mt-0.5 line-clamp-1">
682+
{feature.description}
683+
</p>
684+
)}
644685
</div>
645-
{feature.description && (
646-
<p className="text-xs text-[rgb(var(--muted))] mt-0.5 line-clamp-1">
647-
{feature.description}
648-
</p>
649-
)}
650-
</div>
651-
</button>
686+
</button>
687+
688+
{isConfigurable && isTool && isSelected && (
689+
<button
690+
type="button"
691+
onClick={(event) => toggleSurfaced(feature.id, event)}
692+
className={`mr-3 flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-colors flex-shrink-0
693+
${isSurfaced
694+
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
695+
: 'text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))]'}`}
696+
title="Surface in client tools/list"
697+
data-testid={`surface-toggle-${feature.id}`}
698+
>
699+
<Monitor className="h-3.5 w-3.5" />
700+
Surface
701+
</button>
702+
)}
703+
</div>
652704
);
653705
})}
654706
</div>

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -638,9 +638,10 @@ export function SettingsPage() {
638638
Self-management tools (mcpmux_*)
639639
</CardTitle>
640640
<CardDescription>
641-
When enabled, connected MCP clients see a small built-in toolset that lets
642-
LLMs introspect and — with your approval — reshape the FeatureSet they see.
643-
Writes always trigger a native approval dialog; reads are silent.
641+
When enabled, connected MCP clients see a fixed meta-tool surface (~12 tools)
642+
for search → schema → invoke workflows. FeatureSets control what is invokable;
643+
optional surfaced tools can appear directly in tools/list. Writes always trigger
644+
a native approval dialog; reads are silent.
644645
</CardDescription>
645646
</CardHeader>
646647
<CardContent className="space-y-6">
@@ -650,9 +651,9 @@ export function SettingsPage() {
650651
<div>
651652
<label className="text-sm font-medium">Advertise self-management tools</label>
652653
<p className="text-xs text-[rgb(var(--muted))] mt-1">
653-
Shows <code className="font-mono">mcpmux_list_all_tools</code>,&nbsp;
654-
<code className="font-mono">mcpmux_pin_this_session</code>, and 6 others to
655-
every connected MCP client. Turn off to hide the whole namespace.
654+
Shows <code className="font-mono">mcpmux_search_tools</code>,&nbsp;
655+
<code className="font-mono">mcpmux_invoke_tool</code>, and other meta tools
656+
to every connected MCP client. Turn off to hide the whole namespace.
656657
</p>
657658
</div>
658659
</div>

apps/desktop/src/lib/api/featureSets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface FeatureSetMember {
4545
member_type: MemberType;
4646
member_id: string;
4747
mode: MemberMode;
48+
surfaced?: boolean;
4849
}
4950

5051
/**
@@ -89,6 +90,7 @@ export interface AddMemberInput {
8990
member_type: MemberType;
9091
member_id: string;
9192
mode?: MemberMode;
93+
surfaced?: boolean;
9294
}
9395

9496
/**

crates/mcpmux-core/src/domain/feature_set.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ pub struct FeatureSetMember {
119119
pub member_id: String,
120120
/// Include or exclude
121121
pub mode: MemberMode,
122+
/// When true on an included tool member, promote into client `tools/list`.
123+
#[serde(default)]
124+
pub surfaced: bool,
122125
}
123126

124127
impl FeatureSetMember {
@@ -130,6 +133,7 @@ impl FeatureSetMember {
130133
member_type: MemberType::Feature,
131134
member_id: feature_id.to_string(),
132135
mode: MemberMode::Include,
136+
surfaced: false,
133137
}
134138
}
135139

@@ -141,6 +145,7 @@ impl FeatureSetMember {
141145
member_type: MemberType::Feature,
142146
member_id: feature_id.to_string(),
143147
mode: MemberMode::Exclude,
148+
surfaced: false,
144149
}
145150
}
146151

@@ -152,6 +157,7 @@ impl FeatureSetMember {
152157
member_type: MemberType::FeatureSet,
153158
member_id: included_featureset_id.to_string(),
154159
mode: MemberMode::Include,
160+
surfaced: false,
155161
}
156162
}
157163
}

0 commit comments

Comments
 (0)