Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ The server is repo-aware — it resolves the current working directory to a repo
kcap mcp flows
```

Stdio MCP server that lets coding agents start and interact with AI-powered agent flows — any entry in the server's flow-definition catalog, not just reviews — directly from within a session. The Kurrent Capacitor plugin **auto-registers it for Claude Code** (via `.mcp.json`), so there's nothing to do after `kcap setup` — the flows server derives the target repo from its launch working directory, and Claude Code always runs inside the repo, so one registration works for every repo. It's registered even with no daemon connected; the tools simply stay inert (and `start_flow` returns an error) until a daemon with the repo is available. `kcap setup` / `kcap plugin install --codex` register it for **Codex** in `~/.codex/config.toml`; existing manual/custom entries are never overwritten or claimed, and uninstall removes only unchanged kcap-owned entries. The native Codex plugin descriptor also includes it. The corresponding installers register it for **Cursor**, **GitHub Copilot CLI**, and **Gemini CLI** in their normal MCP configuration files. Because `kcap-flows` launches paid work, it is deliberately not marked read-only or auto-approved on any harness.
Stdio MCP server that lets coding agents start and interact with AI-powered agent flows — any entry in the server's flow-definition catalog, not just reviews — directly from within a session. The Kurrent Capacitor plugin **auto-registers it for Claude Code** (via `.mcp.json`), so there's nothing to do after `kcap setup` — the flows server derives the target repo from its launch working directory, and Claude Code always runs inside the repo, so one registration works for every repo. It's registered even with no daemon connected; the tools simply stay inert (and `start_flow` returns an error) until a daemon with the repo is available. `kcap setup` / `kcap plugin install --codex` register it for **Codex** in `~/.codex/config.toml`; existing manual/custom entries are never overwritten or claimed, and uninstall removes only unchanged kcap-owned entries. The native Codex plugin descriptor also includes it. The corresponding installers register it for **Cursor**, **GitHub Copilot CLI**, and **Gemini CLI** in their normal MCP configuration files. For the harnesses that expose no per-process identity to a long-lived MCP server (Cursor, Copilot, Gemini, Kiro, OpenCode, Antigravity), that registration includes an internal `--driver <vendor>` argument so the flows server can tell which harness is driving and recommend a *different* reviewer; Claude Code and Codex are identified from their own environment and are left unstamped. `--driver` is written by kcap into the registration — it is not a flag you set yourself. Because `kcap-flows` launches paid work, it is deliberately not marked read-only or auto-approved on any harness.

It provides four generic tools:

Expand Down
26 changes: 18 additions & 8 deletions src/Capacitor.Cli.Core/Mcp/HarnessMcpProjections.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,25 @@ public JsonMcpConfigWriter.Change Unregister(string configPath) =>
/// <c>kcap/.mcp.json</c> rather than anything generated. Pi is absent because it registers no MCP
/// config at all — it emits a bridge that discovers tools at runtime.</para></summary>
public static class HarnessMcpProjections {
// Every non-Claude JSON harness receives the same subset — the full set,
// kcap-workitems included (see KcapMcpServers.ForCursor).
public static readonly HarnessMcpProjection Cursor = new("cursor", KcapMcpServers.ForCursor, McpConfigShape.Standard);
public static readonly HarnessMcpProjection Copilot = new("copilot", KcapMcpServers.ForCursor, McpConfigShape.Copilot);
public static readonly HarnessMcpProjection Gemini = new("gemini", KcapMcpServers.ForCursor, McpConfigShape.Gemini);
public static readonly HarnessMcpProjection Kiro = new("kiro", KcapMcpServers.ForCursor, McpConfigShape.Standard);
public static readonly HarnessMcpProjection OpenCode = new("opencode", KcapMcpServers.ForCursor, McpConfigShape.OpenCode);
public static readonly HarnessMcpProjection Antigravity = new("antigravity", KcapMcpServers.ForCursor, McpConfigShape.Standard);
// Every non-Claude JSON harness receives the same subset (the full set, kcap-workitems included),
// with its flows entry stamped `--driver <harness>` — the harness string IS the driver vendor
// (see KcapMcpServers.ForHarness). These are the harnesses that export no env signal, so the
// stamp is how the flows server learns who is driving.
public static readonly HarnessMcpProjection Cursor = new("cursor", KcapMcpServers.ForHarness("cursor"), McpConfigShape.Standard);
public static readonly HarnessMcpProjection Copilot = new("copilot", KcapMcpServers.ForHarness("copilot"), McpConfigShape.Copilot);
public static readonly HarnessMcpProjection Gemini = new("gemini", KcapMcpServers.ForHarness("gemini"), McpConfigShape.Gemini);
public static readonly HarnessMcpProjection Kiro = new("kiro", KcapMcpServers.ForHarness("kiro"), McpConfigShape.Standard);
public static readonly HarnessMcpProjection OpenCode = new("opencode", KcapMcpServers.ForHarness("opencode"), McpConfigShape.OpenCode);
public static readonly HarnessMcpProjection Antigravity = new("antigravity", KcapMcpServers.ForHarness("antigravity"), McpConfigShape.Standard);

public static readonly IReadOnlyList<HarnessMcpProjection> All =
[Cursor, Copilot, Gemini, Kiro, OpenCode, Antigravity];

/// <summary>The exact set of vendor tokens kcap stamps as <c>--driver</c> on a flows
/// registration — the harness names above. <c>DriverVendor</c> validates an incoming stamp
/// against this (plus the two env-inferred vendors) so a malformed or stale registration can
/// never echo arbitrary text as <c>driver_vendor</c>. Derived from the projections so a new
/// harness is stamped and accepted from one edit.</summary>
public static readonly IReadOnlyList<string> DriverStampVendors =
[.. All.Select(p => p.Harness)];
}
26 changes: 23 additions & 3 deletions src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ public sealed record KcapMcpServer(string Name, string[] Args, bool NeedsProject
public static class KcapMcpServers {
public const string Command = "kcap";

/// <summary>The one server whose registration carries a driver stamp (see <see cref="ForHarness"/>).</summary>
internal const string FlowsServerName = "kcap-flows";

/// <summary>The flag that stamps the driving harness's vendor into the flows registration.</summary>
internal const string DriverArg = "--driver";

public static readonly IReadOnlyList<KcapMcpServer> All = [
new("kcap-review", ["mcp", "review"], NeedsProjectCwd: false,
"PR review context tools — query implementation session transcripts.", ReadOnly: true),
Expand All @@ -35,8 +41,22 @@ public static class KcapMcpServers {
/// non-read-only and is never auto-approved.</summary>
public static IReadOnlyList<KcapMcpServer> ForCodex => All;

/// <summary>The shared set for every non-Claude JSON harness (Cursor, Copilot, OpenCode,
/// Kiro, Gemini, Antigravity). The full `All` list — `kcap-workitems` included.
/// Kept as a named seam (mirrors <see cref="ForCodex"/>) for a future per-harness divergence.</summary>
/// <summary>The bare (pre-stamp) set for every non-Claude JSON harness (Cursor, Copilot,
/// OpenCode, Kiro, Gemini, Antigravity) — the full `All` list, `kcap-workitems` included.
/// <see cref="ForHarness"/> derives each harness's actual registration from this by stamping
/// the flows entry with that harness's driver vendor.</summary>
public static IReadOnlyList<KcapMcpServer> ForCursor => All;

/// <summary>The server set one JSON harness registers, with its <c>kcap-flows</c> entry stamped
/// <c>--driver &lt;vendor&gt;</c>. The stamp is the ONLY per-process signal for the driving harness's
/// identity on the six JSON harnesses, which — unlike Claude Code and Codex — export no distinctive
/// env var into the long-lived MCP server child (see <c>DriverVendor</c> / <c>HarnessRequesterContext</c>).
/// The extra argv reaches the SAME <c>kcap mcp flows</c> subcommand and therefore the same tool
/// schema; it only tells the server which vendor is driving, so a reviewer can be recommended that
/// differs from it. Claude/Codex stay on env inference (unstamped), so their registrations are
/// unchanged.</summary>
public static IReadOnlyList<KcapMcpServer> ForHarness(string vendor) =>
[.. ForCursor.Select(s => s.Name == FlowsServerName
? s with { Args = [.. s.Args, DriverArg, vendor] }
: s)];
}
43 changes: 34 additions & 9 deletions src/Capacitor.Cli/Commands/DriverVendor.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
using Capacitor.Cli.Core.Mcp;

namespace Capacitor.Cli.Commands;

/// <summary>Best-effort inference of the DRIVER harness — the coding agent running this MCP server —
/// from the per-process env a harness exports into its children. Only harnesses with a verified,
/// distinctive marker are inferred; anything else returns null so the skill's unknown-driver fallback
/// never claims a "different model". No marker is invented: an unverified or ambiguous (nested)
/// harness stays null rather than risk naming the wrong vendor. Uses the same env evidence as
/// <see cref="Capacitor.Cli.HarnessRequesterContext"/>, kept here so the reviewer-vendor tool can
/// echo <c>driver_vendor</c> without widening that type's contract.</summary>
/// <summary>Resolves the DRIVER harness — the coding agent running this MCP server — so the
/// reviewer-vendor tool can echo <c>driver_vendor</c> and the skill can recommend a reviewer that
/// differs from it. Two evidence sources, in precedence:
/// <list type="number">
/// <item><description>The <c>--driver &lt;vendor&gt;</c> stamp kcap writes into the flows MCP
/// registration for the six JSON harnesses (see <see cref="KcapMcpServers.ForHarness"/>). This is
/// the only per-process signal those harnesses give — they export no distinctive env var into the
/// long-lived MCP child.</description></item>
/// <item><description>Env inference for Claude Code / Codex, which DO export a distinctive
/// own-session variable (see <see cref="Capacitor.Cli.HarnessRequesterContext"/>); their
/// registrations are unstamped and fall through to here.</description></item>
/// </list>
/// Anything unrecognised returns null so the skill's unknown-driver fallback never claims a
/// "different model". No vendor is invented: an ambiguous (nested) env, or a stamp outside the known
/// set, stays null rather than risk naming the wrong vendor.</summary>
public static class DriverVendor {
public static string? Infer() => Infer(Environment.GetEnvironmentVariable);
public static string? Infer(string? driverArg = null) => Infer(driverArg, Environment.GetEnvironmentVariable);

/// <summary>Env-only seam kept for the existing precedence tests.</summary>
internal static string? Infer(Func<string, string?> getEnv) => Infer(null, getEnv);

internal static string? Infer(string? driverArg, Func<string, string?> getEnv) {
// Stamp wins when present and recognised — it is deterministic, unlike inherited env.
if (Normalize(driverArg) is { } stamped) return stamped;

internal static string? Infer(Func<string, string?> getEnv) {
var claude = !string.IsNullOrWhiteSpace(getEnv(HarnessRequesterContext.ClaudeSessionIdVar));
var codex = !string.IsNullOrWhiteSpace(getEnv(HarnessRequesterContext.CodexThreadIdVar));

Expand All @@ -20,4 +36,13 @@ public static class DriverVendor {
if (codex && !claude) return "codex";
return null;
}

// The closed set of tokens that may name a driver: the stamped JSON harnesses plus the two
// env-inferred vendors. Validating here keeps a malformed or stale registration from echoing
// arbitrary text as driver_vendor to the model.
static readonly HashSet<string> NameableVendors =
new(HarnessMcpProjections.DriverStampVendors.Append("claude").Append("codex"), StringComparer.Ordinal);

static string? Normalize(string? v) =>
!string.IsNullOrWhiteSpace(v) && NameableVendors.Contains(v.Trim()) ? v.Trim() : null;
}
6 changes: 4 additions & 2 deletions src/Capacitor.Cli/Commands/McpFlowsServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
namespace Capacitor.Cli.Commands;

static class McpFlowsServer {
public static async Task<int> RunAsync(string baseUrl) {
public static async Task<int> RunAsync(string baseUrl, string? driverArg = null) {
// Requester context is resolved ONCE here, from the running harness rather than from the
// environment this process inherited — see HarnessRequesterContext for why an inherited
// KCAP_SESSION_ID / process cwd names the launching session instead of this driver. Both the
Expand All @@ -24,7 +24,9 @@ public static async Task<int> RunAsync(string baseUrl) {
var requester = HarnessRequesterContext.Resolve();
var cwd = requester.ProjectDir ?? Directory.GetCurrentDirectory();
var repoRoot = GitRepository.FindRoot(cwd);
var driverVendor = DriverVendor.Infer();
// Prefer the `--driver` stamp from this server's own registration (deterministic for the JSON
// harnesses); fall back to env inference for Claude/Codex, whose registrations are unstamped.
var driverVendor = DriverVendor.Infer(driverArg);
var tools = BuildToolsList();

RepositoryPayload? repoInfo = null;
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@
case "sessions":
return await McpSessionsServer.RunAsync(baseUrl!);
case "flows":
return await McpFlowsServer.RunAsync(baseUrl!);
return await McpFlowsServer.RunAsync(baseUrl!, GetArg(args, "--driver"));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
case "flow-result":
return await McpFlowResultServer.RunAsync(baseUrl!);
case "memory":
Expand Down
27 changes: 27 additions & 0 deletions test/Capacitor.Cli.Core.Tests.Unit/Mcp/KcapMcpServersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ public async Task ForCursor_is_the_full_set_including_workitems() {
await Assert.That(names).IsEquivalentTo(new[] { "kcap-review", "kcap-sessions", "kcap-flows", "kcap-memory", "kcap-workitems", "kcap-analytics" });
}

[Test]
public async Task ForHarness_stamps_only_the_flows_entry_with_the_driver_vendor() {
var servers = KcapMcpServers.ForHarness("cursor");

// Same servers, same order as the bare set — only flows' args change.
await Assert.That(servers.Select(s => s.Name).ToArray())
.IsEquivalentTo(KcapMcpServers.ForCursor.Select(s => s.Name).ToArray());

var flows = servers.Single(s => s.Name == "kcap-flows");
await Assert.That(flows.Args).IsEquivalentTo(new[] { "mcp", "flows", "--driver", "cursor" });

// Every non-flows server is byte-identical to the bare set (no accidental stamp elsewhere).
foreach (var s in servers.Where(s => s.Name != "kcap-flows")) {
var bare = KcapMcpServers.ForCursor.Single(b => b.Name == s.Name);
await Assert.That(s.Args).IsEquivalentTo(bare.Args);
}
}

[Test]
public async Task ForHarness_leaves_the_bare_All_list_unstamped() {
// ForHarness must not mutate the shared descriptors — the audit/registry read All as the
// canonical prefix, so a leaked stamp there would misclassify every unstamped entry.
_ = KcapMcpServers.ForHarness("kiro");
var flows = KcapMcpServers.All.Single(s => s.Name == "kcap-flows");
await Assert.That(flows.Args).IsEquivalentTo(new[] { "mcp", "flows" });
}

[Test]
public async Task Review_is_the_only_non_repo_scoped_server() {
var repoScoped = KcapMcpServers.All.Where(s => s.NeedsProjectCwd).Select(s => s.Name).ToArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public async Task Cosmetic_fields_do_not_break_canonical_classification() {
[Arguments("""{ "command": "kcap", "args": ["mcp","memory"] }""")] // wrong args for name
[Arguments("""{ "command": "kcap", "args": ["mcp","flows"], "custom": true }""")] // extra field
[Arguments("""{ "command": "kcap", "args": ["mcp","flows","--extra"] }""")] // extra arg
[Arguments("""{ "command": "kcap", "args": ["mcp","flows","--driver","cursor"] }""")] // a --driver stamp lives in the harness's OWN config (owned via its marker), never in Claude's — if one appears here it is a customization to preserve, not a removable duplicate
[Arguments("""{ "command": "kcap", "args": ["mcp","flows"], "cwd": "/some/other/repo" }""")] // arbitrary cwd redirects execution context
public async Task Divergent_same_name_entry_is_a_conflict_and_never_removed(string entryJson) {
var json = $$"""{ "mcpServers": { "kcap-flows": {{entryJson}} } }""";
Expand Down
31 changes: 31 additions & 0 deletions test/Capacitor.Cli.Tests.Unit/Commands/DriverVendorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,35 @@ public async Task Both_markers_is_ambiguous_null() // nested harness — neither
[Test]
public async Task No_markers_is_null()
=> await Assert.That(DriverVendor.Infer(Env())).IsNull();

// ── the --driver stamp (the six JSON harnesses) ────────────────────────────────────────────

[Test]
[Arguments("cursor")]
[Arguments("copilot")]
[Arguments("gemini")]
[Arguments("kiro")]
[Arguments("opencode")]
[Arguments("antigravity")]
public async Task A_known_driver_stamp_is_used_verbatim(string vendor)
=> await Assert.That(DriverVendor.Infer(vendor, Env())).IsEqualTo(vendor);

[Test]
public async Task The_stamp_wins_over_a_conflicting_env_marker() // deterministic beats inherited
=> await Assert.That(DriverVendor.Infer("cursor", Env(("CLAUDE_CODE_SESSION_ID", "s1")))).IsEqualTo("cursor");

[Test]
public async Task An_unknown_stamp_is_ignored_and_falls_back_to_env()
=> await Assert.That(DriverVendor.Infer("totally-not-a-vendor", Env(("CODEX_THREAD_ID", "t1")))).IsEqualTo("codex");

[Test]
public async Task An_unknown_stamp_with_no_env_is_null() // never echo arbitrary text as driver_vendor
=> await Assert.That(DriverVendor.Infer("bogus", Env())).IsNull();

[Test]
[Arguments("")]
[Arguments(" ")]
[Arguments(null)]
public async Task A_blank_stamp_falls_back_to_env(string? stamp)
=> await Assert.That(DriverVendor.Infer(stamp, Env(("CLAUDE_CODE_SESSION_ID", "s1")))).IsEqualTo("claude");
}
Loading