@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
package/LICENSE
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
CookieCrumbs Source-Available Licence
|
|
2
|
+
Version 1.0, 2 September 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 CookieCrumbs (cookiecrumbs.eu). All rights reserved.
|
|
5
|
+
|
|
6
|
+
This licence covers the CookieCrumbs software published by CookieCrumbs under it
|
|
7
|
+
(the "Software"): the `cookiecrumbs` command line, the `@cookiecrumbs/*` packages
|
|
8
|
+
(MCP server, framework bindings, configuration schema), the `cc.js` banner runtime
|
|
9
|
+
and the source files that accompany them. It also covers the CookieCrumbs brand
|
|
10
|
+
assets in the `brand/` directory (the "Brand Assets"), with the additional rules in
|
|
11
|
+
section 5.
|
|
12
|
+
|
|
13
|
+
The Software exists to be used with the CookieCrumbs service at https://cookiecrumbs.eu
|
|
14
|
+
(the "Service"). That is what this licence lets you do, including in commercial
|
|
15
|
+
projects, without letting anyone take the project and turn it into something else.
|
|
16
|
+
|
|
17
|
+
1. Definitions
|
|
18
|
+
|
|
19
|
+
"You" means the person or organisation exercising rights under this licence.
|
|
20
|
+
"Service Terms" means the terms of service, privacy policy and marketing
|
|
21
|
+
guidelines published at https://cookiecrumbs.eu, as amended from time to time.
|
|
22
|
+
"Modification" means any change to the Software or any work derived from it.
|
|
23
|
+
"Competing Service" means any product or service that offers consent management,
|
|
24
|
+
cookie banners, tracker scanning or consent-record keeping to third parties,
|
|
25
|
+
whether hosted, on-premise or embedded, other than the Service itself.
|
|
26
|
+
|
|
27
|
+
2. Permitted use
|
|
28
|
+
|
|
29
|
+
Subject to the conditions in section 3, CookieCrumbs grants You a worldwide,
|
|
30
|
+
royalty-free, non-exclusive, non-transferable licence to:
|
|
31
|
+
|
|
32
|
+
a) install, run and use the Software, including for commercial purposes, on any
|
|
33
|
+
number of sites and machines, in order to use, configure, automate, integrate
|
|
34
|
+
with or extend the Service;
|
|
35
|
+
b) copy the Software into Your own repositories, build pipelines, containers and
|
|
36
|
+
developer tooling for the purpose in (a);
|
|
37
|
+
c) make Modifications for Your own use with the Service, and contribute them back
|
|
38
|
+
to CookieCrumbs;
|
|
39
|
+
d) redistribute unmodified copies of the Software, and of Your Modifications under
|
|
40
|
+
(c), provided this licence and all copyright, licence and attribution notices
|
|
41
|
+
stay attached and unaltered.
|
|
42
|
+
|
|
43
|
+
3. Conditions
|
|
44
|
+
|
|
45
|
+
a) The Software may only be used together with the Service under an active
|
|
46
|
+
CookieCrumbs account, in accordance with the Service Terms.
|
|
47
|
+
b) You may not use the Software, any part of it or any Modification to build,
|
|
48
|
+
offer, host or operate a Competing Service, nor to help anyone else do so.
|
|
49
|
+
c) You may not remove, hide, alter or circumvent any licence notice, copyright
|
|
50
|
+
notice, attribution, plan entitlement check, telemetry the Service Terms
|
|
51
|
+
disclose, or the "Made & Hosted in Europe by CookieCrumbs" line where the
|
|
52
|
+
Service Terms require it.
|
|
53
|
+
d) You may not use the Software in a way that circumvents the limits of Your plan
|
|
54
|
+
or gains access to data that Your account is not entitled to.
|
|
55
|
+
e) You may not sublicense, sell, rent or lease the Software, or represent that
|
|
56
|
+
You own it or that CookieCrumbs endorses Your product.
|
|
57
|
+
f) You may not register, or attempt to register, any trademark, domain name or
|
|
58
|
+
package name that contains "CookieCrumbs" or is confusingly similar to it.
|
|
59
|
+
|
|
60
|
+
Any use outside these conditions requires a separate written agreement with
|
|
61
|
+
CookieCrumbs (hello@cookiecrumbs.eu).
|
|
62
|
+
|
|
63
|
+
4. Contributions
|
|
64
|
+
|
|
65
|
+
If You send a Modification to CookieCrumbs (for example as a pull request), You
|
|
66
|
+
grant CookieCrumbs a perpetual, worldwide, irrevocable, royalty-free licence to
|
|
67
|
+
use, modify, sublicense and distribute that Modification as part of the Software
|
|
68
|
+
and the Service under any terms. You confirm that You have the right to grant
|
|
69
|
+
this licence.
|
|
70
|
+
|
|
71
|
+
5. Brand Assets
|
|
72
|
+
|
|
73
|
+
The CookieCrumbs name, the cookie-and-crumbs mark, the wordmark and the logo
|
|
74
|
+
lockups are trademarks of CookieCrumbs and are not licensed under sections 2 to
|
|
75
|
+
4. You may use the Brand Assets, unaltered and as provided in the `brand/`
|
|
76
|
+
directory, only:
|
|
77
|
+
|
|
78
|
+
a) to state truthfully that a site, product or integration uses or works with
|
|
79
|
+
CookieCrumbs (for example "Made with CookieCrumbs", "Consent by CookieCrumbs"
|
|
80
|
+
or the attribution line the banner renders);
|
|
81
|
+
b) in articles, talks, documentation and comparisons that refer to CookieCrumbs;
|
|
82
|
+
c) in the ways the marketing guidelines at https://cookiecrumbs.eu expressly
|
|
83
|
+
allow.
|
|
84
|
+
|
|
85
|
+
You may not change the colours, proportions or shapes of the Brand Assets, put
|
|
86
|
+
them inside other marks, use them as or in Your own logo or app icon, or use them
|
|
87
|
+
in a way that suggests that CookieCrumbs made, endorses or is responsible for
|
|
88
|
+
Your product. The Brand Assets must always be recognisable as belonging to
|
|
89
|
+
CookieCrumbs.
|
|
90
|
+
|
|
91
|
+
6. Third-party components
|
|
92
|
+
|
|
93
|
+
The Software depends on open-source packages that keep their own licences (see
|
|
94
|
+
each package's `node_modules` and `package-lock.json`). The wordmark is set in
|
|
95
|
+
Space Grotesk, which is licensed under the SIL Open Font License 1.1. Nothing in
|
|
96
|
+
this licence restricts those components beyond their own terms.
|
|
97
|
+
|
|
98
|
+
7. No warranty
|
|
99
|
+
|
|
100
|
+
THE SOFTWARE AND THE BRAND ASSETS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
101
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
102
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. The
|
|
103
|
+
Software helps You operate a consent banner; it does not make Your site
|
|
104
|
+
compliant by itself and nothing it outputs is legal advice.
|
|
105
|
+
|
|
106
|
+
8. Limitation of liability
|
|
107
|
+
|
|
108
|
+
TO THE EXTENT PERMITTED BY LAW, COOKIECRUMBS IS NOT LIABLE FOR ANY INDIRECT,
|
|
109
|
+
INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR LOSS OF PROFITS,
|
|
110
|
+
DATA OR GOODWILL, ARISING FROM THE SOFTWARE OR THIS LICENCE, EVEN IF ADVISED OF
|
|
111
|
+
THE POSSIBILITY. Liability for intent, gross negligence, injury to life, body or
|
|
112
|
+
health, and under mandatory product-liability law is not limited.
|
|
113
|
+
|
|
114
|
+
9. Termination
|
|
115
|
+
|
|
116
|
+
This licence ends automatically if You breach section 3 or section 5 and do not
|
|
117
|
+
cure the breach within 30 days of being notified, or immediately on a breach of
|
|
118
|
+
section 3(b) or 3(f). It also ends when Your CookieCrumbs account is closed. On
|
|
119
|
+
termination You must stop using the Software and the Brand Assets and delete
|
|
120
|
+
Your copies, except that copies of the consent records and configurations that
|
|
121
|
+
belong to You remain Yours. Sections 4, 7, 8 and 10 survive termination.
|
|
122
|
+
|
|
123
|
+
10. General
|
|
124
|
+
|
|
125
|
+
This licence is governed by the laws of the Federal Republic of Germany,
|
|
126
|
+
excluding its conflict-of-law rules and the UN Convention on Contracts for the
|
|
127
|
+
International Sale of Goods. If any provision is unenforceable, the rest remains
|
|
128
|
+
in force. CookieCrumbs may publish new versions of this licence; new versions
|
|
129
|
+
apply to releases published under them, never retroactively. This is the whole
|
|
130
|
+
agreement about the Software and the Brand Assets, together with the Service
|
|
131
|
+
Terms for Your use of the Service.
|
|
132
|
+
|
|
133
|
+
Questions about this licence, or a request for rights it does not grant:
|
|
134
|
+
hello@cookiecrumbs.eu
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# @cookiecrumbs-eu/mcp
|
|
2
|
+
|
|
3
|
+
The CookieCrumbs [Model Context Protocol](https://modelcontextprotocol.io) server. It gives a coding
|
|
4
|
+
agent read access to your consent banner, your hosted scans and your compliance issues — and, when the
|
|
5
|
+
token carries a write scope, **two-step** writes that land in the audit trail as *via MCP*.
|
|
6
|
+
|
|
7
|
+
stdio only. Node 22+.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
claude mcp add cookiecrumbs -- npx -y @cookiecrumbs-eu/mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// .cursor/mcp.json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"cookiecrumbs": {
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "@cookiecrumbs-eu/mcp"],
|
|
20
|
+
"env": { "COOKIECRUMBS_TOKEN": "cc_live_…" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The token
|
|
27
|
+
|
|
28
|
+
The server reads the token from, in order:
|
|
29
|
+
|
|
30
|
+
1. `COOKIECRUMBS_TOKEN`;
|
|
31
|
+
2. the credentials file the CLI writes (`%APPDATA%\cookiecrumbs\credentials.json` on Windows,
|
|
32
|
+
`~/.config/cookiecrumbs/credentials.json` elsewhere) — one credential store, shared with
|
|
33
|
+
`npx cookiecrumbs`.
|
|
34
|
+
|
|
35
|
+
If neither exists the server still starts, and the first tool call answers with the two ways to fix it.
|
|
36
|
+
It does **not** run a device flow: a stdio server has no terminal to print a user code on and no browser
|
|
37
|
+
to open. Run `npx cookiecrumbs login` once, or create a machine token in the dashboard under
|
|
38
|
+
**Developers → MCP**, then restart the server.
|
|
39
|
+
|
|
40
|
+
Machine tokens can be restricted to one site and one environment. The server picks that up from
|
|
41
|
+
`GET /v1/me` and every tool then defaults to that site.
|
|
42
|
+
|
|
43
|
+
| Variable | Meaning |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `COOKIECRUMBS_TOKEN` | `cc_live_…` bearer token |
|
|
46
|
+
| `COOKIECRUMBS_API` | gateway base (default: the hosted gateway) |
|
|
47
|
+
| `MCP_CLIENT` | overrides the client name in `X-CookieCrumbs-Client: mcp/<client>` |
|
|
48
|
+
| `COOKIECRUMBS_CREDENTIALS` | path to the CLI credentials file |
|
|
49
|
+
|
|
50
|
+
## Tools, and the scopes they need
|
|
51
|
+
|
|
52
|
+
A tool is **advertised only when the token carries one of its scopes**. Hand an agent a read-only
|
|
53
|
+
token and the write tools are not in its tool list at all — there is nothing for it to be tempted by.
|
|
54
|
+
|
|
55
|
+
| Tool | Kind | Scope |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `list_sites` | read | `sites:read` |
|
|
58
|
+
| `get_site` | read | `sites:read` |
|
|
59
|
+
| `get_banner` | read | `banner:read` |
|
|
60
|
+
| `get_compliance_status` | read | `banner:read` |
|
|
61
|
+
| `scan_site` | read | `scans:run` |
|
|
62
|
+
| `get_scan` | read | `scans:read` |
|
|
63
|
+
| `list_findings` | read | `scans:read` |
|
|
64
|
+
| `explain_classification` | read | `scans:read` |
|
|
65
|
+
| `check_first_layer` | read | `banner:read` |
|
|
66
|
+
| `rules_reference` | read | — |
|
|
67
|
+
| `get_declaration` | read | `banner:read` |
|
|
68
|
+
| `list_versions` | read | `banner:read` |
|
|
69
|
+
| `logs_summary` | read | `analytics:read` |
|
|
70
|
+
| `logs_export` | read | `logs:export` |
|
|
71
|
+
| `classify_tracker` | **write, two-step** | `banner:write` |
|
|
72
|
+
| `update_banner` | **write, two-step** | `banner:write` |
|
|
73
|
+
| `push_config` | **write, two-step** | `banner:publish` or `banner:write` |
|
|
74
|
+
| `rollback_version` | **write, two-step** | `banner:publish` |
|
|
75
|
+
|
|
76
|
+
### The whole site (`src/tools-config.ts`)
|
|
77
|
+
|
|
78
|
+
Everything the dashboard can change, an agent can change too — and nothing more (team, billing and
|
|
79
|
+
tokens stay in the dashboard). Same rules: scope-gated, two-step, audited.
|
|
80
|
+
|
|
81
|
+
| Tool | Kind | Scope |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `create_site` | **write, two-step** | `sites:write` |
|
|
84
|
+
| `update_site` — name, retention, settings | **write, two-step** | `sites:write` |
|
|
85
|
+
| `list_domains` | read | `sites:read` |
|
|
86
|
+
| `verify_domain` — re-issues the DNS TXT / meta token | **write, two-step** | `sites:write` |
|
|
87
|
+
| `list_scans` · `get_scan_diff` · `get_scan_schedule` · `get_install_status` | read | `scans:read` |
|
|
88
|
+
| `set_scan_schedule` — cadence, pages, states, URLs, patterns, robots, pause | **write, two-step** | `scans:run` |
|
|
89
|
+
| `check_install` — queue an install check, optionally wait | read | `scans:run` |
|
|
90
|
+
| `list_alerts` | read | `scans:read` |
|
|
91
|
+
| `acknowledge_alert` · `resolve_alert` | **write, two-step** | `scans:run` |
|
|
92
|
+
| `list_services` | read | `banner:read` |
|
|
93
|
+
| `add_service` · `update_service` · `delete_service` (destructive) | **write, two-step** | `banner:write` |
|
|
94
|
+
| `suppress_issue` (reason required) · `unsuppress_issue` | **write, two-step** | `banner:write` |
|
|
95
|
+
| `promote_version` — preview → production, diff first | **write, two-step** | `banner:publish` |
|
|
96
|
+
| `list_templates` · `get_template` | read | `banner:read` |
|
|
97
|
+
| `apply_template` · `save_template` · `delete_template` (destructive) | **write, two-step** | `banner:write` |
|
|
98
|
+
| `list_webhooks` · `list_alert_channels` · `get_usage` · `list_export_destinations` | read | `sites:read` |
|
|
99
|
+
| `create_webhook` (secret shown once) · `update_webhook` · `delete_webhook` (destructive) | **write, two-step** | `sites:write` |
|
|
100
|
+
| `create_alert_channel` · `update_alert_channel` · `delete_alert_channel` (destructive) | **write, two-step** | `sites:write` |
|
|
101
|
+
| `list_export_schedules` | read | `logs:export` |
|
|
102
|
+
| `create_export_schedule` · `update_export_schedule` · `delete_export_schedule` (destructive) | **write, two-step** | `logs:export` |
|
|
103
|
+
|
|
104
|
+
Site, service, template, webhook and channel arguments accept an id **or a name** (a domain for
|
|
105
|
+
sites, a URL for webhooks); the tool says what it matched. Deletes carry `destructiveHint` so an
|
|
106
|
+
agent host can ask first.
|
|
107
|
+
|
|
108
|
+
The table lives in [`src/matrix.ts`](src/matrix.ts). `src/tools.ts` and `src/tools-config.ts` build
|
|
109
|
+
their tool definitions from it and the dashboard renders it, so the three can never disagree.
|
|
110
|
+
|
|
111
|
+
## Resources
|
|
112
|
+
|
|
113
|
+
* `cookiecrumbs://sites/<id>/config` — the draft configuration with its legal lint
|
|
114
|
+
* `cookiecrumbs://sites/<id>/declaration` — the current cookie declaration as Markdown
|
|
115
|
+
* `cookiecrumbs://sites/<id>/issues` — open compliance issues with their rules
|
|
116
|
+
|
|
117
|
+
`<id>` accepts a site id, a primary domain or a site name.
|
|
118
|
+
|
|
119
|
+
## The rules the server keeps
|
|
120
|
+
|
|
121
|
+
**Every write is two-step.** The first call — `confirm: false`, which is the default — returns a
|
|
122
|
+
unified diff of what would change (plus the legal lint where a banner config is involved), and
|
|
123
|
+
changes nothing. Only `confirm: true` writes. A confirmed `push_config` also requires a `note`, which
|
|
124
|
+
goes into the version and into the audit trail. Plan limits answer with the feature they need (402),
|
|
125
|
+
and a value the plan clamped (a daily scan cadence on a monthly plan) is reported in the answer,
|
|
126
|
+
never silently applied.
|
|
127
|
+
|
|
128
|
+
**Nothing subject-level leaves the API.** `logs_export` returns an export id and the signed download
|
|
129
|
+
URL. It never streams consent records into a conversation. `logs_summary` reads daily roll-ups only.
|
|
130
|
+
|
|
131
|
+
**Classifications are quoted, not guessed.** `explain_classification` answers with the
|
|
132
|
+
`tracker_patterns` row that matched, the `tracker_db` entry behind it — including its **source** and
|
|
133
|
+
its **licence** — and the regime rule that follows from the category. If the finding is unclassified it
|
|
134
|
+
says exactly that, and offers nothing else.
|
|
135
|
+
|
|
136
|
+
**Citations are real or absent.** The rules reference (`@cookiecrumbs/config/rules`) carries verbatim
|
|
137
|
+
wording only where it was verified against the primary source; everything else carries a summary in our
|
|
138
|
+
own words and `quote: null`. Nothing the server returns is a legal assessment, and every citation block
|
|
139
|
+
says so.
|
|
140
|
+
|
|
141
|
+
**Every request is attributable.** `X-CookieCrumbs-Client: mcp/<client>` goes out on every call — the
|
|
142
|
+
client name comes from `MCP_CLIENT` or from the MCP `initialize` params — so the gateway records
|
|
143
|
+
`channel = 'mcp'` and the audit row reads *via MCP · <token name>*.
|
|
144
|
+
|
|
145
|
+
## Example
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
> why is _fbp classified as Marketing on e2e.cookiecrumbs.test?
|
|
149
|
+
|
|
150
|
+
# http_cookie `_fbp` on e2e.cookiecrumbs.test (first party)
|
|
151
|
+
## Category: marketing
|
|
152
|
+
Decided by: service (classification_source `service`, confidence 1)
|
|
153
|
+
## The tracker database row this came from
|
|
154
|
+
provider: Meta Pixel (facebook.com)
|
|
155
|
+
source: cookiecrumbs
|
|
156
|
+
licence: CC0
|
|
157
|
+
Consent Mode: ad_storage, ad_user_data, ad_personalization
|
|
158
|
+
## The pattern that matched
|
|
159
|
+
tracker_patterns row …: kind=cookie_name, pattern=`_fbp`, match_type=exact
|
|
160
|
+
## The regime rule applied
|
|
161
|
+
Under an opt-in regime … nothing in category "marketing" may be stored or read before
|
|
162
|
+
the visitor gives consent …
|
|
163
|
+
|
|
164
|
+
Citations:
|
|
165
|
+
• Directive 2002/58/EC (ePrivacy Directive), Article 5(3) — “Member States shall ensure that …”
|
|
166
|
+
• EDPB, Cookie Banner Taskforce report (para. 24) — “… the legal basis for the placement/reading
|
|
167
|
+
of cookies pursuant to Article 5 (3) cannot be the legitimate interests of the controller.”
|
|
168
|
+
|
|
169
|
+
This is not a legal assessment.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Known gaps
|
|
173
|
+
|
|
174
|
+
* `classify_tracker` with `confirm: true` calls `PATCH /v1/services/:id`. Where a gateway does not
|
|
175
|
+
expose that route yet, the dry run still works and the confirmed call answers with what to do
|
|
176
|
+
instead of a raw error.
|
|
177
|
+
* `logs_summary` calls `GET /v1/sites/:id/analytics/daily` and needs the `analytics:read` scope. Where
|
|
178
|
+
the route or the scope is not deployed, the tool is simply not advertised, or answers that nothing
|
|
179
|
+
was read.
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run build -w @cookiecrumbs-eu/mcp # tsc → dist/mcp/src/index.js (the bin)
|
|
185
|
+
npm test -w @cookiecrumbs-eu/mcp # tool registration by scope, diff rendering, error mapping
|
|
186
|
+
```
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { normaliseApi } from "./util.js";
|
|
4
|
+
const exitFor = (status) => (status === 409 || status === 412 || status === 400 ? 2 : 1);
|
|
5
|
+
function codeFromProblem(problem, status) {
|
|
6
|
+
if (typeof problem.code === 'string' && problem.code)
|
|
7
|
+
return problem.code;
|
|
8
|
+
if (typeof problem.type === 'string' && problem.type && problem.type !== 'about:blank') {
|
|
9
|
+
const tail = problem.type.split(/[/#:]/).filter(Boolean).pop();
|
|
10
|
+
if (tail)
|
|
11
|
+
return tail;
|
|
12
|
+
}
|
|
13
|
+
if (typeof problem.title === 'string' && problem.title)
|
|
14
|
+
return problem.title.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
15
|
+
return `http_${status}`;
|
|
16
|
+
}
|
|
17
|
+
/** Map a non-2xx response (RFC 9457 problem+json when available) to a CliError. */
|
|
18
|
+
export function problemToError(status, body, fallbackText = '') {
|
|
19
|
+
const problem = (typeof body === 'object' && body !== null ? body : {});
|
|
20
|
+
const code = codeFromProblem(problem, status);
|
|
21
|
+
const detail = typeof problem.detail === 'string' && problem.detail ? problem.detail : typeof problem.title === 'string' && problem.title ? problem.title : fallbackText || `HTTP ${status}`;
|
|
22
|
+
const extra = {};
|
|
23
|
+
for (const k of ['errors', 'lint', 'issues', 'instance', 'type', 'title'])
|
|
24
|
+
if (problem[k] !== undefined)
|
|
25
|
+
extra[k] = problem[k];
|
|
26
|
+
let exit = exitFor(status);
|
|
27
|
+
if (status === 401)
|
|
28
|
+
exit = 1;
|
|
29
|
+
if (status === 403)
|
|
30
|
+
exit = 1;
|
|
31
|
+
return new CliError(code, detail, exit, { status, extra });
|
|
32
|
+
}
|
|
33
|
+
export class Api {
|
|
34
|
+
base;
|
|
35
|
+
token;
|
|
36
|
+
client;
|
|
37
|
+
constructor(base, token, version) {
|
|
38
|
+
this.base = normaliseApi(base);
|
|
39
|
+
this.token = token;
|
|
40
|
+
this.client = `cli/${version}`;
|
|
41
|
+
}
|
|
42
|
+
url(path, query) {
|
|
43
|
+
const u = new URL(`${this.base}/v1${path.startsWith('/') ? path : '/' + path}`);
|
|
44
|
+
if (query)
|
|
45
|
+
for (const [k, v] of Object.entries(query))
|
|
46
|
+
if (v !== undefined && v !== '')
|
|
47
|
+
u.searchParams.set(k, String(v));
|
|
48
|
+
return u.toString();
|
|
49
|
+
}
|
|
50
|
+
headers(opts, hasBody) {
|
|
51
|
+
const h = {
|
|
52
|
+
'X-CookieCrumbs-Client': this.client,
|
|
53
|
+
'X-Request-Id': randomUUID(),
|
|
54
|
+
Accept: 'application/json, application/problem+json;q=0.9, */*;q=0.1',
|
|
55
|
+
...(opts.headers ?? {}),
|
|
56
|
+
};
|
|
57
|
+
if (hasBody)
|
|
58
|
+
h['Content-Type'] = 'application/json';
|
|
59
|
+
if (!opts.anonymous) {
|
|
60
|
+
if (!this.token)
|
|
61
|
+
throw new CliError('not_logged_in', 'No API token. Run `cookiecrumbs login` or set COOKIECRUMBS_TOKEN.', 2);
|
|
62
|
+
h.Authorization = `Bearer ${this.token}`;
|
|
63
|
+
}
|
|
64
|
+
return h;
|
|
65
|
+
}
|
|
66
|
+
async send(method, path, opts) {
|
|
67
|
+
const hasBody = opts.body !== undefined;
|
|
68
|
+
const url = this.url(path, opts.query);
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = await fetch(url, { method, headers: this.headers(opts, hasBody), body: hasBody ? JSON.stringify(opts.body) : undefined });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
const msg = e instanceof Error ? (e.cause instanceof Error ? `${e.message}: ${e.cause.message}` : e.message) : String(e);
|
|
75
|
+
throw new CliError('network', `Could not reach ${url}: ${msg}`, 1);
|
|
76
|
+
}
|
|
77
|
+
if (res.ok || opts.allowStatus?.includes(res.status))
|
|
78
|
+
return res;
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
let body = null;
|
|
81
|
+
try {
|
|
82
|
+
body = text ? JSON.parse(text) : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
body = null;
|
|
86
|
+
}
|
|
87
|
+
throw problemToError(res.status, body, text.slice(0, 300));
|
|
88
|
+
}
|
|
89
|
+
async request(method, path, opts = {}) {
|
|
90
|
+
const res = await this.send(method, path, opts);
|
|
91
|
+
if (res.status === 204)
|
|
92
|
+
return undefined;
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
if (!text)
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new CliError('bad_response', `Expected JSON from ${method} ${path}, got: ${text.slice(0, 120)}`, 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async requestText(method, path, opts = {}) {
|
|
104
|
+
const res = await this.send(method, path, opts);
|
|
105
|
+
return res.text();
|
|
106
|
+
}
|
|
107
|
+
async download(path, opts = {}) {
|
|
108
|
+
const res = await this.send('GET', path, { ...opts, headers: { Accept: '*/*', ...(opts.headers ?? {}) } });
|
|
109
|
+
const headers = {};
|
|
110
|
+
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
|
111
|
+
const cd = headers['content-disposition'] ?? '';
|
|
112
|
+
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd);
|
|
113
|
+
return { bytes: Buffer.from(await res.arrayBuffer()), headers, filename: m ? decodeURIComponent(m[1]) : null };
|
|
114
|
+
}
|
|
115
|
+
// ---- typed routes ------------------------------------------------------
|
|
116
|
+
health = () => this.request('GET', '/health', { anonymous: true });
|
|
117
|
+
me = () => this.request('GET', '/me');
|
|
118
|
+
sites = () => this.request('GET', '/sites').then(unwrapList('sites'));
|
|
119
|
+
site = (id) => this.request('GET', `/sites/${id}`);
|
|
120
|
+
createSite = (body) => this.request('POST', '/sites', { body });
|
|
121
|
+
updateSite = (id, body) => this.request('PATCH', `/sites/${id}`, { body });
|
|
122
|
+
// ---- site configuration (0063) -----------------------------------------
|
|
123
|
+
scanSchedule = (site) => this.request('GET', `/sites/${site}/scan-schedule`);
|
|
124
|
+
updateScanSchedule = (site, body) => this.request('PATCH', `/sites/${site}/scan-schedule`, { body });
|
|
125
|
+
installChecks = (site, limit) => this.request('GET', `/sites/${site}/install-checks`, { query: { limit } }).then((r) => r.items ?? []);
|
|
126
|
+
requestInstallCheck = (site, url) => this.request('POST', `/sites/${site}/install-checks`, { body: url ? { url } : {} });
|
|
127
|
+
domains = (site) => this.request('GET', `/sites/${site}/domains`).then((r) => r.items ?? []);
|
|
128
|
+
verifyDomain = (id, method) => this.request('POST', `/domains/${id}/verify`, { body: { method } });
|
|
129
|
+
createService = (site, body) => this.request('POST', `/sites/${site}/services`, { body });
|
|
130
|
+
updateService = (id, body) => this.request('PATCH', `/services/${id}`, { body });
|
|
131
|
+
deleteService = (id) => this.request('DELETE', `/services/${id}`);
|
|
132
|
+
issuesOf = (site, status = 'open') => this.request('GET', `/sites/${site}/issues`, { query: { status } }).then((r) => r.items ?? []);
|
|
133
|
+
suppressIssue = (id, reason) => this.request('POST', `/issues/${id}/suppress`, { body: { reason } });
|
|
134
|
+
unsuppressIssue = (id) => this.request('POST', `/issues/${id}/unsuppress`, { body: {} });
|
|
135
|
+
template = (id) => this.request('GET', `/templates/${id}`);
|
|
136
|
+
createTemplate = (body) => this.request('POST', '/templates', { body });
|
|
137
|
+
updateTemplate = (id, body) => this.request('PUT', `/templates/${id}`, { body });
|
|
138
|
+
deleteTemplate = (id) => this.request('DELETE', `/templates/${id}`);
|
|
139
|
+
getDraft = (site, env) => this.request('GET', `/sites/${site}/config/draft`, { query: { env } });
|
|
140
|
+
putDraft = (site, env, config, ifMatch) => this.request('PUT', `/sites/${site}/config/draft`, { query: { env }, body: config, headers: ifMatch ? { 'If-Match': ifMatch } : undefined });
|
|
141
|
+
validate = (site, config) => this.request('POST', `/sites/${site}/config/validate`, { body: config });
|
|
142
|
+
publish = (site, body) => this.request('POST', `/sites/${site}/versions`, { body });
|
|
143
|
+
versions = (site) => this.request('GET', `/sites/${site}/versions`).then(unwrapList('versions'));
|
|
144
|
+
version = (id) => this.request('GET', `/versions/${id}`);
|
|
145
|
+
rollback = (id, env) => this.request('POST', `/versions/${id}/rollback`, { body: { env } });
|
|
146
|
+
promote = (id) => this.request('POST', `/versions/${id}/promote`, { body: {} });
|
|
147
|
+
services = (site, status) => this.request('GET', `/sites/${site}/services`, { query: { status } }).then(unwrapList('items'));
|
|
148
|
+
issues = (site) => this.request('GET', `/sites/${site}/issues`);
|
|
149
|
+
createScan = (site, body) => this.request('POST', `/sites/${site}/scans`, { body });
|
|
150
|
+
scan = (id) => this.request('GET', `/scans/${id}`);
|
|
151
|
+
findings = (id, format, tab = 'all') => this.requestText('GET', `/scans/${id}/findings`, { query: { format, tab }, headers: { Accept: '*/*' } });
|
|
152
|
+
scanDiff = (id) => this.request('GET', `/scans/${id}/diff`);
|
|
153
|
+
declaration = (site, format, lang) => this.requestText('GET', `/sites/${site}/declaration.${format}`, { query: { lang }, headers: { Accept: '*/*' } });
|
|
154
|
+
consents = (site, q) => this.request('GET', `/sites/${site}/consents`, { query: q });
|
|
155
|
+
createExport = (site, body) => this.request('POST', `/sites/${site}/exports`, { body });
|
|
156
|
+
exportJob = (id) => this.request('GET', `/exports/${id}`);
|
|
157
|
+
exportDownload = (id) => this.download(`/exports/${id}/download`);
|
|
158
|
+
tokens = () => this.request('GET', '/tokens').then(unwrapList('tokens'));
|
|
159
|
+
revokeToken = (id, note) => this.request('DELETE', `/tokens/${id}`, { body: note ? { note } : undefined });
|
|
160
|
+
// ---- phase 5 ----------------------------------------------------------
|
|
161
|
+
alerts = (q) => this.request('GET', '/alerts', { query: q }).then((v) => Array.isArray(v) ? { items: v } : { items: v.items ?? [], counts: v.counts });
|
|
162
|
+
acknowledgeAlert = (id) => this.request('POST', `/alerts/${id}/acknowledge`, { body: {} });
|
|
163
|
+
resolveAlert = (id) => this.request('POST', `/alerts/${id}/resolve`, { body: {} });
|
|
164
|
+
webhooks = (siteId) => this.request('GET', '/webhooks', { query: { site_id: siteId } }).then(unwrapList('items'));
|
|
165
|
+
createWebhook = (body) => this.request('POST', '/webhooks', { body });
|
|
166
|
+
rotateWebhook = (id) => this.request('POST', `/webhooks/${id}/rotate`, { body: {} });
|
|
167
|
+
deleteWebhook = (id) => this.request('DELETE', `/webhooks/${id}`);
|
|
168
|
+
webhookDeliveries = (id, limit) => this.request('GET', `/webhooks/${id}/deliveries`, { query: { limit } }).then(unwrapList('items'));
|
|
169
|
+
redeliverWebhook = (id, delivery) => this.request('POST', `/webhooks/${id}/deliveries/${delivery}/redeliver`, { body: {} });
|
|
170
|
+
usage = (period) => this.request('GET', '/usage', { query: { period } });
|
|
171
|
+
templates = () => this.request('GET', '/templates').then(unwrapList('items'));
|
|
172
|
+
applyTemplate = (id, body) => this.request('POST', `/templates/${id}/apply`, { body }).then(unwrapList('results'));
|
|
173
|
+
deviceCode = (body) => this.request('POST', '/device/code', { body, anonymous: true });
|
|
174
|
+
deviceToken = (device_code) => this.request('POST', '/device/token', { body: { device_code }, anonymous: true, allowStatus: [400, 401, 403, 428] });
|
|
175
|
+
}
|
|
176
|
+
/** Some list routes may wrap rows (`{sites:[…]}`); accept both. */
|
|
177
|
+
function unwrapList(key) {
|
|
178
|
+
return (v) => {
|
|
179
|
+
if (Array.isArray(v))
|
|
180
|
+
return v;
|
|
181
|
+
if (v && typeof v === 'object') {
|
|
182
|
+
const o = v;
|
|
183
|
+
for (const k of [key, 'rows', 'data', 'items'])
|
|
184
|
+
if (Array.isArray(o[k]))
|
|
185
|
+
return o[k];
|
|
186
|
+
}
|
|
187
|
+
return [];
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function isDeviceTokenOk(r) {
|
|
191
|
+
return typeof r.token === 'string' && r.token.length > 0;
|
|
192
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { isDeviceTokenOk } from "./api.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { human, spinner } from "./output.js";
|
|
7
|
+
import { openBrowser, sleep, pollMs } from "./util.js";
|
|
8
|
+
/** `%APPDATA%/cookiecrumbs/credentials.json` on Windows, `~/.config/cookiecrumbs/credentials.json` elsewhere. */
|
|
9
|
+
export function credentialsPath() {
|
|
10
|
+
if (process.env.COOKIECRUMBS_CREDENTIALS)
|
|
11
|
+
return process.env.COOKIECRUMBS_CREDENTIALS;
|
|
12
|
+
if (process.platform === 'win32') {
|
|
13
|
+
const base = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
|
|
14
|
+
return join(base, 'cookiecrumbs', 'credentials.json');
|
|
15
|
+
}
|
|
16
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
|
17
|
+
return join(base, 'cookiecrumbs', 'credentials.json');
|
|
18
|
+
}
|
|
19
|
+
export function readCredentials() {
|
|
20
|
+
const file = credentialsPath();
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const c = JSON.parse(readFileSync(file, 'utf8'));
|
|
25
|
+
return c && typeof c.token === 'string' ? c : null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function writeCredentials(c) {
|
|
32
|
+
const file = credentialsPath();
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(file, JSON.stringify({ ...c, saved_at: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(file, 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* Windows ignores POSIX modes; the file sits under the user's profile */
|
|
40
|
+
}
|
|
41
|
+
return file;
|
|
42
|
+
}
|
|
43
|
+
export function clearCredentials() {
|
|
44
|
+
const file = credentialsPath();
|
|
45
|
+
if (!existsSync(file))
|
|
46
|
+
return false;
|
|
47
|
+
rmSync(file);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/** COOKIECRUMBS_TOKEN wins over the credentials file. */
|
|
51
|
+
export function resolveToken() {
|
|
52
|
+
const env = process.env.COOKIECRUMBS_TOKEN?.trim();
|
|
53
|
+
if (env)
|
|
54
|
+
return { token: env, source: 'env', creds: null };
|
|
55
|
+
const creds = readCredentials();
|
|
56
|
+
if (creds)
|
|
57
|
+
return { token: creds.token, source: 'file', creds };
|
|
58
|
+
return { token: null, source: null, creds: null };
|
|
59
|
+
}
|
|
60
|
+
export const DEFAULT_SCOPES = ['banner:read', 'banner:write', 'banner:publish', 'scans:read', 'scans:run', 'logs:read', 'logs:export', 'sites:read', 'sites:write'];
|
|
61
|
+
/** RFC 8628 device flow: print the URL + code, open the browser unless told not to, poll until a token arrives. */
|
|
62
|
+
export async function deviceFlow(api, opts = {}) {
|
|
63
|
+
const code = await api.deviceCode({ scopes: opts.scopes ?? DEFAULT_SCOPES, client: api.client });
|
|
64
|
+
if (!code?.device_code || !code.user_code)
|
|
65
|
+
throw new CliError('bad_response', 'The device code response is missing device_code/user_code.', 1);
|
|
66
|
+
opts.onCode?.(code);
|
|
67
|
+
const url = code.verification_uri_complete || code.verification_uri;
|
|
68
|
+
human.note(`Open ${url}\nand confirm the code ${code.user_code}`, 'Approve this device');
|
|
69
|
+
if (opts.open !== false) {
|
|
70
|
+
if (openBrowser(url))
|
|
71
|
+
human.info('Opening your browser… (pass --no-open to skip)');
|
|
72
|
+
}
|
|
73
|
+
let interval = Math.max(1, Number(code.interval) || 5);
|
|
74
|
+
const deadline = Date.now() + Math.max(30, Number(code.expires_in) || 900) * 1000;
|
|
75
|
+
const s = spinner('Waiting for approval in the dashboard');
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
await sleep(pollMs(interval * 1000));
|
|
78
|
+
const r = await api.deviceToken(code.device_code);
|
|
79
|
+
if (isDeviceTokenOk(r)) {
|
|
80
|
+
s.stop('Approved');
|
|
81
|
+
return { token: r, code };
|
|
82
|
+
}
|
|
83
|
+
const err = r?.error ?? 'authorization_pending';
|
|
84
|
+
if (err === 'authorization_pending')
|
|
85
|
+
continue;
|
|
86
|
+
if (err === 'slow_down') {
|
|
87
|
+
interval += 5;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
s.fail(err === 'access_denied' ? 'Denied in the dashboard' : `Device code ${err}`);
|
|
91
|
+
if (err === 'access_denied')
|
|
92
|
+
throw new CliError('access_denied', 'The request was denied in the dashboard.', 1);
|
|
93
|
+
if (err === 'expired_token')
|
|
94
|
+
throw new CliError('expired_token', 'The device code expired before it was approved. Run `cookiecrumbs login` again.', 1);
|
|
95
|
+
throw new CliError(err, r.error_description ?? `Device flow failed: ${err}`, 1);
|
|
96
|
+
}
|
|
97
|
+
s.fail('Timed out');
|
|
98
|
+
throw new CliError('expired_token', 'Timed out waiting for approval. Run `cookiecrumbs login` again.', 1);
|
|
99
|
+
}
|
|
100
|
+
export function orgLabel(org) {
|
|
101
|
+
if (!org)
|
|
102
|
+
return '—';
|
|
103
|
+
if (typeof org === 'string')
|
|
104
|
+
return org;
|
|
105
|
+
return org.slug ? `${org.name ?? org.slug} (${org.slug})` : (org.name ?? org.id);
|
|
106
|
+
}
|