@cloudhal/mcp-server 0.0.0-stage → 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 +202 -0
- package/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media tools (RFC-0004 §6.2): agents list and read the library, add files with `upload_asset`
|
|
3
|
+
* (base64, ≤ 4 MB) or `fetch_asset_url` (server-side fetch under the SSRF rules, ≤ 25 MB), edit
|
|
4
|
+
* their metadata, and — with `asset:manage` — retire, restore or delete them. Uploads are never
|
|
5
|
+
* public by themselves: publishing decides (§6.3). Agents cannot upload SVG, keep metadata or
|
|
6
|
+
* register external files in v1 (answers 2, 3, 8). Each change takes one snapshot.
|
|
7
|
+
*/
|
|
8
|
+
import { holds } from "@cloudhal/agent-runtime";
|
|
9
|
+
|
|
10
|
+
import { AssetSchema, SnapshotSchema } from "@cloudhal/core-contracts";
|
|
11
|
+
import { termName } from "@cloudhal/core-ts";
|
|
12
|
+
import {
|
|
13
|
+
formatBytes,
|
|
14
|
+
MEDIA_LIMITS,
|
|
15
|
+
MEDIA_TYPES,
|
|
16
|
+
mediaPath,
|
|
17
|
+
safeFetch,
|
|
18
|
+
sniff,
|
|
19
|
+
workersTransport,
|
|
20
|
+
} from "@cloudhal/core-ts/media";
|
|
21
|
+
import {
|
|
22
|
+
ACTOR_OWNERSHIP,
|
|
23
|
+
autoSnapshot,
|
|
24
|
+
callerPerson,
|
|
25
|
+
id,
|
|
26
|
+
|
|
27
|
+
mapError,
|
|
28
|
+
obj,
|
|
29
|
+
quote,
|
|
30
|
+
requireSite,
|
|
31
|
+
SLUG_PATTERN,
|
|
32
|
+
str,
|
|
33
|
+
strip,
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ToolError,
|
|
37
|
+
} from "./common.js";
|
|
38
|
+
|
|
39
|
+
const ASSET = strip(AssetSchema);
|
|
40
|
+
const SNAPSHOT = strip(SnapshotSchema);
|
|
41
|
+
const ASSET_DEFS = (ASSET.$defs ?? {}) ;
|
|
42
|
+
|
|
43
|
+
const READ = {
|
|
44
|
+
readOnlyHint: true,
|
|
45
|
+
destructiveHint: false,
|
|
46
|
+
idempotentHint: true,
|
|
47
|
+
openWorldHint: false,
|
|
48
|
+
} ;
|
|
49
|
+
const WRITE = {
|
|
50
|
+
readOnlyHint: false,
|
|
51
|
+
destructiveHint: false,
|
|
52
|
+
idempotentHint: false,
|
|
53
|
+
openWorldHint: false,
|
|
54
|
+
} ;
|
|
55
|
+
|
|
56
|
+
const MB = 1024 * 1024;
|
|
57
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
/** What agents may fetch: every accepted type except SVG (people only in v1). */
|
|
60
|
+
const FETCH_TYPES = Object.entries(MEDIA_TYPES)
|
|
61
|
+
.filter(([, info]) => !info.peopleOnly)
|
|
62
|
+
.map(([type]) => type);
|
|
63
|
+
|
|
64
|
+
const TEXT_FIELDS = {
|
|
65
|
+
alt: str(
|
|
66
|
+
"Alternative text in `locale` (default: the site's default locale). Images need it, or decorative: true.",
|
|
67
|
+
{ minLength: 1, maxLength: 500 },
|
|
68
|
+
),
|
|
69
|
+
decorative: {
|
|
70
|
+
type: "boolean",
|
|
71
|
+
description: 'The image carries no information and renders with alt="".',
|
|
72
|
+
},
|
|
73
|
+
title: str("Display title in `locale`", { minLength: 1, maxLength: 300 }),
|
|
74
|
+
caption: str("Default caption in `locale` (plain text)", { minLength: 1, maxLength: 1000 }),
|
|
75
|
+
credit: str("Who made it, e.g. 'Photo: Ayşe Yılmaz'", { minLength: 1, maxLength: 300 }),
|
|
76
|
+
license: { ...(ASSET_DEFS.AssetLicense ), $defs: ASSET_DEFS },
|
|
77
|
+
locale: str("BCP 47 locale of alt, title and caption; defaults to the site's default locale"),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const ASSET_OUT = obj(
|
|
81
|
+
{
|
|
82
|
+
asset: ASSET,
|
|
83
|
+
existing: { type: "boolean", description: "The site already had these bytes" },
|
|
84
|
+
url: { type: "string", description: "Site-relative URL (live once published)" },
|
|
85
|
+
usage: { type: "integer" },
|
|
86
|
+
snapshot: SNAPSHOT,
|
|
87
|
+
},
|
|
88
|
+
["asset", "existing", "url", "usage"],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
function url(asset ) {
|
|
92
|
+
return asset.storage.kind === "external" ? asset.storage.url : mediaPath(asset);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function texts(
|
|
96
|
+
site ,
|
|
97
|
+
a ,
|
|
98
|
+
) {
|
|
99
|
+
const locale = (a.locale ) ?? site.defaultLocale;
|
|
100
|
+
const map = (v ) => (typeof v === "string" && v.trim() ? { [locale]: v } : undefined);
|
|
101
|
+
const alt = map(a.alt);
|
|
102
|
+
const title = map(a.title);
|
|
103
|
+
const caption = map(a.caption);
|
|
104
|
+
return {
|
|
105
|
+
...(alt ? { alt } : {}),
|
|
106
|
+
...(title ? { title } : {}),
|
|
107
|
+
...(caption ? { caption } : {}),
|
|
108
|
+
...(typeof a.credit === "string" && a.credit.trim() ? { credit: a.credit } : {}),
|
|
109
|
+
...(a.decorative === true ? { decorative: true } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** "image "Bosphorus at dawn" (JPEG, 3000×2000, 482 KB)" — quoted and shortened (§6.2). */
|
|
114
|
+
export function describeAsset(asset , locale ) {
|
|
115
|
+
const name =
|
|
116
|
+
(locale ? (asset.title?.[locale] ?? asset.alt?.[locale]) : undefined) ??
|
|
117
|
+
Object.values(asset.title ?? {})[0] ??
|
|
118
|
+
asset.slug;
|
|
119
|
+
const type = MEDIA_TYPES[asset.mediaType]?.label ?? asset.mediaType;
|
|
120
|
+
const size = asset.image ? `${asset.image.width}×${asset.image.height}, ` : "";
|
|
121
|
+
const kind = asset.kind === "other" || asset.kind === "data" ? "file" : asset.kind;
|
|
122
|
+
return `${kind} "${quote(name)}" (${type}, ${size}${formatBytes(asset.bytes)})`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Images need alt text in the site's default locale or `decorative: true` (§5.6). */
|
|
126
|
+
function requireAlt(head , a ) {
|
|
127
|
+
const s = sniff(head);
|
|
128
|
+
const image = s.kind === "type" && s.mediaType.startsWith("image/");
|
|
129
|
+
if (image && !(typeof a.alt === "string" && a.alt.trim()) && a.decorative !== true)
|
|
130
|
+
throw new ToolError(
|
|
131
|
+
"invalid",
|
|
132
|
+
"images need alt text (what the image shows, for screen readers) or decorative: true",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Agent limits per key per day (§5.6), checked before anything is fetched or stored. */
|
|
137
|
+
async function dailyLimit(ctx , addBytes ) {
|
|
138
|
+
if (ctx.principal.actor.kind !== "agent") return;
|
|
139
|
+
const maxAssets = ctx.media?.agentDailyAssets ?? 100;
|
|
140
|
+
const maxBytes = ctx.media?.agentDailyBytes ?? 500 * MB;
|
|
141
|
+
const since = new Date((ctx.now?.() ?? new Date()).getTime() - DAY_MS).toISOString();
|
|
142
|
+
const used = await ctx.store.assetUploadStats(ctx.principal.actor.id, since);
|
|
143
|
+
if (used.count >= maxAssets || used.bytes + addBytes > maxBytes)
|
|
144
|
+
throw new ToolError(
|
|
145
|
+
"denied",
|
|
146
|
+
`this agent key added ${used.count} files (${formatBytes(used.bytes)}) in the last 24 hours; the limit is ${maxAssets} files and ${formatBytes(maxBytes)}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A file name without path separators or control characters (kept for search only). */
|
|
151
|
+
function cleanName(name ) {
|
|
152
|
+
const clean = [...(name ?? "")]
|
|
153
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f && c !== "/" && c !== "\\")
|
|
154
|
+
.join("")
|
|
155
|
+
.slice(0, 255);
|
|
156
|
+
return clean || undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function decodeBase64(data ) {
|
|
160
|
+
const clean = data.replace(/\s+/g, "");
|
|
161
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 === 1)
|
|
162
|
+
throw new ToolError("invalid", "data must be base64");
|
|
163
|
+
const bin = atob(clean);
|
|
164
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function requireAsset(ctx , siteId , assetId ) {
|
|
168
|
+
const asset = await ctx.store.getAsset(assetId);
|
|
169
|
+
if (!asset || asset.siteId !== siteId)
|
|
170
|
+
throw new ToolError("not_found", `asset ${assetId} not found`);
|
|
171
|
+
return asset;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Where an asset is used, with the referring node's title (for agents and approval prompts). */
|
|
175
|
+
async function usesOf(ctx , asset , locale ) {
|
|
176
|
+
const usage = (await ctx.store.assetUsage(asset.siteId, [asset.id])).get(asset.id);
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const ref of usage?.refs ?? []) {
|
|
179
|
+
let title ;
|
|
180
|
+
if (ref.nodeKind === "contentEntry")
|
|
181
|
+
title = (await ctx.store.getContentEntry(ref.nodeId))?.title;
|
|
182
|
+
else if (ref.nodeKind === "page") title = (await ctx.store.getPage(ref.nodeId))?.title;
|
|
183
|
+
else if (ref.nodeKind === "term") {
|
|
184
|
+
const t = await ctx.store.getTerm(ref.nodeId);
|
|
185
|
+
title = t ? termName(t, locale) : undefined;
|
|
186
|
+
} else if (ref.nodeKind === "site") title = (await ctx.store.getSite(ref.nodeId))?.name;
|
|
187
|
+
out.push({ ...ref, ...(title ? { title } : {}) });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function report(ctx , asset , extra = {}) {
|
|
193
|
+
ctx.report?.({
|
|
194
|
+
asset: {
|
|
195
|
+
id: asset.id,
|
|
196
|
+
sha256: asset.sha256,
|
|
197
|
+
bytes: asset.bytes,
|
|
198
|
+
mediaType: asset.mediaType,
|
|
199
|
+
source: asset.source,
|
|
200
|
+
...extra,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `asset:upload` edits one's own uploads; `asset:manage` edits any (RFC-0005 table). "Own" is
|
|
207
|
+
* measured against the person behind the caller (RFC-0005 §4): a Contributor's agent may edit
|
|
208
|
+
* what the Contributor or the agent uploaded.
|
|
209
|
+
*/
|
|
210
|
+
async function ownOrManage(a , ctx ) {
|
|
211
|
+
if (holds(ctx.principal.scopes, "asset:manage")) return;
|
|
212
|
+
const asset = await requireAsset(ctx, a.siteId , a.assetId );
|
|
213
|
+
const owner = await (ctx.ownership ?? ACTOR_OWNERSHIP).personOf(asset.uploadedBy);
|
|
214
|
+
const mine = asset.uploadedBy.id === ctx.principal.actor.id || owner === callerPerson(ctx);
|
|
215
|
+
if (!mine)
|
|
216
|
+
throw new ToolError(
|
|
217
|
+
"denied",
|
|
218
|
+
`asset:upload lets an agent edit its own uploads; ${asset.id} was added by ${asset.uploadedBy.name ?? asset.uploadedBy.id} (asset:manage edits any file)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function assetTools() {
|
|
223
|
+
const siteAsset = obj({ siteId: id, assetId: id }, ["siteId", "assetId"]);
|
|
224
|
+
const revInput = obj({ siteId: id, assetId: id, rev: { type: "integer", minimum: 0 } }, [
|
|
225
|
+
"siteId",
|
|
226
|
+
"assetId",
|
|
227
|
+
"rev",
|
|
228
|
+
]);
|
|
229
|
+
const statusTool = (
|
|
230
|
+
name ,
|
|
231
|
+
title ,
|
|
232
|
+
description ,
|
|
233
|
+
) => ({
|
|
234
|
+
name,
|
|
235
|
+
title,
|
|
236
|
+
description,
|
|
237
|
+
inputSchema: revInput,
|
|
238
|
+
outputSchema: obj({ asset: ASSET, snapshot: SNAPSHOT }, ["asset", "snapshot"]),
|
|
239
|
+
annotations: { ...WRITE, idempotentHint: true },
|
|
240
|
+
scopes: ["asset:manage"],
|
|
241
|
+
handler: async (a, ctx) => {
|
|
242
|
+
const asset = await requireAsset(ctx, a.siteId , a.assetId );
|
|
243
|
+
try {
|
|
244
|
+
const next = { ...asset, rev: a.rev };
|
|
245
|
+
const saved =
|
|
246
|
+
name === "retire_asset"
|
|
247
|
+
? await ctx.store.retireAsset(next)
|
|
248
|
+
: await ctx.store.restoreAsset(next);
|
|
249
|
+
report(ctx, saved, { status: saved.status });
|
|
250
|
+
return { asset: saved, snapshot: await autoSnapshot(ctx, asset.siteId, name) };
|
|
251
|
+
} catch (e) {
|
|
252
|
+
mapError(e);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
name: "list_assets",
|
|
260
|
+
title: "List media",
|
|
261
|
+
description:
|
|
262
|
+
"Files of a site's media library, newest first: images, video, audio, documents and data. Each item has its site URL, size, SHA-256, alt text per locale, status and how many places use it. Filter by kind, status, words in title/alt/caption/file name, unused files or images missing alt text.",
|
|
263
|
+
inputSchema: obj(
|
|
264
|
+
{
|
|
265
|
+
siteId: id,
|
|
266
|
+
kind: {
|
|
267
|
+
type: "array",
|
|
268
|
+
items: {
|
|
269
|
+
type: "string",
|
|
270
|
+
enum: ["image", "video", "audio", "document", "data", "other"],
|
|
271
|
+
},
|
|
272
|
+
minItems: 1,
|
|
273
|
+
uniqueItems: true,
|
|
274
|
+
},
|
|
275
|
+
status: {
|
|
276
|
+
type: "array",
|
|
277
|
+
items: { type: "string", enum: ["active", "retired"] },
|
|
278
|
+
minItems: 1,
|
|
279
|
+
uniqueItems: true,
|
|
280
|
+
},
|
|
281
|
+
query: str("Words to look for", { minLength: 1, maxLength: 200 }),
|
|
282
|
+
unused: { type: "boolean" },
|
|
283
|
+
missingAlt: { type: "boolean" },
|
|
284
|
+
uploadedBy: { type: "string", enum: ["human", "agent"] },
|
|
285
|
+
cursor: str("From the previous page's nextCursor", { maxLength: 200 }),
|
|
286
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
287
|
+
},
|
|
288
|
+
["siteId"],
|
|
289
|
+
),
|
|
290
|
+
outputSchema: obj(
|
|
291
|
+
{
|
|
292
|
+
assets: {
|
|
293
|
+
type: "array",
|
|
294
|
+
items: obj({ asset: ASSET, url: { type: "string" }, usage: { type: "integer" } }, [
|
|
295
|
+
"asset",
|
|
296
|
+
"url",
|
|
297
|
+
"usage",
|
|
298
|
+
]),
|
|
299
|
+
},
|
|
300
|
+
nextCursor: { type: "string" },
|
|
301
|
+
},
|
|
302
|
+
["assets"],
|
|
303
|
+
),
|
|
304
|
+
annotations: READ,
|
|
305
|
+
scopes: ["content:read"],
|
|
306
|
+
handler: async (a, ctx) => {
|
|
307
|
+
await requireSite(ctx, a.siteId );
|
|
308
|
+
try {
|
|
309
|
+
const page = await ctx.store.listAssets(a.siteId , {
|
|
310
|
+
...(a.kind ? { kind: a.kind } : {}),
|
|
311
|
+
...(a.status ? { status: a.status } : {}),
|
|
312
|
+
...(a.query ? { query: a.query } : {}),
|
|
313
|
+
...(a.unused ? { unused: true } : {}),
|
|
314
|
+
...(a.missingAlt ? { missingAlt: true } : {}),
|
|
315
|
+
...(a.uploadedBy ? { uploadedBy: a.uploadedBy } : {}),
|
|
316
|
+
...(a.cursor ? { cursor: a.cursor } : {}),
|
|
317
|
+
...(a.limit ? { limit: a.limit } : {}),
|
|
318
|
+
});
|
|
319
|
+
const usage = await ctx.store.assetUsage(
|
|
320
|
+
a.siteId ,
|
|
321
|
+
page.assets.map((x) => x.id),
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
assets: page.assets.map((asset) => ({
|
|
325
|
+
asset,
|
|
326
|
+
url: url(asset),
|
|
327
|
+
usage: usage.get(asset.id)?.total ?? 0,
|
|
328
|
+
})),
|
|
329
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
330
|
+
};
|
|
331
|
+
} catch (e) {
|
|
332
|
+
mapError(e);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "get_asset",
|
|
338
|
+
title: "Get media file",
|
|
339
|
+
description:
|
|
340
|
+
"One file of the media library with its URL, checksum file and every place that uses it (node, field and title).",
|
|
341
|
+
inputSchema: siteAsset,
|
|
342
|
+
outputSchema: obj(
|
|
343
|
+
{
|
|
344
|
+
asset: ASSET,
|
|
345
|
+
url: { type: "string" },
|
|
346
|
+
checksumUrl: { type: "string" },
|
|
347
|
+
uses: { type: "array", items: { type: "object" } },
|
|
348
|
+
},
|
|
349
|
+
["asset", "url", "uses"],
|
|
350
|
+
),
|
|
351
|
+
annotations: READ,
|
|
352
|
+
scopes: ["content:read"],
|
|
353
|
+
handler: async (a, ctx) => {
|
|
354
|
+
const site = await requireSite(ctx, a.siteId );
|
|
355
|
+
const asset = await requireAsset(ctx, site.id, a.assetId );
|
|
356
|
+
return {
|
|
357
|
+
asset,
|
|
358
|
+
url: url(asset),
|
|
359
|
+
...(asset.storage.kind === "blob" ? { checksumUrl: `${mediaPath(asset)}.sha256` } : {}),
|
|
360
|
+
uses: await usesOf(ctx, asset, site.defaultLocale),
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "upload_asset",
|
|
366
|
+
title: "Upload media",
|
|
367
|
+
description: `Add a file to the site's media library from base64 data (at most ${MEDIA_LIMITS.uploadAsset / MB} MB decoded). The server checks the type from the bytes (JPEG, PNG, WebP, AVIF, GIF, MP4, WebM, MP3, M4A, Ogg, WAV, PDF, CSV, TSV, JSON, NDJSON, Parquet; no SVG), strips photo metadata such as GPS, and returns the asset. Images need alt text or decorative: true. Uploading the same bytes again returns the existing asset (existing: true). Use it in an article with coverImage: {assetId} or in markdown as . Nothing is public until the site is published.`,
|
|
368
|
+
inputSchema: obj(
|
|
369
|
+
{
|
|
370
|
+
siteId: id,
|
|
371
|
+
filename: str(
|
|
372
|
+
"Original file name, e.g. ferries.jpg (its extension must match the content)",
|
|
373
|
+
{
|
|
374
|
+
minLength: 1,
|
|
375
|
+
maxLength: 255,
|
|
376
|
+
pattern: "^[^/\\\\\\u0000-\\u001f\\u007f]+$",
|
|
377
|
+
},
|
|
378
|
+
),
|
|
379
|
+
data: str("The file's bytes, base64", {
|
|
380
|
+
minLength: 4,
|
|
381
|
+
maxLength: Math.ceil((MEDIA_LIMITS.uploadAsset * 4) / 3) + 8,
|
|
382
|
+
}),
|
|
383
|
+
mediaType: str(
|
|
384
|
+
"What you believe the type is, e.g. image/png (checked against the bytes)",
|
|
385
|
+
{
|
|
386
|
+
maxLength: 255,
|
|
387
|
+
},
|
|
388
|
+
),
|
|
389
|
+
slug: str("File name in the URL, without extension", {
|
|
390
|
+
pattern: SLUG_PATTERN,
|
|
391
|
+
maxLength: 96,
|
|
392
|
+
}),
|
|
393
|
+
...TEXT_FIELDS,
|
|
394
|
+
},
|
|
395
|
+
["siteId", "filename", "data"],
|
|
396
|
+
),
|
|
397
|
+
outputSchema: ASSET_OUT,
|
|
398
|
+
annotations: WRITE,
|
|
399
|
+
scopes: ["asset:upload"],
|
|
400
|
+
auditArgs: ({ data, ...rest }) => ({
|
|
401
|
+
...rest,
|
|
402
|
+
data: { base64Chars: typeof data === "string" ? data.length : 0 },
|
|
403
|
+
}),
|
|
404
|
+
approvalPrompt: async (a, ctx) => {
|
|
405
|
+
const site = await ctx.store.getSite(a.siteId );
|
|
406
|
+
const size = Math.floor(((a.data )?.length ?? 0) * 0.75);
|
|
407
|
+
const name = (a.alt ?? a.title ?? a.filename) ;
|
|
408
|
+
return `Add media "${quote(name)}" (${quote(a.filename , 80)}, about ${formatBytes(size)}) to "${quote(site?.name ?? (a.siteId ))}"? It becomes public only when the site is published.`;
|
|
409
|
+
},
|
|
410
|
+
precheck: async (a, ctx) => {
|
|
411
|
+
const bytes = decodeBase64(a.data );
|
|
412
|
+
if (bytes.byteLength > MEDIA_LIMITS.uploadAsset)
|
|
413
|
+
throw new ToolError(
|
|
414
|
+
"too_large",
|
|
415
|
+
`upload_asset takes at most ${MEDIA_LIMITS.uploadAsset / MB} MB; use fetch_asset_url for larger public files, or ask a person to upload it in Studio`,
|
|
416
|
+
);
|
|
417
|
+
requireAlt(bytes.subarray(0, 4096), a);
|
|
418
|
+
await dailyLimit(ctx, bytes.byteLength);
|
|
419
|
+
},
|
|
420
|
+
handler: async (a, ctx) => {
|
|
421
|
+
const site = await requireSite(ctx, a.siteId );
|
|
422
|
+
const bytes = decodeBase64(a.data );
|
|
423
|
+
try {
|
|
424
|
+
const r = await ctx.store.ingestAsset({
|
|
425
|
+
siteId: site.id,
|
|
426
|
+
bytes,
|
|
427
|
+
declaredType: a.mediaType ,
|
|
428
|
+
filename: a.filename ,
|
|
429
|
+
uploadedBy: ctx.principal.actor,
|
|
430
|
+
source: { kind: "upload" },
|
|
431
|
+
maxBytes: MEDIA_LIMITS.uploadAsset,
|
|
432
|
+
fields: {
|
|
433
|
+
...texts(site, a),
|
|
434
|
+
...(a.license ? { license: a.license } : {}),
|
|
435
|
+
...(a.slug ? { slug: a.slug } : {}),
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
report(ctx, r.asset, { existing: r.existing });
|
|
439
|
+
const usage =
|
|
440
|
+
(await ctx.store.assetUsage(site.id, [r.asset.id])).get(r.asset.id)?.total ?? 0;
|
|
441
|
+
return {
|
|
442
|
+
asset: r.asset,
|
|
443
|
+
existing: r.existing,
|
|
444
|
+
url: url(r.asset),
|
|
445
|
+
usage,
|
|
446
|
+
...(r.existing ? {} : { snapshot: await autoSnapshot(ctx, site.id, "upload_asset") }),
|
|
447
|
+
};
|
|
448
|
+
} catch (e) {
|
|
449
|
+
mapError(e);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "fetch_asset_url",
|
|
455
|
+
title: "Add media from a URL",
|
|
456
|
+
description: `Have the server download a public https file (at most ${MEDIA_LIMITS.fetchAsset / MB} MB) into the site's media library: the server fetches it under strict rules (https only, no internal hosts or IP addresses, at most 3 redirects, the site's host list when it has one), checks the type from the bytes, strips photo metadata and stores its own copy, so the page never depends on the other site. Images need alt text or decorative: true. Pass the address of the file itself, not of a web page.`,
|
|
457
|
+
inputSchema: obj(
|
|
458
|
+
{
|
|
459
|
+
siteId: id,
|
|
460
|
+
url: str("https address of the file", {
|
|
461
|
+
minLength: 9,
|
|
462
|
+
maxLength: 2048,
|
|
463
|
+
pattern: "^https://",
|
|
464
|
+
}),
|
|
465
|
+
filename: str("File name to keep for search (default: from the URL)", {
|
|
466
|
+
minLength: 1,
|
|
467
|
+
maxLength: 255,
|
|
468
|
+
pattern: "^[^/\\\\\\u0000-\\u001f\\u007f]+$",
|
|
469
|
+
}),
|
|
470
|
+
slug: str("File name in the URL, without extension", {
|
|
471
|
+
pattern: SLUG_PATTERN,
|
|
472
|
+
maxLength: 96,
|
|
473
|
+
}),
|
|
474
|
+
...TEXT_FIELDS,
|
|
475
|
+
},
|
|
476
|
+
["siteId", "url"],
|
|
477
|
+
),
|
|
478
|
+
outputSchema: ASSET_OUT,
|
|
479
|
+
annotations: { ...WRITE, openWorldHint: true },
|
|
480
|
+
scopes: ["asset:upload"],
|
|
481
|
+
approvalPrompt: async (a, ctx) => {
|
|
482
|
+
const site = await ctx.store.getSite(a.siteId );
|
|
483
|
+
return `Download ${quote(a.url , 120)} into the media library of "${quote(site?.name ?? (a.siteId ))}"? It becomes public only when the site is published.`;
|
|
484
|
+
},
|
|
485
|
+
precheck: async (_a, ctx) => {
|
|
486
|
+
await dailyLimit(ctx, 0);
|
|
487
|
+
},
|
|
488
|
+
handler: async (a, ctx) => {
|
|
489
|
+
const site = await requireSite(ctx, a.siteId );
|
|
490
|
+
const siteHosts = (site.metadata?.["cloudhal.media.allowHosts"] ?? "")
|
|
491
|
+
.split(/[\s,]+/)
|
|
492
|
+
.filter(Boolean);
|
|
493
|
+
const allowHosts = [...(ctx.media?.allowHosts ?? []), ...siteHosts];
|
|
494
|
+
const fetchedAt = (ctx.now?.() ?? new Date()).toISOString();
|
|
495
|
+
try {
|
|
496
|
+
const res = await safeFetch(a.url , {
|
|
497
|
+
transport: ctx.media?.fetchTransport ?? workersTransport(),
|
|
498
|
+
maxBytes: MEDIA_LIMITS.fetchAsset,
|
|
499
|
+
accept: FETCH_TYPES,
|
|
500
|
+
...(allowHosts.length ? { allowHosts } : {}),
|
|
501
|
+
blockedHosts: [
|
|
502
|
+
...(ctx.media?.blockedHosts ?? []),
|
|
503
|
+
...site.domains.map((d) => d.hostname),
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
requireAlt(res.bytes.subarray(0, 4096), a);
|
|
507
|
+
await dailyLimit(ctx, res.bytes.byteLength);
|
|
508
|
+
const fromUrl = decodeURIComponent(new URL(res.url).pathname.split("/").pop() ?? "");
|
|
509
|
+
const filename = (a.filename ) ?? (fromUrl || undefined);
|
|
510
|
+
const r = await ctx.store.ingestAsset({
|
|
511
|
+
siteId: site.id,
|
|
512
|
+
bytes: res.bytes,
|
|
513
|
+
declaredType: res.contentType,
|
|
514
|
+
filename: cleanName(filename),
|
|
515
|
+
uploadedBy: ctx.principal.actor,
|
|
516
|
+
source: { kind: "fetch", url: a.url , fetchedAt },
|
|
517
|
+
maxBytes: MEDIA_LIMITS.fetchAsset,
|
|
518
|
+
fields: {
|
|
519
|
+
...texts(site, a),
|
|
520
|
+
...(a.license ? { license: a.license } : {}),
|
|
521
|
+
...(a.slug ? { slug: a.slug } : {}),
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
report(ctx, r.asset, {
|
|
525
|
+
existing: r.existing,
|
|
526
|
+
fetch: {
|
|
527
|
+
requested: a.url,
|
|
528
|
+
final: res.url,
|
|
529
|
+
redirects: res.chain.length - 1,
|
|
530
|
+
...(res.address ? { address: res.address } : {}),
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
const usage =
|
|
534
|
+
(await ctx.store.assetUsage(site.id, [r.asset.id])).get(r.asset.id)?.total ?? 0;
|
|
535
|
+
return {
|
|
536
|
+
asset: r.asset,
|
|
537
|
+
existing: r.existing,
|
|
538
|
+
url: url(r.asset),
|
|
539
|
+
usage,
|
|
540
|
+
...(r.existing
|
|
541
|
+
? {}
|
|
542
|
+
: { snapshot: await autoSnapshot(ctx, site.id, "fetch_asset_url") }),
|
|
543
|
+
};
|
|
544
|
+
} catch (e) {
|
|
545
|
+
if (e instanceof ToolError) throw e;
|
|
546
|
+
mapError(e);
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "update_asset",
|
|
552
|
+
title: "Update media details",
|
|
553
|
+
description:
|
|
554
|
+
"Change a file's alt text, title or caption (in `locale`), credit, license, decorative flag, URL slug or an image's focal point (`focal`: where the subject is, x and y from 0 to 1 from the top-left; cover crops keep it in view; null clears it). Requires the current `rev`. An old URL keeps working after a slug change (it redirects). Agents with asset:upload edit their own uploads; asset:manage edits any file.",
|
|
555
|
+
inputSchema: obj(
|
|
556
|
+
{
|
|
557
|
+
siteId: id,
|
|
558
|
+
assetId: id,
|
|
559
|
+
rev: { type: "integer", minimum: 0 },
|
|
560
|
+
slug: str("New file name in the URL", { pattern: SLUG_PATTERN, maxLength: 128 }),
|
|
561
|
+
...TEXT_FIELDS,
|
|
562
|
+
focal: {
|
|
563
|
+
description: "An image's focal point (RFC-0028), or null to clear it",
|
|
564
|
+
anyOf: [
|
|
565
|
+
{
|
|
566
|
+
type: "object",
|
|
567
|
+
additionalProperties: false,
|
|
568
|
+
required: ["x", "y"],
|
|
569
|
+
properties: {
|
|
570
|
+
x: { type: "number", minimum: 0, maximum: 1 },
|
|
571
|
+
y: { type: "number", minimum: 0, maximum: 1 },
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
{ type: "null" },
|
|
575
|
+
],
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
["siteId", "assetId", "rev"],
|
|
579
|
+
),
|
|
580
|
+
outputSchema: obj({ asset: ASSET, snapshot: SNAPSHOT }, ["asset", "snapshot"]),
|
|
581
|
+
annotations: WRITE,
|
|
582
|
+
scopes: ["asset:upload"],
|
|
583
|
+
precheck: ownOrManage,
|
|
584
|
+
handler: async (a, ctx) => {
|
|
585
|
+
const site = await requireSite(ctx, a.siteId );
|
|
586
|
+
const asset = await requireAsset(ctx, site.id, a.assetId );
|
|
587
|
+
if (asset.rev !== a.rev)
|
|
588
|
+
throw new ToolError(
|
|
589
|
+
"conflict",
|
|
590
|
+
`asset ${asset.id} is at rev ${asset.rev}, you sent ${a.rev}; reload and retry`,
|
|
591
|
+
);
|
|
592
|
+
const locale = (a.locale ) ?? site.defaultLocale;
|
|
593
|
+
const set = (map , value ) =>
|
|
594
|
+
value === undefined ? map : { ...(map ?? {}), [locale]: value };
|
|
595
|
+
const next = {
|
|
596
|
+
...asset,
|
|
597
|
+
...(a.slug ? { slug: a.slug } : {}),
|
|
598
|
+
...(a.alt !== undefined ? { alt: set(asset.alt, a.alt) } : {}),
|
|
599
|
+
...(a.title !== undefined ? { title: set(asset.title, a.title) } : {}),
|
|
600
|
+
...(a.caption !== undefined ? { caption: set(asset.caption, a.caption) } : {}),
|
|
601
|
+
...(a.credit !== undefined ? { credit: a.credit } : {}),
|
|
602
|
+
...(a.license !== undefined ? { license: a.license } : {}),
|
|
603
|
+
...(a.decorative !== undefined ? { decorative: a.decorative } : {}),
|
|
604
|
+
...(a.focal !== undefined ? { image: withFocal(asset, a.focal) } : {}),
|
|
605
|
+
} ;
|
|
606
|
+
try {
|
|
607
|
+
const saved = await ctx.store.updateAsset(next);
|
|
608
|
+
report(ctx, saved);
|
|
609
|
+
return { asset: saved, snapshot: await autoSnapshot(ctx, site.id, "update_asset") };
|
|
610
|
+
} catch (e) {
|
|
611
|
+
mapError(e);
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
statusTool(
|
|
616
|
+
"retire_asset",
|
|
617
|
+
"Retire media",
|
|
618
|
+
"Hide a file from the library and pickers so nothing new uses it. Pages and articles that already show it keep showing it. Requires the current `rev`.",
|
|
619
|
+
),
|
|
620
|
+
statusTool(
|
|
621
|
+
"restore_asset",
|
|
622
|
+
"Restore media",
|
|
623
|
+
"Make a retired file available again. Requires the current `rev`.",
|
|
624
|
+
),
|
|
625
|
+
{
|
|
626
|
+
name: "delete_asset",
|
|
627
|
+
title: "Delete media",
|
|
628
|
+
description:
|
|
629
|
+
"Delete a file from the media library. Refused while anything uses it (the error lists the uses). Its bytes stay as long as a snapshot needs them, so rollback keeps working. Destructive: requires approval.",
|
|
630
|
+
inputSchema: revInput,
|
|
631
|
+
outputSchema: obj({ snapshot: SNAPSHOT }, ["snapshot"]),
|
|
632
|
+
annotations: {
|
|
633
|
+
readOnlyHint: false,
|
|
634
|
+
destructiveHint: true,
|
|
635
|
+
idempotentHint: false,
|
|
636
|
+
openWorldHint: false,
|
|
637
|
+
},
|
|
638
|
+
scopes: ["asset:manage"],
|
|
639
|
+
// A delete that cannot succeed never waits for a person: refuse while in use, with the uses.
|
|
640
|
+
precheck: async (a, ctx) => {
|
|
641
|
+
const site = await requireSite(ctx, a.siteId );
|
|
642
|
+
const asset = await requireAsset(ctx, site.id, a.assetId );
|
|
643
|
+
const uses = await usesOf(ctx, asset, site.defaultLocale);
|
|
644
|
+
if (uses.length > 0)
|
|
645
|
+
throw new ToolError(
|
|
646
|
+
"in_use",
|
|
647
|
+
`${describeAsset(asset, site.defaultLocale)} is used by ${uses
|
|
648
|
+
.slice(0, 5)
|
|
649
|
+
.map((u) => (u.title ? `"${quote(u.title)}"` : u.nodeId))
|
|
650
|
+
.join(
|
|
651
|
+
", ",
|
|
652
|
+
)}${uses.length > 5 ? ` and ${uses.length - 5} more` : ""}; remove those uses first, or retire it instead`,
|
|
653
|
+
{ uses },
|
|
654
|
+
);
|
|
655
|
+
},
|
|
656
|
+
approvalPrompt: async (a, ctx) => {
|
|
657
|
+
const site = await ctx.store.getSite(a.siteId );
|
|
658
|
+
const asset = await ctx.store.getAsset(a.assetId );
|
|
659
|
+
return asset
|
|
660
|
+
? `Delete ${describeAsset(asset, site?.defaultLocale)} from the media library? Earlier snapshots keep its bytes until they are no longer needed.`
|
|
661
|
+
: `Delete asset ${a.assetId}?`;
|
|
662
|
+
},
|
|
663
|
+
handler: async (a, ctx) => {
|
|
664
|
+
const asset = await requireAsset(ctx, a.siteId , a.assetId );
|
|
665
|
+
try {
|
|
666
|
+
await ctx.store.deleteAsset({ ...asset, rev: a.rev });
|
|
667
|
+
report(ctx, asset, { deleted: true });
|
|
668
|
+
return { snapshot: await autoSnapshot(ctx, asset.siteId, "delete_asset") };
|
|
669
|
+
} catch (e) {
|
|
670
|
+
mapError(e);
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** RFC-0028: an image's measured fields with a focal point set (rounded to 0.1 %) or cleared. */
|
|
678
|
+
function withFocal(asset , focal ) {
|
|
679
|
+
if (!asset.image) throw new ToolError("invalid", "only images have a focal point");
|
|
680
|
+
const { focal: _old, ...rest } = asset.image;
|
|
681
|
+
if (focal === null) return rest;
|
|
682
|
+
const f = focal ;
|
|
683
|
+
const r = (v ) => Math.round(Math.min(1, Math.max(0, v)) * 1000) / 1000;
|
|
684
|
+
return { ...rest, focal: { x: r(f.x), y: r(f.y) } };
|
|
685
|
+
}
|