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
@@ -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;