CB-634 (draft): mount the IDE Index MCP into a member, opt-in per profile

Adds `ideMcpUrl` to FleetConfig.Profile (default off). When set, the
Claude Code launcher mounts the IDE Index MCP as a second inline
--mcp-config server named `intellij`, and appends an IDE charter that
pins every ide_* call to the member's own worktree (spec.cwd()). The
charter order is role -> ide -> reply, one --append-system-prompt-file,
reply last (CB-618). The mount gate now fires on ideMcpUrl alone, not
only mcpUrl. ConfigRef treats an ideMcpUrl change as deferred, like the
other launch flags.

Never touches .mcp.json or CLAUDE.md — the mount and the rule arrive as
launch flags, so a project's own config is untouched.

Not yet done (see fleetd #162): the bridged-owned IDE lifecycle
(open on provision, close before worktree removal), and the opencode
adapter (separate ticket). fleetd.example.yaml documents ideMcpUrl and
fixes the stale parityOverlay default.

911 tests green.
This commit is contained in:
Dai Ha
2026-08-23 16:00:36 +02:00
parent 3f4ac2b24e
commit d811b30df3
8 changed files with 168 additions and 19 deletions
+8 -2
View File
@@ -126,6 +126,10 @@ herdrSocket: ~/.config/herdr/herdr.sock
# Use `pane` for the legacy behaviour (split the focused tab).
# mcpUrl → bridged mounts the bridge MCP (--mcp-config, inline) + reply charter
# (--append-system-prompt) as launch flags; nothing is written to the profile.
# ideMcpUrl → opt-in (CB-634), default off. When set, bridged mounts the IDE Index MCP as a
# second inline server named `intellij`, and adds an IDE charter that pins every
# ide_* call to the member's own worktree. A URL, not a boolean — host and port
# are host-specific. Set it only on a host where the IDE actually runs.
# tokenEnv → host env var holding the worker's auth token (value never stored in config);
# omit for a backend that needs no token (e.g. a local ollama).
# cwd → pin this profile's working directory (CB-112). Omit to inherit the primary's
@@ -135,7 +139,8 @@ herdrSocket: ~/.config/herdr/herdr.sock
# skills/MCP/hooks. Omit to leave the worker on the host default.
# parityOverlay → repo-relative paths copied primary→worktree so a worker in a provisioned
# worktree sees the same local config (CB-301-ext). Omit for the default set:
# [.claude/settings.local.json, .env, .envrc].
# [.env, .envrc]. (.claude/settings.local.json is NOT in the default — it
# pre-approves IDE/tool grants a member must not hold ambiently; CB-525/CB-634.)
#
# Do NOT add .mcp.json (CB-525). A worker's tools are whatever its launcher
# mounts — the bridge, and nothing else. Replicating the primary's MCP config
@@ -237,7 +242,8 @@ profiles:
# credentialId: shared-openai # opt-in: quarantine together with every other profile sharing this id (CB-578)
# configDir: /Users/me/.ccs/instances/gx10 # CLAUDE_CONFIG_DIR — inherit that profile's skills/MCP
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
# parityOverlay: [".claude/settings.local.json", ".env", ".envrc"] # never add .mcp.json — see above
# parityOverlay: [".env", ".envrc"] # the default; never add .mcp.json or .claude/settings.local.json — see above
# ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree
gx11: # a second backend, so `placement: weighted` has a choice
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
placement: tab
@@ -276,6 +276,9 @@ public final class ConfigRef implements Supplier<FleetConfig> {
&& Objects.equals(a.workspace(), b.workspace())
&& Objects.equals(a.tabLabel(), b.tabLabel())
&& Objects.equals(a.mcpUrl(), b.mcpUrl())
// CB-634: the IDE MCP mount is a launch flag, fixed at spawn like mcpUrl — a
// reload changes it only for members spawned after, so a changed value is deferred.
&& Objects.equals(a.ideMcpUrl(), b.ideMcpUrl())
&& Objects.equals(a.cwd(), b.cwd())
&& Objects.equals(a.parityOverlay(), b.parityOverlay())
&& Objects.equals(a.gitTokenEnv(), b.gitTokenEnv())
@@ -289,7 +289,8 @@ public record FleetConfig(
Integer maxLoad,
Boolean subscription,
String exhaustedPattern,
String credentialId) {
String credentialId,
String ideMcpUrl) {
/** Peer kind spawned by {@link dev.ltms.fleet.member.ClaudeCodeLauncher} (the default). */
public static final String KIND_CLAUDE_CODE = "claude-code";
@@ -348,6 +349,10 @@ public record FleetConfig(
// "quarantines alone" fallback actually lives, so today's behaviour needs no defaulting
// here at all.
credentialId = (credentialId == null || credentialId.isBlank()) ? null : credentialId;
// CB-634: opt-in per profile, default off. A URL, not a boolean — host and port are
// host-specific, mirroring mcpUrl. When set, the member gets the IDE Index MCP mounted
// (pinned to its own worktree via the charter). Blank ⇒ off.
ideMcpUrl = (ideMcpUrl == null || ideMcpUrl.isBlank()) ? null : ideMcpUrl;
}
/**
@@ -392,7 +397,7 @@ public record FleetConfig(
public Profile withProfile(String p) {
return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription,
exhaustedPattern, credentialId);
exhaustedPattern, credentialId, ideMcpUrl);
}
/** True when this profile is served by the Claude Code adapter (the default kind). */
@@ -441,7 +446,7 @@ public record FleetConfig(
Boolean subscription, String exhaustedPattern) {
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
subscription, exhaustedPattern, null);
subscription, exhaustedPattern, null, null);
}
/** True when this profile's CB-578 stage A backend-exhausted classification is configured. */
@@ -489,6 +494,19 @@ public record FleetConfig(
return mcpUrl != null && !mcpUrl.isBlank();
}
/**
* True when the IDE Index MCP should be mounted into a spawned worker (CB-634), pinned to
* the worker's own worktree via the charter. Opt-in per profile, default off.
*/
public boolean hasIdeMcp() {
return ideMcpUrl != null && !ideMcpUrl.isBlank();
}
/** True when this profile mounts any MCP server into its member — the bridge, the IDE, or both. */
public boolean mountsAnyMcp() {
return hasMcp() || hasIdeMcp();
}
/**
* Render this member's tab label (CB-557): {@code {role}}, {@code {profile}},
* {@code {model}} and {@code {n}} are substituted.
@@ -292,26 +292,34 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
*/
private List<String> argvWithFleet(FleetConfig.Profile cfg, LaunchSpec spec) {
String roleCharter = nonBlank(spec.roleCharter());
// CB-634: the IDE charter is only mounted when the profile also mounts the IDE MCP, and it
// pins every ide_* call to the member's own worktree (spec.cwd()). Ordered between role and
// reply — the reply charter must stay last, it is the rule that must survive.
String ideCharter = cfg.hasIdeMcp() ? ideCharter(spec.cwd()) : null;
String replyCharter = nonBlank(spec.replyCharter());
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents");
if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) {
if (!cfg.mountsAnyMcp() && roleCharter == null && ideCharter == null
&& replyCharter == null && agentFile == null) {
return cfg.argv();
}
List<String> argv = mutableArgv(cfg.argv());
if (cfg.hasMcp()) {
String mcpJson = "{\"mcpServers\":{\"" + PeerLauncher.MCP_MOUNT_NAME
+ "\":{\"type\":\"http\",\"url\":\""
+ cfg.mcpUrl() + "\"}}}";
if (cfg.mountsAnyMcp()) {
argv.add("--mcp-config");
argv.add(mcpJson);
argv.add(mcpConfigJson(cfg));
}
if (roleCharter != null) {
String combined = replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
argv.add("--append-system-prompt-file");
argv.add(writeCharterFile(combined).toString());
} else if (replyCharter != null) {
// Combine the charters in order role -> ide -> reply, dropping any that are absent. When two
// or more survive they must ride one --append-system-prompt-file (CB-618 forbids the inline
// flag and the file flag together). A lone reply charter keeps its proven inline delivery.
List<String> charters = new java.util.ArrayList<>(3);
if (roleCharter != null) charters.add(roleCharter);
if (ideCharter != null) charters.add(ideCharter);
if (replyCharter != null) charters.add(replyCharter);
if (charters.size() == 1 && replyCharter != null && roleCharter == null && ideCharter == null) {
argv.add("--append-system-prompt");
argv.add(replyCharter);
} else if (!charters.isEmpty()) {
argv.add("--append-system-prompt-file");
argv.add(writeCharterFile(String.join("\n\n", charters)).toString());
}
if (agentFile != null) {
argv.add("--agent");
@@ -320,6 +328,44 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
return argv;
}
/**
* The {@code --mcp-config} JSON for this member: always the bridge mount when {@link
* FleetConfig.Profile#hasMcp()}, plus the IDE Index MCP as a second server named {@code
* intellij} when {@link FleetConfig.Profile#hasIdeMcp()} (CB-634). At least one is present —
* the caller only reaches here when {@link FleetConfig.Profile#mountsAnyMcp()} is true.
*/
private static String mcpConfigJson(FleetConfig.Profile cfg) {
StringBuilder servers = new StringBuilder();
if (cfg.hasMcp()) {
servers.append('"').append(PeerLauncher.MCP_MOUNT_NAME)
.append("\":{\"type\":\"http\",\"url\":\"").append(cfg.mcpUrl()).append("\"}");
}
if (cfg.hasIdeMcp()) {
if (servers.length() > 0) servers.append(',');
servers.append("\"intellij\":{\"type\":\"http\",\"url\":\"")
.append(cfg.ideMcpUrl()).append("\"}");
}
return "{\"mcpServers\":{" + servers + "}}";
}
/**
* The IDE charter fragment (CB-634): tells the member to prefer the mounted IDE Index MCP over
* text search, and — the load-bearing rule — to pin every {@code ide_*} call to its own
* worktree. A bare call errors {@code multiple_projects_open}; a call with any other path reads
* a different checkout, which is exactly the CB-525 wrong-tree failure this pin prevents.
*/
private static String ideCharter(String worktree) {
return "## IDE code intelligence — your worktree only\n"
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
+ "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own "
+ "worktree — and never any other path. A call without it errors "
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
+ "you.";
}
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
private static String nonBlank(String s) {
return (s == null || s.isBlank()) ? null : s;
@@ -444,7 +444,7 @@ class FleetMcpTest {
FleetConfig.Profile wcfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null,
null, null, null, null, null, null, null, 0, null, null, null);
null, null, null, null, null, null, null, 0, null, null, null, null);
Map<String, FleetConfig.Profile> profiles = Map.of(wcfg.profile(), wcfg);
ClaudeCodeLauncher delegate = new ClaudeCodeLauncher(
new AgentControl(h), new WorkspaceControl(h), new SubscriptionGuard(Set.of("gx00.gw")),
@@ -63,6 +63,82 @@ class ClaudeCodeLauncherTest {
assertTrue(args.stream().anyMatch(a -> a.contains("fleet_reply")), "reply charter present");
}
// CB-634: a profile with ideMcpUrl set mounts the IDE Index MCP as a second server and pins
// every ide_* call to the member's own worktree via the charter.
/** A profile carrying an ideMcpUrl (plus optional bridge mcpUrl and cwd). ideMcpUrl is the last record component. */
private FleetConfig.Profile ideProfile(String mcpUrl, String ideMcpUrl, String cwd) {
return new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", mcpUrl, cwd, null,
null, null, null, null, null, null, null, null, null, ideMcpUrl);
}
private ClaudeCodeLauncher launcher(FakeHerdr herdr, FleetConfig.Profile cfg) {
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
}
@Test
void mountsIdeMcpAsSecondServerWhenIdeMcpUrlSet() {
FakeHerdr herdr = new FakeHerdr();
launcher(herdr, ideProfile("http://127.0.0.1:8765/mcp",
"http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn();
List<String> args = spawnedArgs(herdr);
assertTrue(args.contains("--mcp-config"));
String json = args.get(args.indexOf("--mcp-config") + 1);
assertTrue(json.contains("\"fleet\"") && json.contains("http://127.0.0.1:8765/mcp"),
"bridge mount still present: " + json);
assertTrue(json.contains("\"intellij\"") && json.contains("29170"),
"IDE Index MCP mounted as a second server named intellij: " + json);
}
@Test
void ideMcpUrlAloneStillEmitsTheMount() {
FakeHerdr herdr = new FakeHerdr();
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn();
List<String> args = spawnedArgs(herdr);
assertTrue(args.contains("--mcp-config"),
"the mount gate fires on ideMcpUrl alone, not only on mcpUrl");
String json = args.get(args.indexOf("--mcp-config") + 1);
assertTrue(json.contains("\"intellij\""), "IDE server present: " + json);
assertFalse(json.contains("\"fleet\""), "no bridge server when mcpUrl is unset: " + json);
}
@Test
void ideCharterPinsTheWorktreeAndSitsBetweenRoleAndReply() {
FakeHerdr herdr = new FakeHerdr();
String roleCharter = "You review changes.";
String worktree = "/tmp/.fleet-worktrees/rev-1";
FleetConfig.Profile cfg = ideProfile("http://127.0.0.1:8765/mcp",
"http://127.0.0.1:29170/index-mcp/streamable-http", worktree);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null,
0, 0L, () -> fleet(Map.of("reviewer", roleCharter), null));
svc.spawn(new SpawnRequest("ltms-local", null, null, null, null, MemberRole.REVIEWER));
List<String> args = spawnedArgs(herdr);
assertTrue(args.stream().noneMatch(a -> a.contains("\n")),
"no argv element may be multi-line: " + args);
assertFalse(args.contains("--append-system-prompt"),
"CB-618: role + ide + reply ride one --append-system-prompt-file, never both flags: " + args);
int fileFlag = args.indexOf("--append-system-prompt-file");
assertTrue(fileFlag >= 0, "the combined charter is mounted via file: " + args);
assertDoesNotThrow(() -> {
String w = Files.readString(Path.of(args.get(fileFlag + 1)));
assertTrue(w.startsWith(roleCharter), "role charter first: " + w);
assertTrue(w.contains("project_path: \"" + worktree + "\""),
"the ide charter pins the member's own worktree: " + w);
assertTrue(w.indexOf("project_path") < w.indexOf(HerdrPeerLauncher.REPLY_CHARTER),
"ide charter sits before the reply charter");
assertTrue(w.endsWith(HerdrPeerLauncher.REPLY_CHARTER),
"the reply charter is last — it is the rule that must survive: " + w);
}, "the --append-system-prompt-file path must be a readable file");
}
@Test
void startRetriesWhileTheSeedShellBoots() {
// tab.create returns before the seed shell reaches its prompt; herdr refuses agent.start
@@ -136,7 +136,7 @@ class CompositePeerLauncherTest {
return new FleetConfig.Profile(profile, "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
"w #{n}", null, null, null, null, null, null, null, null, null,
null, null, credentialId);
null, null, credentialId, null);
}
/**
@@ -252,7 +252,7 @@ class FleetAppTest {
FleetConfig.Profile wcfg = new FleetConfig.Profile(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null,
null, null, null, null, null, null, null, 0, null, null, null);
null, null, null, null, null, null, null, 0, null, null, null, null);
Map<String, FleetConfig.Profile> profiles = Map.of(wcfg.profile(), wcfg);
ClaudeCodeLauncher delegate = new ClaudeCodeLauncher(
new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),