@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.
- package/LICENSE +134 -0
- package/README.md +186 -0
- package/dist/cli/src/api.js +192 -0
- package/dist/cli/src/auth.js +106 -0
- package/dist/cli/src/commands/_shared.js +78 -0
- package/dist/cli/src/commands/alerts.js +85 -0
- package/dist/cli/src/commands/auth.js +92 -0
- package/dist/cli/src/commands/declaration.js +45 -0
- package/dist/cli/src/commands/diff.js +26 -0
- package/dist/cli/src/commands/domains.js +44 -0
- package/dist/cli/src/commands/export.js +136 -0
- package/dist/cli/src/commands/init.js +134 -0
- package/dist/cli/src/commands/install.js +77 -0
- package/dist/cli/src/commands/issues.js +61 -0
- package/dist/cli/src/commands/link.js +41 -0
- package/dist/cli/src/commands/logs.js +98 -0
- package/dist/cli/src/commands/open.js +45 -0
- package/dist/cli/src/commands/pull.js +89 -0
- package/dist/cli/src/commands/push.js +110 -0
- package/dist/cli/src/commands/scan.js +94 -0
- package/dist/cli/src/commands/schedule.js +97 -0
- package/dist/cli/src/commands/services.js +143 -0
- package/dist/cli/src/commands/sites.js +111 -0
- package/dist/cli/src/commands/status.js +90 -0
- package/dist/cli/src/commands/templates.js +133 -0
- package/dist/cli/src/commands/tokens.js +50 -0
- package/dist/cli/src/commands/usage.js +41 -0
- package/dist/cli/src/commands/versions.js +95 -0
- package/dist/cli/src/commands/webhooks.js +164 -0
- package/dist/cli/src/configpkg.js +10 -0
- package/dist/cli/src/diff.js +63 -0
- package/dist/cli/src/errors.js +20 -0
- package/dist/cli/src/frameworks.js +141 -0
- package/dist/cli/src/index.js +100 -0
- package/dist/cli/src/jobs.js +59 -0
- package/dist/cli/src/merge.js +38 -0
- package/dist/cli/src/output.js +112 -0
- package/dist/cli/src/project.js +269 -0
- package/dist/cli/src/util.js +122 -0
- package/dist/config/rules_reference.json +569 -0
- package/dist/config/src/canon.js +36 -0
- package/dist/config/src/declaration.js +38 -0
- package/dist/config/src/defaults.js +804 -0
- package/dist/config/src/export.js +130 -0
- package/dist/config/src/index.js +16 -0
- package/dist/config/src/lint.js +139 -0
- package/dist/config/src/regimes.js +62 -0
- package/dist/config/src/rules.js +90 -0
- package/dist/config/src/schema.js +323 -0
- package/dist/config/src/theme.js +147 -0
- package/dist/config/src/verify.js +51 -0
- package/dist/config/src/webhooks.js +309 -0
- package/dist/mcp/src/auth.js +40 -0
- package/dist/mcp/src/client.js +44 -0
- package/dist/mcp/src/diff.js +134 -0
- package/dist/mcp/src/index.js +25 -0
- package/dist/mcp/src/matrix.js +106 -0
- package/dist/mcp/src/server.js +171 -0
- package/dist/mcp/src/shared.js +147 -0
- package/dist/mcp/src/tools-config.js +943 -0
- package/dist/mcp/src/tools.js +650 -0
- package/package.json +66 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool table. One place that says, for every tool, what it is called, whether it reads or
|
|
3
|
+
* writes, which token scope it needs and how it runs — the dashboard's tool/scope matrix is
|
|
4
|
+
* generated from `TOOL_MATRIX` below, so the documentation cannot drift from the server.
|
|
5
|
+
*
|
|
6
|
+
* Two rules run through the whole file:
|
|
7
|
+
* 1. a tool is only advertised when the token carries a scope that allows it (contracts.md:
|
|
8
|
+
* "write tools are advertised only when the token carries the scope" — we apply the same
|
|
9
|
+
* rule to reads, because a tool that always fails is worse than one that is absent);
|
|
10
|
+
* 2. every write is two-step: `confirm:false` (the default) renders a unified diff plus the
|
|
11
|
+
* lint result and changes nothing; `confirm:true` performs it.
|
|
12
|
+
*
|
|
13
|
+
* This file holds the phase-6 core (banner, scans, findings, rules, logs). Everything a site's
|
|
14
|
+
* configuration consists of beyond the banner draft lives in tools-config.ts; `TOOLS` at the
|
|
15
|
+
* bottom is the union the server registers.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { lintConfig } from "../../config/src/index.js";
|
|
19
|
+
import { citationsFor, rulesFor, ruleById, searchRules, RULE_TOPICS, NOT_LEGAL_ADVICE } from "../../config/src/rules.js";
|
|
20
|
+
import { CliError } from "../../cli/src/errors.js";
|
|
21
|
+
import { jsonDiff, diffStats, mergePatch } from "./diff.js";
|
|
22
|
+
import { MCP_TOOL_MATRIX, meta } from "./matrix.js";
|
|
23
|
+
import { CONFIG_TOOLS } from "./tools-config.js";
|
|
24
|
+
import { ENV, allSites, citationBlock, dryRun, envOf, findingsOf, jsonBlock, latestCompletedScan, lintLines, recentScans, renderRule, resolveSite, topicsForCategory, } from "./shared.js";
|
|
25
|
+
export { toolAllowed, resolveSite } from "./shared.js";
|
|
26
|
+
// --- the tools -------------------------------------------------------------
|
|
27
|
+
const CORE_TOOLS = [
|
|
28
|
+
{
|
|
29
|
+
name: 'list_sites',
|
|
30
|
+
...meta('list_sites'),
|
|
31
|
+
description: 'Every site this token can see, with its environments and public keys.',
|
|
32
|
+
inputSchema: {},
|
|
33
|
+
readOnlyHint: true,
|
|
34
|
+
async run(ctx) {
|
|
35
|
+
const sites = await allSites(ctx);
|
|
36
|
+
if (!sites.length)
|
|
37
|
+
return 'This token can see no sites.';
|
|
38
|
+
return sites
|
|
39
|
+
.map((s) => {
|
|
40
|
+
const envs = (s.environments ?? []).map((e) => `${e.name}=${e.public_key}`).join(', ');
|
|
41
|
+
return `- ${s.primary_domain} ${s.name ? `(${s.name})` : ''}\n id: ${s.id}\n environments: ${envs || '—'}`;
|
|
42
|
+
})
|
|
43
|
+
.join('\n');
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'get_site',
|
|
48
|
+
...meta('get_site'),
|
|
49
|
+
description: 'One site with its environments, languages and current version pointers.',
|
|
50
|
+
inputSchema: { site: z.string().optional().describe('site id, primary domain or name') },
|
|
51
|
+
readOnlyHint: true,
|
|
52
|
+
async run(ctx, args) {
|
|
53
|
+
const id = await resolveSite(ctx, args.site);
|
|
54
|
+
return jsonBlock(await ctx.api.site(id));
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'get_banner',
|
|
59
|
+
...meta('get_banner'),
|
|
60
|
+
description: 'The current draft for an environment, the live version it would replace, and the legal lint of the draft.',
|
|
61
|
+
inputSchema: { site: z.string().optional(), env: ENV.optional().describe('production (default) or preview') },
|
|
62
|
+
readOnlyHint: true,
|
|
63
|
+
async run(ctx, args) {
|
|
64
|
+
const id = await resolveSite(ctx, args.site);
|
|
65
|
+
const env = envOf(ctx, args.env);
|
|
66
|
+
const draft = await ctx.api.getDraft(id, env);
|
|
67
|
+
const versions = await ctx.api.versions(id).catch(() => []);
|
|
68
|
+
const live = versions.filter((v) => (v.environment ?? 'production') === env).sort((a, b) => b.number - a.number)[0] ?? null;
|
|
69
|
+
const lint = draft.lint ?? lintConfig(draft.config);
|
|
70
|
+
return [
|
|
71
|
+
`Site ${id} · environment ${env}`,
|
|
72
|
+
live ? `Live version: v${live.number} (${live.note ?? 'no note'}${live.channel ? `, via ${live.channel}` : ''}${live.actor_label ? `, ${live.actor_label}` : ''})` : 'Live version: none published yet',
|
|
73
|
+
`Draft updated: ${draft.updated_at}`,
|
|
74
|
+
'',
|
|
75
|
+
lintLines(lint),
|
|
76
|
+
'',
|
|
77
|
+
'Draft configuration:',
|
|
78
|
+
jsonBlock(draft.config),
|
|
79
|
+
].join('\n');
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'get_compliance_status',
|
|
84
|
+
...meta('get_compliance_status'),
|
|
85
|
+
description: 'The open compliance issues of a site with the rule each one rests on. There is no score and never will be one.',
|
|
86
|
+
inputSchema: { site: z.string().optional(), status: z.enum(['open', 'resolved', 'suppressed', 'all']).optional() },
|
|
87
|
+
readOnlyHint: true,
|
|
88
|
+
async run(ctx, args) {
|
|
89
|
+
const id = await resolveSite(ctx, args.site);
|
|
90
|
+
const status = typeof args.status === 'string' ? args.status : 'open';
|
|
91
|
+
const body = await ctx.api.request('GET', `/sites/${id}/issues`, { query: { status } });
|
|
92
|
+
const items = body.items ?? [];
|
|
93
|
+
if (!items.length)
|
|
94
|
+
return `No ${status} compliance issues for site ${id}.\n\n${NOT_LEGAL_ADVICE}`;
|
|
95
|
+
const out = [`${items.length} ${status} issue(s) for site ${id}. There is no compliance score — each issue stands on its own rule.`, ''];
|
|
96
|
+
for (const i of items) {
|
|
97
|
+
const code = String(i.code);
|
|
98
|
+
out.push(`## ${code} — ${i.severity}`);
|
|
99
|
+
const rule = i.rule;
|
|
100
|
+
if (rule?.title)
|
|
101
|
+
out.push(rule.title);
|
|
102
|
+
if (rule?.summary)
|
|
103
|
+
out.push(rule.summary);
|
|
104
|
+
if (i.evidence)
|
|
105
|
+
out.push(`Evidence: ${JSON.stringify(i.evidence)}`);
|
|
106
|
+
out.push(citationBlock([code], { limit: 4 }).trimStart());
|
|
107
|
+
out.push('');
|
|
108
|
+
}
|
|
109
|
+
return out.join('\n');
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'scan_site',
|
|
114
|
+
...meta('scan_site'),
|
|
115
|
+
description: 'Queue a scan of the site. Optionally wait for it to finish (up to `wait` seconds) and return the summary.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
site: z.string().optional(),
|
|
118
|
+
env: ENV.optional(),
|
|
119
|
+
states: z.array(z.enum(['no_interaction', 'reject_all', 'accept_all'])).optional(),
|
|
120
|
+
page_cap: z.number().int().min(1).max(5000).optional(),
|
|
121
|
+
wait: z.number().int().min(0).max(600).optional().describe('seconds to wait for completion; 0 = return immediately'),
|
|
122
|
+
},
|
|
123
|
+
readOnlyHint: false,
|
|
124
|
+
async run(ctx, args) {
|
|
125
|
+
const id = await resolveSite(ctx, args.site);
|
|
126
|
+
const scan = await ctx.api.createScan(id, {
|
|
127
|
+
env: envOf(ctx, args.env),
|
|
128
|
+
states: args.states ?? undefined,
|
|
129
|
+
page_cap: args.page_cap ?? undefined,
|
|
130
|
+
});
|
|
131
|
+
const wait = typeof args.wait === 'number' ? args.wait : 0;
|
|
132
|
+
if (!wait)
|
|
133
|
+
return `Scan ${scan.id} queued (status ${scan.status}). Poll with get_scan.`;
|
|
134
|
+
const deadline = Date.now() + wait * 1000;
|
|
135
|
+
let current = scan;
|
|
136
|
+
while (Date.now() < deadline && !['completed', 'failed', 'cancelled'].includes(current.status)) {
|
|
137
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
138
|
+
current = await ctx.api.scan(scan.id);
|
|
139
|
+
}
|
|
140
|
+
return `Scan ${current.id}: ${current.status}\n${jsonBlock(current.summary ?? current.progress ?? {})}`;
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'get_scan',
|
|
145
|
+
...meta('get_scan'),
|
|
146
|
+
description: 'Status, progress and summary of one scan.',
|
|
147
|
+
inputSchema: { scan_id: z.string().describe('scan uuid') },
|
|
148
|
+
readOnlyHint: true,
|
|
149
|
+
async run(ctx, args) {
|
|
150
|
+
return jsonBlock(await ctx.api.scan(String(args.scan_id)));
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'list_findings',
|
|
155
|
+
...meta('list_findings'),
|
|
156
|
+
description: 'Findings of a scan (defaults to the latest completed scan of the site). Tabs: all, preconsent, unknown.',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
site: z.string().optional(),
|
|
159
|
+
scan_id: z.string().optional(),
|
|
160
|
+
tab: z.enum(['all', 'preconsent', 'unknown']).optional(),
|
|
161
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
162
|
+
},
|
|
163
|
+
readOnlyHint: true,
|
|
164
|
+
async run(ctx, args) {
|
|
165
|
+
let scanId = typeof args.scan_id === 'string' ? args.scan_id : null;
|
|
166
|
+
if (!scanId) {
|
|
167
|
+
const site = await resolveSite(ctx, args.site);
|
|
168
|
+
const scan = await latestCompletedScan(ctx, site);
|
|
169
|
+
if (!scan)
|
|
170
|
+
return `No scans for site ${site} yet — run scan_site first.`;
|
|
171
|
+
scanId = String(scan.id);
|
|
172
|
+
}
|
|
173
|
+
const tab = (typeof args.tab === 'string' ? args.tab : 'all');
|
|
174
|
+
const limit = typeof args.limit === 'number' ? args.limit : 100;
|
|
175
|
+
const { findings } = await findingsOf(ctx, scanId, tab);
|
|
176
|
+
if (!findings.length)
|
|
177
|
+
return `Scan ${scanId}: no findings in tab "${tab}".`;
|
|
178
|
+
const rows = findings.slice(0, limit).map((f) => {
|
|
179
|
+
const flag = f.loaded_pre_consent ? ' PRE-CONSENT' : '';
|
|
180
|
+
return `- ${f.kind} ${f.name} @ ${f.host} [${f.party}] → ${f.suggested_category ?? 'unclassified'} (${f.classification_source ?? 'no source'}, status ${f.status}, severity ${f.severity})${flag}`;
|
|
181
|
+
});
|
|
182
|
+
return [`Scan ${scanId} · tab ${tab} · ${findings.length} finding(s)${findings.length > limit ? ` (showing ${limit})` : ''}`, ...rows].join('\n');
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'explain_classification',
|
|
187
|
+
...meta('explain_classification'),
|
|
188
|
+
description: 'Why one cookie, script or request carries the category it does: the matched tracker database row with its source and licence, the pattern that matched, the regime rule applied, and the citation. Unclassified findings are reported as unclassified — never guessed.',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
site: z.string().optional(),
|
|
191
|
+
cookie: z.string().optional().describe('cookie / storage key / script name, e.g. "_fbp"'),
|
|
192
|
+
finding: z.string().optional().describe('finding_key or finding id, if you have it'),
|
|
193
|
+
scan_id: z.string().optional(),
|
|
194
|
+
env: ENV.optional(),
|
|
195
|
+
},
|
|
196
|
+
readOnlyHint: true,
|
|
197
|
+
async run(ctx, args) {
|
|
198
|
+
const site = await resolveSite(ctx, args.site);
|
|
199
|
+
const needle = String(args.cookie ?? args.finding ?? '').trim();
|
|
200
|
+
if (!needle)
|
|
201
|
+
throw new CliError('usage', 'Pass `cookie` (a cookie / script / storage name) or `finding` (a finding_key or id).', 2);
|
|
202
|
+
// Which scans to look in: the one that was asked for, or the recent completed ones
|
|
203
|
+
// newest first. A cookie can be absent from the newest scan (a different start URL, a
|
|
204
|
+
// shallower crawl) and still be a real observation of this site — we say which scan it
|
|
205
|
+
// came from rather than pretending it is not there or inventing it.
|
|
206
|
+
let scanIds;
|
|
207
|
+
if (typeof args.scan_id === 'string' && args.scan_id) {
|
|
208
|
+
scanIds = [args.scan_id];
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const recent = await recentScans(ctx, site);
|
|
212
|
+
if (!recent.length)
|
|
213
|
+
return `No scans for site ${site} yet — run scan_site first.`;
|
|
214
|
+
scanIds = recent.slice(0, 5).map((s) => String(s.id));
|
|
215
|
+
}
|
|
216
|
+
const lower = needle.toLowerCase();
|
|
217
|
+
const pick = (findings) => findings.find((f) => String(f.finding_key ?? '') === needle || String(f.id ?? '') === needle) ??
|
|
218
|
+
findings.find((f) => String(f.name ?? '').toLowerCase() === lower) ??
|
|
219
|
+
findings.find((f) => String(f.name ?? '').toLowerCase().startsWith(lower)) ??
|
|
220
|
+
findings.find((f) => String(f.host ?? '').toLowerCase().includes(lower));
|
|
221
|
+
let hit;
|
|
222
|
+
let scanId = scanIds[0];
|
|
223
|
+
const seenNames = new Set();
|
|
224
|
+
for (const id of scanIds) {
|
|
225
|
+
const { findings } = await findingsOf(ctx, id, 'all');
|
|
226
|
+
for (const f of findings)
|
|
227
|
+
seenNames.add(String(f.name ?? ''));
|
|
228
|
+
const found = pick(findings);
|
|
229
|
+
if (found) {
|
|
230
|
+
hit = found;
|
|
231
|
+
scanId = id;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!hit) {
|
|
236
|
+
const names = [...seenNames].sort().slice(0, 40);
|
|
237
|
+
return `No finding matching "${needle}" in the ${scanIds.length} most recent scan(s) of site ${site} (${scanIds.join(', ')}). They observed: ${names.join(', ') || 'nothing'}.`;
|
|
238
|
+
}
|
|
239
|
+
const cls = hit.attrs?.classification;
|
|
240
|
+
const head = [
|
|
241
|
+
`# ${hit.kind} \`${hit.name}\` on ${hit.host} (${hit.party} party)`,
|
|
242
|
+
`Scan ${scanId} · finding_key \`${hit.finding_key}\` · status ${hit.status} · severity ${hit.severity}` +
|
|
243
|
+
(hit.loaded_pre_consent ? ' · **loaded before consent**' : ''),
|
|
244
|
+
'',
|
|
245
|
+
];
|
|
246
|
+
if (!cls || !hit.suggested_category) {
|
|
247
|
+
return [
|
|
248
|
+
...head,
|
|
249
|
+
'This finding is **unclassified**. Nothing in the tracker database or in this site’s services matched it, so CookieCrumbs has no category for it and will not invent one.',
|
|
250
|
+
'',
|
|
251
|
+
'Until it is classified it is blocked in opt-in regimes when strict blocking is on, and it is reported as `unclassified_tracker`.',
|
|
252
|
+
citationBlock(['unclassified_tracker'], { limit: 4 }),
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
const t = cls.tracker ?? {};
|
|
256
|
+
const p = cls.pattern;
|
|
257
|
+
const s = cls.service;
|
|
258
|
+
const category = String(cls.suggested_category ?? hit.suggested_category);
|
|
259
|
+
const out = [
|
|
260
|
+
...head,
|
|
261
|
+
`## Category: ${category}`,
|
|
262
|
+
`Decided by: ${cls.matched_via ?? cls.classification_source} (classification_source \`${cls.classification_source}\`, confidence ${cls.confidence ?? hit.confidence ?? '—'})`,
|
|
263
|
+
'',
|
|
264
|
+
'## The tracker database row this came from',
|
|
265
|
+
` provider: ${t.provider_name ?? '—'} (${t.provider_domain ?? '—'})`,
|
|
266
|
+
` default category:${' '} ${t.default_category ?? '—'}`,
|
|
267
|
+
` tracker_db id: ${t.id ?? '—'} added in tracker DB version ${t.version_added ?? '—'}`,
|
|
268
|
+
` source: ${t.source ?? '—'}${t.source_ref ? ` (${t.source_ref})` : ''}`,
|
|
269
|
+
` licence: ${t.licence ?? '—'}`,
|
|
270
|
+
` DB confidence: ${t.db_confidence ?? '—'}`,
|
|
271
|
+
` privacy policy: ${t.privacy_url ?? '—'}`,
|
|
272
|
+
` Consent Mode: ${Array.isArray(t.gcm_mapping) ? t.gcm_mapping.join(', ') : '—'}`,
|
|
273
|
+
t.descriptions && typeof t.descriptions === 'object' ? ` description: ${t.descriptions.en ?? ''}` : '',
|
|
274
|
+
'',
|
|
275
|
+
'## The pattern that matched',
|
|
276
|
+
p
|
|
277
|
+
? ` tracker_patterns row ${p.id}: kind=${p.kind}, pattern=\`${p.pattern}\`, match_type=${p.match_type}${p.lifetime_hint_seconds ? `, lifetime hint ${p.lifetime_hint_seconds}s` : ''}`
|
|
278
|
+
: ' No tracker_patterns row matched this finding directly (see the service below).',
|
|
279
|
+
s ? ` Matched through the site service “${s.name}” (${s.status}, source ${s.source}, legal basis ${s.legal_basis}, category ${s.category_key}).` : '',
|
|
280
|
+
'',
|
|
281
|
+
'## The regime rule applied',
|
|
282
|
+
category === 'necessary'
|
|
283
|
+
? ' A strictly-necessary item is never gated: it may be set before any choice, in every regime.'
|
|
284
|
+
: ` Under an opt-in regime (eu_optin, uk_pecr, ch_fadp strict, br_lgpd, ca_qc) nothing in category "${category}" may be stored or read before the visitor gives consent; under us_optout it is granted by default and denied as soon as a Global Privacy Control signal is seen${category === 'marketing' ? ' (marketing is treated as sale/share/targeted advertising)' : ''}.` +
|
|
285
|
+
(hit.loaded_pre_consent
|
|
286
|
+
? '\n This finding was observed **before** the visitor made a choice, which is why it is reported as `preconsent_tracker`.'
|
|
287
|
+
: '\n In this scan it was not observed before the choice, so it is not reported as `preconsent_tracker`.'),
|
|
288
|
+
citationBlock(['preconsent_tracker'], { topics: topicsForCategory(category), limit: 5 }),
|
|
289
|
+
];
|
|
290
|
+
return out.filter((l) => l !== '').join('\n');
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'check_first_layer',
|
|
295
|
+
...meta('check_first_layer'),
|
|
296
|
+
description: 'Run the shared legal lint over the current draft and report what the first layer offers: Accept all / Reject all parity, pre-ticked categories, the re-open control and the closing-counts-as-refusal text. Every finding carries its citation.',
|
|
297
|
+
inputSchema: { site: z.string().optional(), env: ENV.optional() },
|
|
298
|
+
readOnlyHint: true,
|
|
299
|
+
async run(ctx, args) {
|
|
300
|
+
const id = await resolveSite(ctx, args.site);
|
|
301
|
+
const env = envOf(ctx, args.env);
|
|
302
|
+
const draft = await ctx.api.getDraft(id, env);
|
|
303
|
+
const cfg = draft.config;
|
|
304
|
+
const lint = lintConfig(cfg);
|
|
305
|
+
const facts = [];
|
|
306
|
+
const codes = new Set(lint.issues.map((i) => i.code));
|
|
307
|
+
facts.push(`layout: ${cfg.layout}${cfg.layout === 'headless' ? ' — the runtime renders nothing; your own UI must carry Accept all, Reject all and settings with equal prominence' : ''}`);
|
|
308
|
+
const preTicked = (cfg.categories ?? []).filter((c) => c.key !== 'necessary' && c.default).map((c) => c.key);
|
|
309
|
+
facts.push(`categories on by default (other than necessary): ${preTicked.length ? preTicked.join(', ') : 'none'}`);
|
|
310
|
+
if (preTicked.length)
|
|
311
|
+
codes.add('default_on_non_necessary');
|
|
312
|
+
facts.push(`re-open control: ${cfg.behaviour?.reopen_control ?? '—'}`);
|
|
313
|
+
if (!['floating', 'link'].includes(String(cfg.behaviour?.reopen_control)))
|
|
314
|
+
codes.add('reopen_disabled');
|
|
315
|
+
facts.push(`closing the banner counts as a refusal: ${cfg.behaviour?.implied_dismiss ? 'yes — the banner must say so' : 'no'}`);
|
|
316
|
+
if (cfg.behaviour?.implied_dismiss)
|
|
317
|
+
codes.add('implied_dismiss_text_missing');
|
|
318
|
+
facts.push(`Accept all and Reject all share one button style in the runtime, so first-layer parity is a theme question: text ${cfg.theme?.text} on ${cfg.theme?.background}, button ${cfg.theme?.button_text} on ${cfg.theme?.button}.`);
|
|
319
|
+
codes.add('reject_not_layer1');
|
|
320
|
+
return [
|
|
321
|
+
`First-layer check for site ${id} · environment ${env}`,
|
|
322
|
+
'',
|
|
323
|
+
...facts.map((f) => `- ${f}`),
|
|
324
|
+
'',
|
|
325
|
+
lintLines(lint),
|
|
326
|
+
citationBlock([...codes], { limit: 8 }),
|
|
327
|
+
].join('\n');
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'rules_reference',
|
|
332
|
+
...meta('rules_reference'),
|
|
333
|
+
description: 'The shared citation knowledge base: EDPB cookie banner taskforce paragraphs, CNIL, ICO, GDPR/ePrivacy articles, TCF policies, Quebec Law 25 and the US opt-out regulations. Filter by topic, by issue code or by free text. Entries whose exact wording could not be verified carry no quote.',
|
|
334
|
+
inputSchema: {
|
|
335
|
+
topic: z.string().optional().describe(`one of: ${RULE_TOPICS.join(', ')}`),
|
|
336
|
+
issue: z.string().optional().describe('a compliance issue code or a lint code'),
|
|
337
|
+
id: z.string().optional().describe('a rule id'),
|
|
338
|
+
query: z.string().optional().describe('free text'),
|
|
339
|
+
},
|
|
340
|
+
readOnlyHint: true,
|
|
341
|
+
async run(_ctx, args) {
|
|
342
|
+
let hits;
|
|
343
|
+
if (typeof args.id === 'string' && args.id) {
|
|
344
|
+
const one = ruleById(args.id);
|
|
345
|
+
if (!one)
|
|
346
|
+
return `No rule with id "${args.id}". Topics: ${RULE_TOPICS.join(', ')}.`;
|
|
347
|
+
hits = [one];
|
|
348
|
+
}
|
|
349
|
+
else if (typeof args.topic === 'string' && args.topic) {
|
|
350
|
+
hits = rulesFor(args.topic);
|
|
351
|
+
if (!hits.length)
|
|
352
|
+
return `No rules for topic "${args.topic}". Topics: ${RULE_TOPICS.join(', ')}.`;
|
|
353
|
+
}
|
|
354
|
+
else if (typeof args.issue === 'string' && args.issue) {
|
|
355
|
+
hits = citationsFor(args.issue);
|
|
356
|
+
if (!hits.length)
|
|
357
|
+
return `No rules filed against "${args.issue}".`;
|
|
358
|
+
}
|
|
359
|
+
else if (typeof args.query === 'string' && args.query) {
|
|
360
|
+
hits = searchRules(args.query);
|
|
361
|
+
if (!hits.length)
|
|
362
|
+
return `Nothing matches "${args.query}".`;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
hits = searchRules('');
|
|
366
|
+
}
|
|
367
|
+
return [...hits.map(renderRule), '', NOT_LEGAL_ADVICE].join('\n\n');
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'get_declaration',
|
|
372
|
+
...meta('get_declaration'),
|
|
373
|
+
description: 'The current cookie declaration of a site as JSON, HTML or Markdown.',
|
|
374
|
+
inputSchema: { site: z.string().optional(), lang: z.string().optional(), format: z.enum(['json', 'html', 'md']).optional() },
|
|
375
|
+
readOnlyHint: true,
|
|
376
|
+
async run(ctx, args) {
|
|
377
|
+
const id = await resolveSite(ctx, args.site);
|
|
378
|
+
const format = (typeof args.format === 'string' ? args.format : 'md');
|
|
379
|
+
const lang = typeof args.lang === 'string' ? args.lang : undefined;
|
|
380
|
+
return ctx.api.declaration(id, format, lang);
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: 'list_versions',
|
|
385
|
+
...meta('list_versions'),
|
|
386
|
+
description: 'The immutable version timeline of a site: number, environment, note, channel and who published it.',
|
|
387
|
+
inputSchema: { site: z.string().optional(), env: ENV.optional(), limit: z.number().int().min(1).max(200).optional() },
|
|
388
|
+
readOnlyHint: true,
|
|
389
|
+
async run(ctx, args) {
|
|
390
|
+
const id = await resolveSite(ctx, args.site);
|
|
391
|
+
const env = typeof args.env === 'string' ? args.env : null;
|
|
392
|
+
const limit = typeof args.limit === 'number' ? args.limit : 25;
|
|
393
|
+
const versions = (await ctx.api.versions(id))
|
|
394
|
+
.filter((v) => !env || (v.environment ?? 'production') === env)
|
|
395
|
+
.sort((a, b) => b.number - a.number)
|
|
396
|
+
.slice(0, limit);
|
|
397
|
+
if (!versions.length)
|
|
398
|
+
return `Site ${id} has no published versions${env ? ` in ${env}` : ''}.`;
|
|
399
|
+
return versions
|
|
400
|
+
.map((v) => `- v${v.number} [${v.environment ?? 'production'}] ${v.published_at ?? v.created_at ?? ''} ${v.material || v.material_change ? '(material)' : ''}\n note: ${v.note ?? '—'}\n via ${v.channel ?? '—'}${v.actor_label ? ` · ${v.actor_label}` : ''} id ${v.id}`)
|
|
401
|
+
.join('\n');
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: 'logs_summary',
|
|
406
|
+
...meta('logs_summary'),
|
|
407
|
+
description: 'Aggregated consent figures from the daily roll-ups. Never returns subject-level rows.',
|
|
408
|
+
inputSchema: {
|
|
409
|
+
site: z.string().optional(),
|
|
410
|
+
from: z.string().optional().describe('YYYY-MM-DD'),
|
|
411
|
+
to: z.string().optional().describe('YYYY-MM-DD'),
|
|
412
|
+
env: ENV.optional(),
|
|
413
|
+
dimension: z.enum(['region', 'country', 'device', 'language', 'version', 'layout']).optional(),
|
|
414
|
+
},
|
|
415
|
+
readOnlyHint: true,
|
|
416
|
+
async run(ctx, args) {
|
|
417
|
+
const id = await resolveSite(ctx, args.site);
|
|
418
|
+
const today = new Date();
|
|
419
|
+
const iso = (d) => d.toISOString().slice(0, 10);
|
|
420
|
+
const to = typeof args.to === 'string' ? args.to : iso(today);
|
|
421
|
+
const from = typeof args.from === 'string' ? args.from : iso(new Date(today.getTime() - 29 * 86400000));
|
|
422
|
+
try {
|
|
423
|
+
const body = await ctx.api.request('GET', `/sites/${id}/analytics/daily`, {
|
|
424
|
+
query: { from, to, env: envOf(ctx, args.env), dimension: typeof args.dimension === 'string' ? args.dimension : undefined },
|
|
425
|
+
});
|
|
426
|
+
return [`Consent summary for site ${id}, ${from} → ${to} (aggregates only — no subject rows are ever returned).`, jsonBlock(body)].join('\n');
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
if (e instanceof CliError && (e.status === 404 || e.status === 405)) {
|
|
430
|
+
return `The analytics roll-up route \`GET /v1/sites/:id/analytics/daily\` is not available on this gateway yet, so no summary can be produced. Nothing was read.`;
|
|
431
|
+
}
|
|
432
|
+
throw e;
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'logs_export',
|
|
438
|
+
...meta('logs_export'),
|
|
439
|
+
description: 'Create a consent log export and return the export id and the signed download URL. Subject rows are never returned inline — download the artefact yourself if you need it.',
|
|
440
|
+
inputSchema: {
|
|
441
|
+
site: z.string().optional(),
|
|
442
|
+
from: z.string().optional().describe('YYYY-MM-DD'),
|
|
443
|
+
to: z.string().optional().describe('YYYY-MM-DD'),
|
|
444
|
+
format: z.enum(['jsonl', 'csv', 'bundle']).optional(),
|
|
445
|
+
env: ENV.optional(),
|
|
446
|
+
wait: z.number().int().min(0).max(300).optional().describe('seconds to wait for the job to finish (default 60)'),
|
|
447
|
+
},
|
|
448
|
+
readOnlyHint: false,
|
|
449
|
+
async run(ctx, args) {
|
|
450
|
+
const id = await resolveSite(ctx, args.site);
|
|
451
|
+
const format = (typeof args.format === 'string' ? args.format : 'jsonl');
|
|
452
|
+
const kind = format === 'csv' ? 'consent_csv' : format === 'bundle' ? 'audit_bundle' : 'consent_jsonl';
|
|
453
|
+
const job = await ctx.api.createExport(id, {
|
|
454
|
+
kind: kind,
|
|
455
|
+
from: typeof args.from === 'string' ? args.from : undefined,
|
|
456
|
+
to: typeof args.to === 'string' ? args.to : undefined,
|
|
457
|
+
env: envOf(ctx, args.env),
|
|
458
|
+
});
|
|
459
|
+
const wait = typeof args.wait === 'number' ? args.wait : 60;
|
|
460
|
+
const deadline = Date.now() + wait * 1000;
|
|
461
|
+
let current = job;
|
|
462
|
+
while (Date.now() < deadline && !['completed', 'failed', 'expired'].includes(current.status)) {
|
|
463
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
464
|
+
current = await ctx.api.exportJob(job.id);
|
|
465
|
+
}
|
|
466
|
+
const url = ctx.api.url(`/exports/${current.id}/download`);
|
|
467
|
+
return [
|
|
468
|
+
`Export ${current.id} — status ${current.status}, kind ${current.kind}.`,
|
|
469
|
+
current.record_count != null ? `Records: ${current.record_count}` : '',
|
|
470
|
+
current.sha256 ? `SHA-256: ${current.sha256}` : '',
|
|
471
|
+
current.expires_at ? `Expires: ${current.expires_at}` : '',
|
|
472
|
+
'',
|
|
473
|
+
current.status === 'completed'
|
|
474
|
+
? `Download (send the same bearer token):\n ${url}`
|
|
475
|
+
: `Not finished yet. Re-run this tool or fetch ${url} once the job completes.`,
|
|
476
|
+
'',
|
|
477
|
+
'No consent record is returned through this tool — only the export id and its URL.',
|
|
478
|
+
]
|
|
479
|
+
.filter(Boolean)
|
|
480
|
+
.join('\n');
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
// --- write tools: advertised only when the token carries the scope --------
|
|
484
|
+
{
|
|
485
|
+
name: 'classify_tracker',
|
|
486
|
+
...meta('classify_tracker'),
|
|
487
|
+
description: 'Move a service into a different consent category. `confirm:false` (default) shows the diff and the effect; `confirm:true` writes it. Requires PATCH /v1/services/:id on the gateway.',
|
|
488
|
+
inputSchema: {
|
|
489
|
+
site: z.string().optional(),
|
|
490
|
+
service: z.string().describe('service id, or the service / provider name as shown by get_compliance_status'),
|
|
491
|
+
category: z.string().describe('target category key, e.g. marketing, analytics, functional, necessary'),
|
|
492
|
+
legal_basis: z.enum(['consent', 'legitimate_interest', 'contract', 'legal_obligation']).optional(),
|
|
493
|
+
justification: z.string().optional(),
|
|
494
|
+
confirm: z.boolean().optional().describe('false (default) = dry run'),
|
|
495
|
+
},
|
|
496
|
+
readOnlyHint: false,
|
|
497
|
+
destructiveHint: false,
|
|
498
|
+
async run(ctx, args) {
|
|
499
|
+
const id = await resolveSite(ctx, args.site);
|
|
500
|
+
const body = await ctx.api.request('GET', `/sites/${id}/services`);
|
|
501
|
+
const services = body.items ?? [];
|
|
502
|
+
const ref = String(args.service);
|
|
503
|
+
const svc = services.find((s) => String(s.id) === ref) ??
|
|
504
|
+
services.find((s) => String(s.name ?? '').toLowerCase() === ref.toLowerCase()) ??
|
|
505
|
+
services.find((s) => String(s.provider_name ?? '').toLowerCase() === ref.toLowerCase());
|
|
506
|
+
if (!svc)
|
|
507
|
+
return `No service matches "${ref}". Site ${id} has: ${services.map((s) => `${s.name} (${s.id})`).join(', ') || 'none'}.`;
|
|
508
|
+
const patch = { category_key: String(args.category) };
|
|
509
|
+
if (typeof args.legal_basis === 'string')
|
|
510
|
+
patch.legal_basis = args.legal_basis;
|
|
511
|
+
if (typeof args.justification === 'string')
|
|
512
|
+
patch.justification = args.justification;
|
|
513
|
+
const after = mergePatch(svc, patch);
|
|
514
|
+
const diff = jsonDiff(svc, after, `service ${svc.id} (current)`, `service ${svc.id} (proposed)`);
|
|
515
|
+
if (args.confirm !== true) {
|
|
516
|
+
return dryRun(`Would move “${svc.name}” from category "${svc.category_key}" to "${args.category}".`, diff, null, [
|
|
517
|
+
'',
|
|
518
|
+
`Effect: every finding attributed to this service is re-categorised on the next scan, and the cookie declaration changes accordingly.`,
|
|
519
|
+
citationBlock(['unclassified_tracker'], { limit: 4 }),
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const updated = await ctx.api.request('PATCH', `/services/${svc.id}`, { body: patch });
|
|
524
|
+
return `Service ${svc.id} updated.\n${jsonBlock(updated)}`;
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
if (e instanceof CliError && (e.status === 404 || e.status === 405)) {
|
|
528
|
+
return `Nothing was changed: this gateway does not expose \`PATCH /v1/services/:id\` yet, so the confirmed write cannot be made from MCP. Change the category in the dashboard under Site → Services, or upgrade the gateway.`;
|
|
529
|
+
}
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
name: 'update_banner',
|
|
536
|
+
...meta('update_banner'),
|
|
537
|
+
description: 'Apply an RFC 7386 JSON Merge Patch to the banner draft. `confirm:false` (default) returns the unified diff and the lint of the result; `confirm:true` saves the draft. It never publishes — use push_config for that.',
|
|
538
|
+
inputSchema: {
|
|
539
|
+
site: z.string().optional(),
|
|
540
|
+
env: ENV.optional(),
|
|
541
|
+
patch: z.record(z.unknown()).describe('JSON Merge Patch: null removes a key, arrays and scalars replace'),
|
|
542
|
+
confirm: z.boolean().optional(),
|
|
543
|
+
},
|
|
544
|
+
readOnlyHint: false,
|
|
545
|
+
async run(ctx, args) {
|
|
546
|
+
const id = await resolveSite(ctx, args.site);
|
|
547
|
+
const env = envOf(ctx, args.env);
|
|
548
|
+
const draft = await ctx.api.getDraft(id, env);
|
|
549
|
+
const next = mergePatch(draft.config, args.patch);
|
|
550
|
+
const diff = jsonDiff(draft.config, next, `draft ${env} (current)`, `draft ${env} (patched)`);
|
|
551
|
+
const lint = await ctx.api.validate(id, next).catch(() => lintConfig(next));
|
|
552
|
+
if (args.confirm !== true) {
|
|
553
|
+
const stats = diffStats(draft.config, next);
|
|
554
|
+
return dryRun(`Would patch the ${env} draft of site ${id} (+${stats.added}/-${stats.removed} lines).`, diff, lint, [
|
|
555
|
+
'',
|
|
556
|
+
'The draft is not published by this tool — publishing is a separate, audited step (push_config).',
|
|
557
|
+
citationBlock(lint.issues.map((i) => i.code), { limit: 6 }),
|
|
558
|
+
]);
|
|
559
|
+
}
|
|
560
|
+
const saved = await ctx.api.putDraft(id, env, next, draft.updated_at);
|
|
561
|
+
return [`Draft saved for site ${id} (${env}) at ${saved.updated_at}. Nothing is live until it is published.`, lintLines(saved.lint ?? lint)].join('\n');
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
name: 'push_config',
|
|
566
|
+
...meta('push_config'),
|
|
567
|
+
description: 'Replace the draft with a full BannerConfig and publish it as a new immutable version. `confirm:false` (default) returns the unified diff against what is live plus the lint result and publishes nothing; `confirm:true` saves the draft and publishes. The audit trail records it as via MCP with this token’s name.',
|
|
568
|
+
inputSchema: {
|
|
569
|
+
site: z.string().optional(),
|
|
570
|
+
env: ENV.optional(),
|
|
571
|
+
config: z.record(z.unknown()).describe('the full BannerConfig — push merges nothing'),
|
|
572
|
+
note: z.string().optional().describe('change note, required for a confirmed publish'),
|
|
573
|
+
material: z.boolean().optional().describe('true if visitors must be asked again'),
|
|
574
|
+
confirm: z.boolean().optional(),
|
|
575
|
+
},
|
|
576
|
+
readOnlyHint: false,
|
|
577
|
+
destructiveHint: false,
|
|
578
|
+
async run(ctx, args) {
|
|
579
|
+
const id = await resolveSite(ctx, args.site);
|
|
580
|
+
const env = envOf(ctx, args.env);
|
|
581
|
+
const next = args.config;
|
|
582
|
+
const draft = await ctx.api.getDraft(id, env);
|
|
583
|
+
const lint = await ctx.api.validate(id, next).catch(() => lintConfig(next));
|
|
584
|
+
const diff = jsonDiff(draft.config, next, `draft ${env} (current)`, `draft ${env} (proposed)`);
|
|
585
|
+
if (args.confirm !== true) {
|
|
586
|
+
const stats = diffStats(draft.config, next);
|
|
587
|
+
const blocking = lint.issues.filter((i) => i.severity === 'error');
|
|
588
|
+
return dryRun(`Would publish a new ${env} version of site ${id} (+${stats.added}/-${stats.removed} lines).`, diff, lint, [
|
|
589
|
+
'',
|
|
590
|
+
blocking.length ? `This would be REJECTED: ${blocking.length} blocking lint error(s) — publish_config refuses errors.` : 'No blocking lint errors.',
|
|
591
|
+
args.material === true ? 'Marked material: every visitor is asked again.' : 'Not marked material.',
|
|
592
|
+
citationBlock(lint.issues.map((i) => i.code), { limit: 6 }),
|
|
593
|
+
]);
|
|
594
|
+
}
|
|
595
|
+
if (!args.note)
|
|
596
|
+
throw new CliError('usage', 'A confirmed publish needs a `note` — it is written into the version and into the audit trail.', 2);
|
|
597
|
+
await ctx.api.putDraft(id, env, next, draft.updated_at);
|
|
598
|
+
const version = await ctx.api.publish(id, { env, note: String(args.note), material: args.material === true });
|
|
599
|
+
return [
|
|
600
|
+
`Published v${version.number} of site ${id} (${env}).`,
|
|
601
|
+
`note: ${version.note ?? ''}`,
|
|
602
|
+
`channel: ${version.channel ?? 'mcp'}${version.actor_label ? ` · ${version.actor_label}` : ''}`,
|
|
603
|
+
`version id: ${version.id}`,
|
|
604
|
+
'',
|
|
605
|
+
lintLines(lint),
|
|
606
|
+
].join('\n');
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
name: 'rollback_version',
|
|
611
|
+
...meta('rollback_version'),
|
|
612
|
+
description: 'Restore a published version as a new version. `confirm:false` (default) shows the unified diff between what is live and the target plus the lint of the target; `confirm:true` performs the rollback.',
|
|
613
|
+
inputSchema: {
|
|
614
|
+
site: z.string().optional(),
|
|
615
|
+
env: ENV.optional(),
|
|
616
|
+
number: z.number().int().min(1).describe('the version number to restore'),
|
|
617
|
+
confirm: z.boolean().optional(),
|
|
618
|
+
},
|
|
619
|
+
readOnlyHint: false,
|
|
620
|
+
destructiveHint: true,
|
|
621
|
+
async run(ctx, args) {
|
|
622
|
+
const id = await resolveSite(ctx, args.site);
|
|
623
|
+
const env = envOf(ctx, args.env);
|
|
624
|
+
const number = Number(args.number);
|
|
625
|
+
const versions = (await ctx.api.versions(id)).filter((v) => (v.environment ?? 'production') === env);
|
|
626
|
+
const target = versions.find((v) => v.number === number);
|
|
627
|
+
if (!target)
|
|
628
|
+
return `Site ${id} has no v${number} in ${env}. Available: ${versions.map((v) => `v${v.number}`).join(', ') || 'none'}.`;
|
|
629
|
+
const live = [...versions].sort((a, b) => b.number - a.number)[0] ?? null;
|
|
630
|
+
const targetFull = await ctx.api.version(target.id);
|
|
631
|
+
const liveFull = live ? await ctx.api.version(live.id) : null;
|
|
632
|
+
const diff = jsonDiff(liveFull?.config ?? {}, targetFull.config ?? {}, `live v${live?.number ?? '—'}`, `target v${number}`);
|
|
633
|
+
const lint = lintConfig(targetFull.config ?? {});
|
|
634
|
+
if (args.confirm !== true) {
|
|
635
|
+
return dryRun(`Would restore v${number} of site ${id} (${env}) as a new version v${(live?.number ?? number) + 1}.`, diff, lint, [
|
|
636
|
+
'',
|
|
637
|
+
'Rollback never rewrites history: it publishes the old configuration again under a new number.',
|
|
638
|
+
]);
|
|
639
|
+
}
|
|
640
|
+
const created = await ctx.api.rollback(target.id, env);
|
|
641
|
+
return `Restored v${number} as v${created.number} of site ${id} (${env}). Version id ${created.id}.`;
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
/** Every tool the server can register: the phase-6 core plus the site-configuration tools. */
|
|
646
|
+
export const TOOLS = [...CORE_TOOLS, ...CONFIG_TOOLS];
|
|
647
|
+
/** name → definition. */
|
|
648
|
+
export const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
|
649
|
+
/** The tool/scope matrix the dashboard renders — the same table the tools above are built from. */
|
|
650
|
+
export { MCP_TOOL_MATRIX as TOOL_MATRIX };
|