@goodfellow-cms/mcp 0.0.0-stage → 0.2.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/stdio.js ADDED
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env node
2
+ import { a as listCollections, c as mediaImage, d as readEntry, f as readLayout, g as takenAddresses, h as readSettings, i as isMediaAddress, l as pageAddress, m as readPage, n as ToolError, o as listMedia, p as readMenus, r as contentFiles, s as listPages, t as localScreenshots, u as readCollection } from "./screenshots--uulXZYY.js";
3
+ import { github } from "@goodfellow-cms/github";
4
+ import { gitlab } from "@goodfellow-cms/gitlab";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, SITE_DESCRIPTION_PATH, SignInError, entryAddress, entryFieldProblems, entryFileChange, isAddressSegment, isEmptyValue, isReservedPagePath, layoutFileChange, menusFileChange, menusFileSchema, newDraftName, pageFileChange, pageFileToPath, parseSiteDescription, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
9
+ import { z } from "zod";
10
+ //#region src/site.ts
11
+ /**
12
+ * Opening a site for the tools: its backend, its settings, and the
13
+ * description its build published (`goodfellow-site.json`), which says what
14
+ * blocks it has, since the connector doesn't have the site's code.
15
+ */
16
+ function configFrom(description, templates) {
17
+ return {
18
+ components: Object.fromEntries(Object.entries(description.blocks).filter(([, block]) => templates || !block.templateOnly).map(([name, block]) => [name, {
19
+ ...block.label && { label: block.label },
20
+ fields: block.fields,
21
+ ...block.defaultProps && { defaultProps: block.defaultProps },
22
+ render: () => null
23
+ }])),
24
+ root: { fields: description.root.fields }
25
+ };
26
+ }
27
+ const VERSION_PROBLEMS = {
28
+ "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.`,
29
+ "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.`
30
+ };
31
+ async function openSite(context, id) {
32
+ const backend = await context.backend(id);
33
+ await backend.revision();
34
+ const settings = await readSettings(backend);
35
+ 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.");
36
+ const url = settings.url.replace(/\/+$/, "");
37
+ const response = await (context.fetch ?? fetch)(`${url}/${SITE_DESCRIPTION_PATH}`, { headers: { accept: "application/json" } }).catch(() => void 0);
38
+ const result = response?.ok ? parseSiteDescription(await response.text()) : { ok: false };
39
+ 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.`);
40
+ const { description } = result;
41
+ const problem = siteVersionProblem(description);
42
+ return {
43
+ id,
44
+ backend,
45
+ settings,
46
+ url,
47
+ description,
48
+ pageConfig: configFrom(description, false),
49
+ templateConfig: configFrom(description, true),
50
+ ...problem && { versionProblem: VERSION_PROBLEMS[problem](description.goodfellow) }
51
+ };
52
+ }
53
+ //#endregion
54
+ //#region src/tools.ts
55
+ /**
56
+ * The Claude connector's tools. Changes always go into a draft (a branch of
57
+ * the site's repository), which Claude shows the editor in screenshots, then
58
+ * publishes or submits for review when the editor says so. The tools keep
59
+ * nothing between calls: the draft's name, which every change returns, is how
60
+ * Claude carries on with one.
61
+ */
62
+ /** What Claude is told about working with Goodfellow sites. */
63
+ const INSTRUCTIONS = `These tools edit websites built with Goodfellow, for people who aren't developers.
64
+
65
+ - Start with get_site, which lists the site's pages, collections and blocks.
66
+ - 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.
67
+ - 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".
68
+ - Show the editor what a draft looks like with screenshot before anything goes live.
69
+ - Only publish when the editor says to. If they'd like someone to check it first, use submit_for_review instead.
70
+ - Use the site's own words for things: say "publish", not "commit" or "merge".`;
71
+ /** Why `publish` refuses, on sites whose config says `claude: { publish: false }`. */
72
+ 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.";
73
+ const siteArg = z.string().optional().describe("Which site, from list_sites. Leave it out when there's only one.");
74
+ 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.");
75
+ const flatBlock = z.object({
76
+ type: z.string().describe("The block's name, from get_blocks."),
77
+ id: z.string().describe("A short id, unique in this list, such as b1."),
78
+ parent: z.string().nullable().describe("The id of the block this one is inside, listed earlier, or null."),
79
+ slot: z.string().nullable().describe("Which place in the parent block this one goes in, or null."),
80
+ props: z.record(z.string(), z.unknown()).describe("The block's props, as get_blocks describes them.")
81
+ });
82
+ function text(value) {
83
+ return { content: [{
84
+ type: "text",
85
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
86
+ }] };
87
+ }
88
+ function base64(bytes) {
89
+ let binary = "";
90
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
91
+ return btoa(binary);
92
+ }
93
+ function failure(message) {
94
+ return {
95
+ isError: true,
96
+ content: [{
97
+ type: "text",
98
+ text: message
99
+ }]
100
+ };
101
+ }
102
+ /** Turns what went wrong into words the editor can act on. Never includes a token: backends' errors don't. */
103
+ function explain(error) {
104
+ if (error instanceof ToolError) return failure(error.message);
105
+ if (error instanceof DraftError) {
106
+ 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.");
107
+ 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.");
108
+ return failure(error.message);
109
+ }
110
+ 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.");
111
+ 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.");
112
+ return failure(`Something went wrong: ${error.message ?? String(error)}`);
113
+ }
114
+ function countBlocks(content, config) {
115
+ return content.reduce((count, block) => {
116
+ const component = config.components[block.type];
117
+ const nested = slotNames(component).reduce((inner, slot) => inner + countBlocks(block.props[slot] ?? [], config), 0);
118
+ return count + 1 + nested;
119
+ }, 0);
120
+ }
121
+ function newId(type) {
122
+ return `${type}-${Math.random().toString(36).slice(2, 10)}`;
123
+ }
124
+ function blocksFrom(blocks, config) {
125
+ const content = toContent({ blocks }, config, newId);
126
+ return {
127
+ content,
128
+ ignored: blocks.length - countBlocks(content, config)
129
+ };
130
+ }
131
+ function registerTools(server, context) {
132
+ const now = context.now ?? (() => /* @__PURE__ */ new Date());
133
+ async function siteId(given) {
134
+ if (given) return given;
135
+ const sites = await context.sites();
136
+ const [only] = sites;
137
+ if (sites.length === 1 && only) return only.id;
138
+ 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(", ")}.`);
139
+ }
140
+ async function open(given) {
141
+ return openSite(context, await siteId(given));
142
+ }
143
+ async function draftOf(site, name) {
144
+ if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
145
+ return site.backend.drafts.open(name);
146
+ }
147
+ /** The draft to change: the one named, or a new one. Refuses sites that can't take changes. */
148
+ async function changing(site, name) {
149
+ if (site.versionProblem) throw new ToolError(site.versionProblem);
150
+ if (name) return draftOf(site, name);
151
+ if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
152
+ return site.backend.drafts.create(newDraftName(now()));
153
+ }
154
+ /** Where to read from: a draft, or the live site. */
155
+ async function reading(site, name) {
156
+ return name ? (await draftOf(site, name)).store : site.backend;
157
+ }
158
+ async function save(draft, changes, message) {
159
+ const store = draft.store;
160
+ await writeChanges(store, changes, {
161
+ message,
162
+ expectedRevision: await store.revision()
163
+ });
164
+ }
165
+ function tool(name, description, shape, run, readOnly = false) {
166
+ server.registerTool(name, {
167
+ description,
168
+ inputSchema: shape,
169
+ annotations: {
170
+ readOnlyHint: readOnly,
171
+ destructiveHint: false
172
+ }
173
+ }, (async (args) => {
174
+ try {
175
+ return await run(args);
176
+ } catch (error) {
177
+ return explain(error);
178
+ }
179
+ }));
180
+ }
181
+ tool("list_sites", "The Goodfellow sites this connection can edit.", {}, async () => text((await context.sites()).map((site) => ({
182
+ site: site.id,
183
+ ...site.label && { name: site.label }
184
+ }))), true);
185
+ tool("get_site", "An overview of a site: its name and address, pages, collections, menus and blocks. Start here.", {
186
+ site: siteArg,
187
+ draft: draftArg
188
+ }, async ({ site: given, draft }) => {
189
+ const site = await open(given);
190
+ const source = await reading(site, draft);
191
+ const [pages, collections, menus] = await Promise.all([
192
+ listPages(source),
193
+ listCollections(source),
194
+ readMenus(source)
195
+ ]);
196
+ return text({
197
+ site: site.id,
198
+ name: site.settings.title,
199
+ address: site.url,
200
+ ...site.versionProblem && { cantChange: site.versionProblem },
201
+ ...site.description.claude?.publish === false && { cantPublish: NO_PUBLISHING },
202
+ ...site.settings.theme.darkMode && { darkMode: true },
203
+ pages: pages.map((page) => page.address),
204
+ collections: collections.map((collection) => ({
205
+ id: collection.id,
206
+ entries: collection.entries.length
207
+ })),
208
+ menus: Object.keys(menus),
209
+ blocks: Object.entries(site.description.blocks).map(([name, block]) => ({
210
+ name,
211
+ ...block.label && { label: block.label },
212
+ ...block.templateOnly && { onlyInCollectionTemplates: true }
213
+ }))
214
+ });
215
+ }, true);
216
+ 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.", {
217
+ site: siteArg,
218
+ names: z.array(z.string()).optional().describe("Only these blocks. Leave it out for all of them.")
219
+ }, async ({ site: given, names }) => {
220
+ const site = await open(given);
221
+ const blocks = Object.entries(site.templateConfig.components).filter(([name]) => !names || names.includes(name));
222
+ return text({
223
+ pageSettings: objectSchema(site.description.root.fields) ?? {},
224
+ blocks: blocks.map(([name, component]) => ({
225
+ name,
226
+ ...component.label && { label: component.label },
227
+ slots: slotNames(component),
228
+ ...site.description.blocks[name]?.templateOnly && { onlyInCollectionTemplates: true },
229
+ props: objectSchema(component.fields) ?? {}
230
+ }))
231
+ });
232
+ }, true);
233
+ tool("get_page", "A page's settings (such as its title) and its blocks, as a flat list.", {
234
+ site: siteArg,
235
+ address: z.string().describe("The page's address, such as / or /about."),
236
+ draft: draftArg
237
+ }, async ({ site: given, address, draft }) => {
238
+ const site = await open(given);
239
+ const path = pageAddress(address);
240
+ const page = await readPage(await reading(site, draft), path);
241
+ if (!page) throw new ToolError(`The site has no page at ${path}. get_site lists its pages.`);
242
+ return text({
243
+ address: path,
244
+ settings: page.content.data.root.props ?? {},
245
+ blocks: toFlatBlocks(page.content.data.content, site.pageConfig)
246
+ });
247
+ }, true);
248
+ 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.", {
249
+ site: siteArg,
250
+ address: z.string().describe("The page's address, such as /about. A new address makes a new page."),
251
+ draft: draftArg,
252
+ settings: z.record(z.string(), z.unknown()).optional().describe("Page settings to change, such as title and description, as get_blocks describes them."),
253
+ blocks: z.array(flatBlock).optional()
254
+ }, async ({ site: given, address, draft: name, settings, blocks }) => {
255
+ const site = await open(given);
256
+ const path = pageAddress(address);
257
+ const draft = await changing(site, name);
258
+ const existing = await readPage(draft.store, path);
259
+ if (!existing) {
260
+ if (isReservedPagePath(path)) throw new ToolError(`${path} is kept for the admin panel. Choose another address.`);
261
+ if ((await takenAddresses(draft.store)).has(path)) throw new ToolError(`${path} is already a collection item's address. Choose another one.`);
262
+ }
263
+ const before = existing?.content.data ?? {
264
+ root: { props: {} },
265
+ content: []
266
+ };
267
+ const props = {
268
+ ...before.root.props ?? {},
269
+ ...cleanProps(site.description.root.fields, settings ?? {}, void 0)
270
+ };
271
+ const built = blocks ? blocksFrom(blocks, site.pageConfig) : {
272
+ content: before.content,
273
+ ignored: 0
274
+ };
275
+ const data = {
276
+ ...before,
277
+ root: {
278
+ ...before.root,
279
+ props
280
+ },
281
+ content: built.content
282
+ };
283
+ const title = typeof props.title === "string" && props.title ? props.title : path;
284
+ await save(draft, [pageFileChange(path, data)], `${existing ? "Update" : "Add"} page "${title}"`);
285
+ return text({
286
+ draft: draft.name,
287
+ address: path,
288
+ saved: existing ? "changed" : "created",
289
+ ...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.` }
290
+ });
291
+ });
292
+ tool("delete_page", "Deletes a page, in a draft.", {
293
+ site: siteArg,
294
+ address: z.string(),
295
+ draft: draftArg
296
+ }, async ({ site: given, address, draft: name }) => {
297
+ const site = await open(given);
298
+ const path = pageAddress(address);
299
+ if (path === "/") throw new ToolError("The home page can't be deleted. Change it instead.");
300
+ const draft = await changing(site, name);
301
+ const page = await readPage(draft.store, path);
302
+ if (!page) throw new ToolError(`The site has no page at ${path}.`);
303
+ await save(draft, [{
304
+ path: page.file,
305
+ delete: true
306
+ }], `Delete page ${path}`);
307
+ return text({
308
+ draft: draft.name,
309
+ address: path,
310
+ saved: "deleted"
311
+ });
312
+ });
313
+ tool("get_layout", "The site's header or footer: its blocks, as a flat list. Every page shows them.", {
314
+ site: siteArg,
315
+ part: z.enum(["header", "footer"]),
316
+ draft: draftArg
317
+ }, async ({ site: given, part, draft }) => {
318
+ const site = await open(given);
319
+ const layout = await readLayout(await reading(site, draft), part);
320
+ return text({
321
+ part,
322
+ blocks: toFlatBlocks(layout.data.content, site.pageConfig)
323
+ });
324
+ }, true);
325
+ tool("save_layout", "Changes the site's header or footer, in a draft. Give the whole list of blocks: it replaces them.", {
326
+ site: siteArg,
327
+ part: z.enum(["header", "footer"]),
328
+ draft: draftArg,
329
+ blocks: z.array(flatBlock)
330
+ }, async ({ site: given, part, draft: name, blocks }) => {
331
+ const site = await open(given);
332
+ const draft = await changing(site, name);
333
+ const before = await readLayout(draft.store, part);
334
+ const built = blocksFrom(blocks, site.pageConfig);
335
+ await save(draft, [layoutFileChange(part === "header" ? HEADER_FILE : FOOTER_FILE, {
336
+ ...before.data,
337
+ content: built.content
338
+ })], `Update ${part}`);
339
+ return text({
340
+ draft: draft.name,
341
+ part,
342
+ saved: "changed",
343
+ ...built.ignored > 0 && { ignored: built.ignored }
344
+ });
345
+ });
346
+ tool("get_collection", "A collection (such as news or events): its fields, the address its items have, and its items.", {
347
+ site: siteArg,
348
+ collection: z.string().describe("The collection's id, from get_site."),
349
+ draft: draftArg
350
+ }, async ({ site: given, collection: id, draft }) => {
351
+ const site = await open(given);
352
+ const collection = await readCollection(await reading(site, draft), id);
353
+ const { settings } = collection;
354
+ return text({
355
+ id,
356
+ name: settings.name,
357
+ itemName: settings.entryName,
358
+ ...settings.path !== void 0 && { address: settings.path },
359
+ ...settings.markdown && { markdownField: settings.markdown.body },
360
+ fields: settings.fields,
361
+ items: collection.entries.map((entry) => ({
362
+ slug: entry.slug,
363
+ ...settings.path !== void 0 && { address: entryAddress(settings.path, entry.slug) }
364
+ }))
365
+ });
366
+ }, true);
367
+ tool("get_item", "One item of a collection: its fields' values.", {
368
+ site: siteArg,
369
+ collection: z.string(),
370
+ slug: z.string(),
371
+ draft: draftArg
372
+ }, async ({ site: given, collection: id, slug, draft }) => {
373
+ const source = await reading(await open(given), draft);
374
+ const collection = await readCollection(source, id);
375
+ const entry = await readEntry(source, collection, slug);
376
+ if (!entry) throw new ToolError(`The collection "${id}" has no item "${slug}".`);
377
+ return text({
378
+ collection: id,
379
+ slug,
380
+ values: entry.content.fields
381
+ });
382
+ }, true);
383
+ 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.", {
384
+ site: siteArg,
385
+ collection: z.string(),
386
+ slug: z.string().describe("The item's name in its address and file: lowercase letters, numbers and hyphens, such as open-day."),
387
+ values: z.record(z.string(), z.unknown()).describe("Field values, by the fields' names from get_collection."),
388
+ draft: draftArg
389
+ }, async ({ site: given, collection: id, slug, values, draft: name }) => {
390
+ const draft = await changing(await open(given), name);
391
+ const collection = await readCollection(draft.store, id);
392
+ const existing = await readEntry(draft.store, collection, slug);
393
+ if (!existing) {
394
+ if (!isAddressSegment(slug)) throw new ToolError(`"${slug}" can't be an item's name: use lowercase letters, numbers and hyphens.`);
395
+ const path = collection.settings.path === void 0 ? void 0 : entryAddress(collection.settings.path, slug);
396
+ if (path && (isReservedPagePath(path) || (await takenAddresses(draft.store)).has(path))) throw new ToolError(`${path} is already taken on the site. Choose another name.`);
397
+ }
398
+ const known = new Set(collection.settings.fields.map((field) => field.name));
399
+ const unknown = Object.keys(values).filter((field) => !known.has(field));
400
+ if (unknown.length > 0) throw new ToolError(`The collection has no field ${unknown.join(", ")}. get_collection lists its fields.`);
401
+ const fields = {
402
+ ...existing?.content.fields ?? {},
403
+ ...values
404
+ };
405
+ const problems = entryFieldProblems(collection.settings.fields, fields);
406
+ if (problems.length > 0) throw new ToolError(`Those values don't fit the fields: ${problems.join("; ")}.`);
407
+ const missing = collection.settings.fields.filter((field) => field.required && isEmptyValue(fields[field.name]));
408
+ if (missing.length > 0) throw new ToolError(`The item needs a value for: ${missing.map((field) => field.name).join(", ")}.`);
409
+ const change = entryFileChange({
410
+ id,
411
+ settings: collection.settings
412
+ }, slug, fields);
413
+ const title = typeof fields.title === "string" ? fields.title : slug;
414
+ await save(draft, [change], `${existing ? "Update" : "Add"} ${collection.settings.entryName.toLowerCase()} "${title}"`);
415
+ return text({
416
+ draft: draft.name,
417
+ collection: id,
418
+ slug,
419
+ saved: existing ? "changed" : "created"
420
+ });
421
+ });
422
+ tool("delete_item", "Deletes an item of a collection, in a draft.", {
423
+ site: siteArg,
424
+ collection: z.string(),
425
+ slug: z.string(),
426
+ draft: draftArg
427
+ }, async ({ site: given, collection: id, slug, draft: name }) => {
428
+ const draft = await changing(await open(given), name);
429
+ const collection = await readCollection(draft.store, id);
430
+ const entry = collection.entries.find((candidate) => candidate.slug === slug);
431
+ if (!entry) throw new ToolError(`The collection "${id}" has no item "${slug}".`);
432
+ await save(draft, [{
433
+ path: entry.file,
434
+ delete: true
435
+ }], `Delete ${collection.settings.entryName.toLowerCase()} ${slug}`);
436
+ return text({
437
+ draft: draft.name,
438
+ collection: id,
439
+ slug,
440
+ saved: "deleted"
441
+ });
442
+ });
443
+ tool("get_settings", "The site's settings: its name, description, logo, theme colors and fonts, contact details and the like.", {
444
+ site: siteArg,
445
+ draft: draftArg
446
+ }, async ({ site: given, draft }) => {
447
+ const site = await open(given);
448
+ return text(await readSettings(await reading(site, draft)));
449
+ }, true);
450
+ 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.", {
451
+ site: siteArg,
452
+ settings: z.record(z.string(), z.unknown()),
453
+ draft: draftArg
454
+ }, async ({ site: given, settings: changes, draft: name }) => {
455
+ const draft = await changing(await open(given), name);
456
+ const current = await readSettings(draft.store);
457
+ 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.");
458
+ const result = siteSettingsSchema.safeParse({
459
+ ...current,
460
+ ...changes,
461
+ version: current.version
462
+ });
463
+ if (!result.success) throw new ToolError(`Those settings don't fit: ${result.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}.`);
464
+ await save(draft, [siteSettingsFileChange(result.data)], "Update site settings");
465
+ return text({
466
+ draft: draft.name,
467
+ saved: "changed"
468
+ });
469
+ });
470
+ tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them.", {
471
+ site: siteArg,
472
+ draft: draftArg
473
+ }, async ({ site: given, draft }) => {
474
+ const site = await open(given);
475
+ return text(await readMenus(await reading(site, draft)));
476
+ }, true);
477
+ tool("save_menu", "Replaces one of the site's menus, in a draft: a list of links, each with a label and an href.", {
478
+ site: siteArg,
479
+ menu: z.string().describe("The menu's name, such as main or footer."),
480
+ items: z.array(z.unknown()),
481
+ draft: draftArg
482
+ }, async ({ site: given, menu, items, draft: name }) => {
483
+ const draft = await changing(await open(given), name);
484
+ const menus = await readMenus(draft.store);
485
+ const result = menusFileSchema.safeParse({
486
+ version: 1,
487
+ menus: {
488
+ ...menus,
489
+ [menu]: items
490
+ }
491
+ });
492
+ if (!result.success) throw new ToolError(`That menu doesn't fit: ${result.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}.`);
493
+ await save(draft, [menusFileChange(result.data.menus)], `Update menu "${menu}"`);
494
+ return text({
495
+ draft: draft.name,
496
+ menu,
497
+ saved: "changed"
498
+ });
499
+ });
500
+ 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 }) => {
501
+ const site = await open(given);
502
+ return text(await listMedia(site.backend));
503
+ }, true);
504
+ tool("get_draft", "What a draft changes compared with the live site.", {
505
+ site: siteArg,
506
+ draft: z.string()
507
+ }, async ({ site: given, draft: name }) => {
508
+ const draft = await draftOf(await open(given), name);
509
+ const changes = await draft.changes();
510
+ return text({
511
+ draft: draft.name,
512
+ changes: changes.map((change) => {
513
+ const kind = "delete" in change ? "deleted" : "changed";
514
+ if (change.path.startsWith("content/pages/")) return {
515
+ page: pageFileToPath(change.path),
516
+ [kind]: true
517
+ };
518
+ return {
519
+ file: change.path,
520
+ [kind]: true
521
+ };
522
+ })
523
+ });
524
+ }, true);
525
+ if (context.screenshots) {
526
+ const screenshots = context.screenshots;
527
+ 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.", {
528
+ site: siteArg,
529
+ address: z.string().describe("A page's address, such as /about, or an image's, such as /media/photo.jpg."),
530
+ draft: draftArg,
531
+ width: z.union([
532
+ z.literal(390),
533
+ z.literal(768),
534
+ z.literal(1280)
535
+ ]).optional().describe("390 for a phone, 768 for a tablet, 1280 (the default) for a computer."),
536
+ dark: z.boolean().optional().describe("Shows the page as visitors whose computer is set to dark see it, on sites with dark mode.")
537
+ }, async ({ site: given, address, draft: name, width = 1280, dark = false }) => {
538
+ const site = await open(given);
539
+ if (isMediaAddress(address)) {
540
+ const image = await mediaImage(site.backend, address);
541
+ return { content: [{
542
+ type: "image",
543
+ data: base64(await screenshots.take({
544
+ url: `${site.url}${image}`,
545
+ width,
546
+ image: true,
547
+ dark
548
+ })),
549
+ mimeType: "image/png"
550
+ }] };
551
+ }
552
+ const path = pageAddress(address);
553
+ return { content: [{
554
+ type: "image",
555
+ data: base64(name ? await screenshots.take({
556
+ url: `${site.url}/admin/#/preview`,
557
+ width,
558
+ dark,
559
+ preview: {
560
+ files: await contentFiles((await draftOf(site, name)).store),
561
+ path,
562
+ dark
563
+ }
564
+ }) : await screenshots.take({
565
+ url: `${site.url}${path === "/" ? "/" : path}`,
566
+ width,
567
+ dark
568
+ })),
569
+ mimeType: "image/png"
570
+ }] };
571
+ }, true);
572
+ }
573
+ 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.", {
574
+ site: siteArg,
575
+ draft: z.string(),
576
+ message: z.string().describe("What the changes are, in a few words, such as \"New page about the summer fair\".")
577
+ }, async ({ site: given, draft: name, message }) => {
578
+ const site = await open(given);
579
+ if (site.versionProblem) throw new ToolError(site.versionProblem);
580
+ if (site.description.claude?.publish === false) throw new ToolError(NO_PUBLISHING);
581
+ const draft = await draftOf(site, name);
582
+ try {
583
+ await draft.publish(message);
584
+ } catch (error) {
585
+ 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.");
586
+ throw error;
587
+ }
588
+ return text({
589
+ published: true,
590
+ note: "The site is being rebuilt with the changes, which usually takes a minute or two.",
591
+ address: site.url
592
+ });
593
+ });
594
+ 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.", {
595
+ site: siteArg,
596
+ draft: z.string(),
597
+ title: z.string().describe("What the changes are, in a few words."),
598
+ description: z.string().optional().describe("More about the changes, for whoever reviews them.")
599
+ }, async ({ site: given, draft: name, title, description }) => {
600
+ return text({
601
+ submitted: true,
602
+ review: (await (await draftOf(await open(given), name)).submitForReview({
603
+ title,
604
+ ...description && { description }
605
+ })).url
606
+ });
607
+ });
608
+ tool("discard_draft", "Throws a draft and its changes away. The live site isn't affected.", {
609
+ site: siteArg,
610
+ draft: z.string()
611
+ }, async ({ site: given, draft: name }) => {
612
+ await (await draftOf(await open(given), name)).discard();
613
+ return text({ discarded: true });
614
+ });
615
+ }
616
+ //#endregion
617
+ //#region src/index.ts
618
+ /**
619
+ * The Claude connector's tools, for any MCP server: the one on the editor's
620
+ * computer (`goodfellow-mcp`) and the hosted connector's Worker.
621
+ */
622
+ /** Splits a site's id into its host and repository, or `undefined` if it isn't one. */
623
+ function parseSiteId(id) {
624
+ const match = /^(github|gitlab):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
625
+ if (!match) return void 0;
626
+ const [, host, repo] = match;
627
+ if (host === "github" && repo?.split("/").length !== 2) return void 0;
628
+ return {
629
+ host,
630
+ repo: repo ?? ""
631
+ };
632
+ }
633
+ /** An MCP server with Goodfellow's tools. Connect it to a transport. */
634
+ function createGoodfellowServer(context) {
635
+ const server = new McpServer({
636
+ name: "goodfellow",
637
+ version: GOODFELLOW_VERSION
638
+ }, { instructions: INSTRUCTIONS });
639
+ registerTools(server, context);
640
+ return server;
641
+ }
642
+ //#endregion
643
+ //#region src/stdio.ts
644
+ /** Sign-ins keep nothing on disk: the token comes from the environment every time. */
645
+ function memoryStorage() {
646
+ const items = /* @__PURE__ */ new Map();
647
+ return {
648
+ getItem: (key) => items.get(key) ?? null,
649
+ setItem: (key, value) => void items.set(key, value),
650
+ removeItem: (key) => void items.delete(key)
651
+ };
652
+ }
653
+ function fail(message) {
654
+ process.stderr.write(`goodfellow-mcp: ${message}\n`);
655
+ process.exit(1);
656
+ }
657
+ const siteId = process.env.GOODFELLOW_SITE?.trim() || process.argv[2];
658
+ const token = process.env.GOODFELLOW_TOKEN?.trim();
659
+ if (!siteId) fail("Set GOODFELLOW_SITE to the site's repository, such as github:owner/site or gitlab:group/site.");
660
+ const site = parseSiteId(siteId);
661
+ if (!site) fail(`${siteId} isn't a site. Write it as github:owner/site or gitlab:group/site.`);
662
+ if (!token) fail("Set GOODFELLOW_TOKEN to a GitHub or GitLab token that can edit the site.");
663
+ const storage = {
664
+ local: memoryStorage(),
665
+ session: memoryStorage()
666
+ };
667
+ const host = site.host === "github" ? github({
668
+ repo: site.repo,
669
+ signIn: false,
670
+ storage
671
+ }) : gitlab({
672
+ project: site.repo,
673
+ ...process.env.GITLAB_URL && { url: process.env.GITLAB_URL },
674
+ storage
675
+ });
676
+ let signedIn;
677
+ await createGoodfellowServer({
678
+ sites: async () => [{ id: siteId }],
679
+ backend: async (id) => {
680
+ if (id !== siteId) throw new ToolError(`This connector only edits ${siteId}.`);
681
+ signedIn ??= host.signInWithToken(token, false).catch((error) => {
682
+ signedIn = void 0;
683
+ throw error;
684
+ });
685
+ return signedIn;
686
+ },
687
+ screenshots: localScreenshots(process.env.GOODFELLOW_BROWSER ? { executablePath: process.env.GOODFELLOW_BROWSER } : {})
688
+ }).connect(new StdioServerTransport());
689
+ //#endregion
690
+ export {};