@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.
- package/kit/file-manifest.json +2 -2
- package/package.json +3 -3
- package/src/cli/index.js +1 -1
- package/src/core/manifest-verify.js +73 -6
- package/src/core/metrics.js +143 -0
- package/src/core/path-safety.js +30 -0
- package/src/core/sync.js +84 -2
- package/src/mcp-server/index.js +51 -12
package/kit/file-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"timestamp": "2026-05-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/core/path-safety.js
CHANGED
|
@@ -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 <
|
|
142
|
-
const slice =
|
|
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
|
}
|
package/src/mcp-server/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// kit-mcp server — exposes
|
|
1
|
+
// kit-mcp server — exposes 7 tools, each with action-based dispatch (or none).
|
|
2
2
|
//
|
|
3
|
-
// kit
|
|
4
|
-
// sync
|
|
5
|
-
// gates
|
|
6
|
-
// forensics
|
|
7
|
-
// install
|
|
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:
|
|
297
|
-
sync:
|
|
298
|
-
'reverse-sync':handleReverseSync,
|
|
299
|
-
gates:
|
|
300
|
-
forensics:
|
|
301
|
-
install:
|
|
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.
|