@cookiecrumbs-eu/mcp 0.7.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.
Files changed (62) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +186 -0
  3. package/dist/cli/src/api.js +192 -0
  4. package/dist/cli/src/auth.js +106 -0
  5. package/dist/cli/src/commands/_shared.js +78 -0
  6. package/dist/cli/src/commands/alerts.js +85 -0
  7. package/dist/cli/src/commands/auth.js +92 -0
  8. package/dist/cli/src/commands/declaration.js +45 -0
  9. package/dist/cli/src/commands/diff.js +26 -0
  10. package/dist/cli/src/commands/domains.js +44 -0
  11. package/dist/cli/src/commands/export.js +136 -0
  12. package/dist/cli/src/commands/init.js +134 -0
  13. package/dist/cli/src/commands/install.js +77 -0
  14. package/dist/cli/src/commands/issues.js +61 -0
  15. package/dist/cli/src/commands/link.js +41 -0
  16. package/dist/cli/src/commands/logs.js +98 -0
  17. package/dist/cli/src/commands/open.js +45 -0
  18. package/dist/cli/src/commands/pull.js +89 -0
  19. package/dist/cli/src/commands/push.js +110 -0
  20. package/dist/cli/src/commands/scan.js +94 -0
  21. package/dist/cli/src/commands/schedule.js +97 -0
  22. package/dist/cli/src/commands/services.js +143 -0
  23. package/dist/cli/src/commands/sites.js +111 -0
  24. package/dist/cli/src/commands/status.js +90 -0
  25. package/dist/cli/src/commands/templates.js +133 -0
  26. package/dist/cli/src/commands/tokens.js +50 -0
  27. package/dist/cli/src/commands/usage.js +41 -0
  28. package/dist/cli/src/commands/versions.js +95 -0
  29. package/dist/cli/src/commands/webhooks.js +164 -0
  30. package/dist/cli/src/configpkg.js +10 -0
  31. package/dist/cli/src/diff.js +63 -0
  32. package/dist/cli/src/errors.js +20 -0
  33. package/dist/cli/src/frameworks.js +141 -0
  34. package/dist/cli/src/index.js +100 -0
  35. package/dist/cli/src/jobs.js +59 -0
  36. package/dist/cli/src/merge.js +38 -0
  37. package/dist/cli/src/output.js +112 -0
  38. package/dist/cli/src/project.js +269 -0
  39. package/dist/cli/src/util.js +122 -0
  40. package/dist/config/rules_reference.json +569 -0
  41. package/dist/config/src/canon.js +36 -0
  42. package/dist/config/src/declaration.js +38 -0
  43. package/dist/config/src/defaults.js +804 -0
  44. package/dist/config/src/export.js +130 -0
  45. package/dist/config/src/index.js +16 -0
  46. package/dist/config/src/lint.js +139 -0
  47. package/dist/config/src/regimes.js +62 -0
  48. package/dist/config/src/rules.js +90 -0
  49. package/dist/config/src/schema.js +323 -0
  50. package/dist/config/src/theme.js +147 -0
  51. package/dist/config/src/verify.js +51 -0
  52. package/dist/config/src/webhooks.js +309 -0
  53. package/dist/mcp/src/auth.js +40 -0
  54. package/dist/mcp/src/client.js +44 -0
  55. package/dist/mcp/src/diff.js +134 -0
  56. package/dist/mcp/src/index.js +25 -0
  57. package/dist/mcp/src/matrix.js +106 -0
  58. package/dist/mcp/src/server.js +171 -0
  59. package/dist/mcp/src/shared.js +147 -0
  60. package/dist/mcp/src/tools-config.js +943 -0
  61. package/dist/mcp/src/tools.js +650 -0
  62. package/package.json +66 -0
@@ -0,0 +1,309 @@
1
+ // Standard Webhooks (https://www.standardwebhooks.com) — the one implementation shared by the
2
+ // cc-worker dispatcher, the CLI (`cookiecrumbs verify-webhook`), the sample receiver in
3
+ // docs/platform/samples/webhook-receiver.mjs and the tests.
4
+ //
5
+ // webhook-id: the delivery id (public.webhook_deliveries.id)
6
+ // webhook-timestamp: unix seconds
7
+ // webhook-signature: `v1,<base64>` (space separated when several keys are valid)
8
+ //
9
+ // The signature is HMAC-SHA256 over `${id}.${timestamp}.${body}`, keyed with the *raw bytes* of the
10
+ // secret: a `whsec_`-prefixed secret is the base64 of those bytes, anything else is used as UTF-8.
11
+ //
12
+ // No imports: SHA-256, HMAC and base64 are implemented here so the module runs unchanged in Node,
13
+ // Deno, the edge runtime and the browser, and so a customer can copy the verifier into any project.
14
+ export const WEBHOOK_SIGNATURE_VERSION = "v1";
15
+ /** Standard Webhooks recommends five minutes of clock tolerance. */
16
+ export const WEBHOOK_TOLERANCE_SECONDS = 300;
17
+ export const WEBHOOK_SECRET_PREFIX = "whsec_";
18
+ export const WEBHOOK_ID_HEADER = "webhook-id";
19
+ export const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
20
+ export const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
21
+ // ---------------------------------------------------------------------------
22
+ // SHA-256
23
+ // ---------------------------------------------------------------------------
24
+ const K = new Uint32Array([
25
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
26
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
27
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
28
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
29
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
30
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
31
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
32
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
33
+ ]);
34
+ const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
35
+ /** SHA-256 of `data`, as 32 bytes. */
36
+ export function sha256(data) {
37
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
38
+ const len = data.length;
39
+ const padded = new Uint8Array((((len + 8) >> 6) + 1) * 64);
40
+ padded.set(data);
41
+ padded[len] = 0x80;
42
+ const view = new DataView(padded.buffer);
43
+ // length in bits, as a 64-bit big-endian number
44
+ view.setUint32(padded.length - 8, Math.floor(len / 0x20000000) >>> 0);
45
+ view.setUint32(padded.length - 4, ((len % 0x20000000) * 8) >>> 0);
46
+ const w = new Uint32Array(64);
47
+ for (let off = 0; off < padded.length; off += 64) {
48
+ for (let i = 0; i < 16; i++)
49
+ w[i] = view.getUint32(off + i * 4);
50
+ for (let i = 16; i < 64; i++) {
51
+ const x = w[i - 15];
52
+ const y = w[i - 2];
53
+ const s0 = (rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)) >>> 0;
54
+ const s1 = (rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10)) >>> 0;
55
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
56
+ }
57
+ let a = h[0];
58
+ let b = h[1];
59
+ let c = h[2];
60
+ let d = h[3];
61
+ let e = h[4];
62
+ let f = h[5];
63
+ let g = h[6];
64
+ let hh = h[7];
65
+ for (let i = 0; i < 64; i++) {
66
+ const s1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0;
67
+ const ch = ((e & f) ^ (~e & g)) >>> 0;
68
+ const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
69
+ const s0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0;
70
+ const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0;
71
+ const t2 = (s0 + maj) >>> 0;
72
+ hh = g;
73
+ g = f;
74
+ f = e;
75
+ e = (d + t1) >>> 0;
76
+ d = c;
77
+ c = b;
78
+ b = a;
79
+ a = (t1 + t2) >>> 0;
80
+ }
81
+ h[0] = (h[0] + a) >>> 0;
82
+ h[1] = (h[1] + b) >>> 0;
83
+ h[2] = (h[2] + c) >>> 0;
84
+ h[3] = (h[3] + d) >>> 0;
85
+ h[4] = (h[4] + e) >>> 0;
86
+ h[5] = (h[5] + f) >>> 0;
87
+ h[6] = (h[6] + g) >>> 0;
88
+ h[7] = (h[7] + hh) >>> 0;
89
+ }
90
+ const out = new Uint8Array(32);
91
+ const ov = new DataView(out.buffer);
92
+ for (let i = 0; i < 8; i++)
93
+ ov.setUint32(i * 4, h[i]);
94
+ return out;
95
+ }
96
+ /** HMAC-SHA256 (RFC 2104), as 32 bytes. */
97
+ export function hmacSha256(key, message) {
98
+ const block = new Uint8Array(64);
99
+ if (key.length > 64)
100
+ block.set(sha256(key));
101
+ else
102
+ block.set(key);
103
+ const inner = new Uint8Array(64 + message.length);
104
+ const outer = new Uint8Array(64 + 32);
105
+ for (let i = 0; i < 64; i++) {
106
+ inner[i] = block[i] ^ 0x36;
107
+ outer[i] = block[i] ^ 0x5c;
108
+ }
109
+ inner.set(message, 64);
110
+ outer.set(sha256(inner), 64);
111
+ return sha256(outer);
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // base64 / utf-8 / hex helpers (no Buffer, no atob)
115
+ // ---------------------------------------------------------------------------
116
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
117
+ export function toBase64(bytes) {
118
+ let out = "";
119
+ for (let i = 0; i < bytes.length; i += 3) {
120
+ const b0 = bytes[i];
121
+ const b1 = bytes[i + 1];
122
+ const b2 = bytes[i + 2];
123
+ out += B64[b0 >> 2];
124
+ out += B64[((b0 & 3) << 4) | ((b1 ?? 0) >> 4)];
125
+ out += b1 === undefined ? "=" : B64[((b1 & 15) << 2) | ((b2 ?? 0) >> 6)];
126
+ out += b2 === undefined ? "=" : B64[b2 & 63];
127
+ }
128
+ return out;
129
+ }
130
+ export function fromBase64(text) {
131
+ const clean = text.replace(/[\s=]/g, "").replace(/-/g, "+").replace(/_/g, "/");
132
+ const out = new Uint8Array(Math.floor((clean.length * 6) / 8));
133
+ let acc = 0;
134
+ let bits = 0;
135
+ let n = 0;
136
+ for (const ch of clean) {
137
+ const v = B64.indexOf(ch);
138
+ if (v < 0)
139
+ throw new Error("invalid base64");
140
+ acc = (acc << 6) | v;
141
+ bits += 6;
142
+ if (bits >= 8) {
143
+ bits -= 8;
144
+ out[n++] = (acc >> bits) & 0xff;
145
+ }
146
+ }
147
+ return out.subarray(0, n);
148
+ }
149
+ export const toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
150
+ const utf8 = (s) => new TextEncoder().encode(s);
151
+ /** Length-independent, value-constant-time comparison of two ASCII strings. */
152
+ export function timingSafeEqual(a, b) {
153
+ const x = utf8(a);
154
+ const y = utf8(b);
155
+ let diff = x.length ^ y.length;
156
+ const n = Math.max(x.length, y.length);
157
+ for (let i = 0; i < n; i++)
158
+ diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
159
+ return diff === 0;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Secrets
163
+ // ---------------------------------------------------------------------------
164
+ /** The HMAC key of a secret: base64 body for `whsec_…`, raw UTF-8 otherwise. */
165
+ export function webhookSecretKey(secret) {
166
+ const s = (secret ?? "").trim();
167
+ if (!s)
168
+ throw new Error("webhook secret is empty");
169
+ return s.startsWith(WEBHOOK_SECRET_PREFIX) ? fromBase64(s.slice(WEBHOOK_SECRET_PREFIX.length)) : utf8(s);
170
+ }
171
+ /**
172
+ * A new `whsec_` + base64(32 random bytes) secret. Uses the platform CSPRNG; throws when the runtime
173
+ * has none (the database generates the real secrets — this exists for tests and local receivers).
174
+ */
175
+ export function generateWebhookSecret(bytes = 32) {
176
+ const c = globalThis.crypto;
177
+ if (!c?.getRandomValues)
178
+ throw new Error("no CSPRNG available in this runtime");
179
+ return WEBHOOK_SECRET_PREFIX + toBase64(c.getRandomValues(new Uint8Array(bytes)));
180
+ }
181
+ function unixSeconds(t) {
182
+ if (t === undefined || t === null || t === "")
183
+ return String(Math.floor(Date.now() / 1000));
184
+ if (t instanceof Date)
185
+ return String(Math.floor(t.getTime() / 1000));
186
+ const n = typeof t === "number" ? t : Number(t);
187
+ if (!Number.isFinite(n))
188
+ throw new Error(`invalid timestamp: ${String(t)}`);
189
+ // Anything past the year 33658 in seconds is really milliseconds.
190
+ return String(Math.floor(n > 1e12 ? n / 1000 : n));
191
+ }
192
+ export function signWebhook(input) {
193
+ const id = String(input.id ?? "");
194
+ if (!id)
195
+ throw new Error("webhook id is required");
196
+ const timestamp = unixSeconds(input.timestamp);
197
+ const body = input.body ?? "";
198
+ const signedContent = `${id}.${timestamp}.${body}`;
199
+ const mac = hmacSha256(webhookSecretKey(input.secret), utf8(signedContent));
200
+ const signature = `${WEBHOOK_SIGNATURE_VERSION},${toBase64(mac)}`;
201
+ return {
202
+ id,
203
+ timestamp,
204
+ signature,
205
+ signedContent,
206
+ headers: {
207
+ "content-type": "application/json; charset=utf-8",
208
+ [WEBHOOK_ID_HEADER]: id,
209
+ [WEBHOOK_TIMESTAMP_HEADER]: timestamp,
210
+ [WEBHOOK_SIGNATURE_HEADER]: signature,
211
+ },
212
+ };
213
+ }
214
+ function headerValue(headers, name) {
215
+ if (!headers)
216
+ return null;
217
+ const anyH = headers;
218
+ if (typeof anyH.get === "function") {
219
+ const v = anyH.get(name);
220
+ return v == null ? null : String(v);
221
+ }
222
+ const rec = headers;
223
+ for (const key of Object.keys(rec)) {
224
+ if (key.toLowerCase() !== name)
225
+ continue;
226
+ const v = rec[key];
227
+ if (v == null)
228
+ return null;
229
+ return Array.isArray(v) ? (v.length ? String(v[0]) : null) : String(v);
230
+ }
231
+ return null;
232
+ }
233
+ /**
234
+ * Verifies a Standard Webhooks request. Returns a result object rather than throwing so a receiver
235
+ * can log the reason and answer 400 without a try/catch.
236
+ */
237
+ export function verifyWebhook(input) {
238
+ const id = headerValue(input.headers, WEBHOOK_ID_HEADER);
239
+ const ts = headerValue(input.headers, WEBHOOK_TIMESTAMP_HEADER);
240
+ const sigHeader = headerValue(input.headers, WEBHOOK_SIGNATURE_HEADER);
241
+ if (!id)
242
+ return { ok: false, reason: "missing_id", message: `No ${WEBHOOK_ID_HEADER} header.` };
243
+ if (!ts)
244
+ return { ok: false, reason: "missing_timestamp", message: `No ${WEBHOOK_TIMESTAMP_HEADER} header.` };
245
+ if (!sigHeader)
246
+ return { ok: false, reason: "missing_signature", message: `No ${WEBHOOK_SIGNATURE_HEADER} header.` };
247
+ const seconds = Number(ts);
248
+ if (!Number.isFinite(seconds) || !/^\d{1,15}$/.test(ts.trim())) {
249
+ return { ok: false, reason: "invalid_timestamp", message: `${WEBHOOK_TIMESTAMP_HEADER} must be unix seconds.` };
250
+ }
251
+ const tolerance = input.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
252
+ if (tolerance > 0) {
253
+ const nowSeconds = Math.floor((input.now ?? Date.now()) / 1000);
254
+ if (seconds < nowSeconds - tolerance) {
255
+ return { ok: false, reason: "timestamp_too_old", message: `Timestamp is ${nowSeconds - seconds} s old (tolerance ${tolerance} s).` };
256
+ }
257
+ if (seconds > nowSeconds + tolerance) {
258
+ return { ok: false, reason: "timestamp_too_new", message: `Timestamp is ${seconds - nowSeconds} s in the future (tolerance ${tolerance} s).` };
259
+ }
260
+ }
261
+ let expected;
262
+ try {
263
+ const mac = hmacSha256(webhookSecretKey(input.secret), utf8(`${id}.${ts}.${input.body ?? ""}`));
264
+ expected = toBase64(mac);
265
+ }
266
+ catch (e) {
267
+ return { ok: false, reason: "invalid_secret", message: e.message };
268
+ }
269
+ const parts = sigHeader.split(/\s+/).filter(Boolean);
270
+ let sawV1 = false;
271
+ let matched = false;
272
+ for (const part of parts) {
273
+ const comma = part.indexOf(",");
274
+ if (comma < 0)
275
+ continue;
276
+ if (part.slice(0, comma) !== WEBHOOK_SIGNATURE_VERSION)
277
+ continue;
278
+ sawV1 = true;
279
+ // No early exit: every v1 candidate is compared so the timing does not leak which one matched.
280
+ if (timingSafeEqual(part.slice(comma + 1), expected))
281
+ matched = true;
282
+ }
283
+ if (!sawV1)
284
+ return { ok: false, reason: "no_v1_signature", message: `${WEBHOOK_SIGNATURE_HEADER} carries no v1 signature.` };
285
+ if (!matched)
286
+ return { ok: false, reason: "signature_mismatch", message: "The v1 signature does not match the body." };
287
+ return { ok: true, id, timestamp: seconds, signature: `${WEBHOOK_SIGNATURE_VERSION},${expected}` };
288
+ }
289
+ /** The 12 CookieCrumbs event types (mirrors private.webhook_event_types() in migration 0036). */
290
+ export const WEBHOOK_EVENT_TYPES = [
291
+ "scan.completed",
292
+ "scan.failed",
293
+ "tracker.new",
294
+ "issue.opened",
295
+ "issue.resolved",
296
+ "alert.raised",
297
+ "config.published",
298
+ "version.promoted",
299
+ "export.completed",
300
+ "declaration.updated",
301
+ "install.broken",
302
+ "plan.changed",
303
+ ];
304
+ export function isWebhookEnvelope(value) {
305
+ if (!value || typeof value !== "object")
306
+ return false;
307
+ const v = value;
308
+ return typeof v.id === "string" && typeof v.type === "string" && typeof v.created_at === "string" && typeof v.org_id === "string" && "data" in v;
309
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Where the token comes from.
3
+ *
4
+ * `COOKIECRUMBS_TOKEN` wins; otherwise we read the CLI's stored credentials
5
+ * (`%APPDATA%/cookiecrumbs/credentials.json` on Windows, `~/.config/cookiecrumbs/…`
6
+ * elsewhere) through the CLI's own `resolveToken()` — one credential store, not two.
7
+ *
8
+ * There is deliberately **no** device flow here: a stdio MCP server has no terminal to print
9
+ * a user code to and no browser to open, so an unauthenticated start-up returns instructions
10
+ * instead of blocking on a poll loop.
11
+ */
12
+ import { credentialsPath, resolveToken } from "../../cli/src/auth.js";
13
+ import { DEFAULT_API } from "./client.js";
14
+ export function resolveCredentials(env = process.env) {
15
+ const { token, source, creds } = resolveToken();
16
+ const api = (env.COOKIECRUMBS_API ?? creds?.api ?? DEFAULT_API).trim();
17
+ return { token, source, api, credentials: creds ?? null };
18
+ }
19
+ export const LOGIN_INSTRUCTIONS = [
20
+ 'No CookieCrumbs API token is available, so this server cannot talk to the API yet.',
21
+ '',
22
+ 'Pick one of these, then restart the MCP server so it can re-read the token:',
23
+ '',
24
+ ' 1. Log in once with the CLI (opens the dashboard device page in your browser):',
25
+ '',
26
+ ' npx cookiecrumbs login',
27
+ '',
28
+ ` The token is stored at ${'${credentials}'} and this server reads it from there.`,
29
+ '',
30
+ ' 2. Or create a machine token in the dashboard under',
31
+ ' Workspace → Developers → MCP and CLI, and put it in the environment:',
32
+ '',
33
+ ' COOKIECRUMBS_TOKEN=cc_live_…',
34
+ '',
35
+ 'A device login cannot be run from inside this server: stdio has no terminal to show the',
36
+ 'user code on and no browser to open.',
37
+ ].join('\n');
38
+ export function loginInstructions() {
39
+ return LOGIN_INSTRUCTIONS.replace('${credentials}', credentialsPath());
40
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The REST client the MCP tools talk through.
3
+ *
4
+ * It is the CLI's `Api` (packages/cli/src/api.ts) — same typed routes, same RFC 9457
5
+ * problem+json mapping, same `X-Request-Id` — with one difference: the
6
+ * `X-CookieCrumbs-Client` header reads `mcp/<client>` instead of `cli/<version>`, so the
7
+ * gateway writes `channel='mcp'` and the audit rows read "via MCP".
8
+ *
9
+ * The client name is read live on every request: the process may learn it from the MCP
10
+ * `initialize` params after start-up.
11
+ */
12
+ import { Api } from "../../cli/src/api.js";
13
+ import { CliError } from "../../cli/src/errors.js";
14
+ export const DEFAULT_API = 'https://api.cookiecrumbs.eu';
15
+ /** MCP client identifiers end up in audit rows: keep them short and boring. */
16
+ export function sanitiseClient(raw) {
17
+ const s = (raw ?? '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
18
+ return s.slice(0, 40) || 'unknown';
19
+ }
20
+ export class McpApi extends Api {
21
+ constructor(base, token, clientName) {
22
+ super(base, token, '0');
23
+ Object.defineProperty(this, 'client', {
24
+ configurable: true,
25
+ enumerable: true,
26
+ get: () => `mcp/${sanitiseClient(clientName())}`,
27
+ });
28
+ }
29
+ }
30
+ export { CliError };
31
+ /** Extra routes the MCP server needs that the CLI client does not expose yet. */
32
+ export function servicesUrl(site) {
33
+ return `/sites/${site}/services`;
34
+ }
35
+ /**
36
+ * `PATCH /v1/services/:id` and `GET /v1/sites/:id/analytics/daily` are phase-6 REST additions
37
+ * owned by other tracks. When they are not deployed yet the gateway answers 404/405; we turn
38
+ * that into a message the agent can act on instead of a raw problem document.
39
+ */
40
+ export function isRouteMissing(err) {
41
+ if (!(err instanceof CliError))
42
+ return false;
43
+ return (err.status === 404 || err.status === 405) && /not.?found|no.?route|method/i.test(err.detail ?? '');
44
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Unified diff rendering for the two-step write tools.
3
+ *
4
+ * Every write tool with `confirm:false` must show the agent exactly what would change, and a
5
+ * dotted-path list is not enough to review a banner configuration — so we render a real
6
+ * unified diff over a deterministic pretty-printed JSON serialisation (object keys sorted,
7
+ * arrays left in order because array order is meaningful in a BannerConfig).
8
+ */
9
+ /** Deterministic pretty JSON: keys sorted at every level, two-space indent, no trailing space. */
10
+ export function stableJson(value, indent = 2) {
11
+ return JSON.stringify(sortDeep(value), null, indent);
12
+ }
13
+ function sortDeep(value) {
14
+ if (Array.isArray(value))
15
+ return value.map(sortDeep);
16
+ if (value && typeof value === 'object') {
17
+ const out = {};
18
+ for (const k of Object.keys(value).sort())
19
+ out[k] = sortDeep(value[k]);
20
+ return out;
21
+ }
22
+ return value;
23
+ }
24
+ /** Longest common subsequence table over two line arrays (configs are small; O(n·m) is fine). */
25
+ function lcs(a, b) {
26
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
27
+ for (let i = a.length - 1; i >= 0; i--) {
28
+ for (let j = b.length - 1; j >= 0; j--) {
29
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
30
+ }
31
+ }
32
+ return table;
33
+ }
34
+ export function diffLines(a, b) {
35
+ const table = lcs(a, b);
36
+ const ops = [];
37
+ let i = 0;
38
+ let j = 0;
39
+ while (i < a.length && j < b.length) {
40
+ if (a[i] === b[j]) {
41
+ ops.push({ op: ' ', line: a[i] });
42
+ i++;
43
+ j++;
44
+ }
45
+ else if (table[i + 1][j] >= table[i][j + 1]) {
46
+ ops.push({ op: '-', line: a[i] });
47
+ i++;
48
+ }
49
+ else {
50
+ ops.push({ op: '+', line: b[j] });
51
+ j++;
52
+ }
53
+ }
54
+ while (i < a.length)
55
+ ops.push({ op: '-', line: a[i++] });
56
+ while (j < b.length)
57
+ ops.push({ op: '+', line: b[j++] });
58
+ return ops;
59
+ }
60
+ /**
61
+ * A unified diff with `context` lines of context. Returns '' when the two sides are identical,
62
+ * so callers can say "nothing would change" instead of printing an empty diff.
63
+ */
64
+ export function unifiedDiff(before, after, opts = {}) {
65
+ const context = opts.context ?? 3;
66
+ const a = before.split('\n');
67
+ const b = after.split('\n');
68
+ const ops = diffLines(a, b);
69
+ if (!ops.some((o) => o.op !== ' '))
70
+ return '';
71
+ // Mark which context lines to keep around each change.
72
+ const keep = new Array(ops.length).fill(false);
73
+ ops.forEach((o, idx) => {
74
+ if (o.op === ' ')
75
+ return;
76
+ for (let k = Math.max(0, idx - context); k <= Math.min(ops.length - 1, idx + context); k++)
77
+ keep[k] = true;
78
+ });
79
+ const hunks = [];
80
+ let aLine = 1;
81
+ let bLine = 1;
82
+ let current = null;
83
+ ops.forEach((o, idx) => {
84
+ if (keep[idx]) {
85
+ if (!current)
86
+ current = { aStart: aLine, aLines: 0, bStart: bLine, bLines: 0, lines: [] };
87
+ current.lines.push(o.op + o.line);
88
+ if (o.op !== '+')
89
+ current.aLines++;
90
+ if (o.op !== '-')
91
+ current.bLines++;
92
+ }
93
+ else if (current) {
94
+ hunks.push(current);
95
+ current = null;
96
+ }
97
+ if (o.op !== '+')
98
+ aLine++;
99
+ if (o.op !== '-')
100
+ bLine++;
101
+ });
102
+ if (current)
103
+ hunks.push(current);
104
+ const head = [`--- ${opts.fromLabel ?? 'before'}`, `+++ ${opts.toLabel ?? 'after'}`];
105
+ const body = hunks.map((h) => [`@@ -${h.aStart},${h.aLines} +${h.bStart},${h.bLines} @@`, ...h.lines].join('\n'));
106
+ return [...head, ...body].join('\n');
107
+ }
108
+ /** Unified diff of two JSON values, rendered from the deterministic serialisation. */
109
+ export function jsonDiff(before, after, fromLabel, toLabel) {
110
+ return unifiedDiff(stableJson(before), stableJson(after), { fromLabel, toLabel });
111
+ }
112
+ /** `3 changed, 1 added, 0 removed` — the one-line headline above the diff. */
113
+ export function diffStats(before, after) {
114
+ const ops = diffLines(stableJson(before).split('\n'), stableJson(after).split('\n'));
115
+ const added = ops.filter((o) => o.op === '+').length;
116
+ const removed = ops.filter((o) => o.op === '-').length;
117
+ return { added, removed, changed: added > 0 || removed > 0 };
118
+ }
119
+ /**
120
+ * RFC 7386 JSON Merge Patch — the `update_banner` patch format. `null` removes a key;
121
+ * arrays and scalars replace wholesale.
122
+ */
123
+ export function mergePatch(target, patch) {
124
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
125
+ return patch;
126
+ const base = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
127
+ for (const [k, v] of Object.entries(patch)) {
128
+ if (v === null)
129
+ delete base[k];
130
+ else
131
+ base[k] = mergePatch(base[k], v);
132
+ }
133
+ return base;
134
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `cookiecrumbs-mcp` — the stdio entry point.
4
+ *
5
+ * claude mcp add cookiecrumbs -- npx -y @cookiecrumbs-eu/mcp
6
+ *
7
+ * stdout belongs to the MCP protocol: every diagnostic goes to stderr.
8
+ */
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { buildServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
11
+ async function main() {
12
+ const built = await buildServer();
13
+ // The MCP client identifies itself in `initialize`; from then on every REST call carries
14
+ // `X-CookieCrumbs-Client: mcp/<that name>` unless MCP_CLIENT overrode it.
15
+ built.server.server.oninitialized = () => {
16
+ built.setClientName(built.server.server.getClientVersion()?.name);
17
+ };
18
+ await built.server.connect(new StdioServerTransport());
19
+ const scopes = [...built.scopes].join(', ') || 'none';
20
+ process.stderr.write(`${SERVER_NAME} mcp ${SERVER_VERSION} ready — ${built.advertised.length} tool(s); scopes: ${scopes}${built.authError ? ' (no usable token yet)' : ''}\n`);
21
+ }
22
+ main().catch((e) => {
23
+ process.stderr.write(`cookiecrumbs-mcp failed to start: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
24
+ process.exit(1);
25
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The tool/scope table — the single source of truth for what each MCP tool is called, whether
3
+ * it reads or writes, and which token scope it needs.
4
+ *
5
+ * `tools.ts` spreads these rows into the tool definitions it registers, so the server cannot
6
+ * advertise a tool under a different scope than this file says. The dashboard page
7
+ * `/w/:ws/developers/mcp` imports this module directly (`@cookiecrumbs-eu/mcp/matrix`) and renders
8
+ * the matrix from it — which is why this file has **no dependencies at all**: no zod, no node
9
+ * built-ins, nothing that would not survive a browser bundle.
10
+ */
11
+ export const TOOL_META = {
12
+ list_sites: { kind: 'read', scopes: ['sites:read'], title: 'List sites', summary: 'Sites this token can see, with environments and public keys.' },
13
+ get_site: { kind: 'read', scopes: ['sites:read'], title: 'Get one site', summary: 'One site with its environments, languages and version pointers.' },
14
+ get_banner: { kind: 'read', scopes: ['banner:read'], title: 'Get the banner configuration', summary: 'Draft + live version + legal lint for an environment.' },
15
+ get_compliance_status: { kind: 'read', scopes: ['banner:read'], title: 'Open compliance issues with citations', summary: 'Open issues with the rule each one rests on. No score.' },
16
+ scan_site: { kind: 'read', scopes: ['scans:run'], title: 'Queue a hosted scan', summary: 'Queue a scan, optionally wait for the summary.' },
17
+ get_scan: { kind: 'read', scopes: ['scans:read'], title: 'Get a scan', summary: 'Status, progress and summary of one scan.' },
18
+ list_findings: { kind: 'read', scopes: ['scans:read'], title: 'List scan findings', summary: 'Findings per tab: all, pre-consent, unclassified.' },
19
+ explain_classification: { kind: 'read', scopes: ['scans:read'], title: 'Explain why something is classified the way it is', summary: 'The matched tracker DB row with its source and licence, the pattern, the regime rule and the citation.' },
20
+ check_first_layer: { kind: 'read', scopes: ['banner:read'], title: 'Check the first layer', summary: 'The shared legal lint plus what the first layer offers, with citations.' },
21
+ rules_reference: { kind: 'read', scopes: [], title: 'The rules reference', summary: 'EDPB, CNIL, ICO, GDPR/ePrivacy, TCF, Law 25, CCPA — quotes only where verified.' },
22
+ get_declaration: { kind: 'read', scopes: ['banner:read'], title: 'Get the cookie declaration', summary: 'The current declaration as JSON, HTML or Markdown.' },
23
+ list_versions: { kind: 'read', scopes: ['banner:read'], title: 'List published versions', summary: 'The immutable version timeline with channel and author.' },
24
+ logs_summary: { kind: 'read', scopes: ['analytics:read'], title: 'Consent log summary', summary: 'Aggregates from the daily roll-ups. Never subject rows.' },
25
+ logs_export: { kind: 'read', scopes: ['logs:export'], title: 'Export consent logs', summary: 'Returns an export id and the signed URL — never rows inline.' },
26
+ classify_tracker: { kind: 'write', scopes: ['banner:write'], title: 'Classify a tracker (two-step)', summary: 'Move a service into another consent category.' },
27
+ update_banner: { kind: 'write', scopes: ['banner:write'], title: 'Patch the banner draft (two-step)', summary: 'JSON Merge Patch onto the draft. Never publishes.' },
28
+ push_config: { kind: 'write', scopes: ['banner:publish', 'banner:write'], title: 'Push and publish a configuration (two-step)', summary: 'Replace the draft and publish a new immutable version.' },
29
+ rollback_version: { kind: 'write', scopes: ['banner:publish'], title: 'Restore an earlier version (two-step)', summary: 'Publish an old version again under a new number.' },
30
+ // ---- site configuration (tools-config.ts): everything the dashboard can change, from an agent ----
31
+ create_site: { kind: 'write', scopes: ['sites:write'], title: 'Create a site (two-step)', summary: 'A new site with both environments and a first draft.' },
32
+ update_site: { kind: 'write', scopes: ['sites:write'], title: 'Rename a site, set retention or settings (two-step)', summary: 'Name, consent-record retention, site settings.' },
33
+ list_domains: { kind: 'read', scopes: ['sites:read'], title: 'List domains', summary: 'Domains with verification state and the token to publish.' },
34
+ verify_domain: { kind: 'write', scopes: ['sites:write'], title: 'Request domain verification (two-step)', summary: 'Re-issue the DNS TXT / meta token; the worker checks it.' },
35
+ list_scans: { kind: 'read', scopes: ['scans:read'], title: 'List scans', summary: 'Recent scans with status, trigger and summary.' },
36
+ get_scan_diff: { kind: 'read', scopes: ['scans:read'], title: 'Scan diff', summary: 'What changed since the previous scan.' },
37
+ get_scan_schedule: { kind: 'read', scopes: ['scans:read'], title: 'Get the scan schedule', summary: 'Cadence, next run, page cap, states, crawl scope.' },
38
+ set_scan_schedule: { kind: 'write', scopes: ['scans:run'], title: 'Change the scan schedule (two-step)', summary: 'Cadence, pages, states, start URLs, patterns, robots, pause.' },
39
+ get_install_status: { kind: 'read', scopes: ['scans:read'], title: 'Install status', summary: 'The latest install checks: is cc.js on the page?' },
40
+ check_install: { kind: 'read', scopes: ['scans:run'], title: 'Queue an install check', summary: 'Queue a check of the page, optionally wait for the result.' },
41
+ list_alerts: { kind: 'read', scopes: ['scans:read'], title: 'List alerts', summary: 'The alert inbox, open by default.' },
42
+ acknowledge_alert: { kind: 'write', scopes: ['scans:run'], title: 'Acknowledge an alert (two-step)', summary: 'Mark an alert as seen.' },
43
+ resolve_alert: { kind: 'write', scopes: ['scans:run'], title: 'Resolve an alert (two-step)', summary: 'Close an alert.' },
44
+ list_services: { kind: 'read', scopes: ['banner:read'], title: 'List services', summary: 'Declared trackers with category, basis and patterns.' },
45
+ add_service: { kind: 'write', scopes: ['banner:write'], title: 'Add a service (two-step)', summary: 'Declare a tracker by hand.' },
46
+ update_service: { kind: 'write', scopes: ['banner:write'], title: 'Update a service (two-step)', summary: 'Patterns, provider, basis, status of a service.' },
47
+ delete_service: { kind: 'write', scopes: ['banner:write'], title: 'Delete a service (two-step, destructive)', summary: 'Remove a service; findings return to unclassified.' },
48
+ suppress_issue: { kind: 'write', scopes: ['banner:write'], title: 'Suppress a compliance issue (two-step)', summary: 'With a reason that becomes the record.' },
49
+ unsuppress_issue: { kind: 'write', scopes: ['banner:write'], title: 'Reopen a suppressed issue (two-step)', summary: 'Put a suppressed issue back to open.' },
50
+ promote_version: { kind: 'write', scopes: ['banner:publish'], title: 'Promote a preview version (two-step)', summary: 'Publish a preview version to production as it is.' },
51
+ list_templates: { kind: 'read', scopes: ['banner:read'], title: 'List templates', summary: 'Built-in and workspace banner templates.' },
52
+ get_template: { kind: 'read', scopes: ['banner:read'], title: 'Get a template', summary: 'One template with its full config.' },
53
+ apply_template: { kind: 'write', scopes: ['banner:write'], title: 'Apply a template (two-step)', summary: 'Copy template groups into site drafts; never publishes.' },
54
+ save_template: { kind: 'write', scopes: ['banner:write'], title: 'Save a workspace template (two-step)', summary: 'From a site draft or a config; feature shared_templates.' },
55
+ delete_template: { kind: 'write', scopes: ['banner:write'], title: 'Delete a workspace template (two-step, destructive)', summary: 'Built-ins are refused.' },
56
+ list_webhooks: { kind: 'read', scopes: ['sites:read'], title: 'List webhooks', summary: 'Endpoints with events and last delivery; never the secret.' },
57
+ create_webhook: { kind: 'write', scopes: ['sites:write'], title: 'Create a webhook (two-step)', summary: 'The signing secret is returned once.' },
58
+ update_webhook: { kind: 'write', scopes: ['sites:write'], title: 'Update a webhook (two-step)', summary: 'URL, events, description, enabled.' },
59
+ delete_webhook: { kind: 'write', scopes: ['sites:write'], title: 'Delete a webhook (two-step, destructive)', summary: 'Endpoint and delivery history.' },
60
+ list_alert_channels: { kind: 'read', scopes: ['sites:read'], title: 'List alert channels', summary: 'E-mail, Slack and webhook destinations with their rules.' },
61
+ create_alert_channel: { kind: 'write', scopes: ['sites:write'], title: 'Create an alert channel (two-step)', summary: 'E-mail, Slack incoming webhook or webhook; secret write-only.' },
62
+ update_alert_channel: { kind: 'write', scopes: ['sites:write'], title: 'Update an alert channel (two-step)', summary: 'Name, enabled, config, secret.' },
63
+ delete_alert_channel: { kind: 'write', scopes: ['sites:write'], title: 'Delete an alert channel (two-step, destructive)', summary: 'Channel and its rules.' },
64
+ get_usage: { kind: 'read', scopes: ['sites:read'], title: 'Usage meters', summary: 'Pageviews, sites, seats, consents, scan pages, exports.' },
65
+ list_export_destinations: { kind: 'read', scopes: ['sites:read'], title: 'List export destinations', summary: 'Customer-owned buckets; never the secret key.' },
66
+ list_export_schedules: { kind: 'read', scopes: ['logs:export'], title: 'List scheduled exports', summary: 'Recurring proof exports with next and last run.' },
67
+ create_export_schedule: { kind: 'write', scopes: ['logs:export'], title: 'Schedule an export (two-step)', summary: 'Recurring export to a destination; feature scheduled_exports.' },
68
+ update_export_schedule: { kind: 'write', scopes: ['logs:export'], title: 'Change a scheduled export (two-step)', summary: 'Kind, cadence, timing, destination, enabled.' },
69
+ delete_export_schedule: { kind: 'write', scopes: ['logs:export'], title: 'Delete a scheduled export (two-step, destructive)', summary: 'Delivered files stay in the bucket.' },
70
+ };
71
+ /** Spread into a tool definition so the registered tool and this table cannot disagree. */
72
+ export function meta(name) {
73
+ return TOOL_META[name];
74
+ }
75
+ /** Row list for the dashboard, in the order the tools are registered. */
76
+ export const MCP_TOOL_MATRIX = Object.keys(TOOL_META).map((name) => ({
77
+ name,
78
+ ...TOOL_META[name],
79
+ scopes: [...TOOL_META[name].scopes],
80
+ two_step: TOOL_META[name].kind === 'write',
81
+ }));
82
+ /** Every scope any tool can ask for, in a stable order. */
83
+ export const MCP_SCOPES = [...new Set(MCP_TOOL_MATRIX.flatMap((t) => t.scopes))].sort();
84
+ /** The install snippets shown on the dashboard and in the README — one definition, two surfaces. */
85
+ export function installSnippets(apiBase) {
86
+ const api = apiBase ? `\n "COOKIECRUMBS_API": "${apiBase}"` : '';
87
+ return {
88
+ claudeCode: 'claude mcp add cookiecrumbs -- npx -y @cookiecrumbs-eu/mcp',
89
+ cursor: `// .cursor/mcp.json
90
+ {
91
+ "mcpServers": {
92
+ "cookiecrumbs": {
93
+ "command": "npx",
94
+ "args": ["-y", "@cookiecrumbs-eu/mcp"],
95
+ "env": {
96
+ "COOKIECRUMBS_TOKEN": "cc_live_…"${api ? ',' + api : ''}
97
+ }
98
+ }
99
+ }
100
+ }`,
101
+ env: `# The server reads the token from the environment, or from the CLI credentials file
102
+ # written by \`npx cookiecrumbs login\`. It never runs a device flow itself.
103
+ export COOKIECRUMBS_TOKEN=cc_live_…${apiBase ? `\nexport COOKIECRUMBS_API=${apiBase}` : ''}`,
104
+ manual: 'npx -y @cookiecrumbs-eu/mcp # stdio; add it to any MCP client that speaks stdio',
105
+ };
106
+ }