Skip to content

Commit 30b2a17

Browse files
committed
docs: add upstream client mapping reconciliation plan
Planning doc for the API-key inbound auth / id-type binding / Space lock port from upstream PR series #201-#206, executed across Phases 1-4. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 22150c0 commit 30b2a17

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Upstream Client Mapping Reconciliation (`upstream/main` #201#206`dev-rebased`)
2+
3+
**Last Updated:** Jul 16, 2026
4+
**Status:** Planning — not started
5+
**Branch:** N/A — each phase is a separate feature branch off `dev-rebased`
6+
**Base branch:** `dev-rebased` (fork lineage, currently at migration `035_inbound_client_machine.sql`)
7+
**Depends on:** `dev-to-main-port.md` migration-renumbering precedent (020–031 map); this doc extends that pattern for a second wave
8+
**Unblocks:** Headless/remote MCP client support (`mcpk_` API keys) landing on the fork without regressing deny-by-default or machine-scoped routing
9+
10+
---
11+
12+
## Problem
13+
14+
Upstream shipped a 3-PR stacked series (#201 P1, #202 P2, #203 P3) plus two gateway fixes (#205, #206) on Jul 15, 2026 — all after this fork diverged. The series adds:
15+
16+
- **API-key inbound auth** (`mcpk_…` Bearer tokens) for headless/remote clients that can't complete the `mcpmux://` OAuth consent deep link
17+
- **Generalized client → Space/FeatureSet mappings** with a `binding_type` (`path` | `id`) column, plus **lock-confine** (`inbound_clients.locked_space_id`) that pins an API-key client to one Space
18+
- **Nav rename** (Apps → Clients, Workspaces → Mapping) and a non-localhost consent warning
19+
20+
This fork already solved a related but different problem: **deny-by-default routing** (`docs/planning/deny-by-default-bindable-callers.md`) replaced upstream's old `SpaceDefault`/Starter silent fallback with `Unbound` (zero tools until explicitly bound), and **machine-scoped bindings** (`docs/planning/workspace-machine-binding.md`) added a `client_id`/`machine_id` dual-scope axis to `WorkspaceBinding` that upstream's simpler clientId-only model doesn't have.
21+
22+
Porting upstream's series verbatim would reintroduce the exact `SpaceDefault` fallback this fork removed in migration `006_collapse_feature_sets.sql`, and would collide with fork migrations `020``035` (upstream's new migrations also start at `020`).
23+
24+
This is a second, narrower wave of the same problem `dev-to-main-port.md` already solved once — a new upstream migration range needs to slot in without disturbing the fork's routing semantics.
25+
26+
---
27+
28+
## Decisions
29+
30+
| # | Decision | Choice | Rationale |
31+
| - | -------- | ------ | --------- |
32+
| 1 | Unbound vs SpaceDefault | **Keep fork `Unbound` everywhere** — do not adopt upstream's `SpaceDefault`/Starter fallback, including for new API-key clients | Deny-by-default is a deliberate, already-shipped product decision (`deny-by-default-bindable-callers.md`). Reintroducing a silent Starter fallback for API-key clients would create two different default behaviors for the same session type depending on auth method. |
33+
| 2 | `locked_space_id` | **Hybrid** — a client can be locked to one Space, but its FeatureSet within that Space still resolves through the normal binding/machine-scope tiers, not a second hardcoded Starter-style default | Space lock is a real, useful primitive (confine a remote API-key client to one project). But bypassing the resolver entirely (upstream's `resolve_locked()`) would let a locked client dodge deny-by-default. Lock narrows the *Space* search space; it does not grant tools by itself. |
34+
| 3 | Port scope | **All of P1–P3, phased** — bring in API-key auth, mappings, and the nav/consent polish, but sequenced so routing-semantics changes (Decision 1/2) land before any UI that assumes upstream's original defaults | Splitting P1/P2/P3 further than upstream did avoids a half-ported state where API keys exist but mapping UI still assumes `SpaceDefault`. |
35+
| 4 | Identity model | **Both, with an explicit precedence order** — upstream's `binding_type` (`path` \| `id`) coexists with the fork's `client_id`/`machine_id` scoping axes | Upstream's `id` binding type and the fork's clientId mapping solve overlapping cases (routing a rootless/API-key client by identity instead of path) using different schemas. Rather than pick one, both ship with one documented resolution order so machine-scoped homelab routing and simple clientId routing both work. |
36+
| 5 | Nav labels | **Keep fork naming (Projects / Bundles / Clients)** — borrow "Mapping" terminology only in copy/tooltips where it clarifies the new id-binding concept, not as a route/nav rename | The fork's naming is already shipped and tested (`navigation.ts`, e2e testids). A full rename for a two-PR upstream change isn't worth the i18n/testid churn; the *concept* of "mapping" (client → Space/FeatureSet) is what's useful, not the label. |
37+
| 6 | Migration renumbering | **Squash/rewrite into one reconciled migration set**, not a straight append or fork-renumber | Fork already owns `020``035`. Upstream's `020``022` (api keys, binding_type, locked_space) need new numbers *and* their column/table shapes need to be reconciled against the fork's existing `workspace_bindings.client_scope`/`machine_scope` columns and `inbound_clients` schema before they're written — a straight renumber would leave two overlapping-but-different scoping mechanisms in the schema with no unification. |
38+
39+
---
40+
41+
## Scope
42+
43+
**In:**
44+
45+
- `inbound_client_api_keys` table + `mcpk_` Bearer auth path (upstream #201), renumbered and landed after fork's `035`
46+
- `gateway.auth_disabled` auto-start fix (upstream #205) — additive, no fork conflict
47+
- `structuredContent`/`_meta` passthrough fix on `tools/call` (upstream #206) — additive, no fork conflict
48+
- `binding_type` (`path` | `id`) column on `workspace_bindings`, reconciled against existing `client_scope`/`machine_scope` columns into one resolver precedence table
49+
- `locked_space_id` on `inbound_clients`, implemented as a Space-level narrowing filter ahead of the existing resolver tiers — not a bypass
50+
- API-key client registration UI (`RegisterApiKeyClientModal`, `ClientApiKeysSection`) with the "Lock to a Space" option
51+
- Non-localhost OAuth consent note pointing remote users to API-key clients (upstream #203), gated on `network_bind`
52+
- Documented precedence order for: header/roots path binding → id binding (client/machine-scoped) → locked-Space-scoped id binding → deny (`Unbound`)
53+
54+
**Out:**
55+
56+
| Item | Reason / Deferral |
57+
| ---- | ----------------- |
58+
| Upstream `SpaceDefault`/Starter fallback for any tier | Rejected per Decision 1 — conflicts with shipped deny-by-default behavior. |
59+
| Upstream nav rename (Workspaces → Mapping, Apps → Clients) | Rejected per Decision 5 — fork naming stays; concept ships without the route/label churn. |
60+
| `resolve_locked()` as an early-exit bypass of the roots/machine resolver tiers | Rejected per Decision 2 — would let locked clients skip deny-by-default. |
61+
| Legacy in-memory `AccessKey`/`AccessKeyAuth` extractor in `crates/mcpmux-gateway/src/auth/mod.rs` | Stale, unwired dead code predating both upstream and fork API-key work. Delete in a follow-up cleanup ticket, not bundled into this port to keep the diff reviewable. |
62+
| Unifying `client_id` on `workspace_bindings` with `inbound_clients.machine_id` into one caller-identity model | Flagged as a Future TODO in `workspace-machine-binding.md` already; out of scope here — this port adds a *third* identity axis (`binding_type: id`) on top without forcing that unification yet. |
63+
64+
---
65+
66+
## Architecture
67+
68+
### Resolver precedence (post-port)
69+
70+
The existing binding-canonical resolver (`feature_set_resolver.rs`) gains one new signal (`binding_type: id`) and one new narrowing filter (Space lock), inserted without disturbing the existing tiers:
71+
72+
```text
73+
0. Space lock (if inbound_clients.locked_space_id is set)
74+
→ narrows all subsequent tiers to bindings/grants within that Space only
75+
→ no match within the locked Space → Unbound (NOT the locked Space's Starter)
76+
77+
1. reported root / X-Mcpmux-Workspace → path-type WorkspaceBinding
78+
(machine: client → local → global, existing)
79+
2. clientId (API-key or OAuth client) → id-type WorkspaceBinding (NEW — upstream's binding_type=id)
80+
3. client identity, rootless → ClientGrant (existing, default Space only unless locked)
81+
4. machine → scopes (1) and (2); bound-elsewhere → no match on this machine (existing)
82+
else → Unbound (deny by default, unchanged)
83+
```
84+
85+
Tier 2 is new. It sits between the fork's existing path-binding tier and its `ClientGrant` tier, matching upstream's original ordering intent (header/roots binding beats clientId mapping beats grants) while preserving the fork's `Unbound` terminus instead of upstream's `SpaceDefault` terminus.
86+
87+
### Schema reconciliation
88+
89+
| Upstream column (their migration) | Fork equivalent already present | Reconciliation |
90+
| --- | --- | --- |
91+
| `workspace_bindings.binding_type` (`021_binding_type.sql`) | none — fork bindings are implicitly path-typed | Add as a new column in the squashed migration; existing rows backfill `'path'` |
92+
| `inbound_clients.locked_space_id` (`022_inbound_client_locked_space.sql`) | none | Add as a new nullable FK column in the squashed migration |
93+
| `inbound_client_api_keys` table (`020_inbound_client_api_keys.sql`) | none | Add table as-is; no fork equivalent to reconcile against |
94+
|| `workspace_bindings.client_id` (`027_workspace_binding_client_scope.sql`) | Kept distinct from `binding_type: id` rows — this is a *scope* on a path binding, not an identity-keyed binding. Both can match; Tier 1 (path+scope) still runs before Tier 2 (id-type). |
95+
|| `workspace_bindings.machine_id` (`034_workspace_binding_machine_scope.sql`) | Applies to id-type bindings too; an id-type binding can also be machine-scoped. |
96+
97+
### Files to create / modify
98+
99+
| Area | File cluster | Action |
100+
| ---- | ------------- | ------ |
101+
| Storage | `crates/mcpmux-storage/src/migrations/036_inbound_client_api_keys.sql` | Create — squashed/renumbered from upstream `020` |
102+
| Storage | `crates/mcpmux-storage/src/migrations/037_workspace_binding_type.sql` | Create — squashed/renumbered from upstream `021`, adjusted to coexist with `client_scope`/`machine_scope` |
103+
| Storage | `crates/mcpmux-storage/src/migrations/038_inbound_client_locked_space.sql` | Create — squashed/renumbered from upstream `022` |
104+
| Storage | `crates/mcpmux-storage/src/database.rs` | Modify — register `036``038` |
105+
| Storage | `crates/mcpmux-storage/src/repositories/inbound_client_repository.rs` | Modify — add `hash_token`/`validate_api_key`/`create_api_key`/`revoke_api_key`, `get_locked_space`/`set_locked_space` |
106+
| Storage | `crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs` | Modify — add `find_by_id_key`, keep existing `find_exact_for_roots`/`find_exact_for_machine` untouched |
107+
| Core | `crates/mcpmux-core/src/domain/workspace_binding.rs` | Modify — `BindingType` enum, `new_id()` constructor |
108+
| Gateway | `crates/mcpmux-gateway/src/mcp/oauth_middleware.rs` | Modify — add API-key Bearer branch after existing JWT check, before `auth_disabled` anonymous fallback |
109+
| Gateway | `crates/mcpmux-gateway/src/services/feature_set_resolver.rs` | Modify — insert Tier 2 (id-binding) and Tier 0 (Space lock narrowing); no fallback-to-Starter path added |
110+
| Gateway | `crates/mcpmux-gateway/src/server/handlers.rs` | Modify — non-localhost consent note gated on `network_bind` (upstream #203, additive) |
111+
| Gateway | `crates/mcpmux-gateway/src/mcp/handler.rs` | Modify — restore `structuredContent`/`_meta` passthrough on `tools/call` (upstream #206, additive) |
112+
| Tauri | `apps/desktop/src-tauri/src/commands/oauth.rs` | Modify — `register_api_key_client`, `create_client_api_key`, `list_client_api_keys`, `revoke_client_api_key`, optional `locked_space_id` param |
113+
| Tauri | `apps/desktop/src-tauri/src/commands/gateway.rs` | Modify — restore `auth_disabled` seeding on auto-start (upstream #205, additive) |
114+
| UI | `apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx` | Create |
115+
| UI | `apps/desktop/src/features/clients/ClientApiKeysSection.tsx` | Create |
116+
| UI | `apps/desktop/src/features/clients/ClientsPage.tsx` | Modify — "Register client" entry point + API-key panel |
117+
| UI | `apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx` | Modify — Folder vs ID/label toggle at binding create time |
118+
| UI | `apps/desktop/src/features/workspaces/WorkspacesPage.tsx` | Modify — id-type binding display alongside existing path/machine grouping |
119+
| UI | `apps/desktop/src/lib/api/workspaceBindings.ts` | Modify — `binding_type` on types |
120+
| Tests | `tests/rust/tests/streamable_http/api_key_auth.rs` | Create — live/unknown/revoked key auth cases |
121+
| Tests | `tests/rust/tests/integration/feature_set_resolver.rs` | Modify — add id-binding + Space-lock-narrowing cases; assert `Unbound` (not `SpaceDefault`) as the deny terminus in all new tests |
122+
123+
---
124+
125+
## Phases
126+
127+
### Phase 1 — API-key inbound auth, no routing changes (~1 day)
128+
129+
Pure auth-layer addition. No resolver semantics change, so nothing here can regress deny-by-default.
130+
131+
- `036_inbound_client_api_keys.sql` migration + `inbound_client_repository.rs` API-key CRUD (hash/validate/create/revoke)
132+
- `oauth_middleware.rs` — API-key Bearer branch, tried after JWT, before `auth_disabled` anonymous fallback
133+
- Tauri commands: `register_api_key_client`, `create_client_api_key`, `list_client_api_keys`, `revoke_client_api_key`
134+
- UI: `RegisterApiKeyClientModal`, `ClientApiKeysSection`, wire into `ClientsPage.tsx` (no "Lock to a Space" option yet — that's Phase 2)
135+
- Registered API-key clients get **no implicit binding** — they resolve through the existing tiers exactly like any other client, which today means `Unbound` until a binding or grant exists
136+
- `tests/rust/tests/streamable_http/api_key_auth.rs` — live/unknown/revoked key cases
137+
138+
**Outcome:** An `mcpk_` API key authenticates a request and resolves `client_id`, but grants zero tools until that client is bound or granted through the existing (unchanged) mechanisms. `pnpm test:rust` green; no existing resolver test needs to change.
139+
140+
---
141+
142+
### Phase 2 — id-type bindings + resolver Tier 2 (~1 day)
143+
144+
Introduces the new routing signal without touching the deny terminus.
145+
146+
- `037_workspace_binding_type.sql``binding_type` column, backfill existing rows to `'path'`
147+
- `crates/mcpmux-core/src/domain/workspace_binding.rs``BindingType` enum, `new_id()`
148+
- `workspace_binding_repository.rs``find_by_id_key()`
149+
- `feature_set_resolver.rs` — insert Tier 2 (`mapping_binding_for_roots` equivalent: id-type lookup on `client_id`) between the existing path tier and `ClientGrant` tier; all non-matching paths still terminate at `unbound()`, never `default_fallback()`
150+
- UI: `WorkspaceSetupWizard` gets the Folder vs ID toggle; `WorkspacesPage.tsx` displays id-type bindings
151+
- `feature_set_resolver.rs` integration tests: new id-binding cases, explicitly asserting `Unbound` (not `SpaceDefault`) when no id binding matches
152+
153+
**Outcome:** A client can be routed by clientId instead of path (e.g. a rootless API-key client bound directly to a FeatureSet), while a client with neither a path nor an id binding still gets zero tools. `pnpm test:rust` green, all existing `Unbound`-asserting tests unchanged.
154+
155+
---
156+
157+
### Phase 3 — Space lock as a narrowing filter (~half day)
158+
159+
Adds the lock primitive as Decision 2 specifies: a filter, not a bypass.
160+
161+
- `038_inbound_client_locked_space.sql``locked_space_id` on `inbound_clients`
162+
- `inbound_client_repository.rs``get_locked_space`/`set_locked_space`
163+
- `feature_set_resolver.rs` — Tier 0: if a lock exists, restrict Tiers 1–4 to bindings/grants within `locked_space_id`; no match within the locked Space still falls through to `unbound()`, not a locked-Space Starter
164+
- `RegisterApiKeyClientModal.tsx` — "Lock to a Space" dropdown at registration
165+
- Integration tests: locked client + matching in-Space binding → resolves; locked client + binding in a *different* Space → `Unbound` (not the locked Space's fallback); locked client + no binding at all → `Unbound`
166+
167+
**Outcome:** An API-key client can be confined to one Space for security, but still needs an explicit binding or grant within that Space to get any tools — deny-by-default holds inside the lock boundary too. `pnpm test:rust` green.
168+
169+
---
170+
171+
### Phase 4 — Gateway fixes + consent polish (~quarter day)
172+
173+
The two standalone upstream fixes, both additive and independent of Phases 1–3.
174+
175+
- `gateway.rs` / `lib.rs` — restore `auth_disabled` seeding on gateway auto-start (upstream #205)
176+
- `handler.rs` — restore `structuredContent`/`_meta` passthrough on `tools/call` forwarding (upstream #206)
177+
- `handlers.rs` — non-localhost OAuth consent note, gated on `network_bind`, pointing remote users at API-key clients (upstream #203, UI-adjacent only)
178+
- Regression tests for both fixes ported from upstream
179+
180+
**Outcome:** Gateway auto-start honors the persisted auth-disabled setting; downstream tool results with `outputSchema` keep their structured content through the gateway; remote users hitting the OAuth consent page over a non-loopback bind see guidance toward API keys instead of a dead-end browser flow. `pnpm validate` clean.
181+
182+
---
183+
184+
## Key files referenced
185+
186+
| File | Note |
187+
| ---- | ---- |
188+
| [`crates/mcpmux-gateway/src/services/feature_set_resolver.rs`](../../crates/mcpmux-gateway/src/services/feature_set_resolver.rs) | Core of Phases 2–3; existing `Unbound` terminus must survive both new tiers |
189+
| [`crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs`](../../crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs) | `client_scope`/`machine_scope` lookups this port must coexist with, not replace |
190+
| [`crates/mcpmux-storage/src/database.rs`](../../crates/mcpmux-storage/src/database.rs) | Migration registration array — next free slot is `036` |
191+
| [`crates/mcpmux-gateway/src/mcp/oauth_middleware.rs`](../../crates/mcpmux-gateway/src/mcp/oauth_middleware.rs) | JWT-then-API-key auth chain, `auth_disabled` anonymous fallback |
192+
| [`docs/planning/deny-by-default-bindable-callers.md`](./deny-by-default-bindable-callers.md) | The shipped decision this port must not regress — `Unbound` vs `SpaceDefault` |
193+
| [`docs/planning/workspace-machine-binding.md`](./workspace-machine-binding.md) | Existing `client_id`/`machine_id` scope axes this port's `binding_type: id` must slot in alongside |
194+
| [`docs/planning/dev-to-main-port.md`](./dev-to-main-port.md) | Precedent for migration-renumbering-as-feature-branch-PRs approach reused here |
195+
196+
---
197+
198+
## Related documentation
199+
200+
- [`docs/planning/deny-by-default-bindable-callers.md`](./deny-by-default-bindable-callers.md) — why `Unbound` exists and must stay the deny terminus
201+
- [`docs/planning/workspace-machine-binding.md`](./workspace-machine-binding.md) — existing dual client/machine scope axis on `WorkspaceBinding`
202+
- [`docs/planning/per-device-machine-header.md`](./per-device-machine-header.md)`X-Mcpmux-Machine-Id` routing that Tier 2's id-binding lookup must respect
203+
- [`docs/planning/dev-to-main-port.md`](./dev-to-main-port.md) — migration-renumbering precedent and phase-ordering rationale reused in this doc

0 commit comments

Comments
 (0)