Skip to content

Commit 76c6638

Browse files
committed
feat(gateway): FeatureSetResolver v2 + SessionRootsRegistry (shadow mode)
Introduces the runtime surface for the FeatureSet resolver v2. Decisions are computed and logged on every initialize but NOT yet enforced — the existing AuthorizationService::get_client_grants path remains authoritative until a follow-up commit flips the switch. New services: * SessionRootsRegistry — DashMap keyed by mcp-session-id, stores already-normalized workspace roots reported by the peer. * FeatureSetResolverService — resolves one of: Pin (client.pinned_feature_set_id) WorkspaceBinding (longest-prefix match against session roots) SpaceActive (space.active_feature_set_id fallback) Deny (no pin / no binding / no active FS) Gateway wiring: * GatewayDependencies gains inbound_mcp_client_repo + workspace_binding_repo, both wired to the SQLite repos so no DI boilerplate is required at call sites. * ServiceContainer exposes feature_set_resolver + session_roots. * McpMuxGatewayHandler::on_initialized now: - when the peer declared `roots` capability, spawns a task to call peer.list_roots(), normalizes + stores the URIs in the registry, then emits a shadow-mode log of the resolver's decision. - when no roots are declared, resolves immediately against pin / space-active so the shadow log still fires. Peer is cloned via Arc so notifications continue to be delivered. Normalization fix: normalize_workspace_root("") now returns "" so SessionRootsRegistry::set can filter out empty inputs without needing to know the target OS's root sentinel. Shadow-mode log format (grep-friendly): [FeatureSetResolver][shadow] resolved client_id=… session_id=… feature_set_id=… source={Pin|WorkspaceBinding|SpaceActive|Deny} Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d16d50d commit 76c6638

7 files changed

Lines changed: 431 additions & 6 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ impl WorkspaceBinding {
6363
/// This is the single source of truth for path comparisons — always route
6464
/// through here before calling any repository method that takes `workspace_root`.
6565
pub fn normalize_workspace_root(input: &str) -> String {
66+
// Empty in → empty out: callers filter on this to drop garbage roots
67+
// without needing to know about "/" vs "\" filesystem conventions.
68+
if input.is_empty() {
69+
return String::new();
70+
}
71+
6672
// Strip file:// scheme if present; tolerate both "file:///abs/path" and
6773
// "file://host/abs/path" (we don't use host, it's always localhost).
6874
let without_scheme = if let Some(rest) = input.strip_prefix("file://") {

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

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use rmcp::{
1212
use std::sync::Arc;
1313
use tracing::{debug, info, warn};
1414

15-
use super::context::{extract_oauth_context, OAuthContext};
15+
use super::context::{extract_oauth_context, extract_session_id, OAuthContext};
1616
use crate::consumers::MCPNotifier;
1717
use crate::server::ServiceContainer;
1818

@@ -81,6 +81,36 @@ impl McpMuxGatewayHandler {
8181
}
8282
}
8383

84+
/// Shadow-mode log for the new FeatureSet resolver.
85+
///
86+
/// Does not affect routing; prints the resolver's decision so operators
87+
/// can compare against the legacy `get_client_grants` path before we
88+
/// flip the switch.
89+
async fn shadow_log_resolution(
90+
resolver: &crate::services::FeatureSetResolverService,
91+
client_id: &uuid::Uuid,
92+
session_id: Option<&str>,
93+
) {
94+
match resolver.resolve(client_id, session_id).await {
95+
Ok(resolved) => {
96+
info!(
97+
%client_id,
98+
session_id = session_id.unwrap_or("<none>"),
99+
feature_set_id = resolved.feature_set_id.map(|u| u.to_string()).unwrap_or_else(|| "<deny>".into()),
100+
source = ?resolved.source,
101+
"[FeatureSetResolver][shadow] resolved",
102+
);
103+
}
104+
Err(e) => {
105+
warn!(
106+
%client_id,
107+
error = %e,
108+
"[FeatureSetResolver][shadow] resolve failed",
109+
);
110+
}
111+
}
112+
}
113+
84114
/// Build InitializeResult with negotiated protocol version
85115
fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult {
86116
let info = self.get_info();
@@ -158,7 +188,7 @@ impl ServerHandler for McpMuxGatewayHandler {
158188
// Register peer with MCPNotifier for list_changed notification delivery
159189
let peer = std::sync::Arc::new(context.peer);
160190
self.notification_bridge
161-
.register_peer(oauth_ctx.client_id.clone(), peer);
191+
.register_peer(oauth_ctx.client_id.clone(), peer.clone());
162192

163193
// Mark the client stream as active immediately - RMCP's session transport
164194
// handles SSE streaming and message caching internally
@@ -170,6 +200,64 @@ impl ServerHandler for McpMuxGatewayHandler {
170200
.prime_hashes_for_space(oauth_ctx.space_id)
171201
.await;
172202

203+
// Resolver v2 (shadow mode): if the peer advertised the `roots`
204+
// capability, fetch its reported workspace roots and stash them in the
205+
// session registry. We then run the resolver and log its decision —
206+
// the legacy grants path is still authoritative for routing.
207+
if let Some(session_id) = extract_session_id(&context.extensions) {
208+
let declares_roots = peer
209+
.peer_info()
210+
.map(|info| info.capabilities.roots.is_some())
211+
.unwrap_or(false);
212+
if declares_roots {
213+
let peer_for_roots = peer.clone();
214+
let session_roots = self.services.session_roots.clone();
215+
let resolver = self.services.feature_set_resolver.clone();
216+
let client_id_str = oauth_ctx.client_id.clone();
217+
let session_id_for_task = session_id.clone();
218+
tokio::spawn(async move {
219+
match peer_for_roots.list_roots().await {
220+
Ok(result) => {
221+
let uris: Vec<String> =
222+
result.roots.iter().map(|r| r.uri.to_string()).collect();
223+
session_roots
224+
.set(&session_id_for_task, uris.iter().map(|s| s.as_str()));
225+
debug!(
226+
client_id = %client_id_str,
227+
session_id = %session_id_for_task,
228+
roots = ?uris,
229+
"[FeatureSetResolver] fetched MCP roots",
230+
);
231+
if let Ok(client_uuid) = uuid::Uuid::parse_str(&client_id_str) {
232+
Self::shadow_log_resolution(
233+
&resolver,
234+
&client_uuid,
235+
Some(&session_id_for_task),
236+
)
237+
.await;
238+
}
239+
}
240+
Err(e) => {
241+
debug!(
242+
client_id = %client_id_str,
243+
session_id = %session_id_for_task,
244+
error = %e,
245+
"[FeatureSetResolver] peer.list_roots() failed — falling back to Space active FS",
246+
);
247+
}
248+
}
249+
});
250+
} else if let Ok(client_uuid) = uuid::Uuid::parse_str(&oauth_ctx.client_id) {
251+
// No roots declared — resolve immediately against pin / space active FS.
252+
Self::shadow_log_resolution(
253+
&self.services.feature_set_resolver,
254+
&client_uuid,
255+
Some(&session_id),
256+
)
257+
.await;
258+
}
259+
}
260+
173261
info!(
174262
client_id = %oauth_ctx.client_id,
175263
space_id = %oauth_ctx.space_id,

crates/mcpmux-gateway/src/server/dependencies.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use std::sync::Arc;
99
use crate::services::ClientMetadataService;
1010
use mcpmux_core::{
1111
AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository,
12-
InstalledServerRepository, OutboundOAuthRepository, ServerDiscoveryService,
13-
ServerFeatureRepository, ServerLogManager, SpaceRepository,
12+
InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository,
13+
ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceRepository,
14+
WorkspaceBindingRepository,
1415
};
1516
use mcpmux_storage::{Database, InboundClientRepository};
1617
use tokio::sync::Mutex;
@@ -29,6 +30,13 @@ pub struct GatewayDependencies {
2930
pub feature_set_repo: Arc<dyn FeatureSetRepository>,
3031
pub space_repo: Arc<dyn SpaceRepository>,
3132
pub inbound_client_repo: Arc<InboundClientRepository>,
33+
/// Trait-based MCP client repository (for Client entity CRUD + pin setters).
34+
///
35+
/// Used by the FeatureSet resolver v2 — separate from `inbound_client_repo`
36+
/// (which is the concrete OAuth-flow-focused repo).
37+
pub inbound_mcp_client_repo: Arc<dyn InboundMcpClientRepository>,
38+
/// Workspace -> FeatureSet bindings for resolver v2.
39+
pub workspace_binding_repo: Arc<dyn WorkspaceBindingRepository>,
3240

3341
// Services (Business Layer)
3442
pub server_discovery: Arc<ServerDiscoveryService>,
@@ -66,6 +74,15 @@ impl GatewayDependencies {
6674
jwt_secret: Option<zeroize::Zeroizing<[u8; mcpmux_storage::JWT_SECRET_SIZE]>>,
6775
state_dir: Option<PathBuf>,
6876
) -> Self {
77+
// Resolver v2 repositories — always SQLite-backed; no-op at runtime
78+
// until the resolver flag flips out of shadow mode.
79+
let inbound_mcp_client_repo: Arc<dyn InboundMcpClientRepository> = Arc::new(
80+
mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()),
81+
);
82+
let workspace_binding_repo: Arc<dyn WorkspaceBindingRepository> = Arc::new(
83+
mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()),
84+
);
85+
6986
Self {
7087
installed_server_repo,
7188
credential_repo,
@@ -74,6 +91,8 @@ impl GatewayDependencies {
7491
feature_set_repo,
7592
space_repo,
7693
inbound_client_repo,
94+
inbound_mcp_client_repo,
95+
workspace_binding_repo,
7796
server_discovery,
7897
log_manager,
7998
cimd_fetcher,
@@ -214,6 +233,14 @@ impl DependenciesBuilder {
214233
))
215234
});
216235

236+
// Resolver v2 repositories — always SQLite-backed for now.
237+
let inbound_mcp_client_repo: Arc<dyn InboundMcpClientRepository> = Arc::new(
238+
mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()),
239+
);
240+
let workspace_binding_repo: Arc<dyn WorkspaceBindingRepository> = Arc::new(
241+
mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()),
242+
);
243+
217244
Ok(GatewayDependencies {
218245
installed_server_repo: self
219246
.installed_server_repo
@@ -228,6 +255,8 @@ impl DependenciesBuilder {
228255
.ok_or("feature_set_repo is required")?,
229256
space_repo,
230257
inbound_client_repo,
258+
inbound_mcp_client_repo,
259+
workspace_binding_repo,
231260
server_discovery: self
232261
.server_discovery
233262
.ok_or("server_discovery is required")?,

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use std::sync::Arc;
77

88
use crate::pool::{PoolServices, ServerManager, ServiceFactory};
99
use crate::services::{
10-
AuthorizationService, ClientMetadataService, GrantService, PrefixCacheService,
11-
SpaceResolverService,
10+
AuthorizationService, ClientMetadataService, FeatureSetResolverService, GrantService,
11+
PrefixCacheService, SessionRootsRegistry, SpaceResolverService,
1212
};
1313
use mcpmux_core::DomainEvent;
1414

@@ -33,6 +33,15 @@ pub struct ServiceContainer {
3333
/// Authorization service for checking client permissions (SRP)
3434
pub authorization_service: Arc<AuthorizationService>,
3535

36+
/// FeatureSet resolver v2 (pin > workspace > space-active).
37+
///
38+
/// Runs in shadow mode alongside `authorization_service` — its decision
39+
/// is logged on every request but not yet enforced.
40+
pub feature_set_resolver: Arc<FeatureSetResolverService>,
41+
42+
/// Registry of per-session workspace roots (populated from MCP `roots/list`).
43+
pub session_roots: Arc<SessionRootsRegistry>,
44+
3645
/// Space resolver for determining client's active space (SRP)
3746
pub space_resolver_service: Arc<SpaceResolverService>,
3847

@@ -91,6 +100,15 @@ impl ServiceContainer {
91100
deps.feature_set_repo.clone(),
92101
));
93102

103+
// Resolver v2 — runs in shadow mode alongside AuthorizationService.
104+
let session_roots = SessionRootsRegistry::new();
105+
let feature_set_resolver = Arc::new(FeatureSetResolverService::new(
106+
deps.inbound_mcp_client_repo.clone(),
107+
deps.space_repo.clone(),
108+
deps.workspace_binding_repo.clone(),
109+
session_roots.clone(),
110+
));
111+
94112
// Create space resolver service (DIP: inject repository dependencies)
95113
let space_resolver_service = Arc::new(SpaceResolverService::new(
96114
deps.inbound_client_repo.clone(),
@@ -113,6 +131,8 @@ impl ServiceContainer {
113131
server_manager,
114132
startup_orchestrator,
115133
authorization_service,
134+
feature_set_resolver,
135+
session_roots,
116136
space_resolver_service,
117137
prefix_cache_service,
118138
client_metadata_service,

0 commit comments

Comments
 (0)