Skip to content

Commit a45d34c

Browse files
committed
feat(server-clone): Phase 3 — Meta-tool + docs surfacing
Autonomous decisions: - Added InstalledServerRepository to MetaToolContext so mcpmux_list_servers can resolve cloned_from without a core type change (field already on InstalledServer) - Omitted cloned_from from non-clone rows rather than emitting null - Skipped jsg-tech-check migration doc (out of repo per orchestrator) - Updated planning doc status/checkboxes to match Phases 1–3 completion Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 94decc8 commit a45d34c

7 files changed

Lines changed: 152 additions & 24 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ impl ServiceContainer {
131131
deps.feature_set_repo.clone(),
132132
deps.workspace_binding_repo.clone(),
133133
deps.feature_repo.clone(),
134+
deps.installed_server_repo.clone(),
134135
feature_set_resolver.clone(),
135136
pool_services.feature_service.clone(),
136137
session_roots.clone(),

crates/mcpmux-gateway/src/services/meta_tools/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ pub fn build_default_registry(
5656
feature_set_repo: std::sync::Arc<dyn mcpmux_core::FeatureSetRepository>,
5757
binding_repo: std::sync::Arc<dyn mcpmux_core::WorkspaceBindingRepository>,
5858
server_feature_repo: std::sync::Arc<dyn mcpmux_core::ServerFeatureRepository>,
59+
installed_server_repo: std::sync::Arc<dyn mcpmux_core::InstalledServerRepository>,
5960
resolver: std::sync::Arc<crate::services::FeatureSetResolverService>,
6061
feature_service: std::sync::Arc<crate::pool::FeatureService>,
6162
session_roots: std::sync::Arc<crate::services::SessionRootsRegistry>,
@@ -70,6 +71,7 @@ pub fn build_default_registry(
7071
feature_set_repo,
7172
binding_repo,
7273
server_feature_repo,
74+
installed_server_repo,
7375
resolver,
7476
feature_service,
7577
session_roots,

crates/mcpmux-gateway/src/services/meta_tools/registry.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use std::sync::{Arc, Mutex};
99

1010
use async_trait::async_trait;
1111
use mcpmux_core::{
12-
DomainEvent, FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository,
13-
SpaceRepository, WorkspaceBindingRepository,
12+
DomainEvent, FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository,
13+
ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository,
1414
};
1515
use rmcp::model::{CallToolResult, Tool};
1616
use serde_json::Value;
@@ -41,6 +41,7 @@ pub struct MetaToolContext {
4141
pub feature_set_repo: Arc<dyn FeatureSetRepository>,
4242
pub binding_repo: Arc<dyn WorkspaceBindingRepository>,
4343
pub server_feature_repo: Arc<dyn ServerFeatureRepository>,
44+
pub installed_server_repo: Arc<dyn InstalledServerRepository>,
4445
pub resolver: Arc<FeatureSetResolverService>,
4546
pub feature_service: Arc<FeatureService>,
4647
pub session_roots: Arc<SessionRootsRegistry>,

crates/mcpmux-gateway/src/services/meta_tools/tools.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ impl MetaTool for ListServersTool {
211211
fn description(&self) -> &'static str {
212212
"List every MCP server installed in the caller's resolved Space with \
213213
a coarse status per server: enabled_via_binding, enabled_via_session, \
214-
disabled_via_session, or inactive. Use before enable/disable to see \
214+
disabled_via_session, or inactive. Clone installs include optional \
215+
`cloned_from` (source server_id). Use before enable/disable to see \
215216
current routing state without loading every tool."
216217
}
217218

@@ -250,6 +251,17 @@ impl MetaTool for ListServersTool {
250251
.list_for_space(&space_id.to_string())
251252
.await?;
252253

254+
let installed = call
255+
.ctx
256+
.installed_server_repo
257+
.list_for_space(&space_id.to_string())
258+
.await
259+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
260+
let cloned_from_by_server: HashMap<String, Option<String>> = installed
261+
.into_iter()
262+
.map(|s| (s.server_id, s.cloned_from))
263+
.collect();
264+
253265
let mut by_server: HashMap<String, (Option<String>, usize)> = HashMap::new();
254266
for feature in &features {
255267
if feature.feature_type != FeatureType::Tool {
@@ -274,12 +286,16 @@ impl MetaTool for ListServersTool {
274286
&session_enabled,
275287
&session_disabled,
276288
);
277-
json!({
289+
let mut entry = json!({
278290
"id": id,
279291
"name": name,
280292
"tool_count": tool_count,
281293
"status": status,
282-
})
294+
});
295+
if let Some(cloned_from) = cloned_from_by_server.get(&id).and_then(|v| v.as_ref()) {
296+
entry["cloned_from"] = json!(cloned_from);
297+
}
298+
entry
283299
})
284300
.collect();
285301
servers.sort_by(|a, b| {

docs/guide/servers.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,33 @@ Disabling a server immediately disconnects it and removes its tools from connect
100100

101101
![Expanded server view showing available tools and prompts for each connected server](https://mcpmux.com/screenshots/server-expanded.png)
102102

103+
## Multiple Accounts
104+
105+
Some MCP servers only support one account per process. Others accept a per-call account parameter, or you may simply want work and personal credentials in separate contexts. Use this decision tree:
106+
107+
```text
108+
Need more than one account for the same MCP?
109+
├─ The MCP accepts a per-call account parameter (e.g. Google Workspace `user_google_email`)
110+
│ └─ Install once — pass the account on each tool call. No clone needed.
111+
├─ Accounts map to different repo or project context (work vs personal vs client)
112+
│ └─ Use [Spaces](/docs/spaces/) — one install per Space with separate credentials.
113+
└─ Two or more accounts in the SAME Space for a single-account MCP
114+
└─ Clone via **Add another account…** on the server card in My Servers.
115+
```
116+
117+
### Cloning a server
118+
119+
When you need two PostHog workspaces, Firebase projects, or Gmail accounts in one Space:
120+
121+
1. Open **My Servers** and use the server menu → **Add another account…**
122+
2. Choose a suffix (`work`, `personal`, `prod`, etc.) — the clone ID becomes `{server}-{suffix}` (e.g. `posthog-work`)
123+
3. Configure credentials for the clone (secrets are never copied from the source)
124+
4. Enable the clone — tools appear with the clone prefix (e.g. `posthog-work_capture`)
125+
126+
Clones are independent installs: separate credentials, OAuth sessions, and tool prefixes. The source server is unchanged. You cannot clone a clone (max depth 1).
127+
128+
When using [meta tools](/docs/feature-sets/) (`mcpmux_list_servers`), clone rows include an optional `cloned_from` field with the source server ID so an LLM can see lineage.
129+
103130
## Connection Status
104131

105132
The **My Servers** page shows real-time connection status for each server:

docs/planning/server-account-clones.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Server Account Clones (UI-Assisted Multi-Account)
22

33
**Last Updated:** May 23, 2026
4-
**Status:** Planning — decisions locked, not started
5-
**Branch:** TBD — file after planning review
4+
**Status:** In progress — Phases 1–3 complete, Phase 4 pending
5+
**Branch:** `feat/server-account-clones`
66
**Base branch:** `main`
77
**Issue:** TBD — file after planning review
88
**Depends on:** None (orthogonal to session meta-tools; benefits from but does not require PR #154)
@@ -163,27 +163,27 @@ Multi-account need?
163163

164164
**Effort:** ~1 day
165165

166-
- [ ] Migration: `cloned_from TEXT NULL` on `installed_servers`
167-
- [ ] `InstalledServer.cloned_from` field + repo round-trip
168-
- [ ] `ServerAppService::clone_server`:
166+
- [x] Migration: `cloned_from TEXT NULL` on `installed_servers`
167+
- [x] `InstalledServer.cloned_from` field + repo round-trip
168+
- [x] `ServerAppService::clone_server`:
169169
- Load source install + definition from `cached_definition`
170170
- Derive `new_id = "{base}-{suffix}"` using same normalization as `UserServerEntry::normalize_server_id`
171171
- Reject if `(space_id, new_id)` exists or source is missing
172172
- Patch definition `alias` to suffix (or user override)
173173
- 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?)`
174+
- [x] Unit tests: happy path, collision, missing source, suffix normalization (no underscores)
175+
- [x] Tauri command `clone_server(space_id, source_server_id, suffix, alias?)`
176176

177177
**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.
178178

179179
### Phase 2 — Clone wizard UI
180180

181181
**Effort:** ~1 day
182182

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
183+
- [x] `CloneAccountModal` — suffix field with suggestions (`work`, `personal`, `prod`, `staging`), live alias preview, inline collision error
184+
- [x] `ServerActionMenu` → "Add another account…" on registry and manual installs (not on clones)
185+
- [x] Post-clone flow: open existing `ConfigEditorModal` for credential entry before enable
186+
- [x] `SourceBadge` shows clone lineage
187187
- [ ] Optional: collapsed "Accounts" group on `ServersPage` when `cloned_from` matches same base (visual only, no schema)
188188

189189
**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.
@@ -192,9 +192,9 @@ Multi-account need?
192192

193193
**Effort:** ~0.5 day
194194

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)
195+
- [x] `mcpmux_list_servers` returns optional `cloned_from` for clone rows
196+
- [x] `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) — out of repo; deferred
198198

199199
**Outcome:** LLM manifest shows clone lineage. Docs explain when to clone vs use a Space. Migration checklist has explicit suffix naming convention.
200200

tests/rust/tests/integration/meta_tools.rs

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ use std::time::Duration;
1212
use futures::FutureExt;
1313
use mcpmux_core::{
1414
normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetMember,
15-
FeatureSetRepository, InboundMcpClientRepository, MemberMode, MemberType, ServerFeature,
16-
ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository,
15+
FeatureSetRepository, InboundMcpClientRepository, InstalledServer, InstalledServerRepository,
16+
MemberMode, MemberType, ServerFeature, ServerFeatureRepository, SpaceRepository,
17+
WorkspaceBindingRepository,
1718
};
1819
use mcpmux_gateway::pool::FeatureService;
1920
use mcpmux_gateway::services::{
@@ -22,9 +23,9 @@ use mcpmux_gateway::services::{
2223
SessionRootsRegistry,
2324
};
2425
use mcpmux_storage::{
25-
Database, InboundClientRepository, SqliteFeatureSetRepository,
26-
SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceRepository,
27-
SqliteWorkspaceBindingRepository,
26+
generate_master_key, Database, FieldEncryptor, InboundClientRepository,
27+
SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository,
28+
SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository,
2829
};
2930
use serde_json::{json, Value};
3031
use tokio::sync::{broadcast, Mutex};
@@ -37,6 +38,7 @@ struct Fixture {
3738
client_repo: Arc<dyn InboundMcpClientRepository>,
3839
feature_set_repo: Arc<dyn FeatureSetRepository>,
3940
binding_repo: Arc<dyn WorkspaceBindingRepository>,
41+
installed_server_repo: Arc<dyn InstalledServerRepository>,
4042
session_roots: Arc<SessionRootsRegistry>,
4143
session_overrides: Arc<SessionOverrideRegistry>,
4244
feature_service: Arc<FeatureService>,
@@ -50,6 +52,11 @@ struct Fixture {
5052
event_rx: broadcast::Receiver<DomainEvent>,
5153
}
5254

55+
fn test_encryptor() -> Arc<FieldEncryptor> {
56+
let key = generate_master_key().expect("generate key");
57+
Arc::new(FieldEncryptor::new(&key).expect("create encryptor"))
58+
}
59+
5360
impl Fixture {
5461
async fn new() -> Self {
5562
let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap()));
@@ -63,6 +70,9 @@ impl Fixture {
6370
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));
6471
let server_feature_repo: Arc<dyn ServerFeatureRepository> =
6572
Arc::new(SqliteServerFeatureRepository::new(db.clone()));
73+
let installed_server_repo: Arc<dyn InstalledServerRepository> = Arc::new(
74+
SqliteInstalledServerRepository::new(db.clone(), test_encryptor()),
75+
);
6676

6777
let default_space = space_repo.get_default().await.unwrap().unwrap();
6878
let space_id = default_space.id;
@@ -127,6 +137,7 @@ impl Fixture {
127137
feature_set_repo.clone(),
128138
binding_repo.clone(),
129139
server_feature_repo.clone(),
140+
installed_server_repo.clone(),
130141
resolver,
131142
feature_service.clone(),
132143
session_roots.clone(),
@@ -142,6 +153,7 @@ impl Fixture {
142153
client_repo,
143154
feature_set_repo,
144155
binding_repo,
156+
installed_server_repo,
145157
session_roots,
146158
session_overrides,
147159
feature_service,
@@ -353,6 +365,67 @@ async fn list_servers_shows_session_override_statuses() {
353365
assert_eq!(server_status(&body, "firebase"), "enabled_via_session");
354366
}
355367

368+
#[tokio::test(flavor = "multi_thread")]
369+
async fn list_servers_includes_cloned_from_for_clone_installs() {
370+
let f = Fixture::new().await;
371+
let space_id = f.space_id.to_string();
372+
373+
let posthog = InstalledServer::new(&space_id, "posthog");
374+
f.installed_server_repo
375+
.install(&posthog)
376+
.await
377+
.unwrap();
378+
let posthog_work = InstalledServer::new(&space_id, "posthog-work")
379+
.with_cloned_from("posthog");
380+
f.installed_server_repo
381+
.install(&posthog_work)
382+
.await
383+
.unwrap();
384+
385+
let mut clone_tool = ServerFeature::tool(f.space_id, "posthog-work", "capture");
386+
clone_tool.display_name = Some("PostHog (work)".into());
387+
f.registry
388+
.context()
389+
.server_feature_repo
390+
.upsert(&clone_tool)
391+
.await
392+
.unwrap();
393+
394+
let result = f
395+
.registry
396+
.call(
397+
"mcpmux_list_servers",
398+
&f.client_id,
399+
Some(&f.session_id),
400+
json!({}),
401+
)
402+
.await
403+
.unwrap();
404+
let body = Fixture::result_json(&result);
405+
let clone_entry = body
406+
.get("servers")
407+
.unwrap()
408+
.as_array()
409+
.unwrap()
410+
.iter()
411+
.find(|s| s.get("id").and_then(|v| v.as_str()) == Some("posthog-work"))
412+
.expect("clone server in manifest");
413+
assert_eq!(
414+
clone_entry.get("cloned_from").and_then(|v| v.as_str()),
415+
Some("posthog")
416+
);
417+
418+
let github_entry = body
419+
.get("servers")
420+
.unwrap()
421+
.as_array()
422+
.unwrap()
423+
.iter()
424+
.find(|s| s.get("id").and_then(|v| v.as_str()) == Some("github"))
425+
.expect("github in manifest");
426+
assert!(github_entry.get("cloned_from").is_none());
427+
}
428+
356429
#[tokio::test(flavor = "multi_thread")]
357430
async fn enable_server_adds_tools_on_next_list() {
358431
let f = Fixture::new().await;
@@ -774,6 +847,9 @@ async fn bare_registry(
774847
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));
775848
let server_feature_repo: Arc<dyn ServerFeatureRepository> =
776849
Arc::new(SqliteServerFeatureRepository::new(db.clone()));
850+
let installed_server_repo: Arc<dyn InstalledServerRepository> = Arc::new(
851+
SqliteInstalledServerRepository::new(db.clone(), test_encryptor()),
852+
);
777853

778854
let _space = space_repo.get_default().await.unwrap().unwrap();
779855
let client = Client::new("c", "t");
@@ -801,6 +877,7 @@ async fn bare_registry(
801877
feature_set_repo,
802878
binding_repo,
803879
server_feature_repo,
880+
installed_server_repo,
804881
resolver,
805882
feature_service,
806883
SessionRootsRegistry::new(),
@@ -894,6 +971,9 @@ async fn master_switch_toggles_registry_visibility() {
894971
Arc::new(SqliteWorkspaceBindingRepository::new(db.clone()));
895972
let server_feature_repo: Arc<dyn ServerFeatureRepository> =
896973
Arc::new(SqliteServerFeatureRepository::new(db.clone()));
974+
let installed_server_repo: Arc<dyn InstalledServerRepository> = Arc::new(
975+
SqliteInstalledServerRepository::new(db.clone(), test_encryptor()),
976+
);
897977
let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone()));
898978
let resolver = Arc::new(FeatureSetResolverService::new(
899979
space_repo.clone(),
@@ -915,6 +995,7 @@ async fn master_switch_toggles_registry_visibility() {
915995
feature_set_repo,
916996
binding_repo,
917997
server_feature_repo,
998+
installed_server_repo,
918999
resolver,
9191000
feature_service,
9201001
SessionRootsRegistry::new(),

0 commit comments

Comments
 (0)