Skip to content

Commit 98bceb8

Browse files
committed
feat(routing): per-client grants, multi-FS bindings, capability-branched resolver
Replaces the resolver's permissive Tier-2 fallback (which silently routed unbound sessions through the default Space's Default FeatureSet) with a capability-branched four-tier model. Roots-capable sessions route via WorkspaceBinding or pend; rootless clients route via per-client grants restored from the pre-resolver-v2 design; everything else denies. Resolver (crates/mcpmux-gateway/src/services/feature_set_resolver.rs): Tier 1 roots reported + binding match -> binding.feature_set_ids Tier 1b roots reported + no binding -> Deny + WorkspaceNeedsBinding Tier 1c declared roots, none yet -> PendingRoots (empty) Tier 2 client declared rootless -> client_grants for (client, space) Tier 3 no signal -> Deny ResolvedFeatureSet now carries Vec<String> so multi-FS bindings and multi-grant clients fan into a single union allow set. fingerprint() gives change-detection a stable key. Storage: 009 restore client_grants table (junction client_id x space x FS) 010 inbound_clients.reports_roots 011 inbound_clients.roots_capability_known (tri-state UI) 012 workspace_binding_feature_sets junction; recreate workspace_bindings without the legacy single feature_set_id column 013 feature_set_type 'default' -> 'starter' 014 rewrite the auto-seeded Starter FS's stale 'Default' / 'fallback feature set for this space' copy (only when row still matches the seed exactly so renamed FSes are untouched) Notifier (mcp_notifier.rs): client_peers -> sessions, keyed on mcp-session-id. Fanout consults the same FeatureSetResolverService the request handlers use, so a session redirected to a non-default Space via a binding is matched correctly. New ClientGrantChanged DomainEvent wired through the GrantService write path; per-peer push covers grant edits without a reconnect. Capability: on_initialized stamps SessionRootsRegistry::set_roots_capable for every session and InboundClientRepository::mark_roots_capability sticky- positively for every client (a one-off rootless reconnect from a normally-rooted client doesn't bounce the badge). The Clients UI hides the per-client grants section entirely except for explicitly- rootless clients. Multi-FS bindings: WorkspaceBinding.feature_set_id (single) -> feature_set_ids: Vec<String>. Tauri create/update commands accept arrays, validate non-empty, and dedup while preserving operator-chosen order. Inspector DTO surfaces the full FS list (binding_id + feature_sets[]); Workspaces page multi-select picker with always-on search and max-h scrolling. Same search treatment ported to the per-client grants list. Default -> Starter rename (FeatureSetType, copy, helper): The 'Default' name implied a routing fallback that no longer exists. Renamed throughout (DB + enum + helpers + UI). The id prefix fs_default_<space> is preserved for FK stability. parse('default') still resolves to Starter so a stale read during the migration window is harmless; isStarterFeatureSet() helper accepts both. Verified: cargo check --workspace, cargo clippy --workspace --all-targets -- -D warnings, pnpm typecheck. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent a313ffd commit 98bceb8

46 files changed

Lines changed: 2549 additions & 624 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,20 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val
685685
"workspace_root": workspace_root,
686686
}),
687687
),
688+
689+
// Per-client grant edited — Clients page re-fetches the toggles for
690+
// the affected client. MCPNotifier handles the corresponding
691+
// `list_changed` push to the client's open peers separately.
692+
DomainEvent::ClientGrantChanged {
693+
client_id,
694+
space_id,
695+
} => (
696+
"client-grant-changed",
697+
serde_json::json!({
698+
"client_id": client_id,
699+
"space_id": space_id,
700+
}),
701+
),
688702
}
689703
}
690704

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@ pub async fn get_oauth_clients(
692692
metadata_cache_ttl: client.metadata_cache_ttl,
693693
last_seen: client.last_seen,
694694
created_at: client.created_at,
695+
reports_roots: client.reports_roots,
696+
roots_capability_known: client.roots_capability_known,
695697
})
696698
.collect();
697699

@@ -762,6 +764,20 @@ pub struct OAuthClientInfo {
762764

763765
pub last_seen: Option<String>,
764766
pub created_at: String,
767+
768+
/// Sticky-positive bit: `true` once any session of this client
769+
/// declared the MCP `roots` capability. Meaningful only when
770+
/// `roots_capability_known` is `true` — for a brand-new client we
771+
/// haven't seen `initialize` for yet, this defaults to `false` but
772+
/// the UI must hide the "Rootless" badge instead of trusting it.
773+
pub reports_roots: bool,
774+
775+
/// `true` once we've processed at least one `notifications/initialized`
776+
/// for this client. Until then, the UI treats the capability as
777+
/// unknown (no badge). Once known, the badge resolves to either
778+
/// "Reports workspace" (`reports_roots = true`) or "Rootless"
779+
/// (`reports_roots = false`).
780+
pub roots_capability_known: bool,
765781
}
766782

767783
/// Request to update client settings.
@@ -825,6 +841,8 @@ pub async fn update_oauth_client(
825841
metadata_cache_ttl: updated_client.metadata_cache_ttl,
826842
last_seen: updated_client.last_seen,
827843
created_at: updated_client.created_at,
844+
reports_roots: updated_client.reports_roots,
845+
roots_capability_known: updated_client.roots_capability_known,
828846
})
829847
}
830848

@@ -972,3 +990,108 @@ pub async fn open_url(url: String) -> Result<(), String> {
972990
Ok(())
973991
}
974992
}
993+
994+
// ============================================================================
995+
// Client grants — rootless OAuth-client fallback path.
996+
//
997+
// Roots-capable sessions ignore these grants; the resolver routes them via
998+
// `WorkspaceBinding`. These commands target the older `client_grants` table
999+
// (restored in migration 009) and back the per-client FS toggles in the
1000+
// Clients UI. Each write is funnelled through `GrantService` so a
1001+
// `ClientGrantChanged` domain event fires + MCPNotifier pushes
1002+
// `list_changed` to that client's open peers.
1003+
// ============================================================================
1004+
1005+
/// Read the FeatureSet ids granted to a (client, space) pair.
1006+
///
1007+
/// Returns an empty Vec when nothing is granted — the UI renders the
1008+
/// "no defaults configured" state in that case. The default-FS layering
1009+
/// from older revisions is *not* applied here: the resolver itself decides
1010+
/// what an unconfigured grant means (deny when rootless), and the UI shows
1011+
/// the literal grant set so the user can see exactly what they configured.
1012+
#[tauri::command]
1013+
pub async fn get_oauth_client_grants(
1014+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1015+
client_id: String,
1016+
space_id: String,
1017+
) -> Result<Vec<String>, String> {
1018+
let gw_state = gateway_state.read().await;
1019+
let Some(ref grant_service) = gw_state.grant_service else {
1020+
return Err("Gateway not running".to_string());
1021+
};
1022+
grant_service
1023+
.get_grants_for_space(&client_id, &space_id)
1024+
.await
1025+
.map_err(|e| format!("Failed to get grants: {}", e))
1026+
}
1027+
1028+
/// Grant a feature set to an OAuth client in a specific space.
1029+
/// Idempotent at the DB layer; always emits `ClientGrantChanged`.
1030+
#[tauri::command]
1031+
pub async fn grant_oauth_client_feature_set(
1032+
app_handle: tauri::AppHandle,
1033+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1034+
client_id: String,
1035+
space_id: String,
1036+
feature_set_id: String,
1037+
) -> Result<(), String> {
1038+
info!(
1039+
"[OAuth] grant_oauth_client_feature_set: client_id={}, space_id={}, feature_set_id={}",
1040+
client_id, space_id, feature_set_id
1041+
);
1042+
1043+
let gw_state = gateway_state.read().await;
1044+
let Some(ref grant_service) = gw_state.grant_service else {
1045+
error!("[OAuth] Grant service unavailable (gateway not running)");
1046+
return Err("Gateway not running".to_string());
1047+
};
1048+
1049+
grant_service
1050+
.grant_feature_set(&client_id, &space_id, &feature_set_id)
1051+
.await
1052+
.map_err(|e| format!("Failed to grant feature set: {}", e))?;
1053+
1054+
if let Err(e) = app_handle.emit(
1055+
"oauth-client-changed",
1056+
serde_json::json!({
1057+
"action": "grants_updated",
1058+
"client_id": client_id,
1059+
}),
1060+
) {
1061+
error!("[OAuth] Failed to emit oauth-client-changed event: {}", e);
1062+
}
1063+
1064+
Ok(())
1065+
}
1066+
1067+
/// Revoke a feature set from an OAuth client in a specific space.
1068+
#[tauri::command]
1069+
pub async fn revoke_oauth_client_feature_set(
1070+
app_handle: tauri::AppHandle,
1071+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1072+
client_id: String,
1073+
space_id: String,
1074+
feature_set_id: String,
1075+
) -> Result<(), String> {
1076+
let gw_state = gateway_state.read().await;
1077+
let Some(ref grant_service) = gw_state.grant_service else {
1078+
return Err("Gateway not running".to_string());
1079+
};
1080+
1081+
grant_service
1082+
.revoke_feature_set(&client_id, &space_id, &feature_set_id)
1083+
.await
1084+
.map_err(|e| format!("Failed to revoke feature set: {}", e))?;
1085+
1086+
if let Err(e) = app_handle.emit(
1087+
"oauth-client-changed",
1088+
serde_json::json!({
1089+
"action": "grants_updated",
1090+
"client_id": client_id,
1091+
}),
1092+
) {
1093+
error!("[OAuth] Failed to emit oauth-client-changed event: {}", e);
1094+
}
1095+
1096+
Ok(())
1097+
}

0 commit comments

Comments
 (0)