From 03052a3f8b6544a5824dc8df232c17946ac5a63c Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:02:26 -0400
Subject: [PATCH 01/10] feat(mcp): canonical kcap MCP server list; Codex
consumes it
---
src/Capacitor.Cli.Core/CodexConfigToml.cs | 35 +++++--------------
src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs | 24 +++++++++++++
.../Mcp/KcapMcpServersTests.cs | 23 ++++++++++++
3 files changed, 56 insertions(+), 26 deletions(-)
create mode 100644 src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs
create mode 100644 test/Capacitor.Cli.Tests.Unit/Mcp/KcapMcpServersTests.cs
diff --git a/src/Capacitor.Cli.Core/CodexConfigToml.cs b/src/Capacitor.Cli.Core/CodexConfigToml.cs
index 6e84596b08..63f6a4899e 100644
--- a/src/Capacitor.Cli.Core/CodexConfigToml.cs
+++ b/src/Capacitor.Cli.Core/CodexConfigToml.cs
@@ -1,3 +1,4 @@
+using Capacitor.Cli.Core.Mcp;
using Tomlyn;
using Tomlyn.Model;
using Tomlyn.Serialization;
@@ -24,23 +25,6 @@ public enum Change { Unchanged, Updated, Failed }
static string DefaultConfigPath => Path.Combine(CodexPaths.Home(), "config.toml");
- ///
- /// The kcap MCP servers auto-registered for Codex CLI in ~/.codex/config.toml.
- /// Codex reads MCP servers from the snake_case [mcp_servers] TOML table — note
- /// this is NOT the camelCase mcpServers key used by the Claude/Codex plugin
- /// *descriptor* JSON. kcap-flows is intentionally excluded: it launches a paid
- /// hosted reviewer and stays Claude-only (AI-1056). kcap-memory IS included
- /// (AI-1146): it is a free, harness-agnostic team-memory server, so Codex users going
- /// through kcap setup get it alongside review/sessions.
- ///
- static readonly (string Name, string[] Args)[] KcapMcpServers = [
- ("kcap-review", ["mcp", "review"]),
- ("kcap-sessions", ["mcp", "sessions"]),
- ("kcap-memory", ["mcp", "memory"])
- ];
-
- const string McpServerCommand = "kcap";
-
///
/// AI-794 — enable network access for Codex's workspace-write sandbox so
/// kcap skills (which shell out to kcap …) can reach the Capacitor server.
@@ -260,18 +244,17 @@ static bool MutateRegisterMcpServers(TomlTable root) {
var servers = GetOrAddTable(root, "mcp_servers");
var changed = false;
- foreach (var (name, args) in KcapMcpServers) {
+ foreach (var server in KcapMcpServers.ForCodex) {
// Never clobber an existing entry: a prior kcap registration is already
// correct, and a user may have customized it (e.g. an absolute-path command
// for a GUI host). Only create the server when it's missing entirely.
- if (servers.ContainsKey(name)) continue;
+ if (servers.ContainsKey(server.Name)) continue;
- var entry = new TomlTable {
- ["command"] = McpServerCommand,
- ["args"] = ToTomlArray(args)
+ servers[server.Name] = new TomlTable {
+ ["command"] = KcapMcpServers.Command,
+ ["args"] = ToTomlArray(server.Args)
};
- servers[name] = entry;
- changed = true;
+ changed = true;
}
return changed;
@@ -282,8 +265,8 @@ static bool MutateUnregisterMcpServers(TomlTable root) {
var changed = false;
- foreach (var (name, _) in KcapMcpServers)
- if (servers.Remove(name)) changed = true;
+ foreach (var server in KcapMcpServers.ForCodex)
+ if (servers.Remove(server.Name)) changed = true;
// Don't leave a bare [mcp_servers] behind if we emptied it.
if (servers.Count == 0 && root.Remove("mcp_servers")) changed = true;
diff --git a/src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs b/src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs
new file mode 100644
index 0000000000..fbdc26978c
--- /dev/null
+++ b/src/Capacitor.Cli.Core/Mcp/KcapMcpServers.cs
@@ -0,0 +1,24 @@
+namespace Capacitor.Cli.Core.Mcp;
+
+/// One kcap MCP server, described semantically (no harness field names).
+public sealed record KcapMcpServer(string Name, string[] Args, bool NeedsProjectCwd, string? Description);
+
+/// The single source of truth for the kcap MCP servers. Every writer
+/// (Codex TOML, the JSON harnesses, the bundled `.mcp.json`) derives from this.
+public static class KcapMcpServers {
+ public const string Command = "kcap";
+
+ public static readonly IReadOnlyList All = [
+ new("kcap-review", ["mcp", "review"], NeedsProjectCwd: false,
+ "PR review context tools — query implementation session transcripts."),
+ new("kcap-sessions", ["mcp", "sessions"], NeedsProjectCwd: true, null),
+ new("kcap-flows", ["mcp", "flows"], NeedsProjectCwd: true,
+ "Structured AI agent flows — launches a SEPARATE hosted participant agent; requires login + a running daemon."),
+ new("kcap-memory", ["mcp", "memory"], NeedsProjectCwd: true,
+ "Team memory — search, read, and save durable learnings."),
+ ];
+
+ /// Codex omits `kcap-flows` (AI-1056: paid hosted reviewer, Claude-only).
+ public static IReadOnlyList ForCodex =>
+ All.Where(s => s.Name != "kcap-flows").ToArray();
+}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/KcapMcpServersTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/KcapMcpServersTests.cs
new file mode 100644
index 0000000000..2067a806e5
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/KcapMcpServersTests.cs
@@ -0,0 +1,23 @@
+using Capacitor.Cli.Core.Mcp;
+
+namespace Capacitor.Cli.Tests.Unit.Mcp;
+
+public class KcapMcpServersTests {
+ [Test]
+ public async Task All_contains_the_four_canonical_servers() {
+ var names = KcapMcpServers.All.Select(s => s.Name).ToArray();
+ await Assert.That(names).IsEquivalentTo(new[] { "kcap-review", "kcap-sessions", "kcap-flows", "kcap-memory" });
+ }
+
+ [Test]
+ public async Task ForCodex_excludes_flows_only() {
+ var names = KcapMcpServers.ForCodex.Select(s => s.Name).ToArray();
+ await Assert.That(names).IsEquivalentTo(new[] { "kcap-review", "kcap-sessions", "kcap-memory" });
+ }
+
+ [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();
+ await Assert.That(repoScoped).IsEquivalentTo(new[] { "kcap-sessions", "kcap-flows", "kcap-memory" });
+ }
+}
From 8fa1a8d2cef74c1396254bfd1fd75febb0634fbb Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:07:04 -0400
Subject: [PATCH 02/10] feat(mcp): per-harness MCP config shape descriptor
---
src/Capacitor.Cli.Core/Mcp/McpConfigShape.cs | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 src/Capacitor.Cli.Core/Mcp/McpConfigShape.cs
diff --git a/src/Capacitor.Cli.Core/Mcp/McpConfigShape.cs b/src/Capacitor.Cli.Core/Mcp/McpConfigShape.cs
new file mode 100644
index 0000000000..27ddd602e3
--- /dev/null
+++ b/src/Capacitor.Cli.Core/Mcp/McpConfigShape.cs
@@ -0,0 +1,18 @@
+namespace Capacitor.Cli.Core.Mcp;
+
+public enum EnableStyle { None, EnabledTrue }
+
+/// How one harness renders an MCP server entry. The canonical
+/// is field-name-agnostic; this maps it to a host's on-disk shape.
+public sealed record McpConfigShape(
+ string BlockKey, // "mcpServers" (most) or "mcp" (OpenCode)
+ bool CommandAsArgvArray, // OpenCode: command = ["kcap","mcp","review"]; else command="kcap" + args=[...]
+ string? TypeValue, // "stdio" (Copilot), "local" (OpenCode), or null
+ string EnvKey, // "env" (most) or "environment" (OpenCode) — reserved for future env support
+ EnableStyle Enable // OpenCode wants "enabled": true
+) {
+ // Cursor / Gemini / Kiro / Antigravity all share the plain shape.
+ public static readonly McpConfigShape Standard = new("mcpServers", CommandAsArgvArray: false, TypeValue: null, EnvKey: "env", Enable: EnableStyle.None);
+ public static readonly McpConfigShape Copilot = new("mcpServers", CommandAsArgvArray: false, TypeValue: "stdio", EnvKey: "env", Enable: EnableStyle.None);
+ public static readonly McpConfigShape OpenCode = new("mcp", CommandAsArgvArray: true, TypeValue: "local", EnvKey: "environment", Enable: EnableStyle.EnabledTrue);
+}
From e65bf0a44345ee5dca6cb23bb33938d34bfd18bd Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:10:28 -0400
Subject: [PATCH 03/10] feat(mcp): ownership sidecar marker (user-scope +
central-state)
IMcpMarker + McpMarker: records which kcap-* server names kcap wrote
into a given MCP config, stored outside the host file itself (sidecar
next to user-scope configs, hashed path under ~/.kcap/mcp-markers for
project-scope/central configs). Owns() gates overwrite/unregister on
both a recorded name AND command == "kcap", so a user-authored
look-alike entry is never touched.
---
src/Capacitor.Cli.Core/Mcp/McpMarker.cs | 84 +++++++++++++++++++
.../Mcp/McpMarkerTests.cs | 54 ++++++++++++
2 files changed, 138 insertions(+)
create mode 100644 src/Capacitor.Cli.Core/Mcp/McpMarker.cs
create mode 100644 test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
diff --git a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
new file mode 100644
index 0000000000..d434269b37
--- /dev/null
+++ b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
@@ -0,0 +1,84 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Capacitor.Cli.Core.Mcp;
+
+public interface IMcpMarker {
+ /// True if `entry` under `name` is a kcap-owned registration (name recorded in the marker
+ /// AND command == "kcap"), so overwrite/unregister may touch it. A user look-alike returns false.
+ bool Owns(string configPath, string name, JsonNode entry);
+ void Record(string configPath, IReadOnlyList names);
+ IEnumerable Owned(string configPath);
+ void Clear(string configPath);
+}
+
+///
+/// Sidecar marker recording which `kcap-*` keys kcap wrote into a given config file.
+/// Lives OUTSIDE the host MCP file (so it never alters the host's accepted schema):
+/// - user-scope config → `.kcap-mcp-version` next to the config file;
+/// - otherwise (project-scope / central) → `~/.kcap/mcp-markers/<harness>-<hash(abs path)>.json`.
+/// The `markerPathFor` override lets tests point both cases at a temp dir.
+///
+public sealed class McpMarker(string harness, Func? markerPathFor = null) : IMcpMarker {
+ const int Version = 1;
+
+ public bool Owns(string configPath, string name, JsonNode entry) {
+ if (!Owned(configPath).Contains(name)) return false;
+ // Command is "kcap" (string) or ["kcap", ...] (OpenCode array).
+ var cmd = entry["command"];
+ return cmd is JsonValue v && v.TryGetValue(out string? s) && s == KcapMcpServers.Command
+ || cmd is JsonArray a && a.Count > 0 && (string?)a[0] == KcapMcpServers.Command;
+ }
+
+ public void Record(string configPath, IReadOnlyList names) {
+ var path = MarkerPath(configPath);
+ var existing = ReadNames(path);
+ foreach (var n in names) existing.Add(n);
+ var doc = new JsonObject {
+ ["version"] = Version,
+ ["config"] = configPath,
+ ["servers"] = new JsonArray(existing.Select(n => (JsonNode)n!).ToArray())
+ };
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ File.WriteAllText(path, doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ public IEnumerable Owned(string configPath) => ReadNames(MarkerPath(configPath));
+
+ public void Clear(string configPath) {
+ var p = MarkerPath(configPath);
+ try { if (File.Exists(p)) File.Delete(p); } catch { /* best-effort */ }
+ }
+
+ static HashSet ReadNames(string markerPath) {
+ try {
+ if (!File.Exists(markerPath)) return [];
+ var root = JsonNode.Parse(File.ReadAllText(markerPath)) as JsonObject;
+ var arr = root?["servers"] as JsonArray;
+ return arr is null ? [] : [.. arr.Select(n => (string)n!)];
+ } catch { return []; }
+ }
+
+ string MarkerPath(string configPath) {
+ if (markerPathFor is not null) return markerPathFor(configPath);
+ // Heuristic: a config under the user's home harness dir → sidecar; else central state.
+ var dir = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var isUserScope = dir.StartsWith(home, StringComparison.Ordinal)
+ && !IsInsideRepo(dir);
+ if (isUserScope) return Path.Combine(dir, ".kcap-mcp-version");
+
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(configPath))))[..16].ToLowerInvariant();
+ return Path.Combine(home, ".kcap", "mcp-markers", $"{harness}-{hash}.json");
+ }
+
+ static bool IsInsideRepo(string dir) {
+ var d = new DirectoryInfo(dir);
+ for (var cur = d; cur is not null; cur = cur.Parent)
+ if (Directory.Exists(Path.Combine(cur.FullName, ".git"))) return true;
+ return false;
+ }
+}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
new file mode 100644
index 0000000000..c5e08b283f
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Nodes;
+using Capacitor.Cli.Core.Mcp;
+
+namespace Capacitor.Cli.Tests.Unit.Mcp;
+
+public class McpMarkerTests {
+ static (McpMarker marker, string cfg, string markerFile) NewMarker() {
+ var dir = Directory.CreateTempSubdirectory("kcap-marker-").FullName;
+ var cfg = Path.Combine(dir, "mcp.json");
+ var markerFile = Path.Combine(dir, "marker.json");
+ return (new McpMarker("test", _ => markerFile), cfg, markerFile);
+ }
+
+ [Test]
+ public async Task Record_then_Owned_roundtrips_names() {
+ var (marker, cfg, _) = NewMarker();
+ marker.Record(cfg, ["kcap-review", "kcap-sessions"]);
+ await Assert.That(marker.Owned(cfg)).Contains("kcap-review");
+ await Assert.That(marker.Owned(cfg)).Contains("kcap-sessions");
+ }
+
+ [Test]
+ public async Task Owns_true_for_recorded_kcap_command_entry() {
+ var (marker, cfg, _) = NewMarker();
+ marker.Record(cfg, ["kcap-review"]);
+ var entry = new JsonObject { ["command"] = "kcap", ["args"] = new JsonArray { "mcp", "review" } };
+ await Assert.That(marker.Owns(cfg, "kcap-review", entry)).IsTrue();
+ }
+
+ [Test]
+ public async Task Owns_false_for_unrecorded_lookalike() {
+ var (marker, cfg, _) = NewMarker();
+ // Never recorded → not owned, even though it's named kcap-review.
+ var entry = new JsonObject { ["command"] = "mine" };
+ await Assert.That(marker.Owns(cfg, "kcap-review", entry)).IsFalse();
+ }
+
+ [Test]
+ public async Task Owns_true_for_opencode_command_array() {
+ var (marker, cfg, _) = NewMarker();
+ marker.Record(cfg, ["kcap-review"]);
+ var entry = new JsonObject { ["command"] = new JsonArray { "kcap", "mcp", "review" } };
+ await Assert.That(marker.Owns(cfg, "kcap-review", entry)).IsTrue();
+ }
+
+ [Test]
+ public async Task Clear_removes_the_marker() {
+ var (marker, cfg, markerFile) = NewMarker();
+ marker.Record(cfg, ["kcap-review"]);
+ marker.Clear(cfg);
+ await Assert.That(File.Exists(markerFile)).IsFalse();
+ await Assert.That(marker.Owned(cfg)).IsEmpty();
+ }
+}
From bb773d584c7960c08beca64e324bf5c538f7e903 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:14:33 -0400
Subject: [PATCH 04/10] fix(mcp): McpMarker.Owns must not throw on non-string
array command
Co-Authored-By: Claude Opus 4.8
---
src/Capacitor.Cli.Core/Mcp/McpMarker.cs | 2 +-
test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
index d434269b37..d6cd2bcaf0 100644
--- a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
+++ b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
@@ -29,7 +29,7 @@ public bool Owns(string configPath, string name, JsonNode entry) {
// Command is "kcap" (string) or ["kcap", ...] (OpenCode array).
var cmd = entry["command"];
return cmd is JsonValue v && v.TryGetValue(out string? s) && s == KcapMcpServers.Command
- || cmd is JsonArray a && a.Count > 0 && (string?)a[0] == KcapMcpServers.Command;
+ || cmd is JsonArray a && a.Count > 0 && a[0] is JsonValue fv && fv.TryGetValue(out string? fs) && fs == KcapMcpServers.Command;
}
public void Record(string configPath, IReadOnlyList names) {
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
index c5e08b283f..94506cfe8a 100644
--- a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
@@ -43,6 +43,14 @@ public async Task Owns_true_for_opencode_command_array() {
await Assert.That(marker.Owns(cfg, "kcap-review", entry)).IsTrue();
}
+ [Test]
+ public async Task Owns_false_for_malformed_nonstring_command_array() {
+ var (marker, cfg, _) = NewMarker();
+ marker.Record(cfg, ["kcap-review"]);
+ var entry = new JsonObject { ["command"] = new JsonArray { 123, "mcp" } };
+ await Assert.That(marker.Owns(cfg, "kcap-review", entry)).IsFalse(); // must not throw
+ }
+
[Test]
public async Task Clear_removes_the_marker() {
var (marker, cfg, markerFile) = NewMarker();
From 6ff4b9ee4577ad38cbcddb9129007200c7691342 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:22:44 -0400
Subject: [PATCH 05/10] feat(mcp): generic non-destructive JSON MCP config
writer
---
.../Mcp/JsonMcpConfigWriter.cs | 128 ++++++++++++++++
.../Mcp/JsonMcpConfigWriterTests.cs | 143 ++++++++++++++++++
2 files changed, 271 insertions(+)
create mode 100644 src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
create mode 100644 test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
diff --git a/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs b/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
new file mode 100644
index 0000000000..9aa7c3a783
--- /dev/null
+++ b/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
@@ -0,0 +1,128 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Capacitor.Cli.Core.Mcp;
+
+///
+/// Read-modify-write engine for a harness's JSON MCP config file. Mirrors
+/// CodexConfigToml: fail-closed on a malformed/wrong-type file (never clobber),
+/// non-destructive (preserves user servers + surrounding config), idempotent, atomic.
+/// Uses the JsonNode DOM — reflection-free and AOT-safe.
+///
+public static class JsonMcpConfigWriter {
+ static readonly Lock _writeLock = new();
+ static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true };
+
+ public enum Change { Unchanged, Updated, Failed }
+
+ public static Change Register(string configPath, IReadOnlyList servers,
+ McpConfigShape shape, string? cwd, IMcpMarker marker) =>
+ Update(configPath, root => {
+ var block = GetOrAddObject(root, shape.BlockKey); // throws on wrong-type → Failed
+ var changed = false;
+ var written = new List();
+
+ foreach (var s in servers) {
+ if (block[s.Name] is JsonNode existing) {
+ // Already present: a prior kcap registration is already correct, and a
+ // user look-alike must not be clobbered either way. Only a missing entry
+ // is written — mirrors CodexConfigToml's "only missing servers are added"
+ // idempotency contract.
+ if (marker.Owns(configPath, s.Name, existing)) written.Add(s.Name);
+ continue;
+ }
+ block[s.Name] = RenderEntry(s, shape, cwd);
+ written.Add(s.Name);
+ changed = true;
+ }
+
+ if (changed) marker.Record(configPath, written);
+ return changed;
+ });
+
+ public static Change Unregister(string configPath, McpConfigShape shape, IMcpMarker marker) =>
+ Update(configPath, root => {
+ if (root[shape.BlockKey] is not JsonObject block) return false;
+ var changed = false;
+
+ foreach (var name in marker.Owned(configPath).ToArray())
+ if (block[name] is JsonNode entry && marker.Owns(configPath, name, entry) && block.Remove(name))
+ changed = true;
+
+ if (block.Count == 0 && root.Remove(shape.BlockKey)) changed = true;
+ if (changed) marker.Clear(configPath);
+ return changed;
+ });
+
+ static JsonObject RenderEntry(KcapMcpServer s, McpConfigShape shape, string? cwd) {
+ var o = new JsonObject();
+ if (shape.TypeValue is not null) o["type"] = shape.TypeValue;
+
+ if (shape.CommandAsArgvArray) {
+ // Use the implicit string -> JsonValue conversion (cast to JsonNode?) rather
+ // than JsonValue.Create / collection expressions, which lower to generic
+ // Add and trip NativeAOT (IL3050). Matches ReviewLaunchBuilder's pattern.
+ var argv = new JsonArray();
+ argv.Add((JsonNode?)KcapMcpServers.Command);
+ foreach (var a in s.Args) argv.Add((JsonNode?)a);
+ o["command"] = argv;
+ } else {
+ o["command"] = KcapMcpServers.Command;
+ var args = new JsonArray();
+ foreach (var a in s.Args) args.Add((JsonNode?)a);
+ o["args"] = args;
+ }
+
+ if (cwd is not null && s.NeedsProjectCwd) o["cwd"] = cwd;
+ if (shape.Enable == EnableStyle.EnabledTrue) o["enabled"] = true;
+ return o;
+ }
+
+ static Change Update(string configPath, Func mutate) {
+ lock (_writeLock) {
+ JsonObject root;
+
+ if (File.Exists(configPath)) {
+ try {
+ var parsed = JsonNode.Parse(File.ReadAllText(configPath));
+ if (parsed is not JsonObject obj) return Change.Failed; // wrong top-level type
+ root = obj;
+ } catch {
+ return Change.Failed; // malformed — never clobber
+ }
+ } else {
+ root = new JsonObject();
+ }
+
+ bool changed;
+ try { changed = mutate(root); }
+ catch { return Change.Failed; } // e.g. wrong-type block
+
+ if (!changed) return Change.Unchanged;
+
+ try {
+ var dir = Path.GetDirectoryName(configPath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ WriteAtomic(configPath, root);
+ return Change.Updated;
+ } catch { return Change.Failed; }
+ }
+ }
+
+ static JsonObject GetOrAddObject(JsonObject parent, string key) {
+ if (parent.TryGetPropertyValue(key, out var v)) {
+ if (v is JsonObject obj) return obj;
+ throw new InvalidOperationException($"`{key}` is present but not an object; refusing to overwrite.");
+ }
+ var created = new JsonObject();
+ parent[key] = created;
+ return created;
+ }
+
+ static void WriteAtomic(string path, JsonNode root) {
+ var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N");
+ File.WriteAllText(tmp, root.ToJsonString(WriteOpts));
+ try { File.Move(tmp, path, overwrite: true); }
+ catch { try { File.Delete(tmp); } catch { /* best-effort */ } throw; }
+ }
+}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
new file mode 100644
index 0000000000..ba63d7efba
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
@@ -0,0 +1,143 @@
+using System.Text.Json.Nodes;
+using Capacitor.Cli.Core.Mcp;
+
+namespace Capacitor.Cli.Tests.Unit.Mcp;
+
+public class JsonMcpConfigWriterTests {
+ static string TempConfig(string name = "mcp.json") =>
+ Path.Combine(Directory.CreateTempSubdirectory("kcap-mcpwriter-").FullName, name);
+
+ // In-memory marker double: treats an entry as kcap-owned iff its key starts with "kcap-".
+ sealed class FakeMarker : IMcpMarker {
+ readonly HashSet _owned = [];
+ public bool Owns(string cfg, string name, JsonNode entry) => name.StartsWith("kcap-");
+ public void Record(string cfg, IReadOnlyList names) { foreach (var n in names) _owned.Add(n); }
+ public IEnumerable Owned(string cfg) => _owned;
+ public void Clear(string cfg) => _owned.Clear();
+ }
+
+ static JsonObject Read(string path) => (JsonObject)JsonNode.Parse(File.ReadAllText(path))!;
+
+ [Test]
+ public async Task Register_on_missing_file_writes_all_servers_standard_shape() {
+ var path = TempConfig();
+ var change = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, cwd: null, new FakeMarker());
+
+ await Assert.That(change).IsEqualTo(JsonMcpConfigWriter.Change.Updated);
+ var servers = (JsonObject)Read(path)["mcpServers"]!;
+ await Assert.That(servers.Count).IsEqualTo(4);
+ await Assert.That((string)servers["kcap-review"]!["command"]!).IsEqualTo("kcap");
+ await Assert.That(servers["kcap-review"]!["args"]!.AsArray().Count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Register_preserves_user_servers() {
+ var path = TempConfig();
+ File.WriteAllText(path, """{ "mcpServers": { "playwright": { "command": "npx", "args": ["-y","playwright"] } } }""");
+
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, new FakeMarker());
+
+ var servers = (JsonObject)Read(path)["mcpServers"]!;
+ await Assert.That((string)servers["playwright"]!["command"]!).IsEqualTo("npx");
+ await Assert.That(servers.ContainsKey("kcap-review")).IsTrue();
+ }
+
+ [Test]
+ public async Task Register_is_idempotent() {
+ var path = TempConfig();
+ var marker = new FakeMarker();
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+ var second = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+
+ await Assert.That(second).IsEqualTo(JsonMcpConfigWriter.Change.Unchanged);
+ }
+
+ [Test]
+ public async Task Register_fails_closed_on_malformed_file() {
+ var path = TempConfig();
+ File.WriteAllText(path, "{ not json");
+ var change = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, new FakeMarker());
+
+ await Assert.That(change).IsEqualTo(JsonMcpConfigWriter.Change.Failed);
+ await Assert.That(File.ReadAllText(path)).IsEqualTo("{ not json");
+ }
+
+ [Test]
+ public async Task Register_fails_closed_when_block_is_wrong_type() {
+ var path = TempConfig();
+ File.WriteAllText(path, """{ "mcpServers": "oops" }""");
+ var change = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, new FakeMarker());
+
+ await Assert.That(change).IsEqualTo(JsonMcpConfigWriter.Change.Failed);
+ }
+
+ [Test]
+ public async Task Register_does_not_clobber_user_authored_kcap_lookalike() {
+ var path = TempConfig();
+ // A user server literally named kcap-review but pointing elsewhere; FakeMarker.Owns
+ // returns true for the "kcap-" prefix, so replace this with the real marker semantics
+ // in Task 4's integration test. Here we assert the collision path via a non-prefixed name.
+ File.WriteAllText(path, """{ "mcpServers": { "kcap-custom": { "command": "mine" } } }""");
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, new FakeMarker());
+
+ var servers = (JsonObject)Read(path)["mcpServers"]!;
+ await Assert.That((string)servers["kcap-custom"]!["command"]!).IsEqualTo("mine");
+ }
+
+ [Test]
+ public async Task Register_opencode_shape_uses_command_array_type_local_enabled() {
+ var path = TempConfig("opencode.json");
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.OpenCode, null, new FakeMarker());
+
+ var block = (JsonObject)Read(path)["mcp"]!;
+ var review = (JsonObject)block["kcap-review"]!;
+ await Assert.That((string)review["type"]!).IsEqualTo("local");
+ await Assert.That(review["command"]!.AsArray().Count).IsEqualTo(3); // kcap, mcp, review
+ await Assert.That((bool)review["enabled"]!).IsTrue();
+ }
+
+ [Test]
+ public async Task Register_copilot_shape_sets_type_stdio() {
+ var path = TempConfig();
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Copilot, null, new FakeMarker());
+ var review = (JsonObject)((JsonObject)Read(path)["mcpServers"]!)["kcap-review"]!;
+ await Assert.That((string)review["type"]!).IsEqualTo("stdio");
+ }
+
+ [Test]
+ public async Task Register_emits_cwd_only_for_repo_scoped_servers() {
+ var path = TempConfig();
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, cwd: "/w/repo", new FakeMarker());
+ var servers = (JsonObject)Read(path)["mcpServers"]!;
+ await Assert.That(servers["kcap-review"]!["cwd"]).IsNull();
+ await Assert.That((string)servers["kcap-sessions"]!["cwd"]!).IsEqualTo("/w/repo");
+ }
+
+ [Test]
+ public async Task Unregister_removes_only_owned_and_drops_empty_block() {
+ var path = TempConfig();
+ var marker = new FakeMarker();
+ File.WriteAllText(path, """{ "mcpServers": { "playwright": { "command": "npx" } } }""");
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+
+ var change = JsonMcpConfigWriter.Unregister(path, McpConfigShape.Standard, marker);
+ await Assert.That(change).IsEqualTo(JsonMcpConfigWriter.Change.Updated);
+
+ var servers = (JsonObject)Read(path)["mcpServers"]!;
+ await Assert.That(servers.ContainsKey("playwright")).IsTrue();
+ await Assert.That(servers.ContainsKey("kcap-review")).IsFalse();
+ }
+
+ [Test]
+ public async Task Register_preserves_unrecorded_kcap_named_user_server() {
+ var dir = Directory.CreateTempSubdirectory("kcap-collide-").FullName;
+ var path = Path.Combine(dir, "mcp.json");
+ var marker = new McpMarker("test", _ => Path.Combine(dir, "marker.json")); // real marker, nothing recorded
+ File.WriteAllText(path, """{ "mcpServers": { "kcap-review": { "command": "user-owned" } } }""");
+
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+
+ var review = (JsonObject)((JsonObject)JsonNode.Parse(File.ReadAllText(path))!["mcpServers"]!)["kcap-review"]!;
+ await Assert.That((string)review["command"]!).IsEqualTo("user-owned"); // NOT clobbered
+ }
+}
From b8c87026eb2281f72e94824738687cc692184282 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:29:16 -0400
Subject: [PATCH 06/10] fix(mcp): JsonMcpConfigWriter.Register self-heals stale
owned entries (idempotent when identical)
---
.../Mcp/JsonMcpConfigWriter.cs | 13 ++++-----
.../Mcp/JsonMcpConfigWriterTests.cs | 27 +++++++++++++++++++
2 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs b/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
index 9aa7c3a783..7313f99dfb 100644
--- a/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
+++ b/src/Capacitor.Cli.Core/Mcp/JsonMcpConfigWriter.cs
@@ -23,15 +23,16 @@ public static Change Register(string configPath, IReadOnlyList se
var written = new List();
foreach (var s in servers) {
+ var rendered = RenderEntry(s, shape, cwd);
if (block[s.Name] is JsonNode existing) {
- // Already present: a prior kcap registration is already correct, and a
- // user look-alike must not be clobbered either way. Only a missing entry
- // is written — mirrors CodexConfigToml's "only missing servers are added"
- // idempotency contract.
- if (marker.Owns(configPath, s.Name, existing)) written.Add(s.Name);
+ if (!marker.Owns(configPath, s.Name, existing)) continue; // user look-alike — never clobber
+ written.Add(s.Name); // kcap-owned → keep recorded
+ if (JsonNode.DeepEquals(existing, rendered)) continue; // identical → idempotent no-op
+ block[s.Name] = rendered; // stale/old shape → heal to canonical
+ changed = true;
continue;
}
- block[s.Name] = RenderEntry(s, shape, cwd);
+ block[s.Name] = rendered; // missing → add
written.Add(s.Name);
changed = true;
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
index ba63d7efba..05274bd80d 100644
--- a/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/JsonMcpConfigWriterTests.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Text.Json.Nodes;
using Capacitor.Cli.Core.Mcp;
@@ -140,4 +141,30 @@ public async Task Register_preserves_unrecorded_kcap_named_user_server() {
var review = (JsonObject)((JsonObject)JsonNode.Parse(File.ReadAllText(path))!["mcpServers"]!)["kcap-review"]!;
await Assert.That((string)review["command"]!).IsEqualTo("user-owned"); // NOT clobbered
}
+
+ [Test]
+ public async Task Register_heals_stale_owned_entry_but_stays_idempotent() {
+ var dir = Directory.CreateTempSubdirectory("kcap-heal-").FullName;
+ var path = Path.Combine(dir, "mcp.json");
+ var marker = new McpMarker("test", _ => Path.Combine(dir, "marker.json"));
+
+ // First registration records ownership + writes canonical entries.
+ JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+ // Re-running with no changes is a no-op (idempotent).
+ var again = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+ await Assert.That(again).IsEqualTo(JsonMcpConfigWriter.Change.Unchanged);
+
+ // Simulate a stale owned entry (still command "kcap", so still kcap-owned).
+ var root = (JsonObject)JsonNode.Parse(File.ReadAllText(path))!;
+ ((JsonObject)root["mcpServers"]!)["kcap-review"] =
+ new JsonObject { ["command"] = "kcap", ["args"] = new JsonArray { "mcp", "OLD-review" } };
+ File.WriteAllText(path, root.ToJsonString());
+
+ // Re-register heals it back to canonical.
+ var change = JsonMcpConfigWriter.Register(path, KcapMcpServers.All, McpConfigShape.Standard, null, marker);
+ await Assert.That(change).IsEqualTo(JsonMcpConfigWriter.Change.Updated);
+ var review = (JsonObject)((JsonObject)JsonNode.Parse(File.ReadAllText(path))!["mcpServers"]!)["kcap-review"]!;
+ var args = review["args"]!.AsArray().Select(n => (string)n!).ToArray();
+ await Assert.That(args).IsEquivalentTo(new[] { "mcp", "review" }); // healed
+ }
}
From ab95f4225dd2035b298db379efa9ef5cd0259525 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:35:11 -0400
Subject: [PATCH 07/10] =?UTF-8?q?test(mcp):=20anti-drift=20contract=20?=
=?UTF-8?q?=E2=80=94=20bundled=20configs=20match=20canonical=20list?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Mcp/McpCanonicalContractTests.cs | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 test/Capacitor.Cli.Tests.Unit/Mcp/McpCanonicalContractTests.cs
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpCanonicalContractTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpCanonicalContractTests.cs
new file mode 100644
index 0000000000..e5e513a194
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpCanonicalContractTests.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Nodes;
+using Capacitor.Cli.Core.Mcp;
+
+namespace Capacitor.Cli.Tests.Unit.Mcp;
+
+public class McpCanonicalContractTests {
+ // Resolve the repo's kcap/ dir relative to the test assembly (…/src/cli).
+ static string KcapDir() {
+ var d = new DirectoryInfo(AppContext.BaseDirectory);
+ for (; d is not null; d = d.Parent)
+ if (Directory.Exists(Path.Combine(d.FullName, "kcap")) &&
+ File.Exists(Path.Combine(d.FullName, "kcap", ".mcp.json")))
+ return Path.Combine(d.FullName, "kcap");
+ throw new DirectoryNotFoundException("kcap/ not found above test base dir");
+ }
+
+ static string[] Keys(string file) =>
+ [.. ((JsonObject)JsonNode.Parse(File.ReadAllText(Path.Combine(KcapDir(), file)))!["mcpServers"]!)
+ .Select(kv => kv.Key)];
+
+ [Test]
+ public async Task Bundled_claude_mcp_json_matches_the_canonical_list() {
+ await Assert.That(Keys(".mcp.json"))
+ .IsEquivalentTo(KcapMcpServers.All.Select(s => s.Name).ToArray());
+ }
+
+ [Test]
+ public async Task Bundled_codex_mcp_json_matches_the_codex_subset() {
+ await Assert.That(Keys(".codex-mcp.json"))
+ .IsEquivalentTo(KcapMcpServers.ForCodex.Select(s => s.Name).ToArray());
+ }
+
+ [Test]
+ public async Task Codex_subset_excludes_flows_but_keeps_memory() {
+ var names = KcapMcpServers.ForCodex.Select(s => s.Name).ToArray();
+ await Assert.That(names).DoesNotContain("kcap-flows");
+ await Assert.That(names).Contains("kcap-memory");
+ }
+}
From e3b3edbc413c74abb0ddd9234f981034cbcb91c6 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 13:23:52 -0400
Subject: [PATCH 08/10] =?UTF-8?q?fix(mcp):=20McpMarker=20=E2=80=94=20valid?=
=?UTF-8?q?ate=20marker=20config/harness,=20guard=20Owns=20on=20non-object?=
=?UTF-8?q?,=20detect=20.git=20worktrees?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three robustness fixes from code review:
- Record now stamps `harness` alongside `config`; ReadNames only trusts a
marker whose stored harness AND config match the request, so a
per-directory user-scope sidecar can no longer leak ownership across
configs/harnesses that happen to share a directory.
- Owns guards against a non-JsonObject entry (array/scalar) before indexing
"command", returning false instead of throwing.
- IsInsideRepo now treats `.git` as either a directory or a file, since
worktrees and submodules use a `.git` file pointing at the real gitdir.
Adds two tests: Owns_false_and_no_throw_for_nonobject_entry and
Owned_ignores_marker_recorded_for_a_different_config.
---
src/Capacitor.Cli.Core/Mcp/McpMarker.cs | 27 +++++++++++--------
.../Mcp/McpMarkerTests.cs | 23 ++++++++++++++++
2 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
index d6cd2bcaf0..37e4efe39c 100644
--- a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
+++ b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
@@ -26,18 +26,19 @@ public sealed class McpMarker(string harness, Func? markerPathFo
public bool Owns(string configPath, string name, JsonNode entry) {
if (!Owned(configPath).Contains(name)) return false;
- // Command is "kcap" (string) or ["kcap", ...] (OpenCode array).
- var cmd = entry["command"];
+ if (entry is not JsonObject obj) return false; // malformed/non-object entry → not ours; never throw
+ var cmd = obj["command"];
return cmd is JsonValue v && v.TryGetValue(out string? s) && s == KcapMcpServers.Command
|| cmd is JsonArray a && a.Count > 0 && a[0] is JsonValue fv && fv.TryGetValue(out string? fs) && fs == KcapMcpServers.Command;
}
public void Record(string configPath, IReadOnlyList names) {
var path = MarkerPath(configPath);
- var existing = ReadNames(path);
+ var existing = ReadNames(path, configPath);
foreach (var n in names) existing.Add(n);
var doc = new JsonObject {
["version"] = Version,
+ ["harness"] = harness,
["config"] = configPath,
["servers"] = new JsonArray(existing.Select(n => (JsonNode)n!).ToArray())
};
@@ -46,19 +47,22 @@ public void Record(string configPath, IReadOnlyList names) {
File.WriteAllText(path, doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
- public IEnumerable Owned(string configPath) => ReadNames(MarkerPath(configPath));
+ public IEnumerable Owned(string configPath) => ReadNames(MarkerPath(configPath), configPath);
public void Clear(string configPath) {
var p = MarkerPath(configPath);
try { if (File.Exists(p)) File.Delete(p); } catch { /* best-effort */ }
}
- static HashSet ReadNames(string markerPath) {
+ HashSet ReadNames(string markerPath, string configPath) {
try {
if (!File.Exists(markerPath)) return [];
- var root = JsonNode.Parse(File.ReadAllText(markerPath)) as JsonObject;
- var arr = root?["servers"] as JsonArray;
- return arr is null ? [] : [.. arr.Select(n => (string)n!)];
+ if (JsonNode.Parse(File.ReadAllText(markerPath)) is not JsonObject root) return [];
+ // A user-scope sidecar is per-directory and could be shared; only trust a marker
+ // that pertains to THIS harness + config path (else treat as not-ours → preserve).
+ if ((string?)root["harness"] != harness) return [];
+ if ((string?)root["config"] != configPath) return [];
+ return root["servers"] is JsonArray arr ? [.. arr.Select(n => (string)n!)] : [];
} catch { return []; }
}
@@ -76,9 +80,10 @@ string MarkerPath(string configPath) {
}
static bool IsInsideRepo(string dir) {
- var d = new DirectoryInfo(dir);
- for (var cur = d; cur is not null; cur = cur.Parent)
- if (Directory.Exists(Path.Combine(cur.FullName, ".git"))) return true;
+ for (var cur = new DirectoryInfo(dir); cur is not null; cur = cur.Parent) {
+ var git = Path.Combine(cur.FullName, ".git");
+ if (Directory.Exists(git) || File.Exists(git)) return true; // .git is a file in worktrees/submodules
+ }
return false;
}
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
index 94506cfe8a..a33b48216e 100644
--- a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
@@ -59,4 +59,27 @@ public async Task Clear_removes_the_marker() {
await Assert.That(File.Exists(markerFile)).IsFalse();
await Assert.That(marker.Owned(cfg)).IsEmpty();
}
+
+ [Test]
+ public async Task Owns_false_and_no_throw_for_nonobject_entry() {
+ var (marker, cfg, _) = NewMarker();
+ marker.Record(cfg, ["kcap-review"]);
+ JsonNode arrEntry = new JsonArray { "x" };
+ await Assert.That(marker.Owns(cfg, "kcap-review", arrEntry)).IsFalse(); // must not throw
+ JsonNode valEntry = JsonValue.Create("disabled")!;
+ await Assert.That(marker.Owns(cfg, "kcap-review", valEntry)).IsFalse();
+ }
+
+ [Test]
+ public async Task Owned_ignores_marker_recorded_for_a_different_config() {
+ var dir = Directory.CreateTempSubdirectory("kcap-marker-x-").FullName;
+ var shared = Path.Combine(dir, ".kcap-mcp-version");
+ var m = new McpMarker("test", _ => shared); // both configs resolve to the SAME sidecar (simulates per-dir collision)
+ var cfgA = Path.Combine(dir, "a.json");
+ var cfgB = Path.Combine(dir, "b.json");
+ m.Record(cfgA, ["kcap-review"]);
+ await Assert.That(m.Owned(cfgA)).Contains("kcap-review"); // A owns it
+ await Assert.That(m.Owned(cfgB)).IsEmpty(); // B must NOT inherit A's marker
+ await Assert.That(m.Owns(cfgB, "kcap-review", new JsonObject { ["command"] = "kcap" })).IsFalse(); // → preserved
+ }
}
From 40dc40f1c815c1afaa03c9b6f8cc95ff10056825 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 13:29:46 -0400
Subject: [PATCH 09/10] fix(mcp): normalize marker config identity via
Path.GetFullPath
McpMarker stored/compared the raw configPath while MarkerPath resolved
via Path.GetFullPath, so equivalent path forms (relative vs absolute,
./x vs x) for the same config file failed the "config" match and made
owned entries invisible to self-heal/unregister.
---
src/Capacitor.Cli.Core/Mcp/McpMarker.cs | 4 ++--
test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs | 11 +++++++++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
index 37e4efe39c..64479505ef 100644
--- a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
+++ b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
@@ -39,7 +39,7 @@ public void Record(string configPath, IReadOnlyList names) {
var doc = new JsonObject {
["version"] = Version,
["harness"] = harness,
- ["config"] = configPath,
+ ["config"] = Path.GetFullPath(configPath),
["servers"] = new JsonArray(existing.Select(n => (JsonNode)n!).ToArray())
};
var dir = Path.GetDirectoryName(path);
@@ -61,7 +61,7 @@ HashSet ReadNames(string markerPath, string configPath) {
// A user-scope sidecar is per-directory and could be shared; only trust a marker
// that pertains to THIS harness + config path (else treat as not-ours → preserve).
if ((string?)root["harness"] != harness) return [];
- if ((string?)root["config"] != configPath) return [];
+ if ((string?)root["config"] != Path.GetFullPath(configPath)) return [];
return root["servers"] is JsonArray arr ? [.. arr.Select(n => (string)n!)] : [];
} catch { return []; }
}
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
index a33b48216e..338cd5e5c3 100644
--- a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
@@ -82,4 +82,15 @@ public async Task Owned_ignores_marker_recorded_for_a_different_config() {
await Assert.That(m.Owned(cfgB)).IsEmpty(); // B must NOT inherit A's marker
await Assert.That(m.Owns(cfgB, "kcap-review", new JsonObject { ["command"] = "kcap" })).IsFalse(); // → preserved
}
+
+ [Test]
+ public async Task Owned_matches_across_equivalent_path_forms() {
+ var dir = Directory.CreateTempSubdirectory("kcap-marker-norm-").FullName;
+ var shared = Path.Combine(dir, ".kcap-mcp-version");
+ var m = new McpMarker("test", _ => shared);
+ var abs = Path.Combine(dir, "mcp.json");
+ var equiv = Path.Combine(dir, ".", "mcp.json"); // same file, non-canonical form
+ m.Record(abs, ["kcap-review"]);
+ await Assert.That(m.Owned(equiv)).Contains("kcap-review"); // equivalent path form still recognized
+ }
}
From 215932f77a2f57886fac5a7b2ddc127bc272e6f4 Mon Sep 17 00:00:00 2001
From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com>
Date: Mon, 6 Jul 2026 13:37:26 -0400
Subject: [PATCH 10/10] fix(mcp): per-config user-scope marker sidecar (no
same-dir record collision)
Include the config file name in the user-scope sidecar path so two
config files in the same directory get independent ownership markers
instead of overwriting each other's record (which orphaned kcap
entries).
---
src/Capacitor.Cli.Core/Mcp/McpMarker.cs | 4 +++-
.../Mcp/McpMarkerTests.cs | 15 +++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
index 64479505ef..fa549c532a 100644
--- a/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
+++ b/src/Capacitor.Cli.Core/Mcp/McpMarker.cs
@@ -73,7 +73,9 @@ string MarkerPath(string configPath) {
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var isUserScope = dir.StartsWith(home, StringComparison.Ordinal)
&& !IsInsideRepo(dir);
- if (isUserScope) return Path.Combine(dir, ".kcap-mcp-version");
+ // Per-config sidecar (include the config file name) so multiple configs sharing a
+ // directory never overwrite each other's ownership record.
+ if (isUserScope) return Path.Combine(dir, $".kcap-mcp-version-{Path.GetFileName(configPath)}");
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(configPath))))[..16].ToLowerInvariant();
return Path.Combine(home, ".kcap", "mcp-markers", $"{harness}-{hash}.json");
diff --git a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
index 338cd5e5c3..89e98f98f3 100644
--- a/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
+++ b/test/Capacitor.Cli.Tests.Unit/Mcp/McpMarkerTests.cs
@@ -93,4 +93,19 @@ public async Task Owned_matches_across_equivalent_path_forms() {
m.Record(abs, ["kcap-review"]);
await Assert.That(m.Owned(equiv)).Contains("kcap-review"); // equivalent path form still recognized
}
+
+ [Test]
+ public async Task Two_configs_in_same_dir_have_independent_ownership() {
+ var dir = Directory.CreateTempSubdirectory("kcap-marker-indep-").FullName;
+ var m = new McpMarker("test"); // real MarkerPath resolution (no override)
+ var a = Path.Combine(dir, "a.json");
+ var b = Path.Combine(dir, "b.json");
+ try {
+ m.Record(a, ["kcap-review"]);
+ await Assert.That(m.Owned(a)).Contains("kcap-review"); // a owns it
+ await Assert.That(m.Owned(b)).IsEmpty(); // b is independent of a
+ } finally {
+ m.Clear(a); // remove the central-state marker this test created under ~/.kcap
+ }
+ }
}