@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server itself.
|
|
3
|
+
*
|
|
4
|
+
* Start-up order:
|
|
5
|
+
* 1. resolve the token (COOKIECRUMBS_TOKEN, else the CLI credentials file);
|
|
6
|
+
* 2. `GET /v1/me` to learn the scopes, the organisation and any site/environment restriction;
|
|
7
|
+
* 3. register only the tools those scopes allow — write tools disappear entirely for a
|
|
8
|
+
* read-only token — plus the three resources.
|
|
9
|
+
*
|
|
10
|
+
* When there is no token, or /me fails, the server still starts and still advertises the
|
|
11
|
+
* tools, but every call answers with what to do about it. A stdio server has nowhere to run a
|
|
12
|
+
* device flow, so it never tries.
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { lintConfig } from "../../config/src/index.js";
|
|
16
|
+
import { CliError } from "../../cli/src/errors.js";
|
|
17
|
+
import { McpApi, sanitiseClient } from "./client.js";
|
|
18
|
+
import { loginInstructions, resolveCredentials } from "./auth.js";
|
|
19
|
+
import { TOOLS, TOOL_MATRIX, resolveSite, toolAllowed } from "./tools.js";
|
|
20
|
+
export const SERVER_NAME = 'cookiecrumbs';
|
|
21
|
+
export const SERVER_VERSION = '0.7.0';
|
|
22
|
+
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
23
|
+
const failure = (s) => ({ content: [{ type: 'text', text: s }], isError: true });
|
|
24
|
+
/** RFC 9457 problem+json → something an agent can act on. */
|
|
25
|
+
export function describeError(e) {
|
|
26
|
+
if (e instanceof CliError) {
|
|
27
|
+
const lines = [`${e.code}: ${e.detail}`];
|
|
28
|
+
if (e.status)
|
|
29
|
+
lines.push(`HTTP ${e.status}`);
|
|
30
|
+
if (e.status === 401)
|
|
31
|
+
lines.push('The token is missing, revoked or expired. Create a new one in the dashboard (Developers → MCP and CLI) or run `npx cookiecrumbs login`, then restart this server.');
|
|
32
|
+
if (e.status === 403)
|
|
33
|
+
lines.push('The token does not carry the scope this call needs. Issue a token with the required scope.');
|
|
34
|
+
if (e.status === 402)
|
|
35
|
+
lines.push('The workspace plan does not include this feature.');
|
|
36
|
+
if (e.status === 422 && e.extra?.lint)
|
|
37
|
+
lines.push(`Lint: ${JSON.stringify(e.extra.lint)}`);
|
|
38
|
+
if (e.status === 429)
|
|
39
|
+
lines.push('Rate limited (600 requests per minute per token). Retry in a moment.');
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
43
|
+
}
|
|
44
|
+
export async function buildServer(opts = {}) {
|
|
45
|
+
const env = opts.env ?? process.env;
|
|
46
|
+
const resolved = resolveCredentials(env);
|
|
47
|
+
const token = opts.token !== undefined ? opts.token : resolved.token;
|
|
48
|
+
// MCP_CLIENT wins; otherwise the name the MCP client sends in `initialize`.
|
|
49
|
+
let clientName = env.MCP_CLIENT?.trim() || null;
|
|
50
|
+
let initialiseName = null;
|
|
51
|
+
const currentClient = () => sanitiseClient(clientName ?? initialiseName ?? 'unknown');
|
|
52
|
+
const api = opts.api ?? new McpApi(resolved.api, token, currentClient);
|
|
53
|
+
let me = opts.me ?? null;
|
|
54
|
+
let authError = null;
|
|
55
|
+
if (!token) {
|
|
56
|
+
authError = loginInstructions();
|
|
57
|
+
}
|
|
58
|
+
else if (!me) {
|
|
59
|
+
try {
|
|
60
|
+
me = await api.me();
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
authError = `Could not verify the token against ${resolved.api}.\n\n${describeError(e)}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const scopes = new Set(me?.token?.scopes ?? []);
|
|
67
|
+
const ctx = {
|
|
68
|
+
api,
|
|
69
|
+
me,
|
|
70
|
+
scopes,
|
|
71
|
+
siteId: me?.token?.site_id ?? null,
|
|
72
|
+
environment: me?.token?.environment ?? null,
|
|
73
|
+
sites: null,
|
|
74
|
+
};
|
|
75
|
+
// Without a working token we cannot know the scopes, so we advertise everything and let each
|
|
76
|
+
// call explain what to do — better than an empty, silent server.
|
|
77
|
+
const advertised = authError ? TOOLS : TOOLS.filter((t) => toolAllowed(t, scopes));
|
|
78
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
79
|
+
capabilities: { tools: {}, resources: {} },
|
|
80
|
+
instructions: instructionsFor(me, advertised, authError),
|
|
81
|
+
});
|
|
82
|
+
for (const def of advertised) {
|
|
83
|
+
server.registerTool(def.name, {
|
|
84
|
+
title: def.title,
|
|
85
|
+
description: def.kind === 'write' ? `${def.description}\n\nTwo-step: call with confirm:false (default) first and show the diff to the user.` : def.description,
|
|
86
|
+
inputSchema: def.inputSchema,
|
|
87
|
+
annotations: {
|
|
88
|
+
title: def.title,
|
|
89
|
+
readOnlyHint: def.readOnlyHint,
|
|
90
|
+
destructiveHint: def.destructiveHint ?? false,
|
|
91
|
+
idempotentHint: def.kind === 'read',
|
|
92
|
+
openWorldHint: true,
|
|
93
|
+
},
|
|
94
|
+
}, async (args) => {
|
|
95
|
+
if (authError && def.scopes.length)
|
|
96
|
+
return failure(authError);
|
|
97
|
+
try {
|
|
98
|
+
return text(await def.run(ctx, args ?? {}));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return failure(describeError(e));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
registerResources(server, ctx, () => authError);
|
|
106
|
+
return {
|
|
107
|
+
server,
|
|
108
|
+
ctx,
|
|
109
|
+
advertised,
|
|
110
|
+
scopes,
|
|
111
|
+
authError,
|
|
112
|
+
setClientName(name) {
|
|
113
|
+
initialiseName = name?.trim() || null;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function instructionsFor(me, advertised, authError) {
|
|
118
|
+
const lines = [
|
|
119
|
+
'CookieCrumbs — consent banner, hosted scans, consent logs and the compliance rules reference.',
|
|
120
|
+
'',
|
|
121
|
+
'House rules:',
|
|
122
|
+
' • Every write tool is two-step. Call it with confirm:false first, show the unified diff and the',
|
|
123
|
+
' lint result to the person you are working with, and only then call it again with confirm:true.',
|
|
124
|
+
' • Nothing here is legal advice. Quote the citations; do not paraphrase them into advice.',
|
|
125
|
+
' • No tool ever returns subject-level consent records. logs_export returns an id and a URL.',
|
|
126
|
+
];
|
|
127
|
+
if (authError) {
|
|
128
|
+
lines.push('', 'No usable token yet:', authError);
|
|
129
|
+
}
|
|
130
|
+
else if (me) {
|
|
131
|
+
lines.push('', `Token: ${me.token?.name ?? '—'} (${me.token?.kind ?? '—'}) scopes: ${(me.token?.scopes ?? []).join(', ') || 'none'}`, me.token?.site_id ? `Restricted to site ${me.token.site_id}${me.token.environment ? ` / ${me.token.environment}` : ''}.` : 'Not restricted to one site.', `Workspace: ${me.org?.name ?? me.org?.slug ?? me.org?.id ?? '—'} (plan ${me.org?.plan ?? '—'})`, `Tools available with these scopes: ${advertised.map((t) => t.name).join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
function registerResources(server, ctx, authError) {
|
|
136
|
+
const guard = async (fn) => {
|
|
137
|
+
const err = authError();
|
|
138
|
+
if (err)
|
|
139
|
+
return err;
|
|
140
|
+
try {
|
|
141
|
+
return await fn();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return describeError(e);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
server.registerResource('site-config', new ResourceTemplate('cookiecrumbs://sites/{siteId}/config', { list: undefined }), { title: 'Banner configuration', description: 'The production draft configuration of a site, with its legal lint.', mimeType: 'application/json' }, async (uri, vars) => {
|
|
148
|
+
const body = await guard(async () => {
|
|
149
|
+
const id = await resolveSite(ctx, String(vars.siteId));
|
|
150
|
+
const draft = await ctx.api.getDraft(id, ctx.environment === 'preview' ? 'preview' : 'production');
|
|
151
|
+
return JSON.stringify({ site_id: id, updated_at: draft.updated_at, lint: draft.lint ?? lintConfig(draft.config), config: draft.config }, null, 2);
|
|
152
|
+
});
|
|
153
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: body }] };
|
|
154
|
+
});
|
|
155
|
+
server.registerResource('site-declaration', new ResourceTemplate('cookiecrumbs://sites/{siteId}/declaration', { list: undefined }), { title: 'Cookie declaration', description: 'The current cookie declaration of a site as Markdown.', mimeType: 'text/markdown' }, async (uri, vars) => {
|
|
156
|
+
const body = await guard(async () => {
|
|
157
|
+
const id = await resolveSite(ctx, String(vars.siteId));
|
|
158
|
+
return ctx.api.declaration(id, 'md');
|
|
159
|
+
});
|
|
160
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: body }] };
|
|
161
|
+
});
|
|
162
|
+
server.registerResource('site-issues', new ResourceTemplate('cookiecrumbs://sites/{siteId}/issues', { list: undefined }), { title: 'Compliance issues', description: 'Open compliance issues of a site with the rule each one rests on.', mimeType: 'application/json' }, async (uri, vars) => {
|
|
163
|
+
const body = await guard(async () => {
|
|
164
|
+
const id = await resolveSite(ctx, String(vars.siteId));
|
|
165
|
+
const res = await ctx.api.request('GET', `/sites/${id}/issues`, { query: { status: 'open' } });
|
|
166
|
+
return JSON.stringify({ site_id: id, note: 'This is not a legal assessment.', issues: res.items ?? [] }, null, 2);
|
|
167
|
+
});
|
|
168
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: body }] };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export { TOOL_MATRIX, TOOLS };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers every tool file shares: the tool context and definition types, scope gating, site
|
|
3
|
+
* resolution (id / domain / name), lint and citation rendering, the dry-run block, and the scan
|
|
4
|
+
* lookups. tools.ts (the phase-6 core) and tools-config.ts (site configuration) both build on these.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { citationsFor, formatCitation, NOT_LEGAL_ADVICE } from "../../config/src/rules.js";
|
|
8
|
+
import { CliError } from "../../cli/src/errors.js";
|
|
9
|
+
import { stableJson } from "./diff.js";
|
|
10
|
+
// --- small helpers ---------------------------------------------------------
|
|
11
|
+
export const ENV = z.enum(['production', 'preview']);
|
|
12
|
+
/** A tool is available when the token carries one of its scopes (or it needs none). */
|
|
13
|
+
export function toolAllowed(def, scopes) {
|
|
14
|
+
return def.scopes.length === 0 || def.scopes.some((s) => scopes.has(s));
|
|
15
|
+
}
|
|
16
|
+
export function envOf(ctx, given) {
|
|
17
|
+
const v = typeof given === 'string' ? given : null;
|
|
18
|
+
if (v === 'production' || v === 'preview')
|
|
19
|
+
return v;
|
|
20
|
+
if (ctx.environment === 'production' || ctx.environment === 'preview')
|
|
21
|
+
return ctx.environment;
|
|
22
|
+
return 'production';
|
|
23
|
+
}
|
|
24
|
+
export async function allSites(ctx) {
|
|
25
|
+
if (!ctx.sites)
|
|
26
|
+
ctx.sites = await ctx.api.sites();
|
|
27
|
+
return ctx.sites;
|
|
28
|
+
}
|
|
29
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
/** Accept a site id, a primary domain or a name — agents rarely have the uuid to hand. */
|
|
31
|
+
export async function resolveSite(ctx, ref) {
|
|
32
|
+
const raw = typeof ref === 'string' ? ref.trim() : '';
|
|
33
|
+
if (!raw) {
|
|
34
|
+
if (ctx.siteId)
|
|
35
|
+
return ctx.siteId;
|
|
36
|
+
const sites = await allSites(ctx);
|
|
37
|
+
if (sites.length === 1)
|
|
38
|
+
return sites[0].id;
|
|
39
|
+
throw new CliError('site_required', `Pass \`site\`. This token can see: ${sites.map((s) => `${s.primary_domain} (${s.id})`).join(', ') || 'no sites'}.`, 2);
|
|
40
|
+
}
|
|
41
|
+
if (UUID.test(raw))
|
|
42
|
+
return raw;
|
|
43
|
+
const sites = await allSites(ctx);
|
|
44
|
+
const needle = raw.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
45
|
+
const hit = sites.find((s) => s.primary_domain?.toLowerCase() === needle) ??
|
|
46
|
+
sites.find((s) => s.name?.toLowerCase() === raw.toLowerCase()) ??
|
|
47
|
+
sites.find((s) => s.primary_domain?.toLowerCase().includes(needle));
|
|
48
|
+
if (!hit)
|
|
49
|
+
throw new CliError('not_found', `No site matches "${raw}". Known: ${sites.map((s) => s.primary_domain).join(', ') || 'none'}.`, 2);
|
|
50
|
+
return hit.id;
|
|
51
|
+
}
|
|
52
|
+
export function lintLines(lint) {
|
|
53
|
+
if (!lint)
|
|
54
|
+
return 'Lint: not available.';
|
|
55
|
+
const issues = lint.issues ?? [];
|
|
56
|
+
if (!issues.length)
|
|
57
|
+
return 'Lint: no issues.';
|
|
58
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
59
|
+
const sorted = [...issues].sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
|
|
60
|
+
const head = `Lint: ${issues.filter((i) => i.severity === 'error').length} error(s), ${issues.filter((i) => i.severity === 'warning').length} warning(s), ${issues.filter((i) => i.severity === 'info').length} info.`;
|
|
61
|
+
return [head, ...sorted.map((i) => ` [${i.severity}] ${i.code} @ ${i.path}: ${i.message}`)].join('\n');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The citations behind a set of issue/lint codes. `topics` narrows a broad code to the part of
|
|
65
|
+
* the reference that actually bears on this case (a marketing cookie does not need the ICO's
|
|
66
|
+
* analytics exception), and `limit` keeps a tool answer readable. Entries with a verified quote
|
|
67
|
+
* come first — an entry we could not verify is still shown, but it is never dressed up.
|
|
68
|
+
*/
|
|
69
|
+
export function citationBlock(codes, opts = {}) {
|
|
70
|
+
const seen = new Map();
|
|
71
|
+
for (const c of codes)
|
|
72
|
+
for (const r of citationsFor(c))
|
|
73
|
+
seen.set(r.id, r);
|
|
74
|
+
let picked = [...seen.values()];
|
|
75
|
+
if (opts.topics?.length) {
|
|
76
|
+
const narrowed = picked.filter((r) => opts.topics.includes(r.topic));
|
|
77
|
+
if (narrowed.length)
|
|
78
|
+
picked = narrowed;
|
|
79
|
+
}
|
|
80
|
+
picked.sort((a, b) => Number(!a.quote) - Number(!b.quote));
|
|
81
|
+
if (opts.limit)
|
|
82
|
+
picked = picked.slice(0, opts.limit);
|
|
83
|
+
if (!picked.length)
|
|
84
|
+
return '';
|
|
85
|
+
const lines = ['', 'Citations:'];
|
|
86
|
+
for (const r of picked) {
|
|
87
|
+
lines.push(` • ${formatCitation(r)}`);
|
|
88
|
+
if (r.quote)
|
|
89
|
+
lines.push(` “${r.quote}”`);
|
|
90
|
+
else
|
|
91
|
+
lines.push(` (no verified quotation on file) ${r.summary}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('', NOT_LEGAL_ADVICE);
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
/** Which corner of the reference bears on a category. */
|
|
97
|
+
export function topicsForCategory(category) {
|
|
98
|
+
switch (category) {
|
|
99
|
+
case 'necessary':
|
|
100
|
+
return ['essential_cookies', 'prior_consent'];
|
|
101
|
+
case 'analytics':
|
|
102
|
+
return ['prior_consent', 'analytics', 'consent_definition'];
|
|
103
|
+
case 'marketing':
|
|
104
|
+
return ['prior_consent', 'legal_basis', 'consent_definition'];
|
|
105
|
+
default:
|
|
106
|
+
return ['prior_consent', 'consent_definition'];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function renderRule(r) {
|
|
110
|
+
return [
|
|
111
|
+
`# ${r.id} (topic: ${r.topic})`,
|
|
112
|
+
`Source: ${r.source}`,
|
|
113
|
+
`URL: ${r.source_url}`,
|
|
114
|
+
`Published: ${r.published}${r.paragraph ? ` Paragraph: ${r.paragraph}` : ''}`,
|
|
115
|
+
r.quote ? `Quote: “${r.quote}”` : 'Quote: — none on file; the wording of this source was not verified, so nothing is quoted.',
|
|
116
|
+
`Summary: ${r.summary}`,
|
|
117
|
+
`Applies to: ${r.applies_to.join(', ')}`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
/** Dry-run block shared by every write tool. */
|
|
121
|
+
export function dryRun(what, diff, lint, extra = []) {
|
|
122
|
+
const stats = diff ? '' : 'Nothing would change — the target already has this content.\n';
|
|
123
|
+
return [
|
|
124
|
+
`DRY RUN — nothing was changed. ${what}`,
|
|
125
|
+
'',
|
|
126
|
+
stats + (diff ? '```diff\n' + diff + '\n```' : ''),
|
|
127
|
+
'',
|
|
128
|
+
lintLines(lint),
|
|
129
|
+
...extra,
|
|
130
|
+
'',
|
|
131
|
+
'Call the same tool again with `confirm: true` to apply it.',
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
export const jsonBlock = (v) => '```json\n' + stableJson(v) + '\n```';
|
|
135
|
+
export async function recentScans(ctx, site) {
|
|
136
|
+
const res = await ctx.api.request('GET', `/sites/${site}/scans`, { query: { limit: 20 } });
|
|
137
|
+
const items = res?.items ?? [];
|
|
138
|
+
const completed = items.filter((s) => s.status === 'completed');
|
|
139
|
+
return completed.length ? completed : items;
|
|
140
|
+
}
|
|
141
|
+
export async function latestCompletedScan(ctx, site) {
|
|
142
|
+
return (await recentScans(ctx, site))[0] ?? null;
|
|
143
|
+
}
|
|
144
|
+
export async function findingsOf(ctx, scanId, tab) {
|
|
145
|
+
const body = await ctx.api.request('GET', `/scans/${scanId}/findings`, { query: { format: 'json', tab } });
|
|
146
|
+
return { findings: body.findings ?? [], scan: body.scan ?? {} };
|
|
147
|
+
}
|