@crabcrop/mcp 0.0.0-stage → 0.1.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/README.md +62 -2
- package/dist/index.js +416 -0
- package/node_modules/@crabcrop/url/README.md +81 -0
- package/node_modules/@crabcrop/url/dist/explain.d.ts +42 -0
- package/node_modules/@crabcrop/url/dist/explain.js +654 -0
- package/node_modules/@crabcrop/url/dist/index.d.ts +84 -0
- package/node_modules/@crabcrop/url/dist/index.js +182 -0
- package/node_modules/@crabcrop/url/dist/operations.d.ts +178 -0
- package/node_modules/@crabcrop/url/dist/operations.js +491 -0
- package/node_modules/@crabcrop/url/package.json +31 -0
- package/package.json +48 -4
- package/server.json +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @crabcrop/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A first-party MCP (Model Context Protocol) server for crabcrop, so coding agents can sign URLs, debug them, inspect results, and purge the cache without hand-rolling the scheme. It is a thin wrapper over [`@crabcrop/url`](../typescript) and the server's HTTP surface. The explainer comes from `@crabcrop/url`; the [playground](../../playground) ships its own copy of the same grammar, and both are pinned by a mirrored grammar corpus in their test suites, while signing everywhere runs the canonical `spec/test-vectors.json`.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `sign_url` | Build and sign a URL from an operation string and a source (optional expiry). |
|
|
10
|
+
| `explain_url` | Parse a URL back into its signature kind, explained segments, and source; malformed segments are flagged. |
|
|
11
|
+
| `fetch_meta` | Fetch the server's meta JSON for a URL (dimensions, format, bytes, quality, placeholders on request). Only fetches URLs on the configured `CRABCROP_BASE` origin, never follows redirects, and caps the response at 256 KiB. |
|
|
12
|
+
| `purge_result` | Evict a cached result by its URL remainder; a preset URL works as-is (the server expands it before matching). Only purges on the configured `CRABCROP_BASE` origin (a per-call `base` must match it) and refuses paths with dot segments. |
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
The server runs over stdio with Node >= 22. Once published, point your MCP client at the installed package:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"crabcrop": {
|
|
22
|
+
"command": "npx",
|
|
23
|
+
"args": ["-y", "@crabcrop/mcp"],
|
|
24
|
+
"env": {
|
|
25
|
+
"CRABCROP_BASE": "https://images.example.com",
|
|
26
|
+
"CRABCROP_SECRET": "your-secret"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
From a checkout, it also runs directly from source (Node type-strips the TypeScript):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"crabcrop": {
|
|
39
|
+
"command": "node",
|
|
40
|
+
"args": ["/path/to/crabcrop/sdks/mcp/src/index.ts"],
|
|
41
|
+
"env": {
|
|
42
|
+
"CRABCROP_BASE": "https://images.example.com",
|
|
43
|
+
"CRABCROP_SECRET": "your-secret"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For Claude Code:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add crabcrop \
|
|
54
|
+
--env CRABCROP_BASE=https://images.example.com \
|
|
55
|
+
--env CRABCROP_SECRET=your-secret \
|
|
56
|
+
-- node /path/to/crabcrop/sdks/mcp/src/index.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`CRABCROP_BASE` and `CRABCROP_SECRET` are the defaults, and the environment is the recommended home for the secret: `sign_url` and `purge_result` still accept per-call `base`/`secret` overrides, but the per-call secret is deprecated because agentic clients persist tool arguments in their transcripts. With no secret configured, `sign_url` produces `unsafe` URLs for servers that allow them. `purge_result` always requires a secret, and it only ever sends its POST to the configured `CRABCROP_BASE` origin: a per-call `base` must match that origin, with no `CRABCROP_BASE` set the tool refuses outright, and paths carrying dot segments are refused, the same origin discipline `fetch_meta` follows.
|
|
60
|
+
|
|
61
|
+
Both this package and `@crabcrop/url` ship compiled `dist/` builds (their `prepack` runs `tsc`). The published tarball is self-contained: the declared `@crabcrop/url` dependency is a `file:` path, which no registry install could resolve, so the package bundles it (`bundleDependencies`); the tarball carries the SDK's published form under its own `node_modules`, and installing the package is all a consumer does. In this repository's tests (no install step), a register hook maps `@crabcrop/url` to the SDK sources, and the pack test does the real thing instead of simulating it: `npm pack`, `npm install` the tarball into a fresh project, and a JSON-RPC round trip against the installed bin. The end-to-end test (`npm test`) spawns this server plus a real crabcrop binary and runs sign -> fetch -> meta -> purge against it; it needs `cargo build` to have produced `target/debug/crabcrop` and uses the bench fixture photo as its source.
|
|
62
|
+
|
|
63
|
+
`server.json` in this directory is the MCP registry manifest (name `io.github.crabcrop/crabcrop`, matching the `mcpName` field in `package.json`); its versions are bumped by the same one-version tooling as every other artifact. The registry submission itself is a separate step and has not been made yet.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* A first-party MCP server for crabcrop: four tools over
|
|
4
|
+
* stdio, thin wrappers on the TypeScript SDK and the HTTP
|
|
5
|
+
* surface. No dependencies.
|
|
6
|
+
*
|
|
7
|
+
* sign_url build and sign a crabcrop URL
|
|
8
|
+
* explain_url parse a URL back into its segments, flagging errors
|
|
9
|
+
* fetch_meta the server's JSON meta for a URL
|
|
10
|
+
* purge_result evict a cached result
|
|
11
|
+
*
|
|
12
|
+
* Configuration: CRABCROP_BASE and CRABCROP_SECRET in the
|
|
13
|
+
* environment are the defaults for sign_url and purge_result,
|
|
14
|
+
* so agents do not pass the secret on every call. Per-call
|
|
15
|
+
* secret arguments exist as an override but are deprecated:
|
|
16
|
+
* agentic clients persist tool arguments in transcripts, so
|
|
17
|
+
* the environment is the right home for the secret.
|
|
18
|
+
*
|
|
19
|
+
* Run: node src/index.ts (Node >= 22, for type stripping)
|
|
20
|
+
*/
|
|
21
|
+
import { buildURL, explainUrl, purgeSignature } from "@crabcrop/url";
|
|
22
|
+
const SERVER = { name: "crabcrop", version: "0.1.0" };
|
|
23
|
+
const PROTOCOL = "2024-11-05";
|
|
24
|
+
const envBase = process.env.CRABCROP_BASE ?? "";
|
|
25
|
+
const envSecret = process.env.CRABCROP_SECRET ?? null;
|
|
26
|
+
function textResult(text, isError = false) {
|
|
27
|
+
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Tool arguments arrive as untyped JSON. Validate each one
|
|
31
|
+
* instead of coercing with String(...): a missing argument
|
|
32
|
+
* would otherwise be signed into a URL as the literal
|
|
33
|
+
* segment "undefined", and numbers and objects would
|
|
34
|
+
* stringify silently.
|
|
35
|
+
*/
|
|
36
|
+
function stringArg(args, name) {
|
|
37
|
+
const value = args[name];
|
|
38
|
+
if (typeof value !== "string") {
|
|
39
|
+
throw new Error(value === undefined
|
|
40
|
+
? `missing required argument: ${name}`
|
|
41
|
+
: `argument ${name} must be a string, got ${typeof value}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function optionalStringArg(args, name) {
|
|
46
|
+
const value = args[name];
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
throw new Error(`argument ${name} must be a string, got ${typeof value}`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function numberArg(args, name) {
|
|
55
|
+
const value = args[name];
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (typeof value !== "number") {
|
|
59
|
+
throw new Error(`argument ${name} must be a number, got ${typeof value}`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function boolArg(args, name) {
|
|
64
|
+
const value = args[name];
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (typeof value !== "boolean") {
|
|
68
|
+
throw new Error(`argument ${name} must be a boolean, got ${typeof value}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Read a response body with a hard byte cap; null when exceeded. */
|
|
73
|
+
async function readCapped(resp, cap) {
|
|
74
|
+
if (!resp.body)
|
|
75
|
+
return "";
|
|
76
|
+
const reader = resp.body.getReader();
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done)
|
|
82
|
+
break;
|
|
83
|
+
total += value.byteLength;
|
|
84
|
+
if (total > cap) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
const merged = new Uint8Array(total);
|
|
91
|
+
let offset = 0;
|
|
92
|
+
for (const chunk of chunks) {
|
|
93
|
+
merged.set(chunk, offset);
|
|
94
|
+
offset += chunk.byteLength;
|
|
95
|
+
}
|
|
96
|
+
return new TextDecoder().decode(merged);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether any path component of `path` is a dot segment.
|
|
100
|
+
* Mirrors has_dot_segment in the Rust loader: a component
|
|
101
|
+
* exactly `.` or `..`, with dots inside a name (`a.b`,
|
|
102
|
+
* `c..d`) counting as ordinary content. Because the string
|
|
103
|
+
* is later parsed as a WHATWG URL, the percent-encoded
|
|
104
|
+
* spellings (`%2e`, `.%2e`, `%2e.`, `%2e%2e`) count too, and
|
|
105
|
+
* backslashes are separators for http(s) URLs, so components
|
|
106
|
+
* are split on both.
|
|
107
|
+
*/
|
|
108
|
+
function hasDotSegment(path) {
|
|
109
|
+
return path.split(/[/\\]/).some((segment) => {
|
|
110
|
+
const decoded = segment.toLowerCase().replaceAll("%2e", ".");
|
|
111
|
+
return decoded === "." || decoded === "..";
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const TOOLS = [
|
|
115
|
+
{
|
|
116
|
+
name: "sign_url",
|
|
117
|
+
description: "Build and sign a crabcrop URL from an operation string and a source. " +
|
|
118
|
+
"Returns the full URL. Uses CRABCROP_BASE / CRABCROP_SECRET from the " +
|
|
119
|
+
"environment unless base/secret arguments override them; with no secret " +
|
|
120
|
+
"the URL carries the literal `unsafe` signature segment. Prefer the environment for the secret: per-call secret arguments are deprecated.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
operations: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Operation string, e.g. \"crop:1200x630:smart/filters:format(webp)\"; may be empty and may contain preset: references.",
|
|
127
|
+
},
|
|
128
|
+
source: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "Source locator: an http(s) URL, s3://bucket/key, gs://bucket/key, or file:// path.",
|
|
131
|
+
},
|
|
132
|
+
base: { type: "string", description: "crabcrop endpoint origin. Defaults to CRABCROP_BASE." },
|
|
133
|
+
secret: { type: "string", description: "Signing secret. Defaults to CRABCROP_SECRET; omit for unsafe URLs. Sensitive, deprecated: prefer the CRABCROP_SECRET environment variable; a per-call secret is kept in client transcripts." },
|
|
134
|
+
expires: { type: "number", description: "Optional unix-seconds expiry, emitted as an exp: segment." },
|
|
135
|
+
keyId: { type: "string", description: "Optional key ID stamped on the signature segment (`<key-id>:<tag>`) for servers running a secret rotation; they verify a keyed segment against the named key only." },
|
|
136
|
+
},
|
|
137
|
+
required: ["operations", "source"],
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
},
|
|
140
|
+
async run(args) {
|
|
141
|
+
const base = optionalStringArg(args, "base") ?? envBase;
|
|
142
|
+
if (!base)
|
|
143
|
+
throw new Error("no base: pass the base argument or set CRABCROP_BASE");
|
|
144
|
+
const secret = optionalStringArg(args, "secret") ?? envSecret;
|
|
145
|
+
const url = buildURL(base, secret, stringArg(args, "operations"), stringArg(args, "source"), {
|
|
146
|
+
expires: numberArg(args, "expires"),
|
|
147
|
+
keyId: optionalStringArg(args, "keyId"),
|
|
148
|
+
});
|
|
149
|
+
return textResult(url);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "explain_url",
|
|
154
|
+
description: "Parse a crabcrop URL back into its pieces: the signature kind, each " +
|
|
155
|
+
"operation segment with a plain-language explanation, exp: rendered as " +
|
|
156
|
+
"a UTC instant with an expired verdict, and the source. Malformed " +
|
|
157
|
+
"segments and unknown filters are reported as errors.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
url: { type: "string", description: "The full crabcrop URL or bare path." },
|
|
162
|
+
},
|
|
163
|
+
required: ["url"],
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
},
|
|
166
|
+
async run(args) {
|
|
167
|
+
const out = explainUrl(stringArg(args, "url"));
|
|
168
|
+
const lines = [];
|
|
169
|
+
if (out.signature) {
|
|
170
|
+
lines.push(`signature (${out.signature.kind}): ${out.signature.value}`);
|
|
171
|
+
lines.push(` ${out.signature.detail}`);
|
|
172
|
+
}
|
|
173
|
+
for (const seg of out.segments) {
|
|
174
|
+
lines.push(`${seg.kind}: ${seg.raw}`);
|
|
175
|
+
lines.push(` ${seg.error ? `ERROR: ${seg.error}` : seg.detail ?? ""}`);
|
|
176
|
+
}
|
|
177
|
+
if (out.source) {
|
|
178
|
+
lines.push(`source: ${out.source}`);
|
|
179
|
+
}
|
|
180
|
+
for (const error of out.errors) {
|
|
181
|
+
lines.push(`ERROR: ${error}`);
|
|
182
|
+
}
|
|
183
|
+
return textResult(lines.join("\n"), out.errors.length > 0);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "fetch_meta",
|
|
188
|
+
description: "Fetch the crabcrop meta JSON for a URL (the processed result's width, " +
|
|
189
|
+
"height, format, byte size, quality, and placeholder data on request). " +
|
|
190
|
+
"The URL must already be signed if the server requires signatures. " +
|
|
191
|
+
"For safety the URL's origin must match CRABCROP_BASE: this tool will " +
|
|
192
|
+
"not fetch arbitrary URLs.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: {
|
|
196
|
+
url: { type: "string", description: "The full crabcrop URL." },
|
|
197
|
+
placeholders: { type: "boolean", description: "Also request placeholder data (default false)." },
|
|
198
|
+
},
|
|
199
|
+
required: ["url"],
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
},
|
|
202
|
+
async run(args) {
|
|
203
|
+
if (!envBase) {
|
|
204
|
+
return textResult("fetch_meta requires CRABCROP_BASE to be set: it only fetches URLs on the configured server's origin", true);
|
|
205
|
+
}
|
|
206
|
+
const urlArg = stringArg(args, "url");
|
|
207
|
+
let target;
|
|
208
|
+
try {
|
|
209
|
+
target = new URL(urlArg);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return textResult(`fetch_meta: not a valid URL: ${urlArg}`, true);
|
|
213
|
+
}
|
|
214
|
+
if (target.origin !== new URL(envBase).origin) {
|
|
215
|
+
return textResult(`fetch_meta: refusing to fetch ${target.origin}; only the configured server origin (${new URL(envBase).origin}) is allowed`, true);
|
|
216
|
+
}
|
|
217
|
+
const accept = boolArg(args, "placeholders")
|
|
218
|
+
? "application/json; placeholders=1"
|
|
219
|
+
: "application/json";
|
|
220
|
+
let resp;
|
|
221
|
+
try {
|
|
222
|
+
resp = await fetch(target, {
|
|
223
|
+
headers: { accept },
|
|
224
|
+
redirect: "error",
|
|
225
|
+
signal: AbortSignal.timeout(10_000),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
return textResult(`meta request failed: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
230
|
+
}
|
|
231
|
+
const body = await readCapped(resp, 256 * 1024);
|
|
232
|
+
if (body === null) {
|
|
233
|
+
return textResult("meta request failed: response exceeded the 256 KiB cap", true);
|
|
234
|
+
}
|
|
235
|
+
if (!resp.ok) {
|
|
236
|
+
return textResult(`meta request failed: HTTP ${resp.status}: ${body}`, true);
|
|
237
|
+
}
|
|
238
|
+
return textResult(body);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "purge_result",
|
|
243
|
+
description: "Evict a cached result from a crabcrop server. The path is the cached " +
|
|
244
|
+
"URL's remainder after its signature (operations and source; a preset " +
|
|
245
|
+
"URL works as-is, the server expands it). Requires the signing secret (argument or " +
|
|
246
|
+
"CRABCROP_SECRET). Returns how many entries were removed. " +
|
|
247
|
+
"For safety the request only goes to the configured CRABCROP_BASE " +
|
|
248
|
+
"origin (a base argument must match it) and paths with dot segments " +
|
|
249
|
+
"are refused: this tool will not POST to arbitrary endpoints.",
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
path: { type: "string", description: "Remainder of the cached URL, e.g. \"300x200/https://example.com/a.jpg\"." },
|
|
254
|
+
base: { type: "string", description: "crabcrop endpoint origin. Defaults to CRABCROP_BASE; its origin must match CRABCROP_BASE." },
|
|
255
|
+
secret: { type: "string", description: "Signing secret. Defaults to CRABCROP_SECRET. Sensitive, deprecated: prefer the CRABCROP_SECRET environment variable; a per-call secret is kept in client transcripts." },
|
|
256
|
+
},
|
|
257
|
+
required: ["path"],
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
},
|
|
260
|
+
async run(args) {
|
|
261
|
+
// The fetch_meta treatment (audit PL-1, extended by
|
|
262
|
+
// R2-10): the POST may only ever go to the configured
|
|
263
|
+
// server's origin. Without the check, a caller-chosen
|
|
264
|
+
// base made the tool an SSRF oracle: it POSTed to
|
|
265
|
+
// internal endpoints and read the responses back, and
|
|
266
|
+
// dot segments in the path resolved out of the
|
|
267
|
+
// /purge/ prefix onto arbitrary paths on that host.
|
|
268
|
+
// Refusing before any fetch keeps every disallowed or
|
|
269
|
+
// unreachable target indistinguishable: no request is
|
|
270
|
+
// sent, so there is no timing, status, or body signal
|
|
271
|
+
// to tell them apart.
|
|
272
|
+
if (!envBase) {
|
|
273
|
+
return textResult("purge_result requires CRABCROP_BASE to be set: it only purges on the configured server's origin", true);
|
|
274
|
+
}
|
|
275
|
+
const configuredOrigin = new URL(envBase).origin;
|
|
276
|
+
const base = (optionalStringArg(args, "base") ?? envBase).replace(/\/+$/, "");
|
|
277
|
+
let baseOrigin;
|
|
278
|
+
try {
|
|
279
|
+
baseOrigin = new URL(base).origin;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return textResult(`purge_result: not a valid base URL: ${base}`, true);
|
|
283
|
+
}
|
|
284
|
+
if (baseOrigin !== configuredOrigin) {
|
|
285
|
+
return textResult(`purge_result: refusing to purge ${baseOrigin}; only the configured server origin (${configuredOrigin}) is allowed`, true);
|
|
286
|
+
}
|
|
287
|
+
const secret = optionalStringArg(args, "secret") ?? envSecret;
|
|
288
|
+
if (!secret)
|
|
289
|
+
throw new Error("purge requires a secret: pass the secret argument or set CRABCROP_SECRET");
|
|
290
|
+
const target = stringArg(args, "path").replace(/^\//, "");
|
|
291
|
+
if (hasDotSegment(target)) {
|
|
292
|
+
return textResult("purge_result: refusing a path with dot segments: they would resolve out of the purge prefix", true);
|
|
293
|
+
}
|
|
294
|
+
// The SDK's helper: purge signatures are
|
|
295
|
+
// domain-separated from image signatures (audit P-1),
|
|
296
|
+
// and the derivation lives in exactly one place.
|
|
297
|
+
const signature = purgeSignature(secret, target);
|
|
298
|
+
// The same network hygiene as fetch_meta: no
|
|
299
|
+
// redirects, a bounded wait, and a capped body, so a
|
|
300
|
+
// hung or hostile endpoint cannot hang the tool call
|
|
301
|
+
// or stream an unbounded response.
|
|
302
|
+
let resp;
|
|
303
|
+
try {
|
|
304
|
+
resp = await fetch(`${base}/purge/${signature}/${target}`, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
redirect: "error",
|
|
307
|
+
signal: AbortSignal.timeout(10_000),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
return textResult(`purge request failed: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
312
|
+
}
|
|
313
|
+
const body = await readCapped(resp, 256 * 1024);
|
|
314
|
+
if (body === null) {
|
|
315
|
+
return textResult("purge failed: response exceeded the 256 KiB cap", true);
|
|
316
|
+
}
|
|
317
|
+
if (!resp.ok) {
|
|
318
|
+
return textResult(`purge failed: HTTP ${resp.status}: ${body.trim()}`, true);
|
|
319
|
+
}
|
|
320
|
+
return textResult(body.trim());
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
async function handle(req) {
|
|
325
|
+
switch (req.method) {
|
|
326
|
+
case "initialize":
|
|
327
|
+
return {
|
|
328
|
+
protocolVersion: PROTOCOL,
|
|
329
|
+
capabilities: { tools: {} },
|
|
330
|
+
serverInfo: SERVER,
|
|
331
|
+
};
|
|
332
|
+
case "ping":
|
|
333
|
+
return {};
|
|
334
|
+
case "tools/list":
|
|
335
|
+
return {
|
|
336
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({
|
|
337
|
+
name,
|
|
338
|
+
description,
|
|
339
|
+
inputSchema,
|
|
340
|
+
})),
|
|
341
|
+
};
|
|
342
|
+
case "tools/call": {
|
|
343
|
+
const params = req.params ?? {};
|
|
344
|
+
const tool = TOOLS.find((t) => t.name === params.name);
|
|
345
|
+
if (!tool) {
|
|
346
|
+
return textResult(`unknown tool: ${String(params.name)}`, true);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
return await tool.run(params.arguments ?? {});
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
return textResult(`error: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
default:
|
|
356
|
+
return undefined; // Notifications and unknown methods: no reply.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Transport: newline-delimited JSON-RPC over stdio. Lines
|
|
360
|
+
// are capped (readline would buffer an unbounded line in
|
|
361
|
+
// memory) and requests are handled one at a time through a
|
|
362
|
+
// promise chain, so side-effecting calls cannot race
|
|
363
|
+
// (audit PL-9).
|
|
364
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
365
|
+
let chain = Promise.resolve();
|
|
366
|
+
let buffered = "";
|
|
367
|
+
let discarding = false;
|
|
368
|
+
function enqueue(line) {
|
|
369
|
+
chain = chain.then(async () => {
|
|
370
|
+
const trimmed = line.trim();
|
|
371
|
+
if (!trimmed)
|
|
372
|
+
return;
|
|
373
|
+
let req;
|
|
374
|
+
try {
|
|
375
|
+
req = JSON.parse(trimmed);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (req.id === undefined || req.id === null) {
|
|
381
|
+
await handle(req); // A notification: handle silently.
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const result = await handle(req);
|
|
385
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: req.id, result: result ?? {} })}\n`);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
process.stdin.setEncoding("utf8");
|
|
389
|
+
process.stdin.on("data", (chunk) => {
|
|
390
|
+
buffered += chunk;
|
|
391
|
+
for (;;) {
|
|
392
|
+
const nl = buffered.indexOf("\n");
|
|
393
|
+
if (discarding) {
|
|
394
|
+
if (nl === -1) {
|
|
395
|
+
buffered = "";
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
buffered = buffered.slice(nl + 1);
|
|
399
|
+
discarding = false;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (nl === -1) {
|
|
403
|
+
if (buffered.length > MAX_LINE_BYTES) {
|
|
404
|
+
// An over-long line: drop it and everything up to
|
|
405
|
+
// its newline, and answer with a protocol error.
|
|
406
|
+
buffered = "";
|
|
407
|
+
discarding = true;
|
|
408
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "request line too large" } })}\n`);
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const line = buffered.slice(0, nl);
|
|
413
|
+
buffered = buffered.slice(nl + 1);
|
|
414
|
+
enqueue(line);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @crabcrop/url
|
|
2
|
+
|
|
3
|
+
Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from TypeScript or JavaScript. Standard library only (`node:crypto`); the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md). Signing is pinned by the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json), which this package runs as tests, and the explainer's verdicts are pinned by a grammar corpus mirrored in the playground's test suite.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { buildURL, buildSrcSet, purgeGroupSignature, purgeSignature, sign, uploadSignature, verifyUrlSignature } from "@crabcrop/url";
|
|
9
|
+
|
|
10
|
+
// A signed URL. The operation string is passed verbatim and may
|
|
11
|
+
// contain several segments and preset: references.
|
|
12
|
+
const url = buildURL(
|
|
13
|
+
"https://images.example.com",
|
|
14
|
+
"your-secret",
|
|
15
|
+
"crop:1200x630:smart/filters:format(webp)",
|
|
16
|
+
"https://example.com/photo.jpg",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// An expiring URL (unix seconds), emitted as a leading exp: segment.
|
|
20
|
+
const expiring = buildURL(
|
|
21
|
+
"https://images.example.com",
|
|
22
|
+
"your-secret",
|
|
23
|
+
"300x200",
|
|
24
|
+
"https://example.com/photo.jpg",
|
|
25
|
+
{ expires: 1893456000 },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// A responsive srcset: one {width}x0 URL per width.
|
|
29
|
+
const srcset = buildSrcSet(
|
|
30
|
+
"https://images.example.com",
|
|
31
|
+
"your-secret",
|
|
32
|
+
"https://example.com/photo.jpg",
|
|
33
|
+
[400, 800, 1200],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// The raw signature for a remainder, if you assemble URLs yourself.
|
|
37
|
+
const signature = sign("your-secret", "300x200/https://example.com/photo.jpg");
|
|
38
|
+
|
|
39
|
+
// Verify a signed URL's signature locally, in constant time
|
|
40
|
+
// (unsigned or malformed URLs return null).
|
|
41
|
+
const ok = verifyUrlSignature("your-secret", url);
|
|
42
|
+
|
|
43
|
+
// Purge signatures for the server's /purge and /purge-group endpoints
|
|
44
|
+
// are domain-separated under a derived subkey; the helpers sign the
|
|
45
|
+
// right strings so callers never assemble them by hand.
|
|
46
|
+
const purge = purgeSignature("your-secret", "300x200/https://example.com/photo.jpg");
|
|
47
|
+
const group = purgeGroupSignature("your-secret", "thumb");
|
|
48
|
+
|
|
49
|
+
// Upload signatures for the server's PUT /upload/<signature>/<name>
|
|
50
|
+
// endpoint live in a third domain, under their own derived subkey.
|
|
51
|
+
const upload = uploadSignature("your-secret", "heroes/photo.jpg");
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Pass `null` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode.
|
|
55
|
+
|
|
56
|
+
## Typed operations
|
|
57
|
+
|
|
58
|
+
The operation string is the spec, and passing it verbatim stays the primary API. For callers who would rather build it, `ops` and `filter` are typed builders that validate each argument against the server's grammar and return the canonical spelling, so the typed and hand-written forms sign byte-identical URLs (the test suite pins this against the canonical corpus):
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { buildURL, filter, joinOps, ops } from "@crabcrop/url";
|
|
62
|
+
|
|
63
|
+
const operations = joinOps(
|
|
64
|
+
ops.crop(1200, 630, "smart"),
|
|
65
|
+
ops.filters(filter.format("webp"), filter.quality(80)),
|
|
66
|
+
);
|
|
67
|
+
// operations === "crop:1200x630:smart/filters:format(webp):quality(80)"
|
|
68
|
+
const url = buildURL("https://images.example.com", "your-secret", operations, "https://example.com/photo.jpg");
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A value the server would reject (`filter.quality(0)`, `filter.brighten(256)`, `ops.crop(0, 100)`) throws at build time instead of minting a URL that fails at the edge. The closed sets are exported as types (`ImageFormat`, `CropAlign`, `Anchor`, `BlendMode`, `MaskShape`, `Dimension`), and `FILTER_DESCRIPTIONS` is the filter metadata table the explainer uses.
|
|
72
|
+
|
|
73
|
+
## Runtime
|
|
74
|
+
|
|
75
|
+
The package is TypeScript and runs directly on Node >= 22 (type stripping), which is also how the tests run:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node --test
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In this repository the framework loaders and the MCP server resolve `@crabcrop/url` to these sources; a standalone install comes from npm once published, or from the repository with `"@crabcrop/url": "file:../typescript"` as the MCP package in this repo does.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a crabcrop URL (or bare path) back into its pieces and
|
|
3
|
+
* explain each segment, mirroring the server's grammar. This is
|
|
4
|
+
* the canonical implementation: the playground keeps a plain-JS
|
|
5
|
+
* copy for its no-build static page, and both are pinned to the
|
|
6
|
+
* same verdict corpus (audit PL-10, P-3).
|
|
7
|
+
*/
|
|
8
|
+
/** The segment kinds an explanation is made of. */
|
|
9
|
+
export type SegmentKind = "expiry" | "maxage" | "debug" | "preset" | "operation";
|
|
10
|
+
/** The signature kinds an explanation can report. */
|
|
11
|
+
export type SignatureKind = "hmac" | "unsafe" | "invalid";
|
|
12
|
+
export interface ExplainedSegment {
|
|
13
|
+
raw: string;
|
|
14
|
+
kind: SegmentKind;
|
|
15
|
+
title: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
expired?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface Explanation {
|
|
21
|
+
signature: {
|
|
22
|
+
kind: SignatureKind;
|
|
23
|
+
value: string;
|
|
24
|
+
detail: string;
|
|
25
|
+
keyId?: string;
|
|
26
|
+
} | null;
|
|
27
|
+
segments: ExplainedSegment[];
|
|
28
|
+
source: string;
|
|
29
|
+
remainder: string;
|
|
30
|
+
errors: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare const FILTER_DESCRIPTIONS: Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Parse a crabcrop URL (or bare path) back into its pieces,
|
|
35
|
+
* explaining each segment. Mirrors the server's dispatch:
|
|
36
|
+
* segments are operations until the first segment that is not
|
|
37
|
+
* one; everything from there is the source.
|
|
38
|
+
*
|
|
39
|
+
* Returns { signature, segments, source, remainder, errors }
|
|
40
|
+
* where segments carry { raw, kind, title, detail?, error? }.
|
|
41
|
+
*/
|
|
42
|
+
export declare function explainUrl(input: string, nowSeconds?: number): Explanation;
|