Skip to content

Commit ecf6bb8

Browse files
committed
feat: FeatureSet resolver v2 — authoritative + UI + migration 003
Switches the gateway from per-client grants to the FeatureSetResolver (pin > workspace binding > space-active FS) and lays the Tauri/UI surface so users can actually drive the new model. Enforcement flip: * AuthorizationService::get_client_grants now delegates to FeatureSetResolverService; `client_grants` table is no longer consulted. Shadow-mode logging is removed in favour of the real path. * Call sites in mcp/handler.rs (list_tools, list_prompts, list_resources, get_prompt, read_resource, call_tool) and server/handlers.rs thread `mcp-session-id` through so workspace-binding resolution works. * Legacy repo methods (grant_feature_set / revoke_feature_set / get_grants_for_space / get_all_grants / has_grants_for_space / set_grants_for_space) are retained as no-ops for API compat — Tauri commands, GrantService, PermissionAppService, ClientService, and existing tests keep compiling without the dropped table. Migration 003: * DROP TABLE client_grants (the dead column `inbound_clients.grants` is left for now to avoid a second schema bump on older SQLite; unused in reads and writes already). Domain/API: * `AppState` gains `workspace_binding_repository`. * SpaceService::set_active_feature_set. * New Tauri commands: - set_space_active_feature_set - update_client_pin - list_workspace_bindings - list_workspace_bindings_for_space - create_workspace_binding - update_workspace_binding - delete_workspace_binding * TS bindings (`lib/api/spaces.ts`, `clients.ts`, `workspaceBindings.ts`) expose the new fields + commands. UI: * FeatureSets page: each card gains an "Active" badge (green ring + pill) when it's the Space's fallback, and a "Set Active" link in the footer that swaps it optimistically. * New Workspaces page (`features/workspaces/WorkspacesPage.tsx`): lists bindings for the current Space, with a form to create one (root + FeatureSet dropdown) and delete buttons per row. Paths are normalized Rust-side before storage so Windows/Unix/file:// inputs all compare consistently. Approval-dialog wiring-in (Step 3) is intentionally scoped down: the backend now accepts `update_client_pin` on an existing client, which is what the Connections UI will call once each approval needs to pick a Space + optional FS pin. Full dialog restyle is deferred — the underlying command + storage are ready. Tests: * New integration suite `integration::feature_set_resolver` with 8 tests over real SQLite-backed repos: - falls through to space-active when no pin and no roots - pin wins over space-active - pin wins over workspace binding - workspace binding beats space-active when no pin - deny when no pin / no binding / no space-active - longest-prefix wins across nested bindings - falls through when roots don't match any binding - deny for unknown client * All existing integration/database/streamable_http/oauth tests continue to pass (193 total). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 76c6638 commit ecf6bb8

24 files changed

Lines changed: 1122 additions & 245 deletions

File tree

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ pub struct ClientResponse {
2222
pub connection_mode: String,
2323
pub locked_space_id: Option<String>,
2424
pub grants: HashMap<String, Vec<String>>,
25+
/// Resolver v2: Space this access key belongs to (chosen at approval).
26+
pub pinned_space_id: Option<String>,
27+
/// Resolver v2: explicit FS pin (`None` → follow workspace / space active).
28+
pub pinned_feature_set_id: Option<String>,
2529
pub last_seen: Option<String>,
2630
}
2731

@@ -48,6 +52,8 @@ impl From<Client> for ClientResponse {
4852
connection_mode: mode,
4953
locked_space_id: locked_id,
5054
grants,
55+
pinned_space_id: c.pinned_space_id.map(|u| u.to_string()),
56+
pinned_feature_set_id: c.pinned_feature_set_id.map(|u| u.to_string()),
5157
last_seen: c.last_seen.map(|dt| dt.to_rfc3339()),
5258
}
5359
}
@@ -127,6 +133,48 @@ pub async fn create_client(
127133
Ok(client.into())
128134
}
129135

136+
/// Pin a client to a Space + optional FeatureSet (resolver v2).
137+
///
138+
/// Precedence used by [`mcpmux_gateway::services::FeatureSetResolverService`]:
139+
/// 1. `pinned_feature_set_id` (this pin) → source = Pin
140+
/// 2. workspace binding matches a root → source = WorkspaceBinding
141+
/// 3. space's `active_feature_set_id` → source = SpaceActive
142+
///
143+
/// Pass `pinned_feature_set_id = None` to let the resolver fall through to
144+
/// workspace/space default.
145+
#[tauri::command]
146+
pub async fn update_client_pin(
147+
client_id: String,
148+
pinned_space_id: String,
149+
pinned_feature_set_id: Option<String>,
150+
state: State<'_, AppState>,
151+
) -> Result<(), String> {
152+
let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?;
153+
let space_uuid = Uuid::parse_str(&pinned_space_id).map_err(|e| e.to_string())?;
154+
let fs_uuid = pinned_feature_set_id
155+
.as_ref()
156+
.map(|s| Uuid::parse_str(s))
157+
.transpose()
158+
.map_err(|e| e.to_string())?;
159+
160+
state
161+
.client_repository
162+
.set_pin(&client_uuid, &space_uuid, fs_uuid.as_ref())
163+
.await
164+
.map_err(|e| {
165+
tracing::error!("[update_client_pin] {e}");
166+
e.to_string()
167+
})?;
168+
169+
tracing::info!(
170+
client_id = %client_id,
171+
pinned_space_id = %pinned_space_id,
172+
pinned_feature_set_id = ?pinned_feature_set_id,
173+
"[update_client_pin] pin updated",
174+
);
175+
Ok(())
176+
}
177+
130178
/// Delete a client.
131179
#[tauri::command]
132180
pub async fn delete_client(id: String, state: State<'_, AppState>) -> Result<(), String> {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod server_feature;
1919
pub mod server_manager;
2020
pub mod settings;
2121
pub mod space;
22+
pub mod workspace_binding;
2223

2324
// Re-export commands for convenience
2425
pub use client::*;
@@ -36,3 +37,4 @@ pub use server_feature::*;
3637
pub use server_manager::*;
3738
pub use settings::*;
3839
pub use space::*;
40+
pub use workspace_binding::*;

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ pub async fn get_active_space(state: State<'_, AppState>) -> Result<Option<Space
179179
Ok(active)
180180
}
181181

182+
/// Set (or clear with `None`) the active FeatureSet for a Space.
183+
///
184+
/// The active FS is the fallback applied when a connected client has no
185+
/// access-key pin and no workspace-binding match. See migration 002 for the
186+
/// schema and `FeatureSetResolverService` for enforcement.
187+
#[tauri::command]
188+
pub async fn set_space_active_feature_set(
189+
space_id: String,
190+
feature_set_id: Option<String>,
191+
state: State<'_, AppState>,
192+
) -> Result<(), String> {
193+
let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
194+
let fs_uuid = feature_set_id
195+
.as_ref()
196+
.map(|s| Uuid::parse_str(s))
197+
.transpose()
198+
.map_err(|e| e.to_string())?;
199+
200+
state
201+
.space_service
202+
.set_active_feature_set(&space_uuid, fs_uuid.as_ref())
203+
.await
204+
.map_err(|e| {
205+
tracing::error!("[set_space_active_feature_set] {e}");
206+
e.to_string()
207+
})?;
208+
209+
info!(
210+
space_id = %space_id,
211+
feature_set_id = ?feature_set_id,
212+
"[set_space_active_feature_set] active FS updated",
213+
);
214+
Ok(())
215+
}
216+
182217
/// Set the active space.
183218
#[tauri::command]
184219
pub async fn set_active_space<R: tauri::Runtime>(
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Tauri commands for workspace-root FeatureSet bindings.
2+
//!
3+
//! Bindings are the middle tier of the FeatureSet resolver (pin > binding >
4+
//! space-active). Paths passed in from the UI are normalized via
5+
//! [`mcpmux_core::normalize_workspace_root`] before storage so lookups from
6+
//! the gateway's session registry hit them consistently.
7+
8+
use mcpmux_core::{normalize_workspace_root, WorkspaceBinding};
9+
use serde::{Deserialize, Serialize};
10+
use tauri::State;
11+
use tracing::{error, info};
12+
use uuid::Uuid;
13+
14+
use crate::state::AppState;
15+
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
pub struct WorkspaceBindingDto {
18+
pub id: String,
19+
pub space_id: String,
20+
pub workspace_root: String,
21+
pub feature_set_id: String,
22+
pub created_at: String,
23+
pub updated_at: String,
24+
}
25+
26+
impl From<WorkspaceBinding> for WorkspaceBindingDto {
27+
fn from(b: WorkspaceBinding) -> Self {
28+
Self {
29+
id: b.id.to_string(),
30+
space_id: b.space_id.to_string(),
31+
workspace_root: b.workspace_root,
32+
feature_set_id: b.feature_set_id.to_string(),
33+
created_at: b.created_at.to_rfc3339(),
34+
updated_at: b.updated_at.to_rfc3339(),
35+
}
36+
}
37+
}
38+
39+
/// List every binding across all Spaces.
40+
#[tauri::command]
41+
pub async fn list_workspace_bindings(
42+
state: State<'_, AppState>,
43+
) -> Result<Vec<WorkspaceBindingDto>, String> {
44+
state
45+
.workspace_binding_repository
46+
.list()
47+
.await
48+
.map(|v| v.into_iter().map(Into::into).collect())
49+
.map_err(|e| {
50+
error!("[workspace_binding::list] {e}");
51+
e.to_string()
52+
})
53+
}
54+
55+
/// List bindings for a specific Space.
56+
#[tauri::command]
57+
pub async fn list_workspace_bindings_for_space(
58+
space_id: String,
59+
state: State<'_, AppState>,
60+
) -> Result<Vec<WorkspaceBindingDto>, String> {
61+
let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
62+
state
63+
.workspace_binding_repository
64+
.list_for_space(&space_uuid)
65+
.await
66+
.map(|v| v.into_iter().map(Into::into).collect())
67+
.map_err(|e| e.to_string())
68+
}
69+
70+
/// Create a new binding. `workspace_root` is normalized before storage.
71+
#[tauri::command]
72+
pub async fn create_workspace_binding(
73+
space_id: String,
74+
workspace_root: String,
75+
feature_set_id: String,
76+
state: State<'_, AppState>,
77+
) -> Result<WorkspaceBindingDto, String> {
78+
let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
79+
let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?;
80+
let normalized = normalize_workspace_root(&workspace_root);
81+
if normalized.is_empty() {
82+
return Err("workspace_root cannot be empty".into());
83+
}
84+
let binding = WorkspaceBinding::new(space_uuid, normalized, fs_uuid);
85+
state
86+
.workspace_binding_repository
87+
.create(&binding)
88+
.await
89+
.map_err(|e| e.to_string())?;
90+
info!(
91+
space_id = %binding.space_id,
92+
workspace_root = %binding.workspace_root,
93+
feature_set_id = %binding.feature_set_id,
94+
"[workspace_binding] created",
95+
);
96+
Ok(binding.into())
97+
}
98+
99+
/// Update an existing binding (e.g., point it at a different FS).
100+
#[tauri::command]
101+
pub async fn update_workspace_binding(
102+
id: String,
103+
workspace_root: String,
104+
feature_set_id: String,
105+
state: State<'_, AppState>,
106+
) -> Result<WorkspaceBindingDto, String> {
107+
let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?;
108+
let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?;
109+
let normalized = normalize_workspace_root(&workspace_root);
110+
if normalized.is_empty() {
111+
return Err("workspace_root cannot be empty".into());
112+
}
113+
let existing = state
114+
.workspace_binding_repository
115+
.get(&id_uuid)
116+
.await
117+
.map_err(|e| e.to_string())?
118+
.ok_or_else(|| format!("binding not found: {}", id))?;
119+
let updated = WorkspaceBinding {
120+
id: existing.id,
121+
space_id: existing.space_id,
122+
workspace_root: normalized,
123+
feature_set_id: fs_uuid,
124+
created_at: existing.created_at,
125+
updated_at: chrono::Utc::now(),
126+
};
127+
state
128+
.workspace_binding_repository
129+
.update(&updated)
130+
.await
131+
.map_err(|e| e.to_string())?;
132+
Ok(updated.into())
133+
}
134+
135+
/// Delete a binding by id.
136+
#[tauri::command]
137+
pub async fn delete_workspace_binding(
138+
id: String,
139+
state: State<'_, AppState>,
140+
) -> Result<(), String> {
141+
let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?;
142+
state
143+
.workspace_binding_repository
144+
.delete(&id_uuid)
145+
.await
146+
.map_err(|e| e.to_string())
147+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,7 @@ pub fn run() {
736736
commands::delete_space,
737737
commands::get_active_space,
738738
commands::set_active_space,
739+
commands::set_space_active_feature_set,
739740
commands::open_space_config_file,
740741
commands::read_space_config,
741742
commands::save_space_config,
@@ -793,6 +794,13 @@ pub fn run() {
793794
commands::get_all_client_grants,
794795
commands::grant_feature_set_to_client,
795796
commands::revoke_feature_set_from_client,
797+
commands::update_client_pin,
798+
// Workspace binding commands (resolver v2)
799+
commands::list_workspace_bindings,
800+
commands::list_workspace_bindings_for_space,
801+
commands::create_workspace_binding,
802+
commands::update_workspace_binding,
803+
commands::delete_workspace_binding,
796804
// Config export commands
797805
commands::preview_config_export,
798806
commands::export_config_to_file,

apps/desktop/src-tauri/src/state/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ use mcpmux_core::{
88
FeatureSetRepository, GatewayPortService, InboundMcpClientRepository,
99
InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService,
1010
ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, SpaceRepository,
11-
SpaceService,
11+
SpaceService, WorkspaceBindingRepository,
1212
};
1313
use mcpmux_storage::{
1414
Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository,
1515
SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository,
1616
SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceRepository,
17+
SqliteWorkspaceBindingRepository,
1718
};
1819
use std::path::PathBuf;
1920
use std::sync::Arc;
@@ -48,6 +49,8 @@ pub struct AppState {
4849
pub feature_set_repository: Arc<dyn FeatureSetRepository>,
4950
/// Client repository for AI clients
5051
pub client_repository: Arc<dyn InboundMcpClientRepository>,
52+
/// Workspace-root -> FeatureSet bindings (resolver v2)
53+
pub workspace_binding_repository: Arc<dyn WorkspaceBindingRepository>,
5154
/// Server feature repository for discovered MCP features (implements core trait)
5255
pub server_feature_repository: Arc<SqliteServerFeatureRepository>,
5356
/// Server feature repository cast to core trait (for gateway services)
@@ -103,6 +106,9 @@ impl AppState {
103106
let client_repository: Arc<dyn InboundMcpClientRepository> =
104107
Arc::new(SqliteInboundMcpClientRepository::new(db.clone()));
105108

109+
let workspace_binding_repository: Arc<dyn WorkspaceBindingRepository> =
110+
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));
111+
106112
let server_feature_repository = Arc::new(SqliteServerFeatureRepository::new(db.clone()));
107113
let server_feature_repository_core: Arc<dyn CoreServerFeatureRepository> =
108114
server_feature_repository.clone();
@@ -162,6 +168,7 @@ impl AppState {
162168
backend_oauth_repository,
163169
feature_set_repository,
164170
client_repository,
171+
workspace_binding_repository,
165172
server_feature_repository,
166173
server_feature_repository_core,
167174
encryptor,

0 commit comments

Comments
 (0)