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 + } + } }