@sebastienrousseau/cloudcdn-mcp 0.1.1
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-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +306 -0
- package/index.js +23 -0
- package/lib/api-client.js +195 -0
- package/lib/resources/index.js +134 -0
- package/lib/tools/account.js +135 -0
- package/lib/tools/ai.js +148 -0
- package/lib/tools/assets.js +66 -0
- package/lib/tools/core.js +109 -0
- package/lib/tools/delivery.js +127 -0
- package/lib/tools/insights.js +96 -0
- package/lib/tools/logs.js +34 -0
- package/lib/tools/storage.js +71 -0
- package/lib/tools/tokens.js +57 -0
- package/lib/tools/webhooks.js +59 -0
- package/package.json +73 -0
- package/server.js +87 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudCDN API client — a small `fetch` wrapper used by every MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* Reads configuration from environment variables (the MCP host populates
|
|
5
|
+
* them via the `env` block in its config file):
|
|
6
|
+
*
|
|
7
|
+
* CLOUDCDN_BASE_URL — API base URL (default: https://cloudcdn.pro)
|
|
8
|
+
* CLOUDCDN_ACCESS_KEY — AccessKey header (storage, assets, insights)
|
|
9
|
+
* CLOUDCDN_ACCOUNT_KEY — AccountKey header (core, pipeline, audit, …)
|
|
10
|
+
* CLOUDCDN_PURGE_KEY — x-api-key header (cache purge)
|
|
11
|
+
* CLOUDCDN_ANALYTICS_KEY — x-api-key header (analytics endpoints)
|
|
12
|
+
*
|
|
13
|
+
* Public surface:
|
|
14
|
+
*
|
|
15
|
+
* get(path, opts) — GET
|
|
16
|
+
* post(path, body, opts) — POST (JSON body if not a typed array)
|
|
17
|
+
* patch(path, body, opts) — PATCH
|
|
18
|
+
* put(path, body, opts) — PUT
|
|
19
|
+
* del(path, opts) — DELETE
|
|
20
|
+
* head(path, opts) — HEAD
|
|
21
|
+
* BASE_URL — resolved base URL (read-only)
|
|
22
|
+
*
|
|
23
|
+
* Each helper returns `{ ok: boolean, status: number, data: unknown }`.
|
|
24
|
+
* For non-JSON responses, `data` is `{ contentType, contentLength, url }`
|
|
25
|
+
* (no body parsing — useful for binary endpoints like /api/transform).
|
|
26
|
+
*
|
|
27
|
+
* @module @sebastienrousseau/cloudcdn-mcp/api-client
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const BASE_URL = process.env.CLOUDCDN_BASE_URL || 'https://cloudcdn.pro';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the auth headers for a request, based on which auth name the
|
|
34
|
+
* caller picked. Each entry is a thunk so the env vars are read at call
|
|
35
|
+
* time (not at module load) — keeps tests reliable.
|
|
36
|
+
*
|
|
37
|
+
* @type {Record<'access'|'account'|'purge'|'analytics'|'none', () => Record<string, string>>}
|
|
38
|
+
*/
|
|
39
|
+
const AUTH_HEADERS = {
|
|
40
|
+
access: () => {
|
|
41
|
+
const key = process.env.CLOUDCDN_ACCESS_KEY;
|
|
42
|
+
return key ? { AccessKey: key } : {};
|
|
43
|
+
},
|
|
44
|
+
account: () => {
|
|
45
|
+
const key = process.env.CLOUDCDN_ACCOUNT_KEY;
|
|
46
|
+
return key ? { AccountKey: key } : {};
|
|
47
|
+
},
|
|
48
|
+
purge: () => {
|
|
49
|
+
const key = process.env.CLOUDCDN_PURGE_KEY;
|
|
50
|
+
return key ? { 'x-api-key': key } : {};
|
|
51
|
+
},
|
|
52
|
+
analytics: () => {
|
|
53
|
+
const key = process.env.CLOUDCDN_ANALYTICS_KEY;
|
|
54
|
+
return key ? { 'x-api-key': key } : {};
|
|
55
|
+
},
|
|
56
|
+
none: () => ({}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds an absolute URL with serialized query params.
|
|
61
|
+
*
|
|
62
|
+
* Undefined and null param values are silently dropped — that lets call
|
|
63
|
+
* sites pass `{ project, format }` even when one of them is unset.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} path - Relative API path (e.g. `/api/assets`).
|
|
66
|
+
* @param {Record<string, unknown>} [params] - Query string parameters.
|
|
67
|
+
* @returns {string} Fully qualified URL.
|
|
68
|
+
*/
|
|
69
|
+
function buildUrl(path, params = {}) {
|
|
70
|
+
const url = new URL(path, BASE_URL);
|
|
71
|
+
for (const [k, v] of Object.entries(params)) {
|
|
72
|
+
if (v !== undefined && v !== null) {
|
|
73
|
+
url.searchParams.set(k, String(v));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Issues a request and returns the structured response shape used by every
|
|
81
|
+
* MCP tool handler. Internal — call {@link get}/{@link post}/etc. instead.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} method - HTTP verb.
|
|
84
|
+
* @param {string} path - Relative API path.
|
|
85
|
+
* @param {object} [opts]
|
|
86
|
+
* @param {'access'|'account'|'purge'|'analytics'|'none'} [opts.auth='none'] - Auth profile.
|
|
87
|
+
* @param {Record<string, unknown>} [opts.params] - Query string parameters.
|
|
88
|
+
* @param {unknown} [opts.body] - Request body (JSON-serialized unless ArrayBuffer/Uint8Array).
|
|
89
|
+
* @param {Record<string, string>} [opts.headers] - Extra request headers.
|
|
90
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
91
|
+
*/
|
|
92
|
+
async function request(method, path, { auth = 'none', params, body, headers: extra } = {}) {
|
|
93
|
+
const url = buildUrl(path, params);
|
|
94
|
+
const authHeaders = AUTH_HEADERS[auth]?.() || {};
|
|
95
|
+
|
|
96
|
+
const headers = {
|
|
97
|
+
...authHeaders,
|
|
98
|
+
...extra,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const opts = { method, headers };
|
|
102
|
+
|
|
103
|
+
if (body !== undefined) {
|
|
104
|
+
if (body instanceof ArrayBuffer || body instanceof Uint8Array) {
|
|
105
|
+
opts.body = body;
|
|
106
|
+
} else {
|
|
107
|
+
headers['Content-Type'] = 'application/json';
|
|
108
|
+
opts.body = JSON.stringify(body);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const res = await fetch(url, opts);
|
|
113
|
+
const contentType = res.headers.get('content-type') || '';
|
|
114
|
+
|
|
115
|
+
if (contentType.includes('application/json')) {
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
return { ok: res.ok, status: res.status, data };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Non-JSON — return metadata only (binary responses are not buffered).
|
|
121
|
+
return {
|
|
122
|
+
ok: res.ok,
|
|
123
|
+
status: res.status,
|
|
124
|
+
data: {
|
|
125
|
+
contentType,
|
|
126
|
+
contentLength: res.headers.get('content-length'),
|
|
127
|
+
url,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* GET — no request body.
|
|
134
|
+
* @param {string} path
|
|
135
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
136
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
137
|
+
*/
|
|
138
|
+
export function get(path, opts) {
|
|
139
|
+
return request('GET', path, opts);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* POST — JSON-serialised body unless a typed array is given.
|
|
144
|
+
* @param {string} path
|
|
145
|
+
* @param {unknown} body
|
|
146
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
147
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
148
|
+
*/
|
|
149
|
+
export function post(path, body, opts = {}) {
|
|
150
|
+
return request('POST', path, { ...opts, body });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* PATCH — JSON-serialised body unless a typed array is given.
|
|
155
|
+
* @param {string} path
|
|
156
|
+
* @param {unknown} body
|
|
157
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
158
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
159
|
+
*/
|
|
160
|
+
export function patch(path, body, opts = {}) {
|
|
161
|
+
return request('PATCH', path, { ...opts, body });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* PUT — JSON body by default, raw bytes if `body` is an ArrayBuffer / Uint8Array.
|
|
166
|
+
* @param {string} path
|
|
167
|
+
* @param {unknown} body
|
|
168
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
169
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
170
|
+
*/
|
|
171
|
+
export function put(path, body, opts = {}) {
|
|
172
|
+
return request('PUT', path, { ...opts, body });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* DELETE — no request body.
|
|
177
|
+
* @param {string} path
|
|
178
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
179
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
180
|
+
*/
|
|
181
|
+
export function del(path, opts) {
|
|
182
|
+
return request('DELETE', path, opts);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* HEAD — no request body, no response body.
|
|
187
|
+
* @param {string} path
|
|
188
|
+
* @param {Parameters<typeof request>[2]} [opts]
|
|
189
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown }>}
|
|
190
|
+
*/
|
|
191
|
+
export function head(path, opts) {
|
|
192
|
+
return request('HEAD', path, opts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { BASE_URL };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only MCP resources exposed under the `cloudcdn://` scheme.
|
|
3
|
+
*
|
|
4
|
+
* Resources are passive — agents read them to populate context — and so
|
|
5
|
+
* each handler just forwards to a REST endpoint and serialises the JSON
|
|
6
|
+
* body back. All six refresh on every read.
|
|
7
|
+
*
|
|
8
|
+
* @module @sebastienrousseau/cloudcdn-mcp/lib/resources
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as api from '../api-client.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register the six MCP resources (`manifest`, `zones`, `rules`, `health`,
|
|
15
|
+
* `openapi`, `insights-today`) on the given MCP server.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ resource: Function }} server - The MCP server instance.
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
20
|
+
export function registerResources(server) {
|
|
21
|
+
server.resource(
|
|
22
|
+
'manifest',
|
|
23
|
+
'cloudcdn://manifest',
|
|
24
|
+
{
|
|
25
|
+
description: 'Complete JSON manifest of all CDN assets with names, paths, projects, categories, formats, and sizes.',
|
|
26
|
+
mimeType: 'application/json',
|
|
27
|
+
},
|
|
28
|
+
async () => {
|
|
29
|
+
const res = await api.get('/manifest.json');
|
|
30
|
+
return {
|
|
31
|
+
contents: [{
|
|
32
|
+
uri: 'cloudcdn://manifest',
|
|
33
|
+
mimeType: 'application/json',
|
|
34
|
+
text: JSON.stringify(res.data, null, 2),
|
|
35
|
+
}],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
server.resource(
|
|
41
|
+
'zones',
|
|
42
|
+
'cloudcdn://zones',
|
|
43
|
+
{
|
|
44
|
+
description: 'List of all CDN zones with file counts and storage usage.',
|
|
45
|
+
mimeType: 'application/json',
|
|
46
|
+
},
|
|
47
|
+
async () => {
|
|
48
|
+
const res = await api.get('/api/core/zones', { auth: 'account' });
|
|
49
|
+
return {
|
|
50
|
+
contents: [{
|
|
51
|
+
uri: 'cloudcdn://zones',
|
|
52
|
+
mimeType: 'application/json',
|
|
53
|
+
text: JSON.stringify(res.data, null, 2),
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
server.resource(
|
|
60
|
+
'rules',
|
|
61
|
+
'cloudcdn://rules',
|
|
62
|
+
{
|
|
63
|
+
description: 'Current _headers and _redirects edge configuration files.',
|
|
64
|
+
mimeType: 'application/json',
|
|
65
|
+
},
|
|
66
|
+
async () => {
|
|
67
|
+
const res = await api.get('/api/core/rules', { auth: 'account' });
|
|
68
|
+
return {
|
|
69
|
+
contents: [{
|
|
70
|
+
uri: 'cloudcdn://rules',
|
|
71
|
+
mimeType: 'application/json',
|
|
72
|
+
text: JSON.stringify(res.data, null, 2),
|
|
73
|
+
}],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.resource(
|
|
79
|
+
'health',
|
|
80
|
+
'cloudcdn://health',
|
|
81
|
+
{
|
|
82
|
+
description: 'Live service-health snapshot — binding presence (KV / AI / Vectorize / Durable Object / Queue), per-binding latency, healthy/degraded state. Refresh on every read.',
|
|
83
|
+
mimeType: 'application/json',
|
|
84
|
+
},
|
|
85
|
+
async () => {
|
|
86
|
+
const res = await api.get('/api/health', { params: { deep: 1 } });
|
|
87
|
+
return {
|
|
88
|
+
contents: [{
|
|
89
|
+
uri: 'cloudcdn://health',
|
|
90
|
+
mimeType: 'application/json',
|
|
91
|
+
text: JSON.stringify(res.data, null, 2),
|
|
92
|
+
}],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.resource(
|
|
98
|
+
'openapi',
|
|
99
|
+
'cloudcdn://openapi',
|
|
100
|
+
{
|
|
101
|
+
description: 'Full OpenAPI 3.1 specification for the CloudCDN REST API — every path, schema, tag group, security scheme, and example. Agents can read this as the source-of-truth contract when wiring direct HTTP calls.',
|
|
102
|
+
mimeType: 'application/json',
|
|
103
|
+
},
|
|
104
|
+
async () => {
|
|
105
|
+
const res = await api.get('/api-reference/openapi.json');
|
|
106
|
+
return {
|
|
107
|
+
contents: [{
|
|
108
|
+
uri: 'cloudcdn://openapi',
|
|
109
|
+
mimeType: 'application/json',
|
|
110
|
+
text: JSON.stringify(res.data, null, 2),
|
|
111
|
+
}],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.resource(
|
|
117
|
+
'insights-today',
|
|
118
|
+
'cloudcdn://insights/today',
|
|
119
|
+
{
|
|
120
|
+
description: 'Current-day insights summary — request volume, bandwidth served, cache hit ratio, top assets, error rate. Pulls from /api/insights/summary; refreshes on every read.',
|
|
121
|
+
mimeType: 'application/json',
|
|
122
|
+
},
|
|
123
|
+
async () => {
|
|
124
|
+
const res = await api.get('/api/insights/summary', { auth: 'access' });
|
|
125
|
+
return {
|
|
126
|
+
contents: [{
|
|
127
|
+
uri: 'cloudcdn://insights/today',
|
|
128
|
+
mimeType: 'application/json',
|
|
129
|
+
text: JSON.stringify(res.data, null, 2),
|
|
130
|
+
}],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account-management + diagnostics MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Phase 0 / Phase 1 / Phase 2A endpoints that don't yet have
|
|
5
|
+
* MCP coverage:
|
|
6
|
+
*
|
|
7
|
+
* - explain_cache_miss → GET /api/insights/cache-explain
|
|
8
|
+
* - account_cap_get → GET /api/account/cap
|
|
9
|
+
* - account_cap_set → PATCH /api/account/cap
|
|
10
|
+
* - account_zone_list → GET /api/auth/onboarding/zone
|
|
11
|
+
* - account_zone_provision → POST /api/auth/onboarding/zone
|
|
12
|
+
*
|
|
13
|
+
* Auth: these endpoints are session-cookie-gated, NOT
|
|
14
|
+
* AccountKey/AccessKey. The MCP server is account-key-based today, so
|
|
15
|
+
* the LLM agent needs a session-bearer flow before these calls land
|
|
16
|
+
* end-to-end. For now the tools surface the response from the API as
|
|
17
|
+
* the agent sees it (401 unauthenticated when no cookie present) —
|
|
18
|
+
* useful for the agent to learn what flow to ask the user to run.
|
|
19
|
+
*
|
|
20
|
+
* @module @sebastienrousseau/cloudcdn-mcp/lib/tools/account
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import * as api from '../api-client.js';
|
|
25
|
+
|
|
26
|
+
export function registerAccountTools(server) {
|
|
27
|
+
server.tool(
|
|
28
|
+
'explain_cache_miss',
|
|
29
|
+
'Diagnose why a CloudCDN URL hit or missed the edge cache. Returns the cf-cache-status + age + cache-control + cache-tag + a verdict ("hit_fresh" / "miss_origin_hit" / "bypass_no_store" / etc.) + actionable suggestions ("request again — should HIT", "purge by tag with …"). The URL MUST be on cloudcdn.pro. Session-cookie-gated.',
|
|
30
|
+
{
|
|
31
|
+
url: z.string().url().describe('Absolute https://cloudcdn.pro/... URL to inspect'),
|
|
32
|
+
},
|
|
33
|
+
async ({ url }) => {
|
|
34
|
+
const res = await api.get('/api/insights/cache-explain', { params: { url } });
|
|
35
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
server.tool(
|
|
40
|
+
'account_cap_get',
|
|
41
|
+
"Read the calling account's monthly spend cap in USD. capMode = 'degrade_only' (default — never bill overage; return 402 past zero) or 'hard_cap_with_opt_in_overage' (refuse past N until the user opts into overage billing). Session-cookie-gated.",
|
|
42
|
+
{},
|
|
43
|
+
async () => {
|
|
44
|
+
const res = await api.get('/api/account/cap');
|
|
45
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
server.tool(
|
|
50
|
+
'account_cap_set',
|
|
51
|
+
'Set the calling account\'s monthly spend cap in USD. 0 = degrade-only (never bill overage). N > 0 = hard cap at $N/month past which requests return 402. Audit-logged via auditEvent. Session-cookie-gated.',
|
|
52
|
+
{
|
|
53
|
+
monthlyCapUsd: z.number().int().min(0).max(1_000_000).describe('Whole USD per calendar month. 0 = never bill overage.'),
|
|
54
|
+
},
|
|
55
|
+
async ({ monthlyCapUsd }) => {
|
|
56
|
+
const res = await api.patch('/api/account/cap', { monthlyCapUsd });
|
|
57
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
server.tool(
|
|
62
|
+
'account_zone_list',
|
|
63
|
+
'List the calling account\'s provisioned zones (the onboarding-wizard / multi-tenant per-account zones, distinct from the GitOps tenant zones surfaced by `zone_list`). Session-cookie-gated.',
|
|
64
|
+
{},
|
|
65
|
+
async () => {
|
|
66
|
+
const res = await api.get('/api/auth/onboarding/zone');
|
|
67
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
server.tool(
|
|
72
|
+
'account_zone_provision',
|
|
73
|
+
'Provision a new account-scoped zone. Validates the origin via HEAD (5s timeout), slugifies the name, persists an account_zones row with an aspirational edge_hostname (<slug>.cdn.cloudcdn.pro) marked live=false until the Phase 3 routing pipeline flips it. Session-cookie-gated.',
|
|
74
|
+
{
|
|
75
|
+
name: z.string().min(1).max(64).describe('Display name (will be slugified for the edge hostname)'),
|
|
76
|
+
originUrl: z.string().url().describe('Origin URL — http:// or https://'),
|
|
77
|
+
},
|
|
78
|
+
async ({ name, originUrl }) => {
|
|
79
|
+
const res = await api.post('/api/auth/onboarding/zone', { name, originUrl });
|
|
80
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// ── Mutations (Phase 2C v2) ───────────────────────────────────
|
|
85
|
+
|
|
86
|
+
server.tool(
|
|
87
|
+
'purge_cache',
|
|
88
|
+
'Invalidate cached content. Pass `urls` (1-30 absolute https://cloudcdn.pro/... URLs), `tags` (Cache-Tag values), or `everything: true`. Returns the number of items invalidated. AccountKey-gated (uses CLOUDCDN_ACCOUNT_KEY env var).',
|
|
89
|
+
{
|
|
90
|
+
urls: z.array(z.string().url()).max(30).optional().describe('Specific URLs to purge (max 30)'),
|
|
91
|
+
tags: z.array(z.string().min(1).max(128)).max(30).optional().describe('Cache-Tag values to purge (max 30)'),
|
|
92
|
+
everything: z.boolean().optional().describe('Purge the entire zone — use sparingly (degrades global edge cache for ~5 min).'),
|
|
93
|
+
},
|
|
94
|
+
async ({ urls, tags, everything }) => {
|
|
95
|
+
const body = {};
|
|
96
|
+
if (urls && urls.length) body.urls = urls;
|
|
97
|
+
if (tags && tags.length) body.tags = tags;
|
|
98
|
+
if (everything === true) body.everything = true;
|
|
99
|
+
const res = await api.post('/api/purge', body);
|
|
100
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.tool(
|
|
105
|
+
'deploy_function',
|
|
106
|
+
'Create or update an edge-routing rule (a "function" in the rule-engine sense). The body is the same shape as POST /api/core/rules: { name, pattern, action: "rewrite"|"redirect"|"block"|"transform"|"cache", target?, ttl?, priority? }. AccountKey-gated.',
|
|
107
|
+
{
|
|
108
|
+
name: z.string().min(1).max(64).describe('Human-friendly rule name'),
|
|
109
|
+
pattern: z.string().min(1).max(512).describe('URL pattern to match (glob or regex, see /api/core/rules docs)'),
|
|
110
|
+
action: z.enum(['rewrite', 'redirect', 'block', 'transform', 'cache']).describe('What the rule does when matched'),
|
|
111
|
+
target: z.string().max(512).optional().describe('Destination URL/path for rewrite|redirect actions'),
|
|
112
|
+
ttl: z.number().int().min(0).max(31_536_000).optional().describe('Cache TTL in seconds (for action=cache)'),
|
|
113
|
+
priority: z.number().int().min(0).max(1000).optional().describe('Lower wins. Default = 100.'),
|
|
114
|
+
},
|
|
115
|
+
async (input) => {
|
|
116
|
+
const res = await api.post('/api/core/rules', input);
|
|
117
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
server.tool(
|
|
122
|
+
'get_analytics',
|
|
123
|
+
'Fetch account-wide statistics for a time range. Returns request counts, cache hit ratio, bytes served, AI invocations, error breakdown. Use `zone` to scope to a specific tenant zone; omit for account-wide. AccountKey-gated.',
|
|
124
|
+
{
|
|
125
|
+
zone: z.string().min(1).max(128).optional().describe('Zone slug (omit for account-wide)'),
|
|
126
|
+
range: z.enum(['1h', '24h', '7d', '30d']).default('24h').describe('Time range. 30d max on free tier.'),
|
|
127
|
+
},
|
|
128
|
+
async ({ zone, range }) => {
|
|
129
|
+
const params = { range };
|
|
130
|
+
if (zone) params.zone = zone;
|
|
131
|
+
const res = await api.get('/api/core/statistics', { params });
|
|
132
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
}
|
package/lib/tools/ai.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI / search MCP tools — semantic search, RAG concierge, vision
|
|
3
|
+
* endpoints, and placeholder generators. All are public-access.
|
|
4
|
+
*
|
|
5
|
+
* The vision and chat endpoints share a Workers AI budget guard; when the
|
|
6
|
+
* daily quota trips, responses flip to cached / fuzzy / curated mode and
|
|
7
|
+
* mark themselves with `degraded: true` so agents don't need to branch.
|
|
8
|
+
*
|
|
9
|
+
* @module @sebastienrousseau/cloudcdn-mcp/lib/tools/ai
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import * as api from '../api-client.js';
|
|
14
|
+
|
|
15
|
+
const URL_PATH = z.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(2048)
|
|
18
|
+
.describe('Relative asset path (e.g. "/clients/akande/v1/logos/logo.svg"). Absolute URLs are rejected.');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register the AI tools (`semantic_search`, `health_check`,
|
|
22
|
+
* `generate_alt_text`, `smart_crop`, `moderate_image`, `placeholder_lqip`,
|
|
23
|
+
* `placeholder_blurhash`, `chat_ask`, `remove_background`) on the given
|
|
24
|
+
* MCP server.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ tool: Function }} server - The MCP server instance.
|
|
27
|
+
* @returns {void}
|
|
28
|
+
*/
|
|
29
|
+
export function registerAiTools(server) {
|
|
30
|
+
server.tool(
|
|
31
|
+
'semantic_search',
|
|
32
|
+
'Search the asset catalog using natural language. Uses vector similarity (Cloudflare Vectorize) with fuzzy text fallback. Returns `mode: vector|fuzzy|cached` so agents can tell which layer responded; `degraded: true` indicates the AI path is bypassed (quota exhausted or unavailable).',
|
|
33
|
+
{
|
|
34
|
+
q: z.string().describe('Natural language search query (e.g., "dark blue banking background")'),
|
|
35
|
+
limit: z.number().int().min(1).max(50).default(20).optional(),
|
|
36
|
+
},
|
|
37
|
+
async ({ q, limit }) => {
|
|
38
|
+
const res = await api.get('/api/search', { params: { q, limit } });
|
|
39
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
server.tool(
|
|
44
|
+
'health_check',
|
|
45
|
+
'Check the health status of the CloudCDN service and the availability of its backend bindings (KV, AI, Vectorize).',
|
|
46
|
+
{},
|
|
47
|
+
async () => {
|
|
48
|
+
const res = await api.get('/api/health');
|
|
49
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
server.tool(
|
|
54
|
+
'generate_alt_text',
|
|
55
|
+
'Generate a short, accessibility-quality alt-text description for an image using a Workers AI vision model. Caches the result for 24 h. Returns `{ alt, model, source: "ai"|"cached", degraded, dateGenerated }`. Useful at upload time to seed an `alt=` attribute, or on-demand for authoring tools.',
|
|
56
|
+
{ url: URL_PATH },
|
|
57
|
+
async ({ url }) => {
|
|
58
|
+
const res = await api.post('/api/ai/alt-text', { url });
|
|
59
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
server.tool(
|
|
64
|
+
'smart_crop',
|
|
65
|
+
'Identify where the visual subject of an image sits and return a `gravity` value compatible with /api/transform. Output is one of nine compass directions plus "face" and "center", with a confidence band (`high|medium|low`). Chain with the transform tool to get a subject-aware square thumbnail.',
|
|
66
|
+
{ url: URL_PATH },
|
|
67
|
+
async ({ url }) => {
|
|
68
|
+
const res = await api.post('/api/ai/smart-crop', { url });
|
|
69
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.tool(
|
|
74
|
+
'moderate_image',
|
|
75
|
+
'Classify an image\'s safety across five categories (nudity, violence, drugs, hate symbols, gore) using a Workers AI vision model. Returns `{ safe, verdict: "safe"|"borderline"|"unsafe", categories: { ...0-1 floats }, reasoning }`. Use as an automated gate during upload pipelines — on unparseable output the endpoint conservatively returns "borderline" so the caller still has to decide.',
|
|
76
|
+
{ url: URL_PATH },
|
|
77
|
+
async ({ url }) => {
|
|
78
|
+
const res = await api.post('/api/ai/moderate', { url });
|
|
79
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
server.tool(
|
|
84
|
+
'placeholder_lqip',
|
|
85
|
+
'Generate a low-quality image placeholder (LQIP) as a base64 data URI. Useful for progressive-loading UIs that need an immediate visual placeholder while the full asset streams in. Returns `{ lqip: "data:image/webp;base64,...", bytes, width, dateGenerated }`. The result is deterministic per (url, size, blur) and cached for 24 h.',
|
|
86
|
+
{
|
|
87
|
+
url: URL_PATH,
|
|
88
|
+
size: z.number().int().min(8).max(64).default(32).optional()
|
|
89
|
+
.describe('Width of the downsampled placeholder in pixels. Clamped to [8, 64].'),
|
|
90
|
+
blur: z.number().int().min(1).max(250).default(30).optional()
|
|
91
|
+
.describe('Gaussian blur radius applied by Cloudflare Image Resizing.'),
|
|
92
|
+
},
|
|
93
|
+
async ({ url, size, blur }) => {
|
|
94
|
+
const params = { url };
|
|
95
|
+
if (size !== undefined) params.size = size;
|
|
96
|
+
if (blur !== undefined) params.blur = blur;
|
|
97
|
+
const res = await api.get('/api/lqip', { params });
|
|
98
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
server.tool(
|
|
103
|
+
'placeholder_blurhash',
|
|
104
|
+
'Generate a content-addressable placeholder hash (40-char SHA-256 prefix) plus a base64 data URI for an image. Companion to the LQIP tool — same downsampled WebP, but the hash is a stable key clients can use to dedupe placeholders or persist them by content. Cached for 24 h per (url, size) tuple.',
|
|
105
|
+
{
|
|
106
|
+
url: URL_PATH,
|
|
107
|
+
size: z.number().int().min(8).max(64).default(32).optional()
|
|
108
|
+
.describe('Width of the downsampled placeholder in pixels.'),
|
|
109
|
+
},
|
|
110
|
+
async ({ url, size }) => {
|
|
111
|
+
const params = { url };
|
|
112
|
+
if (size !== undefined) params.size = size;
|
|
113
|
+
const res = await api.get('/api/blurhash', { params });
|
|
114
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
server.tool(
|
|
119
|
+
'chat_ask',
|
|
120
|
+
'Ask the CloudCDN AI concierge a natural-language question about the platform — pricing, limits, how to use a specific endpoint, troubleshooting, etc. The concierge is a RAG agent over the `cdn/en/content/` knowledge base. Returns `{ answer, sources: string[], confidence: "high"|"medium"|"low", source: "ai"|"cached"|"curated", degraded: boolean }`. When AI quota is exhausted the curated 30-entry FAQ takes over (`source: "curated"`); the contract is identical so agents need no special branching.',
|
|
121
|
+
{
|
|
122
|
+
question: z.string().min(1).max(2000).describe('Free-form question. Be specific — "how do I purge by tag" beats "purge help".'),
|
|
123
|
+
},
|
|
124
|
+
async ({ question }) => {
|
|
125
|
+
const res = await api.post('/api/chat', { messages: [{ role: 'user', content: question }] });
|
|
126
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
server.tool(
|
|
131
|
+
'remove_background',
|
|
132
|
+
'Remove the background from an image, isolating the subject on a transparent alpha layer. **Not yet implemented** — the endpoint returns HTTP 501 because Cloudflare Workers AI does not currently include a segmentation/matting model (U^2-Net / rembg-class). The route is documented and discoverable so agents and clients can integrate ahead of the model landing; when a model becomes available the implementation will swap in without changing this contract. Use the smart_crop tool today for subject-aware framing as the nearest substitute.',
|
|
133
|
+
{ url: URL_PATH },
|
|
134
|
+
async ({ url }) => {
|
|
135
|
+
// Intentionally lenient: this endpoint always returns 501, and we
|
|
136
|
+
// surface that to the agent as a structured tool result rather than
|
|
137
|
+
// a thrown error — agents should be able to read the "blocked by
|
|
138
|
+
// dependency" message and route around it.
|
|
139
|
+
try {
|
|
140
|
+
const res = await api.post('/api/ai/background-remove', { url });
|
|
141
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const body = err?.response?.data ?? { error: { code: 'NotImplemented', message: err?.message || String(err) } };
|
|
144
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-catalog MCP tools — browse, fetch metadata, and search the
|
|
3
|
+
* read-side asset index. AccessKey auth on every call.
|
|
4
|
+
*
|
|
5
|
+
* @module @sebastienrousseau/cloudcdn-mcp/lib/tools/assets
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import * as api from '../api-client.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the asset tools (`assets_list`, `asset_metadata_get`,
|
|
13
|
+
* `assets_search`) on the given MCP server.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ tool: Function }} server - The MCP server instance.
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function registerAssetsTools(server) {
|
|
19
|
+
server.tool(
|
|
20
|
+
'assets_list',
|
|
21
|
+
'Browse and search the CDN asset catalog with filtering by project, category, and format. Supports pagination and sorting.',
|
|
22
|
+
{
|
|
23
|
+
project: z.string().optional().describe('Filter by project/zone name'),
|
|
24
|
+
category: z.string().optional().describe('Filter by category (logos, banners, icons, etc.)'),
|
|
25
|
+
format: z.string().optional().describe('Filter by file format (svg, png, webp, avif, ico)'),
|
|
26
|
+
q: z.string().optional().describe('Search query for filename or path'),
|
|
27
|
+
page: z.number().int().min(1).default(1).optional(),
|
|
28
|
+
per_page: z.number().int().min(1).max(200).default(50).optional(),
|
|
29
|
+
sort: z.enum(['name', 'size', 'project']).default('name').optional(),
|
|
30
|
+
order: z.enum(['asc', 'desc']).default('asc').optional(),
|
|
31
|
+
},
|
|
32
|
+
async (params) => {
|
|
33
|
+
const res = await api.get('/api/assets', { auth: 'access', params });
|
|
34
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
server.tool(
|
|
39
|
+
'asset_metadata_get',
|
|
40
|
+
'Get the full metadata record for a single asset by path: dimensions, format, size, project, category, ETag, content hash, and any AI-derived fields (alt text, smart-crop gravity, dominant colors) cached against it. Use this before a transform or to populate a card UI.',
|
|
41
|
+
{
|
|
42
|
+
path: z.string().min(1).max(1024).describe('Relative asset path (e.g. "/akande/v1/logos/akande.svg")'),
|
|
43
|
+
},
|
|
44
|
+
async ({ path }) => {
|
|
45
|
+
const res = await api.get('/api/assets/metadata', { auth: 'access', params: { path } });
|
|
46
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
server.tool(
|
|
51
|
+
'assets_search',
|
|
52
|
+
'Search for assets by name or path. Returns matching assets with metadata.',
|
|
53
|
+
{
|
|
54
|
+
q: z.string().describe('Search query (e.g., "logo", "banner-bankingonai")'),
|
|
55
|
+
project: z.string().optional().describe('Narrow search to a specific project'),
|
|
56
|
+
format: z.string().optional().describe('Narrow search to a specific format'),
|
|
57
|
+
},
|
|
58
|
+
async ({ q, project, format }) => {
|
|
59
|
+
const params = { q, per_page: 50 };
|
|
60
|
+
if (project) params.project = project;
|
|
61
|
+
if (format) params.format = format;
|
|
62
|
+
const res = await api.get('/api/assets', { auth: 'access', params });
|
|
63
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|