@goodfellow-cms/mcp 0.4.0 → 0.6.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/README.md +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +105 -35
- package/dist/{screenshots--uulXZYY.js → screenshots-yES3bUkH.js} +43 -5
- package/dist/screenshots.js +1 -1
- package/dist/stdio.js +71 -37
- package/package.json +12 -11
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Work on a [Goodfellow](https://goodfellow-cms.github.io/goodfellow-cms/) site from Claude. An [MCP](https://modelcontextprotocol.io) server with Goodfellow's tools: Claude reads a site's pages, items and settings, sees what blocks it has, makes changes in a draft, takes screenshots, and publishes the draft or submits it for review.
|
|
4
4
|
|
|
5
|
-
See [Work with Claude](https://goodfellow-cms.github.io/goodfellow-cms/docs/claude) for setting it up. This package runs the tools on your own computer, signed in with a GitHub or
|
|
5
|
+
See [Work with Claude](https://goodfellow-cms.github.io/goodfellow-cms/docs/claude) for setting it up. This package runs the tools on your own computer, signed in with a GitHub, GitLab or Bitbucket token that stays there.
|
|
6
6
|
|
|
7
7
|
## Claude Code
|
|
8
8
|
|
package/dist/index.d.ts
CHANGED
|
@@ -56,10 +56,10 @@ export declare const INSTRUCTIONS = "These tools edit websites built with Goodfe
|
|
|
56
56
|
//#endregion
|
|
57
57
|
//#region src/index.d.ts
|
|
58
58
|
/** A site's id for the tools: its host, then its repository, such as `github:parish/site`. */
|
|
59
|
-
export type SiteId = `github:${string}` | `gitlab:${string}`;
|
|
59
|
+
export type SiteId = `github:${string}` | `gitlab:${string}` | `bitbucket:${string}`;
|
|
60
60
|
/** Splits a site's id into its host and repository, or `undefined` if it isn't one. */
|
|
61
61
|
export declare function parseSiteId(id: string): {
|
|
62
|
-
host: "github" | "gitlab";
|
|
62
|
+
host: "github" | "gitlab" | "bitbucket";
|
|
63
63
|
repo: string;
|
|
64
64
|
} | undefined;
|
|
65
65
|
/** An MCP server with Goodfellow's tools. Connect it to a transport. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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,
|
|
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, describedConfig, entryFieldProblems, entryFileChange, entryPathIn, hasLanguages, isAddressSegment, isEmptyValue, isOtherLanguage, isReservedPagePath, layoutFileChange, layoutFileSchema, mainLanguage, mediaFileSchema, menusFileChange, menusFileSchema, newDraftName, normalizePagePath, pageFileChange, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, parseSiteDescription, readTextFiles, siteLanguages, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -21,9 +21,18 @@ async function readSettings(source) {
|
|
|
21
21
|
const text = await source.read(SITE_FILE);
|
|
22
22
|
return text === void 0 ? siteSettingsSchema.parse({ version: 1 }) : parsed(parseContentFile("site", siteSettingsSchema, SITE_FILE, text));
|
|
23
23
|
}
|
|
24
|
+
/** The site's menus, and the ones of its other languages that have menus of their own. */
|
|
24
25
|
async function readMenus(source) {
|
|
25
26
|
const text = await source.read(MENUS_FILE);
|
|
26
|
-
|
|
27
|
+
if (text === void 0) return {
|
|
28
|
+
menus: {},
|
|
29
|
+
languageMenus: {}
|
|
30
|
+
};
|
|
31
|
+
const file = parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text));
|
|
32
|
+
return {
|
|
33
|
+
menus: file.menus,
|
|
34
|
+
languageMenus: file.languages ?? {}
|
|
35
|
+
};
|
|
27
36
|
}
|
|
28
37
|
async function readLayout(source, part) {
|
|
29
38
|
const file = part === "header" ? HEADER_FILE : FOOTER_FILE;
|
|
@@ -106,17 +115,46 @@ async function readEntry(source, collection, slug) {
|
|
|
106
115
|
};
|
|
107
116
|
}
|
|
108
117
|
/**
|
|
118
|
+
* Each item's language, by its name. Only a site with other languages has
|
|
119
|
+
* items in them, so only then are the items' files read, all in one go where
|
|
120
|
+
* the source can.
|
|
121
|
+
*/
|
|
122
|
+
async function entryLanguages(source, collection, site) {
|
|
123
|
+
const main = mainLanguage(site);
|
|
124
|
+
if (!hasLanguages(site)) return new Map(collection.entries.map((entry) => [entry.slug, main]));
|
|
125
|
+
const texts = await readTextFiles(source, collection.entries.map((entry) => entry.file));
|
|
126
|
+
return new Map(collection.entries.map((entry) => {
|
|
127
|
+
const text = texts.get(entry.file);
|
|
128
|
+
const file = text === void 0 ? void 0 : parseEntryFile(collection.settings, entry.file, text);
|
|
129
|
+
return [entry.slug, file?.ok && file.value.inLanguage || main];
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
/** Each item's address, by its name, in its language, where its collection gives items pages. */
|
|
133
|
+
async function entryAddresses(source, collection, site) {
|
|
134
|
+
if (collection.settings.path === void 0) return /* @__PURE__ */ new Map();
|
|
135
|
+
const languages = await entryLanguages(source, collection, site);
|
|
136
|
+
return new Map(collection.entries.flatMap((entry) => {
|
|
137
|
+
const path = entryPathIn(site, collection.settings, entry.slug, languages.get(entry.slug) ?? mainLanguage(site));
|
|
138
|
+
return path === void 0 ? [] : [[entry.slug, path]];
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
109
142
|
* 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
|
|
143
|
+
* never takes one. Entries' addresses come from their collections' settings,
|
|
144
|
+
* and in a site with other languages, their own languages.
|
|
111
145
|
*/
|
|
112
146
|
async function takenAddresses(source) {
|
|
113
147
|
const addresses = new Set((await listPages(source)).map((page) => page.address));
|
|
148
|
+
const site = await readSettings(source);
|
|
114
149
|
for (const collection of await listCollections(source)) {
|
|
115
150
|
const text = await source.read(collection.file);
|
|
116
151
|
if (text === void 0) continue;
|
|
117
152
|
const settings = parseContentFile("collection", collectionFileSchema, collection.file, text);
|
|
118
153
|
if (!settings.ok || settings.value.path === void 0) continue;
|
|
119
|
-
for (const
|
|
154
|
+
for (const address of (await entryAddresses(source, {
|
|
155
|
+
...collection,
|
|
156
|
+
settings: settings.value
|
|
157
|
+
}, site)).values()) addresses.add(address);
|
|
120
158
|
}
|
|
121
159
|
return addresses;
|
|
122
160
|
}
|
|
@@ -163,17 +201,6 @@ async function contentFiles(source) {
|
|
|
163
201
|
* description its build published (`goodfellow-site.json`), which says what
|
|
164
202
|
* blocks it has, since the connector doesn't have the site's code.
|
|
165
203
|
*/
|
|
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
204
|
const VERSION_PROBLEMS = {
|
|
178
205
|
"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
206
|
"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.`
|
|
@@ -195,8 +222,8 @@ async function openSite(context, id) {
|
|
|
195
222
|
settings,
|
|
196
223
|
url,
|
|
197
224
|
description,
|
|
198
|
-
pageConfig:
|
|
199
|
-
templateConfig:
|
|
225
|
+
pageConfig: describedConfig(description, "page"),
|
|
226
|
+
templateConfig: describedConfig(description, "template"),
|
|
200
227
|
...problem && { versionProblem: VERSION_PROBLEMS[problem](description.goodfellow) }
|
|
201
228
|
};
|
|
202
229
|
}
|
|
@@ -221,6 +248,7 @@ const INSTRUCTIONS = `These tools edit websites built with Goodfellow, for peopl
|
|
|
221
248
|
/** Why `publish` refuses, on sites whose config says `claude: { publish: false }`. */
|
|
222
249
|
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
250
|
const siteArg = z.string().optional().describe("Which site, from list_sites. Leave it out when there's only one.");
|
|
251
|
+
const languageArg = z.string().optional().describe("On a site in several languages, the code of a language from get_site. Leave it out for the main language.");
|
|
224
252
|
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
253
|
const flatBlock = z.object({
|
|
226
254
|
type: z.string().describe("The block's name, from get_blocks."),
|
|
@@ -355,7 +383,12 @@ function registerTools(server, context) {
|
|
|
355
383
|
id: collection.id,
|
|
356
384
|
entries: collection.entries.length
|
|
357
385
|
})),
|
|
358
|
-
menus: Object.keys(menus),
|
|
386
|
+
menus: Object.keys(menus.menus),
|
|
387
|
+
...hasLanguages(site.settings) && { languages: siteLanguages(site.settings).map((language) => ({
|
|
388
|
+
code: language.code,
|
|
389
|
+
name: language.name,
|
|
390
|
+
...language.main ? { main: true } : { pagesUnder: language.prefix }
|
|
391
|
+
})) },
|
|
359
392
|
blocks: Object.entries(site.description.blocks).map(([name, block]) => ({
|
|
360
393
|
name,
|
|
361
394
|
...block.label && { label: block.label },
|
|
@@ -431,7 +464,7 @@ function registerTools(server, context) {
|
|
|
431
464
|
content: built.content
|
|
432
465
|
};
|
|
433
466
|
const title = typeof props.title === "string" && props.title ? props.title : path;
|
|
434
|
-
await save(draft, [pageFileChange(path, data)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
467
|
+
await save(draft, [pageFileChange(path, data, existing?.content)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
435
468
|
return text({
|
|
436
469
|
draft: draft.name,
|
|
437
470
|
address: path,
|
|
@@ -498,8 +531,12 @@ function registerTools(server, context) {
|
|
|
498
531
|
collection: z.string().describe("The collection's id, from get_site."),
|
|
499
532
|
draft: draftArg
|
|
500
533
|
}, async ({ site: given, collection: id, draft }) => {
|
|
501
|
-
const
|
|
534
|
+
const source = await reading(await open(given), draft);
|
|
535
|
+
const collection = await readCollection(source, id);
|
|
502
536
|
const { settings } = collection;
|
|
537
|
+
const siteSettings = await readSettings(source);
|
|
538
|
+
const [languages, addresses] = await Promise.all([entryLanguages(source, collection, siteSettings), entryAddresses(source, collection, siteSettings)]);
|
|
539
|
+
const several = hasLanguages(siteSettings);
|
|
503
540
|
return text({
|
|
504
541
|
id,
|
|
505
542
|
name: settings.name,
|
|
@@ -509,7 +546,8 @@ function registerTools(server, context) {
|
|
|
509
546
|
fields: settings.fields,
|
|
510
547
|
items: collection.entries.map((entry) => ({
|
|
511
548
|
slug: entry.slug,
|
|
512
|
-
...
|
|
549
|
+
...addresses.has(entry.slug) && { address: addresses.get(entry.slug) },
|
|
550
|
+
...several && { language: languages.get(entry.slug) }
|
|
513
551
|
}))
|
|
514
552
|
});
|
|
515
553
|
}, true);
|
|
@@ -533,14 +571,19 @@ function registerTools(server, context) {
|
|
|
533
571
|
collection: z.string(),
|
|
534
572
|
slug: z.string().describe("The item's name in its address and file: lowercase letters, numbers and hyphens, such as open-day."),
|
|
535
573
|
values: z.record(z.string(), z.unknown()).describe("Field values, by the fields' names from get_collection."),
|
|
574
|
+
language: z.string().optional().describe("For a new item on a site in several languages, the code of the language it's in, from get_site. Leave it out for the site's main language. Names are the collection's whatever an item's language, so a translation has a name of its own."),
|
|
536
575
|
draft: draftArg
|
|
537
|
-
}, async ({ site: given, collection: id, slug, values, draft: name }) => {
|
|
576
|
+
}, async ({ site: given, collection: id, slug, values, language, draft: name }) => {
|
|
538
577
|
const draft = await changing(await open(given), name);
|
|
539
578
|
const collection = await readCollection(draft.store, id);
|
|
540
579
|
const existing = await readEntry(draft.store, collection, slug);
|
|
580
|
+
const siteSettings = await readSettings(draft.store);
|
|
581
|
+
if (language !== void 0 && existing && language !== (existing.content.inLanguage ?? mainLanguage(siteSettings))) throw new ToolError(`An item's language can't change. Save the translation as an item of its own.`);
|
|
582
|
+
if (language !== void 0 && language !== mainLanguage(siteSettings) && !isOtherLanguage(siteSettings, language)) throw new ToolError(`${language} isn't one of the site's languages. get_site lists them.`);
|
|
583
|
+
const inLanguage = language !== void 0 && language !== mainLanguage(siteSettings) ? language : void 0;
|
|
541
584
|
if (!existing) {
|
|
542
585
|
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
|
|
586
|
+
const path = entryPathIn(siteSettings, collection.settings, slug, inLanguage ?? mainLanguage(siteSettings));
|
|
544
587
|
if (path && (isReservedPagePath(path) || (await takenAddresses(draft.store)).has(path))) throw new ToolError(`${path} is already taken on the site. Choose another name.`);
|
|
545
588
|
}
|
|
546
589
|
const known = new Set(collection.settings.fields.map((field) => field.name));
|
|
@@ -557,7 +600,7 @@ function registerTools(server, context) {
|
|
|
557
600
|
const change = entryFileChange({
|
|
558
601
|
id,
|
|
559
602
|
settings: collection.settings
|
|
560
|
-
}, slug, fields);
|
|
603
|
+
}, slug, fields, existing?.content ?? (inLanguage ? { inLanguage } : void 0));
|
|
561
604
|
const title = typeof fields.title === "string" ? fields.title : slug;
|
|
562
605
|
await save(draft, [change], `${existing ? "Update" : "Add"} ${collection.settings.entryName.toLowerCase()} "${title}"`);
|
|
563
606
|
return text({
|
|
@@ -614,29 +657,56 @@ function registerTools(server, context) {
|
|
|
614
657
|
saved: "changed"
|
|
615
658
|
});
|
|
616
659
|
});
|
|
617
|
-
tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them.", {
|
|
660
|
+
tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them. On a site in several languages, give a language's code for its menus; a language without menus of its own shows the main language's.", {
|
|
618
661
|
site: siteArg,
|
|
662
|
+
language: languageArg,
|
|
619
663
|
draft: draftArg
|
|
620
|
-
}, async ({ site: given, draft }) => {
|
|
621
|
-
|
|
664
|
+
}, async ({ site: given, language, draft }) => {
|
|
665
|
+
const site = await open(given);
|
|
666
|
+
const { menus, languageMenus } = await readMenus(await reading(site, draft));
|
|
667
|
+
if (language === void 0 || language === mainLanguage(site.settings)) return text(menus);
|
|
668
|
+
const own = languageMenus[language];
|
|
669
|
+
return text(own ? own : {
|
|
670
|
+
sameAsMainLanguage: true,
|
|
671
|
+
menus
|
|
672
|
+
});
|
|
622
673
|
}, true);
|
|
623
674
|
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
675
|
site: siteArg,
|
|
625
676
|
menu: z.string().describe("The menu's name, such as main or footer."),
|
|
626
677
|
items: z.array(z.unknown()),
|
|
678
|
+
language: languageArg,
|
|
627
679
|
draft: draftArg
|
|
628
|
-
}, async ({ site: given, menu, items, draft: name }) => {
|
|
680
|
+
}, async ({ site: given, menu, items, language, draft: name }) => {
|
|
629
681
|
const draft = await changing(await open(given), name);
|
|
630
|
-
const menus = await readMenus(draft.store);
|
|
631
|
-
const
|
|
682
|
+
const { menus, languageMenus } = await readMenus(draft.store);
|
|
683
|
+
const settings = await readSettings(draft.store);
|
|
684
|
+
const other = language !== void 0 && language !== mainLanguage(settings);
|
|
685
|
+
if (other && !isOtherLanguage(settings, language)) throw new ToolError(`${language} isn't one of the site's languages. get_site lists them.`);
|
|
686
|
+
const file = other ? {
|
|
687
|
+
version: 1,
|
|
688
|
+
menus,
|
|
689
|
+
languages: {
|
|
690
|
+
...languageMenus,
|
|
691
|
+
[language]: {
|
|
692
|
+
...languageMenus[language] ?? menus,
|
|
693
|
+
[menu]: items
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} : {
|
|
632
697
|
version: 1,
|
|
633
698
|
menus: {
|
|
634
699
|
...menus,
|
|
635
700
|
[menu]: items
|
|
636
|
-
}
|
|
637
|
-
|
|
701
|
+
},
|
|
702
|
+
languages: languageMenus
|
|
703
|
+
};
|
|
704
|
+
const result = menusFileSchema.safeParse(file);
|
|
638
705
|
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(
|
|
706
|
+
await save(draft, [menusFileChange({
|
|
707
|
+
menus: result.data.menus,
|
|
708
|
+
languageMenus: result.data.languages
|
|
709
|
+
})], `Update menu "${menu}"`);
|
|
640
710
|
return text({
|
|
641
711
|
draft: draft.name,
|
|
642
712
|
menu,
|
|
@@ -791,10 +861,10 @@ img[src$=".svg" i] { width: 100%; }
|
|
|
791
861
|
*/
|
|
792
862
|
/** Splits a site's id into its host and repository, or `undefined` if it isn't one. */
|
|
793
863
|
function parseSiteId(id) {
|
|
794
|
-
const match = /^(github|gitlab):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
|
|
864
|
+
const match = /^(github|gitlab|bitbucket):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
|
|
795
865
|
if (!match) return void 0;
|
|
796
866
|
const [, host, repo] = match;
|
|
797
|
-
if (host
|
|
867
|
+
if (host !== "gitlab" && repo?.split("/").length !== 2) return void 0;
|
|
798
868
|
return {
|
|
799
869
|
host,
|
|
800
870
|
repo: repo ?? ""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { COLLECTIONS_DIR, COLLECTION_SETTINGS_FILE, FOOTER_FILE, HEADER_FILE, MEDIA_DIR, MEDIA_FILE, MENUS_FILE, PAGES_DIR, SITE_FILE, collectionFileSchema,
|
|
1
|
+
import { COLLECTIONS_DIR, COLLECTION_SETTINGS_FILE, FOOTER_FILE, HEADER_FILE, MEDIA_DIR, MEDIA_FILE, MENUS_FILE, PAGES_DIR, SITE_FILE, collectionFileSchema, entryPathIn, hasLanguages, isAddressSegment, layoutFileSchema, mainLanguage, mediaFileSchema, menusFileSchema, normalizePagePath, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, readTextFiles, siteSettingsSchema } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/content.ts
|
|
3
3
|
/**
|
|
4
4
|
* Reading a site's content a file at a time. Tools read only the files they
|
|
@@ -18,9 +18,18 @@ async function readSettings(source) {
|
|
|
18
18
|
const text = await source.read(SITE_FILE);
|
|
19
19
|
return text === void 0 ? siteSettingsSchema.parse({ version: 1 }) : parsed(parseContentFile("site", siteSettingsSchema, SITE_FILE, text));
|
|
20
20
|
}
|
|
21
|
+
/** The site's menus, and the ones of its other languages that have menus of their own. */
|
|
21
22
|
async function readMenus(source) {
|
|
22
23
|
const text = await source.read(MENUS_FILE);
|
|
23
|
-
|
|
24
|
+
if (text === void 0) return {
|
|
25
|
+
menus: {},
|
|
26
|
+
languageMenus: {}
|
|
27
|
+
};
|
|
28
|
+
const file = parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text));
|
|
29
|
+
return {
|
|
30
|
+
menus: file.menus,
|
|
31
|
+
languageMenus: file.languages ?? {}
|
|
32
|
+
};
|
|
24
33
|
}
|
|
25
34
|
async function readLayout(source, part) {
|
|
26
35
|
const file = part === "header" ? HEADER_FILE : FOOTER_FILE;
|
|
@@ -103,17 +112,46 @@ async function readEntry(source, collection, slug) {
|
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
/**
|
|
115
|
+
* Each item's language, by its name. Only a site with other languages has
|
|
116
|
+
* items in them, so only then are the items' files read, all in one go where
|
|
117
|
+
* the source can.
|
|
118
|
+
*/
|
|
119
|
+
async function entryLanguages(source, collection, site) {
|
|
120
|
+
const main = mainLanguage(site);
|
|
121
|
+
if (!hasLanguages(site)) return new Map(collection.entries.map((entry) => [entry.slug, main]));
|
|
122
|
+
const texts = await readTextFiles(source, collection.entries.map((entry) => entry.file));
|
|
123
|
+
return new Map(collection.entries.map((entry) => {
|
|
124
|
+
const text = texts.get(entry.file);
|
|
125
|
+
const file = text === void 0 ? void 0 : parseEntryFile(collection.settings, entry.file, text);
|
|
126
|
+
return [entry.slug, file?.ok && file.value.inLanguage || main];
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
/** Each item's address, by its name, in its language, where its collection gives items pages. */
|
|
130
|
+
async function entryAddresses(source, collection, site) {
|
|
131
|
+
if (collection.settings.path === void 0) return /* @__PURE__ */ new Map();
|
|
132
|
+
const languages = await entryLanguages(source, collection, site);
|
|
133
|
+
return new Map(collection.entries.flatMap((entry) => {
|
|
134
|
+
const path = entryPathIn(site, collection.settings, entry.slug, languages.get(entry.slug) ?? mainLanguage(site));
|
|
135
|
+
return path === void 0 ? [] : [[entry.slug, path]];
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
106
139
|
* Every address the site's pages and entries have, so a new page or entry
|
|
107
|
-
* never takes one. Entries' addresses come from their collections' settings
|
|
140
|
+
* never takes one. Entries' addresses come from their collections' settings,
|
|
141
|
+
* and in a site with other languages, their own languages.
|
|
108
142
|
*/
|
|
109
143
|
async function takenAddresses(source) {
|
|
110
144
|
const addresses = new Set((await listPages(source)).map((page) => page.address));
|
|
145
|
+
const site = await readSettings(source);
|
|
111
146
|
for (const collection of await listCollections(source)) {
|
|
112
147
|
const text = await source.read(collection.file);
|
|
113
148
|
if (text === void 0) continue;
|
|
114
149
|
const settings = parseContentFile("collection", collectionFileSchema, collection.file, text);
|
|
115
150
|
if (!settings.ok || settings.value.path === void 0) continue;
|
|
116
|
-
for (const
|
|
151
|
+
for (const address of (await entryAddresses(source, {
|
|
152
|
+
...collection,
|
|
153
|
+
settings: settings.value
|
|
154
|
+
}, site)).values()) addresses.add(address);
|
|
117
155
|
}
|
|
118
156
|
return addresses;
|
|
119
157
|
}
|
|
@@ -237,4 +275,4 @@ function localScreenshots(options = {}) {
|
|
|
237
275
|
} };
|
|
238
276
|
}
|
|
239
277
|
//#endregion
|
|
240
|
-
export {
|
|
278
|
+
export { readSettings as _, entryLanguages as a, listMedia as c, pageAddress as d, readCollection as f, readPage as g, readMenus as h, entryAddresses as i, listPages as l, readLayout as m, ToolError as n, isMediaAddress as o, readEntry as p, contentFiles as r, listCollections as s, localScreenshots as t, mediaImage as u, takenAddresses as v };
|
package/dist/screenshots.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as localScreenshots } from "./screenshots
|
|
1
|
+
import { t as localScreenshots } from "./screenshots-yES3bUkH.js";
|
|
2
2
|
export { localScreenshots };
|
package/dist/stdio.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
2
|
+
import { _ as readSettings, a as entryLanguages, c as listMedia, d as pageAddress, f as readCollection, g as readPage, h as readMenus, i as entryAddresses, l as listPages, m as readLayout, n as ToolError, o as isMediaAddress, p as readEntry, r as contentFiles, s as listCollections, t as localScreenshots, u as mediaImage, v as takenAddresses } from "./screenshots-yES3bUkH.js";
|
|
3
|
+
import { bitbucket } from "@goodfellow-cms/bitbucket";
|
|
3
4
|
import { github } from "@goodfellow-cms/github";
|
|
4
5
|
import { gitlab } from "@goodfellow-cms/gitlab";
|
|
5
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import { ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, SITE_DESCRIPTION_PATH, SignInError,
|
|
7
|
+
import { ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, SITE_DESCRIPTION_PATH, SignInError, describedConfig, entryFieldProblems, entryFileChange, entryPathIn, hasLanguages, isAddressSegment, isEmptyValue, isOtherLanguage, isReservedPagePath, layoutFileChange, mainLanguage, menusFileChange, menusFileSchema, newDraftName, pageFileChange, pageFileToPath, parseSiteDescription, siteLanguages, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
|
|
7
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
9
|
import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
|
|
9
10
|
import { z } from "zod";
|
|
@@ -13,17 +14,6 @@ import { z } from "zod";
|
|
|
13
14
|
* description its build published (`goodfellow-site.json`), which says what
|
|
14
15
|
* blocks it has, since the connector doesn't have the site's code.
|
|
15
16
|
*/
|
|
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
17
|
const VERSION_PROBLEMS = {
|
|
28
18
|
"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
19
|
"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.`
|
|
@@ -45,8 +35,8 @@ async function openSite(context, id) {
|
|
|
45
35
|
settings,
|
|
46
36
|
url,
|
|
47
37
|
description,
|
|
48
|
-
pageConfig:
|
|
49
|
-
templateConfig:
|
|
38
|
+
pageConfig: describedConfig(description, "page"),
|
|
39
|
+
templateConfig: describedConfig(description, "template"),
|
|
50
40
|
...problem && { versionProblem: VERSION_PROBLEMS[problem](description.goodfellow) }
|
|
51
41
|
};
|
|
52
42
|
}
|
|
@@ -71,6 +61,7 @@ const INSTRUCTIONS = `These tools edit websites built with Goodfellow, for peopl
|
|
|
71
61
|
/** Why `publish` refuses, on sites whose config says `claude: { publish: false }`. */
|
|
72
62
|
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
63
|
const siteArg = z.string().optional().describe("Which site, from list_sites. Leave it out when there's only one.");
|
|
64
|
+
const languageArg = z.string().optional().describe("On a site in several languages, the code of a language from get_site. Leave it out for the main language.");
|
|
74
65
|
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
66
|
const flatBlock = z.object({
|
|
76
67
|
type: z.string().describe("The block's name, from get_blocks."),
|
|
@@ -205,7 +196,12 @@ function registerTools(server, context) {
|
|
|
205
196
|
id: collection.id,
|
|
206
197
|
entries: collection.entries.length
|
|
207
198
|
})),
|
|
208
|
-
menus: Object.keys(menus),
|
|
199
|
+
menus: Object.keys(menus.menus),
|
|
200
|
+
...hasLanguages(site.settings) && { languages: siteLanguages(site.settings).map((language) => ({
|
|
201
|
+
code: language.code,
|
|
202
|
+
name: language.name,
|
|
203
|
+
...language.main ? { main: true } : { pagesUnder: language.prefix }
|
|
204
|
+
})) },
|
|
209
205
|
blocks: Object.entries(site.description.blocks).map(([name, block]) => ({
|
|
210
206
|
name,
|
|
211
207
|
...block.label && { label: block.label },
|
|
@@ -281,7 +277,7 @@ function registerTools(server, context) {
|
|
|
281
277
|
content: built.content
|
|
282
278
|
};
|
|
283
279
|
const title = typeof props.title === "string" && props.title ? props.title : path;
|
|
284
|
-
await save(draft, [pageFileChange(path, data)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
280
|
+
await save(draft, [pageFileChange(path, data, existing?.content)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
285
281
|
return text({
|
|
286
282
|
draft: draft.name,
|
|
287
283
|
address: path,
|
|
@@ -348,9 +344,12 @@ function registerTools(server, context) {
|
|
|
348
344
|
collection: z.string().describe("The collection's id, from get_site."),
|
|
349
345
|
draft: draftArg
|
|
350
346
|
}, async ({ site: given, collection: id, draft }) => {
|
|
351
|
-
const
|
|
352
|
-
const collection = await readCollection(
|
|
347
|
+
const source = await reading(await open(given), draft);
|
|
348
|
+
const collection = await readCollection(source, id);
|
|
353
349
|
const { settings } = collection;
|
|
350
|
+
const siteSettings = await readSettings(source);
|
|
351
|
+
const [languages, addresses] = await Promise.all([entryLanguages(source, collection, siteSettings), entryAddresses(source, collection, siteSettings)]);
|
|
352
|
+
const several = hasLanguages(siteSettings);
|
|
354
353
|
return text({
|
|
355
354
|
id,
|
|
356
355
|
name: settings.name,
|
|
@@ -360,7 +359,8 @@ function registerTools(server, context) {
|
|
|
360
359
|
fields: settings.fields,
|
|
361
360
|
items: collection.entries.map((entry) => ({
|
|
362
361
|
slug: entry.slug,
|
|
363
|
-
...
|
|
362
|
+
...addresses.has(entry.slug) && { address: addresses.get(entry.slug) },
|
|
363
|
+
...several && { language: languages.get(entry.slug) }
|
|
364
364
|
}))
|
|
365
365
|
});
|
|
366
366
|
}, true);
|
|
@@ -385,14 +385,19 @@ function registerTools(server, context) {
|
|
|
385
385
|
collection: z.string(),
|
|
386
386
|
slug: z.string().describe("The item's name in its address and file: lowercase letters, numbers and hyphens, such as open-day."),
|
|
387
387
|
values: z.record(z.string(), z.unknown()).describe("Field values, by the fields' names from get_collection."),
|
|
388
|
+
language: z.string().optional().describe("For a new item on a site in several languages, the code of the language it's in, from get_site. Leave it out for the site's main language. Names are the collection's whatever an item's language, so a translation has a name of its own."),
|
|
388
389
|
draft: draftArg
|
|
389
|
-
}, async ({ site: given, collection: id, slug, values, draft: name }) => {
|
|
390
|
+
}, async ({ site: given, collection: id, slug, values, language, draft: name }) => {
|
|
390
391
|
const draft = await changing(await open(given), name);
|
|
391
392
|
const collection = await readCollection(draft.store, id);
|
|
392
393
|
const existing = await readEntry(draft.store, collection, slug);
|
|
394
|
+
const siteSettings = await readSettings(draft.store);
|
|
395
|
+
if (language !== void 0 && existing && language !== (existing.content.inLanguage ?? mainLanguage(siteSettings))) throw new ToolError(`An item's language can't change. Save the translation as an item of its own.`);
|
|
396
|
+
if (language !== void 0 && language !== mainLanguage(siteSettings) && !isOtherLanguage(siteSettings, language)) throw new ToolError(`${language} isn't one of the site's languages. get_site lists them.`);
|
|
397
|
+
const inLanguage = language !== void 0 && language !== mainLanguage(siteSettings) ? language : void 0;
|
|
393
398
|
if (!existing) {
|
|
394
399
|
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
|
|
400
|
+
const path = entryPathIn(siteSettings, collection.settings, slug, inLanguage ?? mainLanguage(siteSettings));
|
|
396
401
|
if (path && (isReservedPagePath(path) || (await takenAddresses(draft.store)).has(path))) throw new ToolError(`${path} is already taken on the site. Choose another name.`);
|
|
397
402
|
}
|
|
398
403
|
const known = new Set(collection.settings.fields.map((field) => field.name));
|
|
@@ -409,7 +414,7 @@ function registerTools(server, context) {
|
|
|
409
414
|
const change = entryFileChange({
|
|
410
415
|
id,
|
|
411
416
|
settings: collection.settings
|
|
412
|
-
}, slug, fields);
|
|
417
|
+
}, slug, fields, existing?.content ?? (inLanguage ? { inLanguage } : void 0));
|
|
413
418
|
const title = typeof fields.title === "string" ? fields.title : slug;
|
|
414
419
|
await save(draft, [change], `${existing ? "Update" : "Add"} ${collection.settings.entryName.toLowerCase()} "${title}"`);
|
|
415
420
|
return text({
|
|
@@ -467,30 +472,56 @@ function registerTools(server, context) {
|
|
|
467
472
|
saved: "changed"
|
|
468
473
|
});
|
|
469
474
|
});
|
|
470
|
-
tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them.", {
|
|
475
|
+
tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them. On a site in several languages, give a language's code for its menus; a language without menus of its own shows the main language's.", {
|
|
471
476
|
site: siteArg,
|
|
477
|
+
language: languageArg,
|
|
472
478
|
draft: draftArg
|
|
473
|
-
}, async ({ site: given, draft }) => {
|
|
479
|
+
}, async ({ site: given, language, draft }) => {
|
|
474
480
|
const site = await open(given);
|
|
475
|
-
|
|
481
|
+
const { menus, languageMenus } = await readMenus(await reading(site, draft));
|
|
482
|
+
if (language === void 0 || language === mainLanguage(site.settings)) return text(menus);
|
|
483
|
+
const own = languageMenus[language];
|
|
484
|
+
return text(own ? own : {
|
|
485
|
+
sameAsMainLanguage: true,
|
|
486
|
+
menus
|
|
487
|
+
});
|
|
476
488
|
}, true);
|
|
477
489
|
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
490
|
site: siteArg,
|
|
479
491
|
menu: z.string().describe("The menu's name, such as main or footer."),
|
|
480
492
|
items: z.array(z.unknown()),
|
|
493
|
+
language: languageArg,
|
|
481
494
|
draft: draftArg
|
|
482
|
-
}, async ({ site: given, menu, items, draft: name }) => {
|
|
495
|
+
}, async ({ site: given, menu, items, language, draft: name }) => {
|
|
483
496
|
const draft = await changing(await open(given), name);
|
|
484
|
-
const menus = await readMenus(draft.store);
|
|
485
|
-
const
|
|
497
|
+
const { menus, languageMenus } = await readMenus(draft.store);
|
|
498
|
+
const settings = await readSettings(draft.store);
|
|
499
|
+
const other = language !== void 0 && language !== mainLanguage(settings);
|
|
500
|
+
if (other && !isOtherLanguage(settings, language)) throw new ToolError(`${language} isn't one of the site's languages. get_site lists them.`);
|
|
501
|
+
const file = other ? {
|
|
502
|
+
version: 1,
|
|
503
|
+
menus,
|
|
504
|
+
languages: {
|
|
505
|
+
...languageMenus,
|
|
506
|
+
[language]: {
|
|
507
|
+
...languageMenus[language] ?? menus,
|
|
508
|
+
[menu]: items
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} : {
|
|
486
512
|
version: 1,
|
|
487
513
|
menus: {
|
|
488
514
|
...menus,
|
|
489
515
|
[menu]: items
|
|
490
|
-
}
|
|
491
|
-
|
|
516
|
+
},
|
|
517
|
+
languages: languageMenus
|
|
518
|
+
};
|
|
519
|
+
const result = menusFileSchema.safeParse(file);
|
|
492
520
|
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(
|
|
521
|
+
await save(draft, [menusFileChange({
|
|
522
|
+
menus: result.data.menus,
|
|
523
|
+
languageMenus: result.data.languages
|
|
524
|
+
})], `Update menu "${menu}"`);
|
|
494
525
|
return text({
|
|
495
526
|
draft: draft.name,
|
|
496
527
|
menu,
|
|
@@ -621,10 +652,10 @@ function registerTools(server, context) {
|
|
|
621
652
|
*/
|
|
622
653
|
/** Splits a site's id into its host and repository, or `undefined` if it isn't one. */
|
|
623
654
|
function parseSiteId(id) {
|
|
624
|
-
const match = /^(github|gitlab):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
|
|
655
|
+
const match = /^(github|gitlab|bitbucket):([\w.-]+(?:\/[\w.-]+)+)$/.exec(id.trim());
|
|
625
656
|
if (!match) return void 0;
|
|
626
657
|
const [, host, repo] = match;
|
|
627
|
-
if (host
|
|
658
|
+
if (host !== "gitlab" && repo?.split("/").length !== 2) return void 0;
|
|
628
659
|
return {
|
|
629
660
|
host,
|
|
630
661
|
repo: repo ?? ""
|
|
@@ -656,10 +687,10 @@ function fail(message) {
|
|
|
656
687
|
}
|
|
657
688
|
const siteId = process.env.GOODFELLOW_SITE?.trim() || process.argv[2];
|
|
658
689
|
const token = process.env.GOODFELLOW_TOKEN?.trim();
|
|
659
|
-
if (!siteId) fail("Set GOODFELLOW_SITE to the site's repository, such as github:owner/site
|
|
690
|
+
if (!siteId) fail("Set GOODFELLOW_SITE to the site's repository, such as github:owner/site, gitlab:group/site or bitbucket:workspace/site.");
|
|
660
691
|
const site = parseSiteId(siteId);
|
|
661
|
-
if (!site) fail(`${siteId} isn't a site. Write it as github:owner/site
|
|
662
|
-
if (!token) fail("Set GOODFELLOW_TOKEN to a GitHub or
|
|
692
|
+
if (!site) fail(`${siteId} isn't a site. Write it as github:owner/site, gitlab:group/site or bitbucket:workspace/site.`);
|
|
693
|
+
if (!token) fail("Set GOODFELLOW_TOKEN to a GitHub, GitLab or Bitbucket token that can edit the site.");
|
|
663
694
|
const storage = {
|
|
664
695
|
local: memoryStorage(),
|
|
665
696
|
session: memoryStorage()
|
|
@@ -668,6 +699,9 @@ const host = site.host === "github" ? github({
|
|
|
668
699
|
repo: site.repo,
|
|
669
700
|
signIn: false,
|
|
670
701
|
storage
|
|
702
|
+
}) : site.host === "bitbucket" ? bitbucket({
|
|
703
|
+
repo: site.repo,
|
|
704
|
+
storage
|
|
671
705
|
}) : gitlab({
|
|
672
706
|
project: site.repo,
|
|
673
707
|
...process.env.GITLAB_URL && { url: process.env.GITLAB_URL },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Work on a Goodfellow site from Claude: read and change its pages, entries and settings, see them in screenshots, and publish them or submit them for review.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -37,25 +37,26 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@
|
|
41
|
-
"
|
|
42
|
-
"@goodfellow-cms/
|
|
43
|
-
"@goodfellow-cms/
|
|
44
|
-
"@goodfellow-cms/
|
|
45
|
-
"@
|
|
40
|
+
"@goodfellow-cms/ai": "0.6.0",
|
|
41
|
+
"@goodfellow-cms/core": "0.6.0",
|
|
42
|
+
"@goodfellow-cms/bitbucket": "0.6.0",
|
|
43
|
+
"@goodfellow-cms/github": "0.6.0",
|
|
44
|
+
"@goodfellow-cms/gitlab": "0.6.0",
|
|
45
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
46
|
+
"zod": "4.6.6"
|
|
46
47
|
},
|
|
47
48
|
"optionalDependencies": {
|
|
48
49
|
"playwright-core": "1.63.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
52
|
+
"@goodfellow-cms/blocks": "0.6.0",
|
|
53
|
+
"@goodfellow-cms/react": "0.6.0",
|
|
51
54
|
"@puckeditor/core": "0.23.0",
|
|
52
55
|
"@types/node": "^22.19.0",
|
|
53
|
-
"fflate": "0.8.3"
|
|
54
|
-
"@goodfellow-cms/blocks": "0.4.0",
|
|
55
|
-
"@goodfellow-cms/react": "0.4.0"
|
|
56
|
+
"fflate": "0.8.3"
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
58
|
-
"node": ">=
|
|
59
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
59
60
|
},
|
|
60
61
|
"publishConfig": {
|
|
61
62
|
"access": "public"
|