|
| 1 | +# Server Account Clones (UI-Assisted Multi-Account) |
| 2 | + |
| 3 | +**Last Updated:** May 23, 2026 |
| 4 | +**Status:** Planning — decisions locked, not started |
| 5 | +**Branch:** TBD — file after planning review |
| 6 | +**Base branch:** `main` |
| 7 | +**Issue:** TBD — file after planning review |
| 8 | +**Depends on:** None (orthogonal to session meta-tools; benefits from but does not require PR #154) |
| 9 | +**Unblocks:** Personal MCP migration (`jsg-tech-check/docs/setup/mcpmux-server-migration.md`) — Gmail, Sheets, PostHog ×2, Firebase ×4, and other single-account stdio servers |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Problem |
| 14 | + |
| 15 | +McpMux installs servers once per `(space_id, server_id)` — enforced by `UNIQUE(space_id, server_id)` on `installed_servers`, `credentials`, and `outbound_oauth_clients`. That model works when one Space maps to one account (Personal vs S2H vs GAIT), but breaks down when a user needs **two accounts for the same MCP in the same Space**: |
| 16 | + |
| 17 | +| Server type | Example need | Current workaround | |
| 18 | +| ----------- | ------------ | ------------------ | |
| 19 | +| Native multi-account | Google Workspace (`user_google_email` per call) | One install — works today | |
| 20 | +| Single-account stdio | PostHog personal + work in Personal Space | Hand-edit user space JSON with suffixed IDs (`posthog-personal`, `posthog-work`) | |
| 21 | +| Single-account OAuth HTTP | Two Notion workspaces in one Space | Same JSON hack or split Spaces | |
| 22 | +| Env-at-startup servers | Firebase ×4 projects | Four manual JSON entries with different env paths | |
| 23 | + |
| 24 | +The workaround **works** — custom entries with unique IDs get separate processes, credential rows, and tool prefixes — but it is undiscoverable, error-prone, and regresses the "click Install" UX. Users migrating from a 40-entry `~/.cursor/mcp.json` hit this immediately. |
| 25 | + |
| 26 | +Spaces remain the canonical answer for **context-level** separation (work vs personal repos). This feature targets **account-level** duplication inside a Space when context splitting is wrong or insufficient. |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Decisions |
| 31 | + |
| 32 | +| # | Decision | Choice | Rationale | |
| 33 | +| - | -------- | ------ | --------- | |
| 34 | +| 1 | Primary approach | **Option 2: UI-assisted clone** — new `server_id` + `manual_entry`, no schema migration | Highest value-to-effort. Removes JSON-editing pain without touching OAuth/credential layer mid-migration. | |
| 35 | +| 2 | Schema change | **Defer Option 3** (`instance_label` column) to optional Phase 5 | Architecturally cleaner long-term, but 2–3 weeks of migration risk across 5+ tables. Ship clones first; revisit when migration volume justifies it. | |
| 36 | +| 3 | Clone identity | **`{base_server_id}-{suffix}`** where suffix is user-chosen (default suggestions: `work`, `personal`, `prod`) | Satisfies unique constraint. Hyphen suffix only — underscores are stripped by `normalize_server_id` and reserved as the tool-name delimiter. | |
| 37 | +| 4 | Definition source | **Copy `cached_definition` from source install** into clone at creation time | Clone is self-contained for offline/gateway startup. Registry updates do not auto-propagate — acceptable tradeoff for v1; document in UI. | |
| 38 | +| 5 | Prefix / alias | **Auto-set alias = suffix** (e.g. `posthog-work` → tools prefixed `posthog-work_*`) | Reuses `PrefixCacheService` first-come assignment. User can override alias in configure step. | |
| 39 | +| 6 | Credentials | **Never copy secrets** — clone starts with empty `input_values` / no OAuth; user configures fresh | Prevents accidental credential sharing. Clone wizard opens configure flow immediately after create. | |
| 40 | +| 7 | Source tracking | **`InstallationSource::ManualEntry`** + optional `cloned_from: Option<String>` metadata on `InstalledServer` | Distinguishes registry installs from clones in UI (`SourceBadge`). `cloned_from` is display-only in v1 — not a FK. | |
| 41 | +| 8 | Registry dedup UX | **"Add another account" disabled when source is already a clone-of-clone** (max depth 1) or when suffix collision detected | Prevents unbounded ID sprawl (`posthog-work-work-work`). Clones clone from registry/original only. | |
| 42 | +| 9 | Spaces unchanged | **No change to Space model** — clone is per-Space like any install | Work/personal split via Spaces stays documented as primary pattern; clones are the escape hatch. | |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## The Model |
| 47 | + |
| 48 | +### What a clone is |
| 49 | + |
| 50 | +A clone is a **new `InstalledServer` row** in the same Space as the source, with: |
| 51 | + |
| 52 | +```text |
| 53 | +InstalledServer { |
| 54 | + server_id: "{base_id}-{suffix}", // e.g. "posthog-work" |
| 55 | + server_name: "{display} ({suffix})", // e.g. "PostHog (work)" |
| 56 | + cached_definition: <copy from source>, |
| 57 | + input_values: {}, // empty — user fills in configure step |
| 58 | + source: ManualEntry, |
| 59 | + cloned_from: Some("{base_id}"), // new optional field, v1 display-only |
| 60 | + enabled: false, // same default as registry install |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +Prefix resolution treats the clone as an independent server. Tool names become `{alias}_{tool}` (e.g. `posthog-work_capture_event`). |
| 65 | + |
| 66 | +### What a clone is NOT |
| 67 | + |
| 68 | +- Not a second OAuth session on the same `server_id` row |
| 69 | +- Not a runtime credential swap (stdio env is fixed at process spawn) |
| 70 | +- Not a registry duplicate — the registry still has one definition for `posthog`; clones are local installs |
| 71 | + |
| 72 | +### Composition with existing patterns |
| 73 | + |
| 74 | +```text |
| 75 | +Multi-account need? |
| 76 | +├─ MCP has per-call account param (Google Workspace) |
| 77 | +│ └─ ONE install — no clone needed |
| 78 | +├─ Accounts map to repo context (Personal / S2H / GAIT) |
| 79 | +│ └─ Spaces — no clone needed |
| 80 | +└─ Two+ accounts in SAME Space, single-account MCP |
| 81 | + └─ Clone via "Add another account" (this feature) |
| 82 | +``` |
| 83 | + |
| 84 | +--- |
| 85 | + |
| 86 | +## Architecture |
| 87 | + |
| 88 | +``` |
| 89 | +┌─────────────────────────────────────────────────────────────────┐ |
| 90 | +│ My Servers UI │ |
| 91 | +│ │ |
| 92 | +│ [PostHog ▼] Connected │ |
| 93 | +│ ├─ Configure / Logs / … │ |
| 94 | +│ └─ "Add another account…" ─────────────────────┐ │ |
| 95 | +└────────────────────────────────────────────────────│────────────┘ |
| 96 | + │ |
| 97 | + ▼ |
| 98 | + ┌──────────────────────────────────┐ |
| 99 | + │ CloneAccountModal │ |
| 100 | + │ • suffix input (work/personal/…) │ |
| 101 | + │ • alias preview (posthog-work) │ |
| 102 | + │ • collision check │ |
| 103 | + └──────────────────────────────────┘ |
| 104 | + │ |
| 105 | + ▼ |
| 106 | + ┌──────────────────────────────────┐ |
| 107 | + │ ServerAppService::clone_server │ |
| 108 | + │ 1. validate unique server_id │ |
| 109 | + │ 2. copy cached_definition │ |
| 110 | + │ 3. set alias in definition JSON │ |
| 111 | + │ 4. install as ManualEntry │ |
| 112 | + │ 5. emit ServerInstalled │ |
| 113 | + └──────────────────────────────────┘ |
| 114 | + │ |
| 115 | + ┌────────────────────────────────┴───────────────┐ |
| 116 | + ▼ ▼ |
| 117 | + installed_servers row credentials row(s) |
| 118 | + (new server_id) (empty until configure) |
| 119 | + │ |
| 120 | + ▼ |
| 121 | + PrefixCache assigns alias on first enable/connect |
| 122 | + │ |
| 123 | + ▼ |
| 124 | + Separate stdio process / OAuth flow with clone's creds |
| 125 | +``` |
| 126 | + |
| 127 | +- **No gateway routing changes** — clone is a distinct `server_id`; existing prefix cache + routing already handle multiple servers in one Space. |
| 128 | +- **FeatureSets** see clones as separate servers — user adds `posthog-work` to a FeatureSet independently. Future Option 3 could add "all instances of server X" grouping. |
| 129 | +- **Meta tools** (`mcpmux_enable_server`) already accept any `server_id` string — clones work with session enable once the user knows the suffixed ID. Phase 3 adds optional `cloned_from` hint in `mcpmux_list_servers` response. |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## Files to create |
| 134 | + |
| 135 | +| File | Purpose | |
| 136 | +| ---- | ------- | |
| 137 | +| `apps/desktop/src/features/servers/CloneAccountModal.tsx` | Suffix input, alias preview, collision feedback, submit → Tauri command | |
| 138 | +| `apps/desktop/src/lib/api/serverClone.ts` | TS wrappers: `cloneServer`, `suggestCloneSuffix`, `isCloneIdAvailable` | |
| 139 | +| `apps/desktop/src-tauri/src/commands/server_clone.rs` | Tauri commands delegating to `ServerAppService::clone_server` | |
| 140 | +| `tests/rust/tests/integration/server_clone.rs` | Clone creates distinct install, empty creds, unique prefix, collision rejection | |
| 141 | +| `docs/planning/server-account-clones.md` | This doc | |
| 142 | + |
| 143 | +## Files to modify |
| 144 | + |
| 145 | +| File | Change | |
| 146 | +| ---- | ------ | |
| 147 | +| [`crates/mcpmux-core/src/domain/installed_server.rs`](../../crates/mcpmux-core/src/domain/installed_server.rs) | Add optional `cloned_from: Option<String>`. Builder `with_cloned_from`. | |
| 148 | +| [`crates/mcpmux-core/src/application/server.rs`](../../crates/mcpmux-core/src/application/server.rs) | `clone_server(space_id, source_server_id, suffix, alias_override?)` — copy definition, derive new ID, install as `ManualEntry`. | |
| 149 | +| [`crates/mcpmux-storage/src/repositories/installed_server_repository.rs`](../../crates/mcpmux-storage/src/repositories/installed_server_repository.rs) | Serialize/deserialize `cloned_from` (new nullable column or JSON in existing row — see Phase 1). | |
| 150 | +| [`crates/mcpmux-storage/src/migrations/`](../../crates/mcpmux-storage/src/migrations/) | New migration: `cloned_from TEXT` nullable on `installed_servers`. | |
| 151 | +| [`apps/desktop/src/features/servers/ServerActionMenu.tsx`](../../apps/desktop/src/features/servers/ServerActionMenu.tsx) | Add "Add another account…" action; hidden for clones-of-clones. | |
| 152 | +| [`apps/desktop/src/features/servers/ServersPage.tsx`](../../apps/desktop/src/features/servers/ServersPage.tsx) | Wire modal, group clones visually under source (optional Phase 2 polish). | |
| 153 | +| [`apps/desktop/src/components/SourceBadge.tsx`](../../apps/desktop/src/components/SourceBadge.tsx) | Badge variant for cloned servers ("Clone of posthog"). | |
| 154 | +| [`apps/desktop/src-tauri/src/lib.rs`](../../apps/desktop/src-tauri/src/lib.rs) | Register `clone_server`, `suggest_clone_suffix`, `is_clone_id_available` commands. | |
| 155 | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | (Phase 3) Optional `cloned_from` field in `mcpmux_list_servers` payload. | |
| 156 | +| [`docs/guide/servers.mdx`](../../docs/guide/servers.mdx) | Document multi-account patterns: Spaces vs native params vs clones. | |
| 157 | + |
| 158 | +--- |
| 159 | + |
| 160 | +## Phasing |
| 161 | + |
| 162 | +### Phase 1 — Core clone API + storage |
| 163 | + |
| 164 | +**Effort:** ~1 day |
| 165 | + |
| 166 | +- [ ] Migration: `cloned_from TEXT NULL` on `installed_servers` |
| 167 | +- [ ] `InstalledServer.cloned_from` field + repo round-trip |
| 168 | +- [ ] `ServerAppService::clone_server`: |
| 169 | + - Load source install + definition from `cached_definition` |
| 170 | + - Derive `new_id = "{base}-{suffix}"` using same normalization as `UserServerEntry::normalize_server_id` |
| 171 | + - Reject if `(space_id, new_id)` exists or source is missing |
| 172 | + - Patch definition `alias` to suffix (or user override) |
| 173 | + - Install via existing `install()` path with `ManualEntry` + `with_cloned_from(source_id)` |
| 174 | +- [ ] Unit tests: happy path, collision, missing source, suffix normalization (no underscores) |
| 175 | +- [ ] Tauri command `clone_server(space_id, source_server_id, suffix, alias?)` |
| 176 | + |
| 177 | +**Outcome:** `clone_server` from Tauri creates a disabled `posthog-work` install with copied definition, empty creds, and `cloned_from = "posthog"`. Verifiable via `list_installed_servers` and SQLite inspection. No UI yet. |
| 178 | + |
| 179 | +### Phase 2 — Clone wizard UI |
| 180 | + |
| 181 | +**Effort:** ~1 day |
| 182 | + |
| 183 | +- [ ] `CloneAccountModal` — suffix field with suggestions (`work`, `personal`, `prod`, `staging`), live alias preview, inline collision error |
| 184 | +- [ ] `ServerActionMenu` → "Add another account…" on registry and manual installs (not on clones) |
| 185 | +- [ ] Post-clone flow: open existing `ConfigEditorModal` for credential entry before enable |
| 186 | +- [ ] `SourceBadge` shows clone lineage |
| 187 | +- [ ] Optional: collapsed "Accounts" group on `ServersPage` when `cloned_from` matches same base (visual only, no schema) |
| 188 | + |
| 189 | +**Outcome:** User clicks "Add another account" on PostHog, enters suffix `work`, gets `posthog-work` card in My Servers, configures API key, enables — tools appear as `posthog-work_*` in gateway. No JSON editing. |
| 190 | + |
| 191 | +### Phase 3 — Meta-tool + docs surfacing |
| 192 | + |
| 193 | +**Effort:** ~0.5 day |
| 194 | + |
| 195 | +- [ ] `mcpmux_list_servers` returns optional `cloned_from` for clone rows |
| 196 | +- [ ] `docs/guide/servers.mdx` section: "Multiple accounts" — decision tree (Spaces / native param / clone) |
| 197 | +- [ ] Migration doc update in `jsg-tech-check` with concrete clone targets (PostHog, Gmail, Sheets, Firebase) |
| 198 | + |
| 199 | +**Outcome:** LLM manifest shows clone lineage. Docs explain when to clone vs use a Space. Migration checklist has explicit suffix naming convention. |
| 200 | + |
| 201 | +### Phase 4 — Validation + edge cases |
| 202 | + |
| 203 | +**Effort:** ~0.5 day |
| 204 | + |
| 205 | +- [ ] Integration test: two clones in one Space, distinct prefixes, both connect with different env |
| 206 | +- [ ] Uninstall clone does not affect source |
| 207 | +- [ ] Uninstall source warns if clones exist (list dependents, offer bulk uninstall) |
| 208 | +- [ ] Prefix collision: two different registry servers cannot claim same alias (existing behavior — verify clones don't break it) |
| 209 | +- [ ] `pnpm validate` + targeted Rust/TS tests |
| 210 | + |
| 211 | +**Outcome:** Clone lifecycle is safe through install → configure → enable → uninstall. Source/uninstall warnings prevent orphaned expectations. |
| 212 | + |
| 213 | +### Phase 5 — (Optional) First-class instances (Option 3) |
| 214 | + |
| 215 | +**Effort:** ~2–3 weeks — **defer until clone UX proves demand** |
| 216 | + |
| 217 | +- [ ] Schema: replace `UNIQUE(space_id, server_id)` with `UNIQUE(space_id, server_id, instance_label)` on `installed_servers`, `credentials`, `outbound_oauth_clients`, `server_features` |
| 218 | +- [ ] `instance_label` default `"default"` for existing rows; migration backfills |
| 219 | +- [ ] UI: one registry card with N instance sub-cards instead of flat clone list |
| 220 | +- [ ] FeatureSet member type: `ServerInstance { server_id, instance_label }` for grouped grants |
| 221 | +- [ ] Data migration: existing clones (`posthog-work`) → `(posthog, instance_label=work)` |
| 222 | +- [ ] Log paths, OAuth refresh, event payloads gain instance dimension |
| 223 | + |
| 224 | +**Outcome:** Registry server is the template; instances are first-class. Clone IDs like `posthog-work` become legacy format migrated to structured instances. Only pursue if Phase 1–4 adoption shows ID-suffix sprawl or FeatureSet pain. |
| 225 | + |
| 226 | +--- |
| 227 | + |
| 228 | +## Out of scope |
| 229 | + |
| 230 | +| Item | Reason | |
| 231 | +| ---- | ------ | |
| 232 | +| Auto-sync clone definition when registry updates | Requires shared definition store or periodic refresh job. Defer; document "clone may drift from registry" in UI. Option 3 addresses properly. | |
| 233 | +| Credential copy / "duplicate with same secrets" | Security footgun. User always re-enters creds on clone. | |
| 234 | +| Runtime account switching on one process | Impossible for stdio env-at-startup servers. Not McpMux's layer to fix. | |
| 235 | +| Wrapper MCP shims per backend | Per-server maintenance burden (Option 5 from brainstorm). Rejected. | |
| 236 | +| Cross-Space clone | Install separately per Space — already works via Spaces. "Clone to another Space" is a nice follow-up, not v1. | |
| 237 | +| Tool-level account selection injection | Would require MCP spec / client header support. Out of scope. | |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## Key files referenced |
| 242 | + |
| 243 | +| File | Why | |
| 244 | +| ---- | --- | |
| 245 | +| [`crates/mcpmux-core/src/application/server.rs`](../../crates/mcpmux-core/src/application/server.rs) | `install()` uniqueness check — clone must use a new `server_id`. | |
| 246 | +| [`crates/mcpmux-core/src/domain/config.rs`](../../crates/mcpmux-core/src/domain/config.rs) | `normalize_server_id` / `normalize_alias` — suffix rules (no underscores). | |
| 247 | +| [`crates/mcpmux-storage/src/migrations/001_initial.sql`](../../crates/mcpmux-storage/src/migrations/001_initial.sql) | Current `UNIQUE(space_id, server_id)` constraints clone works around. | |
| 248 | +| [`crates/mcpmux-gateway/src/services/prefix_cache.rs`](../../crates/mcpmux-gateway/src/services/prefix_cache.rs) | Prefix assignment for clone's alias at connect time. | |
| 249 | +| [`apps/desktop/src/features/servers/ServersPage.tsx`](../../apps/desktop/src/features/servers/ServersPage.tsx) | Primary UI surface for install/configure/enable flow. | |
| 250 | +| [`apps/desktop/src/components/ConfigEditorModal.tsx`](../../apps/desktop/src/components/ConfigEditorModal.tsx) | Reused post-clone credential entry. | |
| 251 | +| [`docs/guide/spaces.mdx`](../../docs/guide/spaces.mdx) | Canonical work/personal separation — clones complement, not replace. | |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +## Related documentation |
| 256 | + |
| 257 | +- [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — session enable works with clone `server_id`s once user knows the suffixed name; Phase 3 links them. |
| 258 | +- [`docs/guide/servers.mdx`](../../docs/guide/servers.mdx) — server management baseline; gets multi-account section in Phase 3. |
| 259 | +- [`docs/guide/spaces.mdx`](../../docs/guide/spaces.mdx) — primary pattern for context-level account separation. |
| 260 | +- Personal migration tracker: `jsg-tech-check/docs/setup/mcpmux-server-migration.md` — consuming checklist for PostHog, Gmail, Sheets, Firebase clones. |
| 261 | + |
| 262 | +--- |
| 263 | + |
| 264 | +## Reconciliation |
| 265 | + |
| 266 | +This doc is the source of truth for server account clones. When implementation starts, update **Status** and **Branch** at the top. Phase 5 remains optional — do not block Phases 1–4 on it. |
| 267 | + |
| 268 | +**Decision record (May 23, 2026):** Option 2 (UI-assisted clone) selected over status quo, first-class instances (deferred Phase 5), per-client credential override (rejected), and wrapper meta-servers (rejected). Brainstorm source: Cursor session on multi-account MCP patterns. |
0 commit comments