@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 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, entryAddress, entryFieldProblems, entryFileChange, isAddressSegment, isEmptyValue, isReservedPagePath, layoutFileChange, layoutFileSchema, mediaFileSchema, menusFileChange, menusFileSchema, newDraftName, normalizePagePath, pageFileChange, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, parseSiteDescription, readTextFiles, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
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
- return text === void 0 ? {} : parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text)).menus;
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 entry of collection.entries) addresses.add(entryAddress(settings.value.path, entry.slug));
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: configFrom(description, false),
199
- templateConfig: configFrom(description, true),
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 collection = await readCollection(await reading(await open(given), draft), id);
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
- ...settings.path !== void 0 && { address: entryAddress(settings.path, entry.slug) }
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.path === void 0 ? void 0 : entryAddress(collection.settings.path, slug);
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
- return text(await readMenus(await reading(await open(given), draft)));
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 result = menusFileSchema.safeParse({
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(result.data.menus)], `Update menu "${menu}"`);
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, entryAddress, isAddressSegment, layoutFileSchema, mediaFileSchema, menusFileSchema, normalizePagePath, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, readTextFiles, siteSettingsSchema } from "@goodfellow-cms/core";
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
- return text === void 0 ? {} : parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text)).menus;
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 entry of collection.entries) addresses.add(entryAddress(settings.value.path, entry.slug));
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 { listCollections as a, mediaImage as c, readEntry as d, readLayout as f, takenAddresses as g, readSettings as h, isMediaAddress as i, pageAddress as l, readPage as m, ToolError as n, listMedia as o, readMenus as p, contentFiles as r, listPages as s, localScreenshots as t, readCollection as u };
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 };
@@ -1,2 +1,2 @@
1
- import { t as localScreenshots } from "./screenshots--uulXZYY.js";
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 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";
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, entryAddress, entryFieldProblems, entryFileChange, isAddressSegment, isEmptyValue, isReservedPagePath, layoutFileChange, menusFileChange, menusFileSchema, newDraftName, pageFileChange, pageFileToPath, parseSiteDescription, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
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: configFrom(description, false),
49
- templateConfig: configFrom(description, true),
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 site = await open(given);
352
- const collection = await readCollection(await reading(site, draft), id);
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
- ...settings.path !== void 0 && { address: entryAddress(settings.path, entry.slug) }
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.path === void 0 ? void 0 : entryAddress(collection.settings.path, slug);
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
- return text(await readMenus(await reading(site, draft)));
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 result = menusFileSchema.safeParse({
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(result.data.menus)], `Update menu "${menu}"`);
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.4.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
- "@modelcontextprotocol/sdk": "1.31.0",
41
- "zod": "4.6.5",
42
- "@goodfellow-cms/ai": "0.4.0",
43
- "@goodfellow-cms/core": "0.4.0",
44
- "@goodfellow-cms/github": "0.4.0",
45
- "@goodfellow-cms/gitlab": "0.4.0"
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": ">=22"
58
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public"