@luanpdd/kit-mcp 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.16.0",
3
- "timestamp": "2026-05-09T14:17:38.936Z",
2
+ "version": "1.18.0",
3
+ "timestamp": "2026-05-09T17:01:35.745Z",
4
4
  "files": {
5
5
  "COMANDOS.md": "d24ec61a6ec35db314cc5f2ae287bfb927b794789c8f1d558c55862f5e6534b2",
6
6
  "COMPATIBILITY.md": "794e336a87045cdf0161785b9a7a0975a49abbd80bdd816b8852251fcc8126ca",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,11 +49,11 @@
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.0.0",
51
51
  "commander": "^14.0.3",
52
- "open": "^11.0.0",
53
52
  "picocolors": "^1.1.1"
54
53
  },
55
54
  "optionalDependencies": {
56
55
  "@inquirer/prompts": "^8.4.2",
57
- "chokidar": "^5.0.0"
56
+ "chokidar": "^5.0.0",
57
+ "open": "^11.0.0"
58
58
  }
59
59
  }
package/src/cli/index.js CHANGED
@@ -30,7 +30,7 @@ import { installMcp, listInstallTargets } from '../mcp-server/install.js';
30
30
  import * as render from './render.js';
31
31
  import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
32
32
  import { readLock, lockPathFor } from '../ui/lockfile.js';
33
- import { checkUpgrade, getLocalVersion } from './upgrade-check.js';
33
+ import { checkUpgrade } from './upgrade-check.js';
34
34
  // PERF-16-04: ui/server.js, ui/wrapper.js, ui/browser.js are loaded LAZILY
35
35
  // inside the subcommand handlers that need them. See:
36
36
  // - maybeWrapForUi (gated on lockfile presence)
@@ -14,8 +14,39 @@ import path from 'node:path';
14
14
  import fs from 'node:fs/promises';
15
15
  import crypto from 'node:crypto';
16
16
 
17
+ // PERF-17-01: parallelize SHA256 hashing in batches of 16. Same pattern
18
+ // as Phase 88.01 sync.js. Hardcoded — env override is overengineering
19
+ // for verifyManifest (single hot path, not user-facing latency budget).
20
+ const BATCH_SIZE = 16;
21
+
22
+ // PERF-17-01: in-memory cache for verifyManifest. Same pattern as kit.js
23
+ // listKit cache (PERF-01). Watch triggers (file save → re-sync) call this
24
+ // back-to-back; the 2nd+ call within TTL hits cache and returns <5ms.
25
+ //
26
+ // Caching rules:
27
+ // - Only cache ok=true results. mismatches/missing → recompute every call
28
+ // so devs see fixes immediately (don't punish them for the slow path).
29
+ // - Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (test isolation + emergency dev escape).
30
+ // - Cache key is kitRoot — different roots are independent entries.
31
+ const VERIFY_CACHE_TTL_MS = 30_000;
32
+ const verifyManifestCache = new Map(); // kitRoot -> { value, ts }
33
+ const NO_CACHE_ENV = 'KIT_MCP_VERIFY_NO_CACHE';
34
+
35
+ /**
36
+ * Test/emergency helper — clears the cache. Exported for unit tests.
37
+ * Production code should never need this; use the env var instead.
38
+ */
39
+ export function clearVerifyManifestCache() { verifyManifestCache.clear(); }
40
+
17
41
  const SKIP_ENV = 'KIT_MCP_SKIP_MANIFEST_CHECK';
18
42
 
43
+ /**
44
+ * SEC-14-05: verify kit/file-manifest.json against actual file contents.
45
+ * PERF-17-01: hashes in Promise.all batches of 16 (was sequential pre-v1.17).
46
+ * Called by syncTo() in install path before any write — refuses to project a tampered kit.
47
+ * @param {string} kitRoot - absolute path to kit/ directory.
48
+ * @returns {Promise<{ok: boolean, skipped?: boolean, reason?: string, mismatches?: Array, missing?: string[]}>}
49
+ */
19
50
  export async function verifyManifest(kitRoot) {
20
51
  if (process.env[SKIP_ENV] === '1') {
21
52
  process.stderr.write(
@@ -24,6 +55,15 @@ export async function verifyManifest(kitRoot) {
24
55
  return { ok: true, skipped: true };
25
56
  }
26
57
 
58
+ // PERF-17-01: cache hit — repeated calls within TTL skip the I/O + hashing.
59
+ // Bypass via KIT_MCP_VERIFY_NO_CACHE=1 (tests + dev emergency escape).
60
+ if (process.env[NO_CACHE_ENV] !== '1') {
61
+ const cached = verifyManifestCache.get(kitRoot);
62
+ if (cached && Date.now() - cached.ts < VERIFY_CACHE_TTL_MS) {
63
+ return cached.value;
64
+ }
65
+ }
66
+
27
67
  const manifestPath = path.join(kitRoot, 'file-manifest.json');
28
68
  let manifest;
29
69
  try {
@@ -50,27 +90,54 @@ export async function verifyManifest(kitRoot) {
50
90
  const mismatches = [];
51
91
  const missing = [];
52
92
 
53
- for (const [rel, expected] of Object.entries(manifest.files)) {
93
+ const entries = Object.entries(manifest.files);
94
+
95
+ // Per-file check — returns { rel, status: 'ok'|'mismatch'|'missing', expected?, actual? }.
96
+ // Pure function (no side effects on shared arrays) so Promise.all in batches
97
+ // is safe — caller aggregates after each batch resolves.
98
+ const checkOne = async ([rel, expected]) => {
54
99
  const abs = path.join(kitRoot, rel);
55
100
  let buf;
56
101
  try {
57
102
  buf = await fs.readFile(abs);
58
103
  } catch {
59
- missing.push(rel);
60
- continue;
104
+ return { rel, status: 'missing' };
61
105
  }
62
106
  // Normalize CRLF→LF before hashing so manifest is platform-stable.
63
107
  // git checkout converts EOL on Windows but Linux CI checks out LF —
64
- // hashing raw bytes would diverge across platforms.
108
+ // hashing raw bytes would diverge across platforms. (PRESERVED from v1.15)
65
109
  const normalized = Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
66
110
  const actual = crypto.createHash('sha256').update(normalized).digest('hex');
67
111
  if (actual !== expected) {
68
- mismatches.push({ path: rel, expected: expected.slice(0, 16), actual: actual.slice(0, 16) });
112
+ return { rel, status: 'mismatch', expected, actual };
113
+ }
114
+ return { rel, status: 'ok' };
115
+ };
116
+
117
+ // Sequential batches — within a batch, Promise.all parallelizes hashing;
118
+ // between batches, await bounds max-in-flight at BATCH_SIZE (defensive
119
+ // against fd ulimit on large kits). Order of completion within a batch
120
+ // doesn't matter — aggregator below is order-independent.
121
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
122
+ const slice = entries.slice(i, i + BATCH_SIZE);
123
+ const results = await Promise.all(slice.map(checkOne));
124
+ for (const r of results) {
125
+ if (r.status === 'mismatch') {
126
+ mismatches.push({ path: r.rel, expected: r.expected.slice(0, 16), actual: r.actual.slice(0, 16) });
127
+ } else if (r.status === 'missing') {
128
+ missing.push(r.rel);
129
+ }
69
130
  }
70
131
  }
71
132
 
72
133
  if (mismatches.length === 0 && missing.length === 0) {
73
- return { ok: true };
134
+ const result = { ok: true };
135
+ // PERF-17-01: cache only ok=true. Mismatch/missing always recompute
136
+ // so dev fixing a tampered file sees the next sync recover immediately.
137
+ if (process.env[NO_CACHE_ENV] !== '1') {
138
+ verifyManifestCache.set(kitRoot, { value: result, ts: Date.now() });
139
+ }
140
+ return result;
74
141
  }
75
142
 
76
143
  // Build a concise reason — first 3 mismatches, plus counts.
@@ -0,0 +1,143 @@
1
+ // OBS-18-01 / OBS-18-02 — in-memory golden signals for kit-mcp server.
2
+ //
3
+ // Phase 94: Eat Your Own Dog Food. The skill `four-golden-signals` says any
4
+ // user-facing service worth its salt instruments Latency + Traffic + Errors
5
+ // + Saturation. The MCP server qualifies — every tool call is a request from
6
+ // an LLM client and tail latency / error rate are exactly the signals an
7
+ // operator wants when something feels off.
8
+ //
9
+ // Scope decisions (see .planning/phases/94-golden-signals-mcp-server/94-CONTEXT.md):
10
+ // - Zero dependencies. Map + array stdlib only — preserves the 6-deps budget
11
+ // that Phase 92.01 fought to maintain and that Phase 93.01 enforces in CI.
12
+ // - In-memory only. No file persistence, no socket export, no OTel SDK.
13
+ // kit-mcp is a developer tool launched on demand by an IDE; cross-process
14
+ // telemetry pipelines are explicit non-goals (see <deferred> block in
15
+ // 94-CONTEXT.md). A future phase can layer OTel on top of this API.
16
+ // - Bounded memory. Histograms cap at HISTOGRAM_CAP=1000 samples per tool
17
+ // with FIFO drop. At cap, p50/p95/p99 over the latest 1000 samples is
18
+ // more useful than an unbounded array that could grow for the lifetime
19
+ // of a long-lived MCP session.
20
+ // - Snapshot is read-only. Returns a fresh plain-object copy so callers
21
+ // can JSON.stringify it without exposing internal Map references.
22
+ //
23
+ // API surface (4 exports):
24
+ // incrementInvocation(tool, status) — counter++ keyed `${tool}:${status}`
25
+ // recordLatency(tool, ms) — push to histogram, FIFO at cap
26
+ // snapshot() — { counters, latency } plain object
27
+ // reset() — clear both maps; called on boot if
28
+ // KIT_MCP_METRICS_RESET=1
29
+ //
30
+ // Boot-time reset honors the env var by calling reset() at module load when
31
+ // the flag is set. This keeps the signal "fresh" for a probe in tests or for
32
+ // an operator who spawned the server with the flag for a clean comparison.
33
+
34
+ const HISTOGRAM_CAP = 1000;
35
+
36
+ const counters = new Map(); // key: `${tool}:${status}` → count (number)
37
+ const histograms = new Map(); // key: tool → number[] (length ≤ HISTOGRAM_CAP)
38
+
39
+ /**
40
+ * Increment the invocation counter for a tool/status pair.
41
+ *
42
+ * @param {string} tool Tool name as it appears in the MCP request payload.
43
+ * @param {'ok'|'error'} [status='ok'] Outcome of the dispatch.
44
+ * @returns {void}
45
+ */
46
+ export function incrementInvocation(tool, status = 'ok') {
47
+ if (typeof tool !== 'string' || tool.length === 0) return;
48
+ const key = `${tool}:${status}`;
49
+ counters.set(key, (counters.get(key) ?? 0) + 1);
50
+ }
51
+
52
+ /**
53
+ * Record an observed latency for a tool. Drops the oldest sample (FIFO) once
54
+ * the per-tool histogram reaches HISTOGRAM_CAP, keeping memory bounded across
55
+ * long-lived MCP sessions.
56
+ *
57
+ * @param {string} tool Tool name.
58
+ * @param {number} ms Elapsed wall-clock time in milliseconds.
59
+ * @returns {void}
60
+ */
61
+ export function recordLatency(tool, ms) {
62
+ if (typeof tool !== 'string' || tool.length === 0) return;
63
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
64
+ let arr = histograms.get(tool);
65
+ if (!arr) {
66
+ arr = [];
67
+ histograms.set(tool, arr);
68
+ }
69
+ arr.push(ms);
70
+ if (arr.length > HISTOGRAM_CAP) arr.shift(); // FIFO drop oldest sample
71
+ }
72
+
73
+ /**
74
+ * Compute a percentile over a sorted ascending array. Linear-interpolation
75
+ * variant matches the typical Prometheus / Datadog reading. For N≤1000
76
+ * (HISTOGRAM_CAP) the sort cost on snapshot is acceptable — snapshots are
77
+ * read on-demand by the metrics-snapshot tool, not on every dispatch.
78
+ *
79
+ * @param {number[]} sorted Ascending-sorted samples.
80
+ * @param {number} p Percentile in [0, 1].
81
+ * @returns {number}
82
+ */
83
+ function percentile(sorted, p) {
84
+ if (sorted.length === 0) return 0;
85
+ if (sorted.length === 1) return sorted[0];
86
+ const rank = p * (sorted.length - 1);
87
+ const lo = Math.floor(rank);
88
+ const hi = Math.ceil(rank);
89
+ if (lo === hi) return sorted[lo];
90
+ const frac = rank - lo;
91
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
92
+ }
93
+
94
+ /**
95
+ * Build a read-only snapshot of all metrics. Counters are returned as a plain
96
+ * object keyed `${tool}:${status}` → count. Latency is keyed by tool to a
97
+ * `{ p50, p95, p99, count }` triple so a single tool never appears split
98
+ * across status outcomes (latency observation point is a single line in the
99
+ * dispatcher, success and failure both record).
100
+ *
101
+ * @returns {{
102
+ * counters: Record<string, number>,
103
+ * latency: Record<string, { p50: number, p95: number, p99: number, count: number }>
104
+ * }}
105
+ */
106
+ export function snapshot() {
107
+ const out = { counters: {}, latency: {} };
108
+ for (const [key, val] of counters) out.counters[key] = val;
109
+ for (const [tool, samples] of histograms) {
110
+ if (samples.length === 0) continue;
111
+ const sorted = [...samples].sort((a, b) => a - b);
112
+ out.latency[tool] = {
113
+ p50: percentile(sorted, 0.50),
114
+ p95: percentile(sorted, 0.95),
115
+ p99: percentile(sorted, 0.99),
116
+ count: samples.length,
117
+ };
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Clear both counters and histograms. Used by tests and by the boot-time
124
+ * KIT_MCP_METRICS_RESET=1 path so an operator can probe a fresh window.
125
+ *
126
+ * @returns {void}
127
+ */
128
+ export function reset() {
129
+ counters.clear();
130
+ histograms.clear();
131
+ }
132
+
133
+ // Boot-time reset honors KIT_MCP_METRICS_RESET=1. We call reset() instead of
134
+ // merely skipping init because the maps are already empty at module load —
135
+ // the call is a no-op today but documents the contract for any future module
136
+ // that imports metrics.js after another module has already populated state.
137
+ if (process.env.KIT_MCP_METRICS_RESET === '1') {
138
+ reset();
139
+ }
140
+
141
+ // Exported for tests only — keeps the API surface explicit while letting unit
142
+ // tests assert on the FIFO behavior at the boundary.
143
+ export const __TEST_HISTOGRAM_CAP = HISTOGRAM_CAP;
@@ -32,6 +32,36 @@ import fs from 'node:fs/promises';
32
32
  // six regexes; one suffices.
33
33
  const SENTINEL = 'MCP sync requires projectRoot to be a git workspace';
34
34
 
35
+ /**
36
+ * SEC-14-03: validate that a `projectRoot` supplied via MCP message points to
37
+ * a real git workspace before any handler dispatches into sync.js / reverse-sync.js.
38
+ *
39
+ * Pure function — never throws. Returns a discriminated union so MCP handlers
40
+ * can wrap rejections in `{ error }` envelopes without try/catch boilerplate
41
+ * (matches handleSync/handleGates/handleForensics in src/mcp-server/index.js).
42
+ *
43
+ * Validation chain (each step short-circuits on rejection):
44
+ * 1. `projectRoot` is non-empty string (rejects nullish, empty, non-string types).
45
+ * 2. `path.resolve()` collapses `..` segments and produces an absolute path.
46
+ * 3. The resolved path exists and is a directory (UNC failures bubble up here).
47
+ * 4. A `.git` entry exists somewhere in the ancestor chain (file or directory —
48
+ * `git worktree` uses a file). Walk-up bounded by `path.dirname()` fixed point.
49
+ *
50
+ * The CLI does NOT call this — `bin/cli.js` trusts whoever invoked it (same
51
+ * trust model as Phase 79.01's gates.run guard). Only MCP-message-sourced paths
52
+ * need this check.
53
+ *
54
+ * Rejection reasons all embed the literal `"git workspace"` string — public
55
+ * contract relied on by test/unit/mcp-projectroot-guard.test.js and downstream
56
+ * MCP clients. Don't rephrase without coordinating callers.
57
+ *
58
+ * @param {unknown} projectRoot - the candidate path supplied by an MCP client.
59
+ * Expected to be an absolute filesystem path; any other shape is rejected.
60
+ * @returns {Promise<{ok: true, resolvedPath: string} | {ok: false, reason: string}>}
61
+ * On success, `resolvedPath` is the path-resolved absolute form of `projectRoot`
62
+ * (callers should use it instead of the raw input). On failure, `reason` is a
63
+ * human-readable string suitable for MCP `{error}` envelopes.
64
+ */
35
65
  export async function validateProjectRoot(projectRoot) {
36
66
  // Reject empty / nullish up-front. We require an explicit projectRoot from
37
67
  // MCP messages — falling back to `process.cwd()` of the MCP server would let
package/src/core/sync.js CHANGED
@@ -31,6 +31,40 @@ function resolveBatchSize() {
31
31
  return n;
32
32
  }
33
33
 
34
+ // PERF-17-02: opt-out of stat-based diff skip. Forces full sync (every op writes)
35
+ // for cleanup/recovery scenarios where target files may be subtly out of sync
36
+ // (manual edits, partial fs corruption) but pass the mtime+size diff heuristic.
37
+ function resolveForceFullSync() {
38
+ return process.env.KIT_MCP_FORCE_FULL_SYNC === '1';
39
+ }
40
+
41
+ /**
42
+ * Project the canonical kit/ into an IDE-specific layout (claude-code, cursor, etc.).
43
+ *
44
+ * Workflow:
45
+ * 1. SEC-14-05: verifyManifest(kitRoot) — refuses tampered kits (Phase 83+90).
46
+ * 2. Build ops[] (rules + agents + commands + skills + framework/hooks treeCopy).
47
+ * 3. PERF-17-02: stat-based diff filter — skip treeCopy ops whose target already
48
+ * matches source (mtime+size). Bypassed via KIT_MCP_FORCE_FULL_SYNC=1.
49
+ * 4. PERF-16-01: Promise.all batches=16 over writeOps (Phase 88.01).
50
+ *
51
+ * onProgress callback receives one event per op (written or skipped); skipped ops
52
+ * carry `skipped: true` for UI granularity.
53
+ *
54
+ * Stable API v1.0+ preserved: return shape unchanged. `written[]` lists all op
55
+ * paths (projected files), not just actually-written — semantics: "what's in the
56
+ * target tree after this call", not "what fs.writeFile ran".
57
+ *
58
+ * @param {string} targetId - registry target id (e.g. 'claude-code', 'cursor').
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.projectRoot=process.cwd()] - destination project root.
61
+ * @param {string} [opts.kitRoot] - canonical kit/ root (auto-resolved if absent).
62
+ * @param {'reference'|'copy'|'symlink'} [opts.mode='reference'] - projection mode.
63
+ * @param {boolean} [opts.dryRun=false] - skip all fs writes; ops still listed.
64
+ * @param {Function} [opts.onProgress] - per-op callback ({phase, current, total, label, skipped?}).
65
+ * @param {object} [opts.kit] - pre-loaded kit (skips listKit re-walk).
66
+ * @returns {Promise<{target, mode, projectRoot, kitRoot, written, dryRun}>}
67
+ */
34
68
  export async function syncTo(targetId, opts = {}) {
35
69
  const target = getTarget(targetId);
36
70
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
@@ -116,6 +150,51 @@ export async function syncTo(targetId, opts = {}) {
116
150
  let completed = 0;
117
151
  const total = ops.length;
118
152
 
153
+ // PERF-17-02: stat-based diff filter — skip ops whose target already matches source.
154
+ // Only applies to treeCopy ops (framework/hooks subtrees) — content ops (agents,
155
+ // commands, skills, rules) include `Generated by kit-mcp at ${ISO timestamp}` so
156
+ // they re-render every time and can't safely diff. treeCopy ops dominate wall
157
+ // time on large kits (327+ files), so this captures the PERF-17-02 win.
158
+ //
159
+ // Filter logic per op:
160
+ // - forceFullSync env set → never skip
161
+ // - !treeCopy (content op) → never skip
162
+ // - target stat fails (absent)→ never skip (must write)
163
+ // - src stat fails (defensive)→ never skip (let copy fail naturally)
164
+ // - target.size === src.size AND target.mtimeMs >= src.mtimeMs → SKIP
165
+ //
166
+ // Implementation: Promise.all over ops produces { op, skip } pairs. Skipped ops
167
+ // emit onProgress({ skipped: true }) and increment the same `completed` counter
168
+ // as written ops (so progress UI shows full ops.length total).
169
+ const forceFullSync = resolveForceFullSync();
170
+
171
+ const diffOne = async (op) => {
172
+ if (forceFullSync) return { op, skip: false };
173
+ if (!op.treeCopy) return { op, skip: false };
174
+ let targetStat;
175
+ try { targetStat = await fs.stat(op.path); }
176
+ catch { return { op, skip: false }; }
177
+ let srcStat;
178
+ try { srcStat = await fs.stat(op.srcAbs); }
179
+ catch { return { op, skip: false }; }
180
+ if (targetStat.size === srcStat.size && targetStat.mtimeMs >= srcStat.mtimeMs) {
181
+ return { op, skip: true };
182
+ }
183
+ return { op, skip: false };
184
+ };
185
+
186
+ // Stats are cheap — no batch limit needed (Promise.all over all ops is fine).
187
+ const diffResults = await Promise.all(ops.map(diffOne));
188
+ const writeOps = [];
189
+ for (const { op, skip } of diffResults) {
190
+ if (skip) {
191
+ completed += 1;
192
+ onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path), skipped: true });
193
+ } else {
194
+ writeOps.push(op);
195
+ }
196
+ }
197
+
119
198
  // Apply one op (mkdir + write or copy + onProgress).
120
199
  // Each op is independent: ops[] is built so writes don't share parent
121
200
  // directories that need ordering — mkdir({recursive:true}) is idempotent
@@ -129,17 +208,20 @@ export async function syncTo(targetId, opts = {}) {
129
208
  }
130
209
  // Counter increment is single-threaded by JS event loop semantics —
131
210
  // no torn reads even with 16 ops resolving in any order.
211
+ // (PERF-17-02: diff filter increments the same counter for skipped ops before
212
+ // this batch loop runs, so `current` in onProgress reflects total progress.)
132
213
  completed += 1;
133
214
  onProgress({ phase: op.kind, current: completed, total, label: path.basename(op.path) });
134
215
  };
135
216
 
217
+ // PERF-16-01 batched writes — now operating on writeOps (post-diff filter).
136
218
  // Sequential batches — within a batch, Promise.all parallelizes writes;
137
219
  // between batches, we await to bound max-in-flight at BATCH_SIZE. If any
138
220
  // op in a batch rejects, Promise.all rejects on first failure (matches
139
221
  // existing behavior — sync.js had no retry logic, so a single fs error
140
222
  // already aborted the install).
141
- for (let i = 0; i < ops.length; i += BATCH_SIZE) {
142
- const slice = ops.slice(i, i + BATCH_SIZE);
223
+ for (let i = 0; i < writeOps.length; i += BATCH_SIZE) {
224
+ const slice = writeOps.slice(i, i + BATCH_SIZE);
143
225
  await Promise.all(slice.map(applyOp));
144
226
  }
145
227
  }
@@ -1,10 +1,11 @@
1
- // kit-mcp server — exposes 5 tools, each with action-based dispatch.
1
+ // kit-mcp server — exposes 7 tools, each with action-based dispatch (or none).
2
2
  //
3
- // kit action: list-agents | list-commands | list-skills | get | search
4
- // sync action: targets | status | install | remove
5
- // gates action: list | get | for-stage
6
- // forensics action: collect | summarize | write-learnings | list-replays | record-replay | load-replay
7
- // install action: targets | install | dry-run (registers this MCP into an IDE)
3
+ // kit action: list-agents | list-commands | list-skills | get | search
4
+ // sync action: targets | status | install | remove
5
+ // gates action: list | get | for-stage
6
+ // forensics action: collect | summarize | write-learnings | list-replays | record-replay | load-replay
7
+ // install action: targets | install | dry-run (registers this MCP into an IDE)
8
+ // metrics-snapshot (parameterless) (OBS-18 four-golden-signals readout)
8
9
  //
9
10
  // Transport: stdio (MCP standard).
10
11
 
@@ -30,6 +31,7 @@ import { recordReplay, listReplays, loadReplay, annotateReplay } from '../core/r
30
31
  import { installMcp, listInstallTargets } from './install.js';
31
32
  import { ensureSidecar } from '../ui/auto-spawn.js';
32
33
  import { wrapProgressForUi } from '../ui/wrapper.js';
34
+ import { incrementInvocation, recordLatency, snapshot as metricsSnapshot } from '../core/metrics.js';
33
35
 
34
36
  const TOOLS = [
35
37
  {
@@ -130,6 +132,17 @@ const TOOLS = [
130
132
  required: ['action'],
131
133
  },
132
134
  },
135
+ {
136
+ // OBS-18 (Phase 94.01): expose four-golden-signals data for the MCP server itself.
137
+ // Read-only (no auth needed beyond the underlying transport): returns counters
138
+ // keyed `${tool}:${status}` and per-tool latency p50/p95/p99/count.
139
+ name: 'metrics-snapshot',
140
+ description: 'Read in-memory golden-signals metrics for this MCP server (counters + latency p50/p95/p99 per tool).',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {},
144
+ },
145
+ },
133
146
  ];
134
147
 
135
148
  // DRIFT-13-03: read version from package.json at module load (NOT inside
@@ -292,13 +305,21 @@ async function handleInstall(args) {
292
305
  }
293
306
  }
294
307
 
308
+ // OBS-18 (Phase 94.01): metrics-snapshot is parameterless and read-only.
309
+ // Returns the live snapshot synchronously — no auth, no projectRoot guard
310
+ // (no disk reads, no shell). Wraps in an async fn for handler-API uniformity.
311
+ async function handleMetricsSnapshot() {
312
+ return metricsSnapshot();
313
+ }
314
+
295
315
  const HANDLERS = {
296
- kit: handleKit,
297
- sync: handleSync,
298
- 'reverse-sync':handleReverseSync,
299
- gates: handleGates,
300
- forensics: handleForensics,
301
- install: handleInstall,
316
+ kit: handleKit,
317
+ sync: handleSync,
318
+ 'reverse-sync': handleReverseSync,
319
+ gates: handleGates,
320
+ forensics: handleForensics,
321
+ install: handleInstall,
322
+ 'metrics-snapshot': handleMetricsSnapshot,
302
323
  };
303
324
 
304
325
  function slim(x) {
@@ -330,12 +351,30 @@ export async function createServer() {
330
351
  const { name, arguments: args } = req.params;
331
352
  const handler = HANDLERS[name];
332
353
  if (!handler) {
354
+ // OBS-18 (Phase 94.01): unknown-tool path counts as an error against
355
+ // the unknown name itself — useful signal if a client is mis-spelling
356
+ // a tool name in production. No latency observation (handler never ran).
357
+ incrementInvocation(name || 'unknown', 'error');
333
358
  return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], isError: true };
334
359
  }
360
+ // OBS-18 (Phase 94.01): timestamp the dispatch boundary. The four-golden-signals
361
+ // skill cares about the *user-facing* latency, which for the MCP server is the
362
+ // time from request receipt (we are inside the SDK callback) to the JSON envelope
363
+ // being ready. Date.now() is sub-millisecond-cheap and aligns with the bucket
364
+ // granularity we report (50/100/250/500ms thresholds in CONTEXT.md).
365
+ const start = Date.now();
335
366
  try {
336
367
  const result = await handler(args ?? {});
368
+ recordLatency(name, Date.now() - start);
369
+ incrementInvocation(name, 'ok');
337
370
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
338
371
  } catch (e) {
372
+ // OBS-18: still record latency on the error path — half the value of a
373
+ // latency histogram is catching tail-latency-then-fail patterns. Status
374
+ // 'error' covers any thrown exception, including Phase 79.01 gates guard
375
+ // and the validateProjectRoot rejection (Phase 83.01).
376
+ recordLatency(name, Date.now() - start);
377
+ incrementInvocation(name, 'error');
339
378
  // SEC-14-06: full stack stays in stderr for operator debug; client envelope is sanitized.
340
379
  // sanitizeMcpError redacts secrets/paths from e.message, preserves e.code (Phase 83
341
380
  // EMANIFESTMISMATCH invariant), and emits NO stack field.