|
1 | 1 | //! FeatureSet Resolver Service. |
2 | 2 | //! |
3 | | -//! Capability-branched resolution. The branch point is the MCP `roots` |
4 | | -//! capability declared by the client at `initialize`: |
| 3 | +//! Binding-canonical resolution. The resolver answers one question: *which |
| 4 | +//! [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) (if any) matches the |
| 5 | +//! signals this caller presents?* Signals are ranked; none are mandatory: |
5 | 6 | //! |
6 | 7 | //! ```text |
7 | 8 | //! resolve(session_id, client_id): |
8 | | -//! // Tier 1 — roots-capable session with reported roots |
| 9 | +//! // Signal 1 — reported root (deprecated MCP primitive, SEP-2577) |
9 | 10 | //! if session reported roots AND a binding matches: |
10 | 11 | //! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding) |
11 | 12 | //! |
12 | | -//! // Tier 1b — roots-capable, roots reported, but no binding yet |
| 13 | +//! // Tier 1b — roots reported, no binding matched |
13 | 14 | //! if session reported roots AND no binding matched: |
14 | | -//! return (default_space, [starter_fs], SpaceDefault) // unmapped folder |
15 | | -//! // (also emits WorkspaceNeedsBinding upstream so the user can still map) |
| 15 | +//! return (scoped_space, [], Unbound) // deny by default |
| 16 | +//! // (upstream emits WorkspaceNeedsBinding so the user can bind) |
16 | 17 | //! |
17 | 18 | //! // Tier 1c — declared `roots` but they haven't arrived yet |
18 | 19 | //! if session declared `roots` AND none yet in registry: |
19 | 20 | //! if within the pending-roots grace window: |
20 | 21 | //! return ([], default_space, PendingRoots) // wait for the root |
21 | 22 | //! else: |
22 | | -//! return (default_space, [starter_fs], SpaceDefault) // gave up waiting |
| 23 | +//! return (default_space, [], Unbound) // gave up waiting |
23 | 24 | //! |
24 | | -//! // Tier 2 — rootless-by-design (Claude.ai web, ChatGPT, …) |
| 25 | +//! // Signal 2 — client identity (rootless-by-design: Claude.ai web, …) |
25 | 26 | //! if client has grants in the default space: |
26 | 27 | //! return (default_space, grants, ClientGrant) |
27 | 28 | //! |
28 | 29 | //! // Tier 3 — no roots, no grants |
29 | | -//! return (default_space, [starter_fs], SpaceDefault) |
| 30 | +//! return (default_space, [], Unbound) |
30 | 31 | //! ``` |
31 | 32 | //! |
32 | | -//! # Default fallback (the "every folder needs mapping" fix) |
| 33 | +//! Signal 3 (machine) scopes binding lookup: client machine → local machine |
| 34 | +//! → global. A path bound only on another machine does not match here. |
33 | 35 | //! |
34 | | -//! When nothing more specific resolves — an unmapped folder (Tier 1b), a |
35 | | -//! rootless client with no grants, or a roots-capable client that never |
36 | | -//! reported a folder (Tier 1c after the grace window) — the resolver falls |
37 | | -//! back to the **default Space's Starter FeatureSet** instead of denying. |
38 | | -//! That makes folders work out of the box: a freshly-opened project gets the |
39 | | -//! Starter tools immediately, and the user only *needs* an explicit |
40 | | -//! [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) when they want a folder |
41 | | -//! to see something *other* than the default. The Starter FS's membership is |
42 | | -//! the control surface: edit it to change what every unmapped folder sees, or |
43 | | -//! empty it to grant nothing by default. (The Starter is builtin and can't be |
44 | | -//! deleted, so the fallback always has a target.) |
| 36 | +//! ## Deny by default |
45 | 37 | //! |
46 | | -//! ## Grace window — avoid "default then mapped" flips |
| 38 | +//! When no binding matches — an unmapped folder (Tier 1b), a rootless client |
| 39 | +//! with no grants, or a roots-capable client that never reported a folder |
| 40 | +//! (Tier 1c after the grace window) — the resolver returns |
| 41 | +//! [`ResolutionSource::Unbound`] with empty `feature_set_ids`. Callers get |
| 42 | +//! zero backend tools until they have an explicit binding (or a client grant |
| 43 | +//! for rootless clients). Meta management tools are appended unconditionally |
| 44 | +//! by the request handler. |
| 45 | +//! |
| 46 | +//! ## Grace window — avoid "empty then mapped" flips |
47 | 47 | //! |
48 | 48 | //! A roots-capable client that's *about* to report a folder must resolve |
49 | | -//! straight to that folder's binding (or the default-for-unmapped), never |
50 | | -//! flash the default tools first and then flip. So while a session has |
51 | | -//! declared (or might declare) `roots` and none have arrived yet, the |
52 | | -//! resolver holds at `PendingRoots` (empty) for a short grace window rather |
53 | | -//! than defaulting immediately. Only once the window lapses with no root in |
54 | | -//! sight does it settle on `SpaceDefault`, so a misbehaving client that never |
55 | | -//! reports isn't stranded on meta-tools forever. A roots-capable session |
56 | | -//! **never** falls through to another client's grants — after the grace it |
57 | | -//! goes straight to the Space default, preserving per-session isolation. |
| 49 | +//! straight to that folder's binding (or `Unbound`), never flash tools first |
| 50 | +//! and then flip. So while a session has declared (or might declare) `roots` |
| 51 | +//! and none have arrived yet, the resolver holds at `PendingRoots` (empty) for |
| 52 | +//! a short grace window. Only once the window lapses with no root in sight |
| 53 | +//! does it settle on `Unbound`. A roots-capable session **never** falls |
| 54 | +//! through to another client's grants — after the grace it goes straight to |
| 55 | +//! `Unbound`, preserving per-session isolation. |
58 | 56 | //! |
59 | | -//! The caller's client identity is used **only** for the rootless fallback — |
60 | | -//! every roots-capable session routes via its own reported roots, regardless |
61 | | -//! of which OAuth client opened it. This is what makes "two VS Code windows |
62 | | -//! sharing one OAuth identity" route independently. |
| 57 | +//! The caller's client identity is used **only** for the rootless Tier-2 grant |
| 58 | +//! lookup — every roots-capable session routes via its own reported roots, |
| 59 | +//! regardless of which OAuth client opened it. This is what makes "two VS Code |
| 60 | +//! windows sharing one OAuth identity" route independently. |
63 | 61 | //! |
64 | 62 | //! # Trust model (deliberate design decision) |
65 | 63 | //! |
@@ -126,17 +124,18 @@ pub enum ResolutionSource { |
126 | 124 | /// Rootless-by-design client. The space-default's per-client |
127 | 125 | /// `client_grants` were applied. |
128 | 126 | ClientGrant, |
129 | | - /// Fell back to the default Space's Starter FeatureSet because nothing |
130 | | - /// more specific resolved — an unmapped folder, a rootless client with |
131 | | - /// no grants, or a roots-capable client that never reported a folder. |
132 | | - /// For the unmapped-folder subcase the upstream caller still emits |
133 | | - /// `WorkspaceNeedsBinding` so the user can attach an explicit mapping. |
| 127 | + /// No binding matched; deny by default (empty `feature_set_ids`). Carries |
| 128 | + /// `space_id` for base-dir context and the bind CTA. Upstream emits |
| 129 | + /// `WorkspaceNeedsBinding` for unmapped folders. |
| 130 | + Unbound, |
| 131 | + /// Deprecated — no longer produced by the resolver. Retained for serde |
| 132 | + /// compatibility (e.g. `set_workspace_root` responses that may still |
| 133 | + /// carry this value from older gateway versions). |
| 134 | + #[doc(hidden)] |
134 | 135 | SpaceDefault, |
135 | 136 | /// No FeatureSet resolved at all. Defensive: reached only when there's no |
136 | 137 | /// default Space, or — degenerately — the default Space somehow has no |
137 | | - /// Starter FeatureSet. The Starter is builtin and seeded with every Space, |
138 | | - /// so this is normally unreachable; to grant nothing by default the user |
139 | | - /// empties the Starter (still `SpaceDefault`, just with no members). |
| 138 | + /// Starter FeatureSet. |
140 | 139 | Deny, |
141 | 140 | } |
142 | 141 |
|
@@ -300,41 +299,15 @@ impl FeatureSetResolverService { |
300 | 299 | self |
301 | 300 | } |
302 | 301 |
|
303 | | - /// Fall back to `space_id`'s Starter FeatureSet. `space_id` is the global |
304 | | - /// default Space for rootless sessions, or a base-dir-scoped Space for an |
305 | | - /// unmapped folder under that Space's base directory. Returns |
306 | | - /// [`ResolutionSource::SpaceDefault`] when a Starter exists (the normal |
307 | | - /// path — it's builtin and seeded per Space), or, defensively, |
308 | | - /// [`ResolutionSource::Deny`] in the degenerate case where the Space has no |
309 | | - /// Starter. |
310 | | - async fn default_fallback(&self, space_id: Uuid) -> Result<ResolvedFeatureSet> { |
311 | | - if let Some(fs) = self |
312 | | - .feature_set_repo |
313 | | - .get_starter_for_space(&space_id.to_string()) |
314 | | - .await? |
315 | | - { |
316 | | - debug!( |
317 | | - %space_id, |
318 | | - feature_set_id = %fs.id, |
319 | | - "[FeatureSetResolver] resolved via SpaceDefault (Starter fallback)", |
320 | | - ); |
321 | | - return Ok(ResolvedFeatureSet { |
322 | | - feature_set_ids: vec![fs.id], |
323 | | - space_id: Some(space_id), |
324 | | - source: ResolutionSource::SpaceDefault, |
325 | | - collision_client_id: None, |
326 | | - }); |
327 | | - } |
328 | | - debug!( |
329 | | - %space_id, |
330 | | - "[FeatureSetResolver] no Starter FeatureSet in Space — deny", |
331 | | - ); |
332 | | - Ok(ResolvedFeatureSet { |
| 302 | + /// Deny by default: no binding matched. Carries `space_id` for base-dir |
| 303 | + /// context and the bind CTA; `feature_set_ids` is empty. |
| 304 | + fn unbound(&self, space_id: Uuid) -> ResolvedFeatureSet { |
| 305 | + ResolvedFeatureSet { |
333 | 306 | feature_set_ids: vec![], |
334 | 307 | space_id: Some(space_id), |
335 | | - source: ResolutionSource::Deny, |
| 308 | + source: ResolutionSource::Unbound, |
336 | 309 | collision_client_id: None, |
337 | | - }) |
| 310 | + } |
338 | 311 | } |
339 | 312 |
|
340 | 313 | /// Borrow the session-roots registry. The notifier uses this to GC |
@@ -419,24 +392,21 @@ impl FeatureSetResolverService { |
419 | 392 | collision_client_id: None, |
420 | 393 | }); |
421 | 394 | } |
422 | | - // Tier 1b: had roots, no binding. The folder is unmapped, so |
423 | | - // fall back to a Starter FS — the folder works immediately |
424 | | - // instead of getting nothing. Scope it to the Space whose base |
| 395 | + // Tier 1b: had roots, no binding. The folder is unmapped — |
| 396 | + // deny by default. Scope `space_id` to the Space whose base |
425 | 397 | // directory claims the root (longest-prefix), if any; otherwise |
426 | | - // the global default Space. Upstream still emits |
427 | | - // WorkspaceNeedsBinding (it prompts on SpaceDefault too) so the |
428 | | - // user can attach an explicit mapping for something other than |
429 | | - // the default. |
| 398 | + // the global default Space. Upstream emits WorkspaceNeedsBinding |
| 399 | + // so the user can attach an explicit binding. |
430 | 400 | let target_space = self |
431 | 401 | .space_for_roots(&reported_roots) |
432 | 402 | .await? |
433 | 403 | .unwrap_or(default_space_id); |
434 | 404 | debug!( |
435 | 405 | %target_space, |
436 | 406 | scoped_by_base_dir = target_space != default_space_id, |
437 | | - "[FeatureSetResolver] roots reported but no binding matched — SpaceDefault", |
| 407 | + "[FeatureSetResolver] roots reported but no binding matched — Unbound", |
438 | 408 | ); |
439 | | - return self.default_fallback(target_space).await; |
| 409 | + return Ok(self.unbound(target_space)); |
440 | 410 | } |
441 | 411 |
|
442 | 412 | // Tier 1c: client declared `roots` but none have ARRIVED yet |
@@ -472,17 +442,16 @@ impl FeatureSetResolverService { |
472 | 442 | collision_client_id: None, |
473 | 443 | }); |
474 | 444 | } |
475 | | - // Grace lapsed with no root in sight — settle on the Space |
476 | | - // default rather than stranding the client on meta-tools |
477 | | - // forever. Go STRAIGHT to the default (not via Tier-2 grants): |
478 | | - // a roots-capable session must never pick up another client's |
479 | | - // grants (per-session isolation invariant). |
| 445 | + // Grace lapsed with no root in sight — deny by default. Go |
| 446 | + // STRAIGHT to Unbound (not via Tier-2 grants): a roots-capable |
| 447 | + // session must never pick up another client's grants |
| 448 | + // (per-session isolation invariant). |
480 | 449 | debug!( |
481 | 450 | session_id = %sid, |
482 | 451 | capability = ?roots_capable_known, |
483 | | - "[FeatureSetResolver] pending-roots grace lapsed, no root reported — SpaceDefault", |
| 452 | + "[FeatureSetResolver] pending-roots grace lapsed, no root reported — Unbound", |
484 | 453 | ); |
485 | | - return self.default_fallback(default_space_id).await; |
| 454 | + return Ok(self.unbound(default_space_id)); |
486 | 455 | } |
487 | 456 | } |
488 | 457 |
|
@@ -516,16 +485,14 @@ impl FeatureSetResolverService { |
516 | 485 | } |
517 | 486 | } |
518 | 487 |
|
519 | | - // Tier 3 — no roots, no grants. Fall back to the Space default so a |
520 | | - // bare client still gets the Starter tools instead of nothing. The |
521 | | - // mcpmux_* meta tools are appended unconditionally by the request |
522 | | - // handler regardless, so the LLM can always self-bind / ask the user |
523 | | - // for a grant from here. |
| 488 | + // Tier 3 — no roots, no grants. Deny by default. The mcpmux_* meta |
| 489 | + // tools are appended unconditionally by the request handler regardless, |
| 490 | + // so the LLM can always self-bind / ask the user for a grant from here. |
524 | 491 | debug!( |
525 | 492 | space_id = %default_space_id, |
526 | 493 | ?client_id, |
527 | | - "[FeatureSetResolver] no roots + no grants — SpaceDefault", |
| 494 | + "[FeatureSetResolver] no roots + no grants — Unbound", |
528 | 495 | ); |
529 | | - self.default_fallback(default_space_id).await |
| 496 | + Ok(self.unbound(default_space_id)) |
530 | 497 | } |
531 | 498 | } |
0 commit comments