@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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/index.d.ts +68 -0
- package/dist/index.js +813 -0
- package/dist/screenshots--uulXZYY.js +240 -0
- package/dist/screenshots.d.ts +29 -0
- package/dist/screenshots.js +2 -0
- package/dist/stdio.d.ts +1 -0
- package/dist/stdio.js +690 -0
- package/package.json +66 -3
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 {};
|