@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 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 GitLab token that stays there.
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, 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,
@@ -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 === "github" && repo?.split("/").length !== 2) return void 0;
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, 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,10 @@
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
+ 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, entryAddress, entryFieldProblems, entryFileChange, isAddressSegment, isEmptyValue, isReservedPagePath, layoutFileChange, menusFileChange, menusFileSchema, newDraftName, pageFileChange, pageFileToPath, parseSiteDescription, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
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: configFrom(description, false),
49
- templateConfig: configFrom(description, true),
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 site = await open(given);
352
- const collection = await readCollection(await reading(site, draft), id);
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
- ...settings.path !== void 0 && { address: entryAddress(settings.path, entry.slug) }
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.path === void 0 ? void 0 : entryAddress(collection.settings.path, slug);
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
- return text(await readMenus(await reading(site, draft)));
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 result = menusFileSchema.safeParse({
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(result.data.menus)], `Update menu "${menu}"`);
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 === "github" && repo?.split("/").length !== 2) return void 0;
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 or gitlab:group/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 or gitlab:group/site.`);
662
- if (!token) fail("Set GOODFELLOW_TOKEN to a GitHub or GitLab token that can edit the site.");
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.4.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
- "@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.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": ">=22"
59
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
59
60
  },
60
61
  "publishConfig": {
61
62
  "access": "public"