@goodfellow-cms/mcp 0.0.0-stage → 0.3.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/dist/index.js ADDED
@@ -0,0 +1,813 @@
1
+ import { COLLECTIONS_DIR, COLLECTION_SETTINGS_FILE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, MEDIA_DIR, MEDIA_FILE, MENUS_FILE, PAGES_DIR, SITE_DESCRIPTION_PATH, SITE_FILE, SignInError, collectionFileSchema, entryAddress, entryFieldProblems, entryFileChange, isAddressSegment, isEmptyValue, isReservedPagePath, layoutFileChange, layoutFileSchema, mediaFileSchema, menusFileChange, menusFileSchema, newDraftName, normalizePagePath, pageFileChange, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, parseSiteDescription, readTextFiles, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
4
+ import { z } from "zod";
5
+ //#region src/content.ts
6
+ /**
7
+ * Reading a site's content a file at a time. Tools read only the files they
8
+ * need, never the whole site, so a call stays within what a free hosting plan
9
+ * allows however big the site is (the hosted connector's Worker gets 50
10
+ * requests and 10 ms of processing per call).
11
+ */
12
+ /** Thrown with a message Claude can pass on to the editor as it is. */
13
+ var ToolError = class extends Error {
14
+ name = "ToolError";
15
+ };
16
+ function parsed(result) {
17
+ if (!result.ok) throw new ToolError(`${result.problem.file} ${result.problem.message} Fix it in the admin panel, or ask the site's developer.`);
18
+ return result.value;
19
+ }
20
+ async function readSettings(source) {
21
+ const text = await source.read(SITE_FILE);
22
+ return text === void 0 ? siteSettingsSchema.parse({ version: 1 }) : parsed(parseContentFile("site", siteSettingsSchema, SITE_FILE, text));
23
+ }
24
+ async function readMenus(source) {
25
+ const text = await source.read(MENUS_FILE);
26
+ return text === void 0 ? {} : parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text)).menus;
27
+ }
28
+ async function readLayout(source, part) {
29
+ const file = part === "header" ? HEADER_FILE : FOOTER_FILE;
30
+ const text = await source.read(file);
31
+ return text === void 0 ? {
32
+ version: 1,
33
+ data: {
34
+ root: {},
35
+ content: []
36
+ }
37
+ } : parsed(parseContentFile("layout", layoutFileSchema, file, text));
38
+ }
39
+ /** A page's address written the site's one way, or a `ToolError` saying why it can't be one. */
40
+ function pageAddress(input) {
41
+ try {
42
+ return normalizePagePath(input.trim().startsWith("/") ? input.trim() : `/${input.trim()}`);
43
+ } catch {
44
+ throw new ToolError(`"${input}" can't be a page's address. Addresses are like /about or /news/open-day: lowercase letters, numbers and hyphens.`);
45
+ }
46
+ }
47
+ /** The site's pages' addresses and files, from the list of files alone. */
48
+ async function listPages(source) {
49
+ return (await source.list(PAGES_DIR)).filter((file) => file.endsWith(".json")).flatMap((file) => {
50
+ try {
51
+ return [{
52
+ address: pageFileToPath(file),
53
+ file
54
+ }];
55
+ } catch {
56
+ return [];
57
+ }
58
+ }).sort((a, b) => a.address.localeCompare(b.address));
59
+ }
60
+ async function readPage(source, address) {
61
+ const file = pagePathToFile(address);
62
+ const text = await source.read(file);
63
+ return text === void 0 ? void 0 : {
64
+ file,
65
+ content: parsed(parseContentFile("page", pageFileSchema, file, text))
66
+ };
67
+ }
68
+ /** The site's collections and their entries' files, from the list of files alone. */
69
+ async function listCollections(source) {
70
+ const folders = /* @__PURE__ */ new Map();
71
+ for (const file of await source.list(COLLECTIONS_DIR)) {
72
+ const [id, name, ...rest] = file.slice(COLLECTIONS_DIR.length + 1).split("/");
73
+ if (!id || !name || rest.length > 0 || !isAddressSegment(id)) continue;
74
+ const folder = folders.get(id) ?? {
75
+ id,
76
+ file: `${COLLECTIONS_DIR}/${id}/${COLLECTION_SETTINGS_FILE}`,
77
+ entries: []
78
+ };
79
+ folders.set(id, folder);
80
+ if (name === COLLECTION_SETTINGS_FILE) continue;
81
+ const match = /^(.+)\.(json|md)$/.exec(name);
82
+ if (match?.[1] && isAddressSegment(match[1])) folder.entries.push({
83
+ slug: match[1],
84
+ file
85
+ });
86
+ }
87
+ return [...folders.values()].sort((a, b) => a.id.localeCompare(b.id));
88
+ }
89
+ /** A collection's settings and its entries' files. */
90
+ async function readCollection(source, id) {
91
+ const collection = (await listCollections(source)).find((candidate) => candidate.id === id);
92
+ const text = collection && await source.read(collection.file);
93
+ if (!collection || text === void 0) throw new ToolError(`The site has no collection "${id}". list_collections lists the ones it has.`);
94
+ return {
95
+ ...collection,
96
+ settings: parsed(parseContentFile("collection", collectionFileSchema, collection.file, text))
97
+ };
98
+ }
99
+ async function readEntry(source, collection, slug) {
100
+ const found = collection.entries.find((entry) => entry.slug === slug);
101
+ const text = found && await source.read(found.file);
102
+ if (!found || text === void 0) return void 0;
103
+ return {
104
+ file: found.file,
105
+ content: parsed(parseEntryFile(collection.settings, found.file, text))
106
+ };
107
+ }
108
+ /**
109
+ * Every address the site's pages and entries have, so a new page or entry
110
+ * never takes one. Entries' addresses come from their collections' settings.
111
+ */
112
+ async function takenAddresses(source) {
113
+ const addresses = new Set((await listPages(source)).map((page) => page.address));
114
+ for (const collection of await listCollections(source)) {
115
+ const text = await source.read(collection.file);
116
+ if (text === void 0) continue;
117
+ const settings = parseContentFile("collection", collectionFileSchema, collection.file, text);
118
+ if (!settings.ok || settings.value.path === void 0) continue;
119
+ for (const entry of collection.entries) addresses.add(entryAddress(settings.value.path, entry.slug));
120
+ }
121
+ return addresses;
122
+ }
123
+ /** The site's media files, with their sizes and credits. */
124
+ async function listMedia(source) {
125
+ const [files, sizes, creditsText] = await Promise.all([
126
+ source.list(MEDIA_DIR),
127
+ source.fileSizes?.(MEDIA_DIR),
128
+ source.read(MEDIA_FILE)
129
+ ]);
130
+ const credits = creditsText === void 0 ? {} : parsed(parseContentFile("media", mediaFileSchema, MEDIA_FILE, creditsText)).credits;
131
+ return files.sort().map((file) => {
132
+ const address = `/${file.slice(7)}`;
133
+ const bytes = sizes?.get(file);
134
+ const credit = credits[address];
135
+ return {
136
+ address,
137
+ ...bytes !== void 0 && { bytes },
138
+ ...credit && { credit }
139
+ };
140
+ });
141
+ }
142
+ /** Image types a browser shows on their own, which `screenshot` can take a picture of. */
143
+ const IMAGE_ADDRESS = /^\/media\/[^?#]+\.(?:jpe?g|png|gif|webp|avif|svg|ico)$/i;
144
+ /** Whether an address is a media file's, rather than a page's. */
145
+ function isMediaAddress(address) {
146
+ return address.trim().startsWith("/media/");
147
+ }
148
+ /** An image's address in the media library, or a `ToolError` saying why it can't be shown. */
149
+ async function mediaImage(source, input) {
150
+ const address = input.trim();
151
+ if (!IMAGE_ADDRESS.test(address) || address.split("/").some((part) => part === ".." || part === ".")) throw new ToolError(`${address} isn't an image Claude can look at. Screenshots show pages, and images in the media library such as /media/photo.jpg; other files, such as PDFs and videos, are opened in the admin panel.`);
152
+ if (!(await source.list(MEDIA_DIR)).includes(`public${address}`)) throw new ToolError(`There's no file at ${address}. list_media lists the media library.`);
153
+ return address;
154
+ }
155
+ /** Every content file, as text: what the preview page needs to show a draft as the site would. */
156
+ async function contentFiles(source) {
157
+ return Object.fromEntries(await readTextFiles(source, await source.list("content")));
158
+ }
159
+ //#endregion
160
+ //#region src/site.ts
161
+ /**
162
+ * Opening a site for the tools: its backend, its settings, and the
163
+ * description its build published (`goodfellow-site.json`), which says what
164
+ * blocks it has, since the connector doesn't have the site's code.
165
+ */
166
+ function configFrom(description, templates) {
167
+ return {
168
+ components: Object.fromEntries(Object.entries(description.blocks).filter(([, block]) => templates || !block.templateOnly).map(([name, block]) => [name, {
169
+ ...block.label && { label: block.label },
170
+ fields: block.fields,
171
+ ...block.defaultProps && { defaultProps: block.defaultProps },
172
+ render: () => null
173
+ }])),
174
+ root: { fields: description.root.fields }
175
+ };
176
+ }
177
+ const VERSION_PROBLEMS = {
178
+ "site-too-old": (site) => `The site was built with Goodfellow ${site}, which is too old for Claude to change it. Ask the site's owner to update it on the admin panel's Updates screen; reading it still works.`,
179
+ "tool-too-old": (site) => `The site was built with Goodfellow ${site}, which is newer than this connector (${GOODFELLOW_VERSION}). Update the connector to change it; reading it still works.`
180
+ };
181
+ async function openSite(context, id) {
182
+ const backend = await context.backend(id);
183
+ await backend.revision();
184
+ const settings = await readSettings(backend);
185
+ if (!settings.url) throw new ToolError("The site doesn't have its address in Site settings yet, and Claude needs it to see what blocks the site has. Set it in the admin panel's Site settings.");
186
+ const url = settings.url.replace(/\/+$/, "");
187
+ const response = await (context.fetch ?? fetch)(`${url}/${SITE_DESCRIPTION_PATH}`, { headers: { accept: "application/json" } }).catch(() => void 0);
188
+ const result = response?.ok ? parseSiteDescription(await response.text()) : { ok: false };
189
+ if (!result.ok) throw new ToolError(`Claude couldn't read what blocks the site has from ${url}/${SITE_DESCRIPTION_PATH}. The site may not be online yet, or was built before Goodfellow could work with Claude: publish any change in the admin panel to rebuild it.`);
190
+ const { description } = result;
191
+ const problem = siteVersionProblem(description);
192
+ return {
193
+ id,
194
+ backend,
195
+ settings,
196
+ url,
197
+ description,
198
+ pageConfig: configFrom(description, false),
199
+ templateConfig: configFrom(description, true),
200
+ ...problem && { versionProblem: VERSION_PROBLEMS[problem](description.goodfellow) }
201
+ };
202
+ }
203
+ //#endregion
204
+ //#region src/tools.ts
205
+ /**
206
+ * The Claude connector's tools. Changes always go into a draft (a branch of
207
+ * the site's repository), which Claude shows the editor in screenshots, then
208
+ * publishes or submits for review when the editor says so. The tools keep
209
+ * nothing between calls: the draft's name, which every change returns, is how
210
+ * Claude carries on with one.
211
+ */
212
+ /** What Claude is told about working with Goodfellow sites. */
213
+ const INSTRUCTIONS = `These tools edit websites built with Goodfellow, for people who aren't developers.
214
+
215
+ - Start with get_site, which lists the site's pages, collections and blocks.
216
+ - Changes are saved in a draft, never straight to the live site. The first change starts one and returns its name: pass that name as "draft" to every later change and read, so they build on each other.
217
+ - Pages, the header and the footer are made of blocks. get_blocks says which blocks the site has and what each one's props are. Blocks are written as a flat list in page order: a block inside another names it as "parent", and which of its places it goes in as "slot".
218
+ - Show the editor what a draft looks like with screenshot before anything goes live.
219
+ - Only publish when the editor says to. If they'd like someone to check it first, use submit_for_review instead.
220
+ - Use the site's own words for things: say "publish", not "commit" or "merge".`;
221
+ /** Why `publish` refuses, on sites whose config says `claude: { publish: false }`. */
222
+ const NO_PUBLISHING = "The site's owner has turned off publishing from Claude. Submit the draft for review instead (submit_for_review), and someone who looks after the site publishes it.";
223
+ const siteArg = z.string().optional().describe("Which site, from list_sites. Leave it out when there's only one.");
224
+ const draftArg = z.string().optional().describe("The draft, as an earlier change returned it. To read: leave it out for the live site. To change: leave it out to start a new draft.");
225
+ const flatBlock = z.object({
226
+ type: z.string().describe("The block's name, from get_blocks."),
227
+ id: z.string().describe("A short id, unique in this list, such as b1."),
228
+ parent: z.string().nullable().describe("The id of the block this one is inside, listed earlier, or null."),
229
+ slot: z.string().nullable().describe("Which place in the parent block this one goes in, or null."),
230
+ props: z.record(z.string(), z.unknown()).describe("The block's props, as get_blocks describes them.")
231
+ });
232
+ function text(value) {
233
+ return { content: [{
234
+ type: "text",
235
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
236
+ }] };
237
+ }
238
+ function base64(bytes) {
239
+ let binary = "";
240
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
241
+ return btoa(binary);
242
+ }
243
+ function failure(message) {
244
+ return {
245
+ isError: true,
246
+ content: [{
247
+ type: "text",
248
+ text: message
249
+ }]
250
+ };
251
+ }
252
+ /** Turns what went wrong into words the editor can act on. Never includes a token: backends' errors don't. */
253
+ function explain(error) {
254
+ if (error instanceof ToolError) return failure(error.message);
255
+ if (error instanceof DraftError) {
256
+ if (error.problem === "not-found") return failure("That draft isn't there any more: it may have been published or discarded. Make the change again without a draft to start a new one.");
257
+ if (error.problem === "no-reviews") return failure("This sign-in can't ask for a review on the site's host. Publish the draft instead, if the editor agrees, or ask the site's owner.");
258
+ return failure(error.message);
259
+ }
260
+ if (error instanceof ConflictError) return failure("Someone changed the same part of the site while this was being saved. Read it again (with the draft), then make the change again.");
261
+ if (error instanceof SignInError) return failure(error.problem === "cant-publish" ? "This sign-in can read the site but can't change it. Ask the site's owner to give you permission to edit it." : "Signing in to the site's host didn't work. Connect Claude to the site again.");
262
+ return failure(`Something went wrong: ${error.message ?? String(error)}`);
263
+ }
264
+ function countBlocks(content, config) {
265
+ return content.reduce((count, block) => {
266
+ const component = config.components[block.type];
267
+ const nested = slotNames(component).reduce((inner, slot) => inner + countBlocks(block.props[slot] ?? [], config), 0);
268
+ return count + 1 + nested;
269
+ }, 0);
270
+ }
271
+ function newId(type) {
272
+ return `${type}-${Math.random().toString(36).slice(2, 10)}`;
273
+ }
274
+ function blocksFrom(blocks, config) {
275
+ const content = toContent({ blocks }, config, newId);
276
+ return {
277
+ content,
278
+ ignored: blocks.length - countBlocks(content, config)
279
+ };
280
+ }
281
+ function registerTools(server, context) {
282
+ const now = context.now ?? (() => /* @__PURE__ */ new Date());
283
+ async function siteId(given) {
284
+ if (given) return given;
285
+ const sites = await context.sites();
286
+ const [only] = sites;
287
+ if (sites.length === 1 && only) return only.id;
288
+ throw new ToolError(sites.length === 0 ? "Claude isn't connected to any sites yet. Connect it to a site first." : `Say which site: ${sites.map((site) => site.id).join(", ")}.`);
289
+ }
290
+ async function open(given) {
291
+ return openSite(context, await siteId(given));
292
+ }
293
+ async function draftOf(site, name) {
294
+ if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
295
+ return site.backend.drafts.open(name);
296
+ }
297
+ /** The draft to change: the one named, or a new one. Refuses sites that can't take changes. */
298
+ async function changing(site, name) {
299
+ if (site.versionProblem) throw new ToolError(site.versionProblem);
300
+ if (name) return draftOf(site, name);
301
+ if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
302
+ return site.backend.drafts.create(newDraftName(now()));
303
+ }
304
+ /** Where to read from: a draft, or the live site. */
305
+ async function reading(site, name) {
306
+ return name ? (await draftOf(site, name)).store : site.backend;
307
+ }
308
+ async function save(draft, changes, message) {
309
+ const store = draft.store;
310
+ await writeChanges(store, changes, {
311
+ message,
312
+ expectedRevision: await store.revision()
313
+ });
314
+ }
315
+ function tool(name, description, shape, run, readOnly = false) {
316
+ server.registerTool(name, {
317
+ description,
318
+ inputSchema: shape,
319
+ annotations: {
320
+ readOnlyHint: readOnly,
321
+ destructiveHint: false
322
+ }
323
+ }, (async (args) => {
324
+ try {
325
+ return await run(args);
326
+ } catch (error) {
327
+ return explain(error);
328
+ }
329
+ }));
330
+ }
331
+ tool("list_sites", "The Goodfellow sites this connection can edit.", {}, async () => text((await context.sites()).map((site) => ({
332
+ site: site.id,
333
+ ...site.label && { name: site.label }
334
+ }))), true);
335
+ tool("get_site", "An overview of a site: its name and address, pages, collections, menus and blocks. Start here.", {
336
+ site: siteArg,
337
+ draft: draftArg
338
+ }, async ({ site: given, draft }) => {
339
+ const site = await open(given);
340
+ const source = await reading(site, draft);
341
+ const [pages, collections, menus] = await Promise.all([
342
+ listPages(source),
343
+ listCollections(source),
344
+ readMenus(source)
345
+ ]);
346
+ return text({
347
+ site: site.id,
348
+ name: site.settings.title,
349
+ address: site.url,
350
+ ...site.versionProblem && { cantChange: site.versionProblem },
351
+ ...site.description.claude?.publish === false && { cantPublish: NO_PUBLISHING },
352
+ ...site.settings.theme.darkMode && { darkMode: true },
353
+ pages: pages.map((page) => page.address),
354
+ collections: collections.map((collection) => ({
355
+ id: collection.id,
356
+ entries: collection.entries.length
357
+ })),
358
+ menus: Object.keys(menus),
359
+ blocks: Object.entries(site.description.blocks).map(([name, block]) => ({
360
+ name,
361
+ ...block.label && { label: block.label },
362
+ ...block.templateOnly && { onlyInCollectionTemplates: true }
363
+ }))
364
+ });
365
+ }, true);
366
+ tool("get_blocks", "What each of the site's blocks is and the props it takes, as JSON Schema, and the places in it where other blocks go (slots). Also the page settings' props.", {
367
+ site: siteArg,
368
+ names: z.array(z.string()).optional().describe("Only these blocks. Leave it out for all of them.")
369
+ }, async ({ site: given, names }) => {
370
+ const site = await open(given);
371
+ const blocks = Object.entries(site.templateConfig.components).filter(([name]) => !names || names.includes(name));
372
+ return text({
373
+ pageSettings: objectSchema(site.description.root.fields) ?? {},
374
+ blocks: blocks.map(([name, component]) => ({
375
+ name,
376
+ ...component.label && { label: component.label },
377
+ slots: slotNames(component),
378
+ ...site.description.blocks[name]?.templateOnly && { onlyInCollectionTemplates: true },
379
+ props: objectSchema(component.fields) ?? {}
380
+ }))
381
+ });
382
+ }, true);
383
+ tool("get_page", "A page's settings (such as its title) and its blocks, as a flat list.", {
384
+ site: siteArg,
385
+ address: z.string().describe("The page's address, such as / or /about."),
386
+ draft: draftArg
387
+ }, async ({ site: given, address, draft }) => {
388
+ const site = await open(given);
389
+ const path = pageAddress(address);
390
+ const page = await readPage(await reading(site, draft), path);
391
+ if (!page) throw new ToolError(`The site has no page at ${path}. get_site lists its pages.`);
392
+ return text({
393
+ address: path,
394
+ settings: page.content.data.root.props ?? {},
395
+ blocks: toFlatBlocks(page.content.data.content, site.pageConfig)
396
+ });
397
+ }, true);
398
+ tool("save_page", "Creates a page, or changes one, in a draft. Give the whole list of blocks: it replaces the page's blocks. Leave blocks out to change only its settings.", {
399
+ site: siteArg,
400
+ address: z.string().describe("The page's address, such as /about. A new address makes a new page."),
401
+ draft: draftArg,
402
+ settings: z.record(z.string(), z.unknown()).optional().describe("Page settings to change, such as title and description, as get_blocks describes them."),
403
+ blocks: z.array(flatBlock).optional()
404
+ }, async ({ site: given, address, draft: name, settings, blocks }) => {
405
+ const site = await open(given);
406
+ const path = pageAddress(address);
407
+ const draft = await changing(site, name);
408
+ const existing = await readPage(draft.store, path);
409
+ if (!existing) {
410
+ if (isReservedPagePath(path)) throw new ToolError(`${path} is kept for the admin panel. Choose another address.`);
411
+ if ((await takenAddresses(draft.store)).has(path)) throw new ToolError(`${path} is already a collection item's address. Choose another one.`);
412
+ }
413
+ const before = existing?.content.data ?? {
414
+ root: { props: {} },
415
+ content: []
416
+ };
417
+ const props = {
418
+ ...before.root.props ?? {},
419
+ ...cleanProps(site.description.root.fields, settings ?? {}, void 0)
420
+ };
421
+ const built = blocks ? blocksFrom(blocks, site.pageConfig) : {
422
+ content: before.content,
423
+ ignored: 0
424
+ };
425
+ const data = {
426
+ ...before,
427
+ root: {
428
+ ...before.root,
429
+ props
430
+ },
431
+ content: built.content
432
+ };
433
+ const title = typeof props.title === "string" && props.title ? props.title : path;
434
+ await save(draft, [pageFileChange(path, data)], `${existing ? "Update" : "Add"} page "${title}"`);
435
+ return text({
436
+ draft: draft.name,
437
+ address: path,
438
+ saved: existing ? "changed" : "created",
439
+ ...built.ignored > 0 && { ignored: `${built.ignored} block(s) weren't saved: their type isn't one of the site's blocks, or they were in a block that wasn't.` }
440
+ });
441
+ });
442
+ tool("delete_page", "Deletes a page, in a draft.", {
443
+ site: siteArg,
444
+ address: z.string(),
445
+ draft: draftArg
446
+ }, async ({ site: given, address, draft: name }) => {
447
+ const site = await open(given);
448
+ const path = pageAddress(address);
449
+ if (path === "/") throw new ToolError("The home page can't be deleted. Change it instead.");
450
+ const draft = await changing(site, name);
451
+ const page = await readPage(draft.store, path);
452
+ if (!page) throw new ToolError(`The site has no page at ${path}.`);
453
+ await save(draft, [{
454
+ path: page.file,
455
+ delete: true
456
+ }], `Delete page ${path}`);
457
+ return text({
458
+ draft: draft.name,
459
+ address: path,
460
+ saved: "deleted"
461
+ });
462
+ });
463
+ tool("get_layout", "The site's header or footer: its blocks, as a flat list. Every page shows them.", {
464
+ site: siteArg,
465
+ part: z.enum(["header", "footer"]),
466
+ draft: draftArg
467
+ }, async ({ site: given, part, draft }) => {
468
+ const site = await open(given);
469
+ const layout = await readLayout(await reading(site, draft), part);
470
+ return text({
471
+ part,
472
+ blocks: toFlatBlocks(layout.data.content, site.pageConfig)
473
+ });
474
+ }, true);
475
+ tool("save_layout", "Changes the site's header or footer, in a draft. Give the whole list of blocks: it replaces them.", {
476
+ site: siteArg,
477
+ part: z.enum(["header", "footer"]),
478
+ draft: draftArg,
479
+ blocks: z.array(flatBlock)
480
+ }, async ({ site: given, part, draft: name, blocks }) => {
481
+ const site = await open(given);
482
+ const draft = await changing(site, name);
483
+ const before = await readLayout(draft.store, part);
484
+ const built = blocksFrom(blocks, site.pageConfig);
485
+ await save(draft, [layoutFileChange(part === "header" ? HEADER_FILE : FOOTER_FILE, {
486
+ ...before.data,
487
+ content: built.content
488
+ })], `Update ${part}`);
489
+ return text({
490
+ draft: draft.name,
491
+ part,
492
+ saved: "changed",
493
+ ...built.ignored > 0 && { ignored: built.ignored }
494
+ });
495
+ });
496
+ tool("get_collection", "A collection (such as news or events): its fields, the address its items have, and its items.", {
497
+ site: siteArg,
498
+ collection: z.string().describe("The collection's id, from get_site."),
499
+ draft: draftArg
500
+ }, async ({ site: given, collection: id, draft }) => {
501
+ const collection = await readCollection(await reading(await open(given), draft), id);
502
+ const { settings } = collection;
503
+ return text({
504
+ id,
505
+ name: settings.name,
506
+ itemName: settings.entryName,
507
+ ...settings.path !== void 0 && { address: settings.path },
508
+ ...settings.markdown && { markdownField: settings.markdown.body },
509
+ fields: settings.fields,
510
+ items: collection.entries.map((entry) => ({
511
+ slug: entry.slug,
512
+ ...settings.path !== void 0 && { address: entryAddress(settings.path, entry.slug) }
513
+ }))
514
+ });
515
+ }, true);
516
+ tool("get_item", "One item of a collection: its fields' values.", {
517
+ site: siteArg,
518
+ collection: z.string(),
519
+ slug: z.string(),
520
+ draft: draftArg
521
+ }, async ({ site: given, collection: id, slug, draft }) => {
522
+ const source = await reading(await open(given), draft);
523
+ const entry = await readEntry(source, await readCollection(source, id), slug);
524
+ if (!entry) throw new ToolError(`The collection "${id}" has no item "${slug}".`);
525
+ return text({
526
+ collection: id,
527
+ slug,
528
+ values: entry.content.fields
529
+ });
530
+ }, true);
531
+ tool("save_item", "Creates an item of a collection, or changes one, in a draft. Values not given stay as they are. Formatted text is simple HTML; a collection's Markdown field is Markdown.", {
532
+ site: siteArg,
533
+ collection: z.string(),
534
+ slug: z.string().describe("The item's name in its address and file: lowercase letters, numbers and hyphens, such as open-day."),
535
+ values: z.record(z.string(), z.unknown()).describe("Field values, by the fields' names from get_collection."),
536
+ draft: draftArg
537
+ }, async ({ site: given, collection: id, slug, values, draft: name }) => {
538
+ const draft = await changing(await open(given), name);
539
+ const collection = await readCollection(draft.store, id);
540
+ const existing = await readEntry(draft.store, collection, slug);
541
+ if (!existing) {
542
+ if (!isAddressSegment(slug)) throw new ToolError(`"${slug}" can't be an item's name: use lowercase letters, numbers and hyphens.`);
543
+ const path = collection.settings.path === void 0 ? void 0 : entryAddress(collection.settings.path, slug);
544
+ if (path && (isReservedPagePath(path) || (await takenAddresses(draft.store)).has(path))) throw new ToolError(`${path} is already taken on the site. Choose another name.`);
545
+ }
546
+ const known = new Set(collection.settings.fields.map((field) => field.name));
547
+ const unknown = Object.keys(values).filter((field) => !known.has(field));
548
+ if (unknown.length > 0) throw new ToolError(`The collection has no field ${unknown.join(", ")}. get_collection lists its fields.`);
549
+ const fields = {
550
+ ...existing?.content.fields ?? {},
551
+ ...values
552
+ };
553
+ const problems = entryFieldProblems(collection.settings.fields, fields);
554
+ if (problems.length > 0) throw new ToolError(`Those values don't fit the fields: ${problems.join("; ")}.`);
555
+ const missing = collection.settings.fields.filter((field) => field.required && isEmptyValue(fields[field.name]));
556
+ if (missing.length > 0) throw new ToolError(`The item needs a value for: ${missing.map((field) => field.name).join(", ")}.`);
557
+ const change = entryFileChange({
558
+ id,
559
+ settings: collection.settings
560
+ }, slug, fields);
561
+ const title = typeof fields.title === "string" ? fields.title : slug;
562
+ await save(draft, [change], `${existing ? "Update" : "Add"} ${collection.settings.entryName.toLowerCase()} "${title}"`);
563
+ return text({
564
+ draft: draft.name,
565
+ collection: id,
566
+ slug,
567
+ saved: existing ? "changed" : "created"
568
+ });
569
+ });
570
+ tool("delete_item", "Deletes an item of a collection, in a draft.", {
571
+ site: siteArg,
572
+ collection: z.string(),
573
+ slug: z.string(),
574
+ draft: draftArg
575
+ }, async ({ site: given, collection: id, slug, draft: name }) => {
576
+ const draft = await changing(await open(given), name);
577
+ const collection = await readCollection(draft.store, id);
578
+ const entry = collection.entries.find((candidate) => candidate.slug === slug);
579
+ if (!entry) throw new ToolError(`The collection "${id}" has no item "${slug}".`);
580
+ await save(draft, [{
581
+ path: entry.file,
582
+ delete: true
583
+ }], `Delete ${collection.settings.entryName.toLowerCase()} ${slug}`);
584
+ return text({
585
+ draft: draft.name,
586
+ collection: id,
587
+ slug,
588
+ saved: "deleted"
589
+ });
590
+ });
591
+ tool("get_settings", "The site's settings: its name, description, logo, theme colors and fonts, contact details and the like.", {
592
+ site: siteArg,
593
+ draft: draftArg
594
+ }, async ({ site: given, draft }) => {
595
+ return text(await readSettings(await reading(await open(given), draft)));
596
+ }, true);
597
+ tool("update_settings", "Changes some of the site's settings, in a draft. Settings not given stay as they are. The site's address is changed on the admin panel's Domain screen instead.", {
598
+ site: siteArg,
599
+ settings: z.record(z.string(), z.unknown()),
600
+ draft: draftArg
601
+ }, async ({ site: given, settings: changes, draft: name }) => {
602
+ const draft = await changing(await open(given), name);
603
+ const current = await readSettings(draft.store);
604
+ if ("url" in changes && changes.url !== current.url) throw new ToolError("The site's address is changed on the admin panel's Domain screen, not by Claude.");
605
+ const result = siteSettingsSchema.safeParse({
606
+ ...current,
607
+ ...changes,
608
+ version: current.version
609
+ });
610
+ if (!result.success) throw new ToolError(`Those settings don't fit: ${result.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}.`);
611
+ await save(draft, [siteSettingsFileChange(result.data)], "Update site settings");
612
+ return text({
613
+ draft: draft.name,
614
+ saved: "changed"
615
+ });
616
+ });
617
+ tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them.", {
618
+ site: siteArg,
619
+ draft: draftArg
620
+ }, async ({ site: given, draft }) => {
621
+ return text(await readMenus(await reading(await open(given), draft)));
622
+ }, true);
623
+ tool("save_menu", "Replaces one of the site's menus, in a draft: a list of links, each with a label and an href.", {
624
+ site: siteArg,
625
+ menu: z.string().describe("The menu's name, such as main or footer."),
626
+ items: z.array(z.unknown()),
627
+ draft: draftArg
628
+ }, async ({ site: given, menu, items, draft: name }) => {
629
+ const draft = await changing(await open(given), name);
630
+ const menus = await readMenus(draft.store);
631
+ const result = menusFileSchema.safeParse({
632
+ version: 1,
633
+ menus: {
634
+ ...menus,
635
+ [menu]: items
636
+ }
637
+ });
638
+ if (!result.success) throw new ToolError(`That menu doesn't fit: ${result.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}.`);
639
+ await save(draft, [menusFileChange(result.data.menus)], `Update menu "${menu}"`);
640
+ return text({
641
+ draft: draft.name,
642
+ menu,
643
+ saved: "changed"
644
+ });
645
+ });
646
+ tool("list_media", "The site's uploaded images and files: the address blocks use for each, such as /media/photo.jpg, its size in bytes, and its credit if it has one. Pages that use a credited file show its credit by themselves, as its license requires, so use it as it is. To see an image, take a screenshot of its address. New files are uploaded in the admin panel.", { site: siteArg }, async ({ site: given }) => {
647
+ return text(await listMedia((await open(given)).backend));
648
+ }, true);
649
+ tool("get_draft", "What a draft changes compared with the live site.", {
650
+ site: siteArg,
651
+ draft: z.string()
652
+ }, async ({ site: given, draft: name }) => {
653
+ const draft = await draftOf(await open(given), name);
654
+ const changes = await draft.changes();
655
+ return text({
656
+ draft: draft.name,
657
+ changes: changes.map((change) => {
658
+ const kind = "delete" in change ? "deleted" : "changed";
659
+ if (change.path.startsWith("content/pages/")) return {
660
+ page: pageFileToPath(change.path),
661
+ [kind]: true
662
+ };
663
+ return {
664
+ file: change.path,
665
+ [kind]: true
666
+ };
667
+ })
668
+ });
669
+ }, true);
670
+ if (context.screenshots) {
671
+ const screenshots = context.screenshots;
672
+ tool("screenshot", "A picture of a page, as it is on the live site or with a draft's changes, or of an image in the media library (its address from list_media), to see what it shows. Show pages to the editor before publishing.", {
673
+ site: siteArg,
674
+ address: z.string().describe("A page's address, such as /about, or an image's, such as /media/photo.jpg."),
675
+ draft: draftArg,
676
+ width: z.union([
677
+ z.literal(390),
678
+ z.literal(768),
679
+ z.literal(1280)
680
+ ]).optional().describe("390 for a phone, 768 for a tablet, 1280 (the default) for a computer."),
681
+ dark: z.boolean().optional().describe("Shows the page as visitors whose computer is set to dark see it, on sites with dark mode.")
682
+ }, async ({ site: given, address, draft: name, width = 1280, dark = false }) => {
683
+ const site = await open(given);
684
+ if (isMediaAddress(address)) {
685
+ const image = await mediaImage(site.backend, address);
686
+ return { content: [{
687
+ type: "image",
688
+ data: base64(await screenshots.take({
689
+ url: `${site.url}${image}`,
690
+ width,
691
+ image: true,
692
+ dark
693
+ })),
694
+ mimeType: "image/png"
695
+ }] };
696
+ }
697
+ const path = pageAddress(address);
698
+ return { content: [{
699
+ type: "image",
700
+ data: base64(name ? await screenshots.take({
701
+ url: `${site.url}/admin/#/preview`,
702
+ width,
703
+ dark,
704
+ preview: {
705
+ files: await contentFiles((await draftOf(site, name)).store),
706
+ path,
707
+ dark
708
+ }
709
+ }) : await screenshots.take({
710
+ url: `${site.url}${path === "/" ? "/" : path}`,
711
+ width,
712
+ dark
713
+ })),
714
+ mimeType: "image/png"
715
+ }] };
716
+ }, true);
717
+ }
718
+ tool("publish", "Publishes a draft: its changes go live, in one go, and the draft is removed. Only when the editor has said to, and not on sites whose owner has turned it off (get_site says so); submit_for_review instead.", {
719
+ site: siteArg,
720
+ draft: z.string(),
721
+ message: z.string().describe("What the changes are, in a few words, such as \"New page about the summer fair\".")
722
+ }, async ({ site: given, draft: name, message }) => {
723
+ const site = await open(given);
724
+ if (site.versionProblem) throw new ToolError(site.versionProblem);
725
+ if (site.description.claude?.publish === false) throw new ToolError(NO_PUBLISHING);
726
+ const draft = await draftOf(site, name);
727
+ try {
728
+ await draft.publish(message);
729
+ } catch (error) {
730
+ if (error instanceof ConflictError) throw new ToolError("Someone published changes to the same pages since this draft started, so publishing it would undo theirs. The draft is still there: read the pages again from the live site, make the changes again in a new draft, then publish that.");
731
+ throw error;
732
+ }
733
+ return text({
734
+ published: true,
735
+ note: "The site is being rebuilt with the changes, which usually takes a minute or two.",
736
+ address: site.url
737
+ });
738
+ });
739
+ tool("submit_for_review", "Asks for a draft to be checked before it goes live: someone who looks after the site reviews it on GitHub or GitLab, and publishes it from there.", {
740
+ site: siteArg,
741
+ draft: z.string(),
742
+ title: z.string().describe("What the changes are, in a few words."),
743
+ description: z.string().optional().describe("More about the changes, for whoever reviews them.")
744
+ }, async ({ site: given, draft: name, title, description }) => {
745
+ return text({
746
+ submitted: true,
747
+ review: (await (await draftOf(await open(given), name)).submitForReview({
748
+ title,
749
+ ...description && { description }
750
+ })).url
751
+ });
752
+ });
753
+ tool("discard_draft", "Throws a draft and its changes away. The live site isn't affected.", {
754
+ site: siteArg,
755
+ draft: z.string()
756
+ }, async ({ site: given, draft: name }) => {
757
+ await (await draftOf(await open(given), name)).discard();
758
+ return text({ discarded: true });
759
+ });
760
+ }
761
+ //#endregion
762
+ //#region src/image-page.ts
763
+ /**
764
+ * A page showing one image at most as wide as the screenshot, which both
765
+ * screenshot setups open in place of the image's own address: browsers show
766
+ * an image opened on its own in a document of their own, which can't always
767
+ * be screenshotted (an SVG's never finishes).
768
+ */
769
+ function attribute(text) {
770
+ return text.replace(/[&"<>]/g, (character) => ({
771
+ "&": "&amp;",
772
+ "\"": "&quot;",
773
+ "<": "&lt;",
774
+ ">": "&gt;"
775
+ })[character] ?? "");
776
+ }
777
+ function imagePage(url) {
778
+ return `<!doctype html>
779
+ <html><head><meta charset="utf-8"><style>
780
+ html, body { margin: 0; background: #fff; }
781
+ img { display: block; max-width: 100%; height: auto; }
782
+ img[src$=".svg" i] { width: 100%; }
783
+ </style></head>
784
+ <body><img src="${attribute(url)}" alt=""></body></html>`;
785
+ }
786
+ //#endregion
787
+ //#region src/index.ts
788
+ /**
789
+ * The Claude connector's tools, for any MCP server: the one on the editor's
790
+ * computer (`goodfellow-mcp`) and the hosted connector's Worker.
791
+ */
792
+ /** Splits a site's id into its host and repository, or `undefined` if it isn't one. */
793
+ function parseSiteId(id) {
794
+ const match = /^(github|gitlab):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
795
+ if (!match) return void 0;
796
+ const [, host, repo] = match;
797
+ if (host === "github" && repo?.split("/").length !== 2) return void 0;
798
+ return {
799
+ host,
800
+ repo: repo ?? ""
801
+ };
802
+ }
803
+ /** An MCP server with Goodfellow's tools. Connect it to a transport. */
804
+ function createGoodfellowServer(context) {
805
+ const server = new McpServer({
806
+ name: "goodfellow",
807
+ version: GOODFELLOW_VERSION
808
+ }, { instructions: INSTRUCTIONS });
809
+ registerTools(server, context);
810
+ return server;
811
+ }
812
+ //#endregion
813
+ export { INSTRUCTIONS, ToolError, contentFiles, createGoodfellowServer, imagePage, parseSiteId };