@goodfellow-cms/mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +103 -33
- package/dist/{screenshots--uulXZYY.js → screenshots-yES3bUkH.js} +43 -5
- package/dist/screenshots.js +1 -1
- package/dist/stdio.js +62 -32
- package/package.json +11 -11
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,
|
|
@@ -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,9 @@
|
|
|
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
3
|
import { github } from "@goodfellow-cms/github";
|
|
4
4
|
import { gitlab } from "@goodfellow-cms/gitlab";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import { ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, SITE_DESCRIPTION_PATH, SignInError,
|
|
6
|
+
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
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
|
|
9
9
|
import { z } from "zod";
|
|
@@ -13,17 +13,6 @@ import { z } from "zod";
|
|
|
13
13
|
* description its build published (`goodfellow-site.json`), which says what
|
|
14
14
|
* blocks it has, since the connector doesn't have the site's code.
|
|
15
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
16
|
const VERSION_PROBLEMS = {
|
|
28
17
|
"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
18
|
"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 +34,8 @@ async function openSite(context, id) {
|
|
|
45
34
|
settings,
|
|
46
35
|
url,
|
|
47
36
|
description,
|
|
48
|
-
pageConfig:
|
|
49
|
-
templateConfig:
|
|
37
|
+
pageConfig: describedConfig(description, "page"),
|
|
38
|
+
templateConfig: describedConfig(description, "template"),
|
|
50
39
|
...problem && { versionProblem: VERSION_PROBLEMS[problem](description.goodfellow) }
|
|
51
40
|
};
|
|
52
41
|
}
|
|
@@ -71,6 +60,7 @@ const INSTRUCTIONS = `These tools edit websites built with Goodfellow, for peopl
|
|
|
71
60
|
/** Why `publish` refuses, on sites whose config says `claude: { publish: false }`. */
|
|
72
61
|
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
62
|
const siteArg = z.string().optional().describe("Which site, from list_sites. Leave it out when there's only one.");
|
|
63
|
+
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
64
|
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
65
|
const flatBlock = z.object({
|
|
76
66
|
type: z.string().describe("The block's name, from get_blocks."),
|
|
@@ -205,7 +195,12 @@ function registerTools(server, context) {
|
|
|
205
195
|
id: collection.id,
|
|
206
196
|
entries: collection.entries.length
|
|
207
197
|
})),
|
|
208
|
-
menus: Object.keys(menus),
|
|
198
|
+
menus: Object.keys(menus.menus),
|
|
199
|
+
...hasLanguages(site.settings) && { languages: siteLanguages(site.settings).map((language) => ({
|
|
200
|
+
code: language.code,
|
|
201
|
+
name: language.name,
|
|
202
|
+
...language.main ? { main: true } : { pagesUnder: language.prefix }
|
|
203
|
+
})) },
|
|
209
204
|
blocks: Object.entries(site.description.blocks).map(([name, block]) => ({
|
|
210
205
|
name,
|
|
211
206
|
...block.label && { label: block.label },
|
|
@@ -281,7 +276,7 @@ function registerTools(server, context) {
|
|
|
281
276
|
content: built.content
|
|
282
277
|
};
|
|
283
278
|
const title = typeof props.title === "string" && props.title ? props.title : path;
|
|
284
|
-
await save(draft, [pageFileChange(path, data)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
279
|
+
await save(draft, [pageFileChange(path, data, existing?.content)], `${existing ? "Update" : "Add"} page "${title}"`);
|
|
285
280
|
return text({
|
|
286
281
|
draft: draft.name,
|
|
287
282
|
address: path,
|
|
@@ -348,9 +343,12 @@ function registerTools(server, context) {
|
|
|
348
343
|
collection: z.string().describe("The collection's id, from get_site."),
|
|
349
344
|
draft: draftArg
|
|
350
345
|
}, async ({ site: given, collection: id, draft }) => {
|
|
351
|
-
const
|
|
352
|
-
const collection = await readCollection(
|
|
346
|
+
const source = await reading(await open(given), draft);
|
|
347
|
+
const collection = await readCollection(source, id);
|
|
353
348
|
const { settings } = collection;
|
|
349
|
+
const siteSettings = await readSettings(source);
|
|
350
|
+
const [languages, addresses] = await Promise.all([entryLanguages(source, collection, siteSettings), entryAddresses(source, collection, siteSettings)]);
|
|
351
|
+
const several = hasLanguages(siteSettings);
|
|
354
352
|
return text({
|
|
355
353
|
id,
|
|
356
354
|
name: settings.name,
|
|
@@ -360,7 +358,8 @@ function registerTools(server, context) {
|
|
|
360
358
|
fields: settings.fields,
|
|
361
359
|
items: collection.entries.map((entry) => ({
|
|
362
360
|
slug: entry.slug,
|
|
363
|
-
...
|
|
361
|
+
...addresses.has(entry.slug) && { address: addresses.get(entry.slug) },
|
|
362
|
+
...several && { language: languages.get(entry.slug) }
|
|
364
363
|
}))
|
|
365
364
|
});
|
|
366
365
|
}, true);
|
|
@@ -385,14 +384,19 @@ function registerTools(server, context) {
|
|
|
385
384
|
collection: z.string(),
|
|
386
385
|
slug: z.string().describe("The item's name in its address and file: lowercase letters, numbers and hyphens, such as open-day."),
|
|
387
386
|
values: z.record(z.string(), z.unknown()).describe("Field values, by the fields' names from get_collection."),
|
|
387
|
+
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
388
|
draft: draftArg
|
|
389
|
-
}, async ({ site: given, collection: id, slug, values, draft: name }) => {
|
|
389
|
+
}, async ({ site: given, collection: id, slug, values, language, draft: name }) => {
|
|
390
390
|
const draft = await changing(await open(given), name);
|
|
391
391
|
const collection = await readCollection(draft.store, id);
|
|
392
392
|
const existing = await readEntry(draft.store, collection, slug);
|
|
393
|
+
const siteSettings = await readSettings(draft.store);
|
|
394
|
+
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.`);
|
|
395
|
+
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.`);
|
|
396
|
+
const inLanguage = language !== void 0 && language !== mainLanguage(siteSettings) ? language : void 0;
|
|
393
397
|
if (!existing) {
|
|
394
398
|
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
|
|
399
|
+
const path = entryPathIn(siteSettings, collection.settings, slug, inLanguage ?? mainLanguage(siteSettings));
|
|
396
400
|
if (path && (isReservedPagePath(path) || (await takenAddresses(draft.store)).has(path))) throw new ToolError(`${path} is already taken on the site. Choose another name.`);
|
|
397
401
|
}
|
|
398
402
|
const known = new Set(collection.settings.fields.map((field) => field.name));
|
|
@@ -409,7 +413,7 @@ function registerTools(server, context) {
|
|
|
409
413
|
const change = entryFileChange({
|
|
410
414
|
id,
|
|
411
415
|
settings: collection.settings
|
|
412
|
-
}, slug, fields);
|
|
416
|
+
}, slug, fields, existing?.content ?? (inLanguage ? { inLanguage } : void 0));
|
|
413
417
|
const title = typeof fields.title === "string" ? fields.title : slug;
|
|
414
418
|
await save(draft, [change], `${existing ? "Update" : "Add"} ${collection.settings.entryName.toLowerCase()} "${title}"`);
|
|
415
419
|
return text({
|
|
@@ -467,30 +471,56 @@ function registerTools(server, context) {
|
|
|
467
471
|
saved: "changed"
|
|
468
472
|
});
|
|
469
473
|
});
|
|
470
|
-
tool("get_menus", "The site's menus, by name: each a list of links, which can have links of their own under them.", {
|
|
474
|
+
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
475
|
site: siteArg,
|
|
476
|
+
language: languageArg,
|
|
472
477
|
draft: draftArg
|
|
473
|
-
}, async ({ site: given, draft }) => {
|
|
478
|
+
}, async ({ site: given, language, draft }) => {
|
|
474
479
|
const site = await open(given);
|
|
475
|
-
|
|
480
|
+
const { menus, languageMenus } = await readMenus(await reading(site, draft));
|
|
481
|
+
if (language === void 0 || language === mainLanguage(site.settings)) return text(menus);
|
|
482
|
+
const own = languageMenus[language];
|
|
483
|
+
return text(own ? own : {
|
|
484
|
+
sameAsMainLanguage: true,
|
|
485
|
+
menus
|
|
486
|
+
});
|
|
476
487
|
}, true);
|
|
477
488
|
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
489
|
site: siteArg,
|
|
479
490
|
menu: z.string().describe("The menu's name, such as main or footer."),
|
|
480
491
|
items: z.array(z.unknown()),
|
|
492
|
+
language: languageArg,
|
|
481
493
|
draft: draftArg
|
|
482
|
-
}, async ({ site: given, menu, items, draft: name }) => {
|
|
494
|
+
}, async ({ site: given, menu, items, language, draft: name }) => {
|
|
483
495
|
const draft = await changing(await open(given), name);
|
|
484
|
-
const menus = await readMenus(draft.store);
|
|
485
|
-
const
|
|
496
|
+
const { menus, languageMenus } = await readMenus(draft.store);
|
|
497
|
+
const settings = await readSettings(draft.store);
|
|
498
|
+
const other = language !== void 0 && language !== mainLanguage(settings);
|
|
499
|
+
if (other && !isOtherLanguage(settings, language)) throw new ToolError(`${language} isn't one of the site's languages. get_site lists them.`);
|
|
500
|
+
const file = other ? {
|
|
501
|
+
version: 1,
|
|
502
|
+
menus,
|
|
503
|
+
languages: {
|
|
504
|
+
...languageMenus,
|
|
505
|
+
[language]: {
|
|
506
|
+
...languageMenus[language] ?? menus,
|
|
507
|
+
[menu]: items
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} : {
|
|
486
511
|
version: 1,
|
|
487
512
|
menus: {
|
|
488
513
|
...menus,
|
|
489
514
|
[menu]: items
|
|
490
|
-
}
|
|
491
|
-
|
|
515
|
+
},
|
|
516
|
+
languages: languageMenus
|
|
517
|
+
};
|
|
518
|
+
const result = menusFileSchema.safeParse(file);
|
|
492
519
|
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(
|
|
520
|
+
await save(draft, [menusFileChange({
|
|
521
|
+
menus: result.data.menus,
|
|
522
|
+
languageMenus: result.data.languages
|
|
523
|
+
})], `Update menu "${menu}"`);
|
|
494
524
|
return text({
|
|
495
525
|
draft: draft.name,
|
|
496
526
|
menu,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,25 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@
|
|
41
|
-
"
|
|
42
|
-
"@goodfellow-cms/
|
|
43
|
-
"@goodfellow-cms/
|
|
44
|
-
"@
|
|
45
|
-
"
|
|
40
|
+
"@goodfellow-cms/ai": "0.5.0",
|
|
41
|
+
"@goodfellow-cms/core": "0.5.0",
|
|
42
|
+
"@goodfellow-cms/github": "0.5.0",
|
|
43
|
+
"@goodfellow-cms/gitlab": "0.5.0",
|
|
44
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
45
|
+
"zod": "4.6.6"
|
|
46
46
|
},
|
|
47
47
|
"optionalDependencies": {
|
|
48
48
|
"playwright-core": "1.63.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@goodfellow-cms/blocks": "0.5.0",
|
|
52
|
+
"@goodfellow-cms/react": "0.5.0",
|
|
51
53
|
"@puckeditor/core": "0.23.0",
|
|
52
54
|
"@types/node": "^22.19.0",
|
|
53
|
-
"fflate": "0.8.3"
|
|
54
|
-
"@goodfellow-cms/blocks": "0.4.0",
|
|
55
|
-
"@goodfellow-cms/react": "0.4.0"
|
|
55
|
+
"fflate": "0.8.3"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
|
-
"node": ">=
|
|
58
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
59
59
|
},
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public"
|