Skip to content

Commit 443649e

Browse files
committed
fix(routing): treat unknown roots-capability as pending, not rootless
Stamp the session roots capability synchronously before the first await in on_initialized so a tools/list racing the notification sees the right flag. In the resolver, treat unknown capability (None — never observed notifications/initialized for this session yet) like roots-capable: return PendingRoots so the next request retries via the on-demand probe instead of falling through to client-grants and getting permanently denied (the "only meta tools until reconnect" bug). Also fixes an incidental clippy lint (unnecessary_sort_by) in prefix_cache.rs surfaced by the newer toolchain. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 9affd6b commit 443649e

4 files changed

Lines changed: 94 additions & 33 deletions

File tree

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

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,15 @@ impl McpMuxGatewayHandler {
211211
if self.services.session_roots.get(sid).is_some() {
212212
return;
213213
}
214-
// Not roots-capable → resolver routes via client grants, no
215-
// probe useful.
216-
if !self
217-
.services
218-
.session_roots
219-
.is_roots_capable(sid)
220-
.unwrap_or(false)
221-
{
214+
// Skip the probe only when we *know* this session is rootless
215+
// (`Some(false)`). When capability is unknown (`None` — we
216+
// haven't observed `notifications/initialized` for this session
217+
// yet, e.g. tools/list racing in before the notification's
218+
// handler completed), still try to probe: the worst case is one
219+
// wasted call on a genuinely rootless client where peer.list_roots()
220+
// returns method-not-found, vs. a stuck PendingRoots / empty
221+
// response on a client that *would* report roots if asked.
222+
if self.services.session_roots.is_roots_capable(sid) == Some(false) {
222223
return;
223224
}
224225
// Cool-down after a recent failed probe so we don't hammer a
@@ -392,11 +393,41 @@ impl ServerHandler for McpMuxGatewayHandler {
392393
}
393394
};
394395

396+
let peer = std::sync::Arc::new(context.peer);
397+
let session_id_for_register = extract_session_id(&context.extensions);
398+
399+
// CRITICAL: stamp the roots capability **before any await** so the
400+
// resolver / probe paths see the right answer if a request from
401+
// this session arrives while we're still partway through this
402+
// handler. The race we hit before this reordering:
403+
//
404+
// on_initialized starts
405+
// register_session ✓
406+
// await prime_hashes_for_space ← yields ~5ms
407+
// tools/list races in here,
408+
// is_roots_capable() == None,
409+
// → resolver falls to "no roots
410+
// + no grants — deny" (Tier 2),
411+
// returns 4 meta tools
412+
// await returns, now set_roots_capable(true) — too late
413+
//
414+
// Stamping synchronously up front guarantees that whatever else
415+
// tokio decides to schedule between here and the spawned
416+
// list_roots() task at the bottom, the resolver has the right
417+
// capability flag.
418+
if let Some(sid) = session_id_for_register.as_deref() {
419+
let declares_roots = peer
420+
.peer_info()
421+
.map(|info| info.capabilities.roots.is_some())
422+
.unwrap_or(false);
423+
self.services
424+
.session_roots
425+
.set_roots_capable(sid, declares_roots);
426+
}
427+
395428
// Register the *session* with MCPNotifier so subsequent fanout can
396429
// re-resolve per session (a single OAuth client can hold multiple
397430
// sessions on different folders, each routing independently).
398-
let peer = std::sync::Arc::new(context.peer);
399-
let session_id_for_register = extract_session_id(&context.extensions);
400431
if let Some(sid) = session_id_for_register.as_deref() {
401432
self.notification_bridge.register_session(
402433
sid.to_string(),
@@ -422,18 +453,14 @@ impl ServerHandler for McpMuxGatewayHandler {
422453
// workspace roots into the session registry so the resolver can pick
423454
// a binding. Then log + (if no binding matched) prompt the UI.
424455
if let Some(session_id) = extract_session_id(&context.extensions) {
425-
let declares_roots = peer
426-
.peer_info()
427-
.map(|info| info.capabilities.roots.is_some())
428-
.unwrap_or(false);
429-
// Stash the capability so the resolver can branch between
430-
// workspace-binding routing (capable) and the per-client grant
431-
// fallback (rootless). Done unconditionally so the registry has
432-
// a definitive answer for every session, not just those with
433-
// roots declared.
434-
self.services
456+
// Capability already stamped at the top of this handler — read
457+
// it back rather than re-deriving so we stay consistent if the
458+
// peer_info() ever flapped during the await above.
459+
let declares_roots = self
460+
.services
435461
.session_roots
436-
.set_roots_capable(&session_id, declares_roots);
462+
.is_roots_capable(&session_id)
463+
.unwrap_or(false);
437464
// Persist the bit on the client row, *always* — the Clients UI
438465
// needs to distinguish "never observed" from "explicitly
439466
// rootless" so its capability badge isn't misleading on

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,20 @@ impl FeatureSetResolverService {
152152
if let Some(sid) = session_id {
153153
let roots = self.session_roots.get(sid);
154154
let has_roots = roots.as_ref().is_some_and(|r| !r.is_empty());
155-
let roots_capable = self.session_roots.is_roots_capable(sid).unwrap_or(false);
155+
// Three states for capability:
156+
// `Some(true)` — declared roots on initialize
157+
// `Some(false)` — explicitly didn't declare; treat as rootless
158+
// `None` — never observed `notifications/initialized`
159+
// for this session yet. Treat as PROBABLY
160+
// capable (most modern MCP clients are) so
161+
// the resolver returns PendingRoots instead
162+
// of falling all the way through to the
163+
// rootless client_grants tier — otherwise a
164+
// tools/list that races on_initialized
165+
// yields "no roots + no grants — deny" and
166+
// the user sees only meta tools until
167+
// reconnect.
168+
let roots_capable_known = self.session_roots.is_roots_capable(sid);
156169

157170
// Tier 1: session reported roots — try a binding match.
158171
if has_roots {
@@ -183,14 +196,19 @@ impl FeatureSetResolverService {
183196
});
184197
}
185198

186-
// Tier 1c: client declared `roots` but they haven't shown up yet.
187-
// Don't fall through to client grants — that's the leak the old
188-
// Tier-2 fallback caused. Return empty; we'll fire `list_changed`
189-
// when roots actually arrive.
190-
if roots_capable {
199+
// Tier 1c: client declared `roots` but they haven't shown up
200+
// yet, OR we haven't observed `initialize` yet so we don't
201+
// know either way. Returning PendingRoots (empty) means the
202+
// first response is empty if the on-demand probe loses the
203+
// race, but the next request retries via the probe + the
204+
// on_initialized list_roots task fires `list_changed` once
205+
// roots actually land. Beats falling through to grants and
206+
// getting permanently denied.
207+
if !matches!(roots_capable_known, Some(false)) {
191208
debug!(
192209
session_id = %sid,
193-
"[FeatureSetResolver] roots-capable, roots pending — empty until they arrive",
210+
capability = ?roots_capable_known,
211+
"[FeatureSetResolver] roots-capable (or unknown), roots pending — empty until they arrive",
194212
);
195213
return Ok(ResolvedFeatureSet {
196214
feature_set_ids: vec![],

crates/mcpmux-gateway/src/services/prefix_cache.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ impl PrefixCacheService {
138138

139139
// Sort by created_at (earliest first)
140140
// TODO: Add verified status priority when registry supports it
141-
servers.sort_by(|a, b| a.created_at.cmp(&b.created_at));
141+
servers.sort_by_key(|a| a.created_at);
142142

143143
// Clear existing cache for this space
144144
self.clear_space(space_id).await;

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,28 @@ async fn deny_when_no_session_id_and_no_grants() {
128128
}
129129

130130
#[tokio::test]
131-
async fn deny_when_session_has_no_roots_and_not_capable() {
132-
// Default capability state is "unknown" (None). The resolver treats
133-
// missing capability info as rootless, so this falls through to Tier 2
134-
// (no client_id supplied → Deny).
131+
async fn pending_when_session_has_no_roots_and_capability_unknown() {
132+
// Default capability state for a session we've never seen
133+
// `notifications/initialized` for is `None` (unknown). The resolver
134+
// treats unknown like roots-capable: returns `PendingRoots` so the
135+
// *next* request retries via the on-demand probe instead of being
136+
// permanently denied. This was the bug where a tools/list racing
137+
// on_initialized resolved to "no roots + no grants — deny" and the
138+
// user saw only meta tools until reconnect.
135139
let f = Fixture::new().await;
136140
let r = f.resolver.resolve(Some("orphan"), None).await.unwrap();
141+
assert_eq!(r.source, ResolutionSource::PendingRoots);
142+
assert!(r.feature_set_ids.is_empty());
143+
}
144+
145+
#[tokio::test]
146+
async fn deny_when_session_explicitly_rootless_and_no_grants() {
147+
// Explicit Some(false) capability — client told us it doesn't
148+
// support roots — and no client grants. This is the only path where
149+
// the resolver legitimately lands on Deny without a session id.
150+
let f = Fixture::new().await;
151+
f.session_roots.set_roots_capable("rootless", false);
152+
let r = f.resolver.resolve(Some("rootless"), None).await.unwrap();
137153
assert_eq!(r.source, ResolutionSource::Deny);
138154
}
139155

0 commit comments

Comments
 (0)