@goodfellow-cms/mcp 0.6.0 → 0.8.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 +6 -6
- package/dist/index.d.ts +6 -0
- package/dist/index.js +71 -14
- package/dist/{screenshots-yES3bUkH.js → screenshots-DTrYohIM.js} +136 -41
- package/dist/screenshots.d.ts +6 -0
- package/dist/screenshots.js +2 -2
- package/dist/stdio.js +291 -45
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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
|
|
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 GitHub, GitLab or Bitbucket tokens that stay there, for as many sites as you like, including sites in folders on your computer.
|
|
6
6
|
|
|
7
7
|
## Claude Code
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
claude mcp add goodfellow --env GOODFELLOW_SITE=github:your-name/your-site --env
|
|
10
|
+
claude mcp add goodfellow --env GOODFELLOW_SITE="github:your-name/your-site, gitlab:your-group/other-site" --env GOODFELLOW_GITHUB_TOKEN=your-github-token --env GOODFELLOW_GITLAB_TOKEN=your-gitlab-token -- npx -y @goodfellow-cms/mcp
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
- `GOODFELLOW_SITE`: the
|
|
14
|
-
- `
|
|
13
|
+
- `GOODFELLOW_SITE`: the sites, separated by commas or new lines: `github:owner/name`, `gitlab:group/name`, `bitbucket:workspace/name`, or a folder's full path (`local:` in front is optional), with `@` and its development server's address after it if that isn't `http://localhost:4321` or `http://localhost:3000`. Sites can be given as arguments too.
|
|
14
|
+
- `GOODFELLOW_GITHUB_TOKEN`, `GOODFELLOW_GITLAB_TOKEN`, `GOODFELLOW_BITBUCKET_TOKEN`: a token for each host, which can change the sites' files and open pull requests or merge requests. `GOODFELLOW_TOKEN` is used for any host without one of its own.
|
|
15
15
|
- `GITLAB_URL`: for a GitLab of your own.
|
|
16
16
|
- `GOODFELLOW_BROWSER`: a Chromium-based browser for screenshots, if not Google Chrome or Microsoft Edge.
|
|
17
17
|
|
|
18
18
|
## Claude Desktop
|
|
19
19
|
|
|
20
|
-
Install the [Goodfellow extension](https://goodfellow-cms.github.io/goodfellow-cms/goodfellow.mcpb), which `pnpm pack-extension` builds from this package.
|
|
20
|
+
Install the [Goodfellow extension](https://goodfellow-cms.github.io/goodfellow-cms/goodfellow.mcpb), which `pnpm pack-extension` builds from this package; [Work with Claude](https://goodfellow-cms.github.io/goodfellow-cms/docs/claude#2-add-goodfellow-to-claude) goes through it step by step.
|
|
21
21
|
|
|
22
22
|
## In your own server
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ import { createGoodfellowServer } from "@goodfellow-cms/mcp";
|
|
|
28
28
|
|
|
29
29
|
const server = createGoodfellowServer({
|
|
30
30
|
sites: async () => [{ id: "github:your-name/your-site" }],
|
|
31
|
-
backend: async (site) => signInSomehow(site), // a GitBackend from @goodfellow-cms/github or
|
|
31
|
+
backend: async (site) => signInSomehow(site), // a GitBackend from @goodfellow-cms/github, gitlab or bitbucket
|
|
32
32
|
});
|
|
33
33
|
```
|
|
34
34
|
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ interface ConnectorContext {
|
|
|
11
11
|
}>>;
|
|
12
12
|
/** Signs in to a site's repository. Throws `ToolError` for a site this connection may not edit. */
|
|
13
13
|
backend(site: string): Promise<GitBackend>;
|
|
14
|
+
/**
|
|
15
|
+
* Where a site is served, where that isn't its address in Site settings:
|
|
16
|
+
* a site in a folder on this computer is read from its development server.
|
|
17
|
+
* Throws `ToolError` where it isn't being served.
|
|
18
|
+
*/
|
|
19
|
+
served?(site: string): Promise<string | undefined>;
|
|
14
20
|
/** Fetches the live site's description. Defaults to the global `fetch`. */
|
|
15
21
|
fetch?: typeof fetch;
|
|
16
22
|
/** Takes screenshots, where this server can. */
|
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, 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";
|
|
1
|
+
import { COLLECTIONS_DIR, COLLECTION_SETTINGS_FILE, ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, MEDIA_DIR, MEDIA_FILE, MENUS_FILE, PAGES_DIR, RateLimitError, SITE_DESCRIPTION_PATH, SITE_FILE, SignInError, collectionFileSchema, describedConfig, entryFieldProblems, entryFileChange, entryPathIn, hasLanguages, isAddressSegment, isEmptyValue, isOtherLanguage, isReservedPagePath, layoutFileChange, layoutFileSchema, limitsContent, limitsFor, mainLanguage, mediaFileSchema, menusFileChange, menusFileSchema, newDraftName, normalizePagePath, outsideLimits, pageFileChange, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, parseSiteDescription, readPermissions, readTextFiles, reviewersFor, 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";
|
|
@@ -205,16 +205,40 @@ const VERSION_PROBLEMS = {
|
|
|
205
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.`,
|
|
206
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.`
|
|
207
207
|
};
|
|
208
|
-
|
|
208
|
+
/** The site's description, from its address, or `undefined` where it can't be read there. */
|
|
209
|
+
async function readDescription(context, url) {
|
|
210
|
+
const response = await (context.fetch ?? fetch)(`${url}/${SITE_DESCRIPTION_PATH}`, { headers: { accept: "application/json" } }).catch(() => void 0);
|
|
211
|
+
const result = response?.ok ? parseSiteDescription(await response.text()) : { ok: false };
|
|
212
|
+
return result.ok ? result.description : void 0;
|
|
213
|
+
}
|
|
214
|
+
/** Where the host's own Pages publishes the site: its custom domain first, then its address there, such as `name.gitlab.io`. */
|
|
215
|
+
async function pagesAddresses(backend) {
|
|
216
|
+
const found = await backend.pages?.site().catch(() => void 0);
|
|
217
|
+
return [found?.domain && `https://${found.domain}`, found?.url].filter((url) => Boolean(url));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Opens a site. Its blocks come from the description its build published, so
|
|
221
|
+
* it's read from the site's address in Site settings, or, where that's empty,
|
|
222
|
+
* from where the host's Pages publishes it. `addresses` remembers what was
|
|
223
|
+
* found, by site, so the host is asked once.
|
|
224
|
+
*/
|
|
225
|
+
async function openSite(context, id, addresses = /* @__PURE__ */ new Map()) {
|
|
209
226
|
const backend = await context.backend(id);
|
|
210
227
|
await backend.revision();
|
|
211
228
|
const settings = await readSettings(backend);
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
229
|
+
const own = (await context.served?.(id) ?? settings.url)?.replace(/\/+$/, "");
|
|
230
|
+
const known = addresses.get(id);
|
|
231
|
+
const candidates = own ? [own] : known ? [known] : (await pagesAddresses(backend)).map((url) => url.replace(/\/+$/, ""));
|
|
232
|
+
if (candidates.length === 0) throw new ToolError("Claude needs the site's address to see what blocks it has, and the site doesn't have one in Site settings, nor does its host say where it's published. Set it in the admin panel: Site settings, General → Site address.");
|
|
233
|
+
let url = candidates[0] ?? "";
|
|
234
|
+
let description;
|
|
235
|
+
for (const candidate of candidates) {
|
|
236
|
+
description = await readDescription(context, candidate);
|
|
237
|
+
url = candidate;
|
|
238
|
+
if (description) break;
|
|
239
|
+
}
|
|
240
|
+
if (!description) throw new ToolError(`Claude couldn't read what blocks the site has from ${url}/${SITE_DESCRIPTION_PATH}. The site may not be online yet, or was built before Goodfellow could work with Claude: publish any change in the admin panel to rebuild it.`);
|
|
241
|
+
if (!own) addresses.set(id, url);
|
|
218
242
|
const problem = siteVersionProblem(description);
|
|
219
243
|
return {
|
|
220
244
|
id,
|
|
@@ -286,6 +310,7 @@ function explain(error) {
|
|
|
286
310
|
return failure(error.message);
|
|
287
311
|
}
|
|
288
312
|
if (error instanceof ConflictError) return failure("Someone changed the same part of the site while this was being saved. Read it again (with the draft), then make the change again.");
|
|
313
|
+
if (error instanceof RateLimitError) return failure(`The site's host is limiting how many requests Claude can make.${error.resetsAt ? ` It can carry on after ${error.resetsAt.toISOString().replace(/\.\d+Z$/, "Z")}` : " It can carry on in a few minutes"}: what's saved so far stays in the draft. Fewer, larger changes help, such as one save_page with every block rather than one for each.`);
|
|
289
314
|
if (error instanceof SignInError) return failure(error.problem === "cant-publish" ? "This sign-in can read the site but can't change it. Ask the site's owner to give you permission to edit it." : "Signing in to the site's host didn't work. Connect Claude to the site again.");
|
|
290
315
|
return failure(`Something went wrong: ${error.message ?? String(error)}`);
|
|
291
316
|
}
|
|
@@ -315,8 +340,10 @@ function registerTools(server, context) {
|
|
|
315
340
|
if (sites.length === 1 && only) return only.id;
|
|
316
341
|
throw new ToolError(sites.length === 0 ? "Claude isn't connected to any sites yet. Connect it to a site first." : `Say which site: ${sites.map((site) => site.id).join(", ")}.`);
|
|
317
342
|
}
|
|
343
|
+
/** Sites' addresses found on their hosts' Pages, where Site settings has none. */
|
|
344
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
318
345
|
async function open(given) {
|
|
319
|
-
return openSite(context, await siteId(given));
|
|
346
|
+
return openSite(context, await siteId(given), addresses);
|
|
320
347
|
}
|
|
321
348
|
async function draftOf(site, name) {
|
|
322
349
|
if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
|
|
@@ -326,13 +353,37 @@ function registerTools(server, context) {
|
|
|
326
353
|
async function changing(site, name) {
|
|
327
354
|
if (site.versionProblem) throw new ToolError(site.versionProblem);
|
|
328
355
|
if (name) return draftOf(site, name);
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
const drafts = site.backend.drafts;
|
|
357
|
+
if (!drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
|
|
358
|
+
try {
|
|
359
|
+
return await drafts.create(newDraftName(now()));
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!(error instanceof DraftError) || error.problem !== "exists") throw error;
|
|
362
|
+
const [open] = await drafts.list();
|
|
363
|
+
throw new ToolError(`There's a draft already, ${open ?? "started earlier"}: pass it as "draft" to add to it, or publish or discard it first.`);
|
|
364
|
+
}
|
|
331
365
|
}
|
|
332
366
|
/** Where to read from: a draft, or the live site. */
|
|
333
367
|
async function reading(site, name) {
|
|
334
368
|
return name ? (await draftOf(site, name)).store : site.backend;
|
|
335
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* The draft's changes outside what an owner lets the connected person change
|
|
372
|
+
* (`content/permissions.json`), in words, or none where they have no limits.
|
|
373
|
+
* Only the files the changes touch are read.
|
|
374
|
+
*/
|
|
375
|
+
async function outsideTheirLimits(site, draft) {
|
|
376
|
+
const limits = limitsFor(await readPermissions(site.backend), site.backend.user.login);
|
|
377
|
+
if (!limits) return [];
|
|
378
|
+
const changes = await draft.changes();
|
|
379
|
+
return outsideLimits(await limitsContent(site.backend, changes), limits, changes).map((problem) => problem.kind === "page" ? `the page ${problem.address}` : problem.path);
|
|
380
|
+
}
|
|
381
|
+
/** Who's asked to review: the people the site's permissions name, else its owners where the sign-in can list them. */
|
|
382
|
+
async function reviewersOf(site) {
|
|
383
|
+
const owners = await site.backend.editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
|
|
384
|
+
const self = site.backend.user.login.toLowerCase();
|
|
385
|
+
return reviewersFor(await readPermissions(site.backend), owners ?? []).filter((login) => login.toLowerCase() !== self);
|
|
386
|
+
}
|
|
336
387
|
async function save(draft, changes, message) {
|
|
337
388
|
const store = draft.store;
|
|
338
389
|
await writeChanges(store, changes, {
|
|
@@ -794,6 +845,8 @@ function registerTools(server, context) {
|
|
|
794
845
|
if (site.versionProblem) throw new ToolError(site.versionProblem);
|
|
795
846
|
if (site.description.claude?.publish === false) throw new ToolError(NO_PUBLISHING);
|
|
796
847
|
const draft = await draftOf(site, name);
|
|
848
|
+
const outside = await outsideTheirLimits(site, draft);
|
|
849
|
+
if (outside.length > 0) throw new ToolError(`An owner has limited what you can change on this site, and this draft changes things outside it: ${outside.join(", ")}. Submit it for review instead (submit_for_review), and a reviewer publishes it.`);
|
|
797
850
|
try {
|
|
798
851
|
await draft.publish(message);
|
|
799
852
|
} catch (error) {
|
|
@@ -802,7 +855,7 @@ function registerTools(server, context) {
|
|
|
802
855
|
}
|
|
803
856
|
return text({
|
|
804
857
|
published: true,
|
|
805
|
-
note: "The site is being rebuilt with the changes, which usually takes a minute or two.",
|
|
858
|
+
note: site.id.startsWith("local:") ? "The changes are in the site's folder on this computer now. Publish them as you would any change to the site's files, such as by committing and pushing them." : "The site is being rebuilt with the changes, which usually takes a minute or two.",
|
|
806
859
|
address: site.url
|
|
807
860
|
});
|
|
808
861
|
});
|
|
@@ -812,11 +865,15 @@ function registerTools(server, context) {
|
|
|
812
865
|
title: z.string().describe("What the changes are, in a few words."),
|
|
813
866
|
description: z.string().optional().describe("More about the changes, for whoever reviews them.")
|
|
814
867
|
}, async ({ site: given, draft: name, title, description }) => {
|
|
868
|
+
const site = await open(given);
|
|
869
|
+
const draft = await draftOf(site, name);
|
|
870
|
+
const reviewers = await reviewersOf(site);
|
|
815
871
|
return text({
|
|
816
872
|
submitted: true,
|
|
817
|
-
review: (await
|
|
873
|
+
review: (await draft.submitForReview({
|
|
818
874
|
title,
|
|
819
|
-
...description && { description }
|
|
875
|
+
...description && { description },
|
|
876
|
+
...reviewers.length > 0 && { reviewers }
|
|
820
877
|
})).url
|
|
821
878
|
});
|
|
822
879
|
});
|
|
@@ -222,57 +222,152 @@ img[src$=".svg" i] { width: 100%; }
|
|
|
222
222
|
* Screenshots on the editor's computer, with the Chrome or Microsoft Edge
|
|
223
223
|
* it already has, through Playwright. Nothing is downloaded: without either
|
|
224
224
|
* browser, the screenshot tool says so.
|
|
225
|
+
*
|
|
226
|
+
* Each picture gets a browser of its own, without the GPU, which headless
|
|
227
|
+
* browsers on some computers can't keep running (Chrome closes when its GPU
|
|
228
|
+
* process fails), and a page is ready once it has loaded with its fonts and
|
|
229
|
+
* images and its requests have stopped for a moment, rather than when the
|
|
230
|
+
* network is idle for good, which a page may never be. A browser that closes
|
|
231
|
+
* part-way is tried once more, with the other browser where there is one, and
|
|
232
|
+
* what it said is passed on.
|
|
225
233
|
*/
|
|
226
|
-
/** How long
|
|
227
|
-
const
|
|
234
|
+
/** How long one picture may take in all: Claude's apps give up on a tool after a minute. */
|
|
235
|
+
const SCREENSHOT_TIME = 45e3;
|
|
236
|
+
/** How tall a picture may be: taller pages are cut off there, since Claude shrinks taller images anyway. */
|
|
237
|
+
const MAX_SCREENSHOT_HEIGHT = 8e3;
|
|
238
|
+
/** How long requests must have stopped for before a page counts as ready. */
|
|
239
|
+
const QUIET = 500;
|
|
240
|
+
/** How long to wait for that quiet before taking the picture anyway. */
|
|
241
|
+
const MAX_SETTLE = 8e3;
|
|
242
|
+
/** What the browser itself said, which Playwright puts in its errors after "Browser logs:", without its own lines. */
|
|
243
|
+
function browserSaid(message) {
|
|
244
|
+
return (message.split(/Browser logs:/)[1]?.split(/Call log:/)[0] ?? "").replace(new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*m`, "g"), "").split("\n").map((line) => line.replace(/^\s*\[pid=\d+\]\[(?:err|out)\]\s*/, "").trim()).filter((line) => line && !/^<launch(?:ing|ed)>|^\[pid=\d+\]/.test(line)).slice(-4).join(" / ");
|
|
245
|
+
}
|
|
246
|
+
/** A browser that closed, or a page that crashed, while a picture was being taken. */
|
|
247
|
+
var BrowserClosed = class extends Error {
|
|
248
|
+
name = "BrowserClosed";
|
|
249
|
+
};
|
|
250
|
+
/** Waits until the page's requests have stopped for `QUIET`, its fonts are loaded and its images are in, or until `MAX_SETTLE`. */
|
|
251
|
+
async function settle(page, inFlight, until) {
|
|
252
|
+
const settleUntil = Math.min(until, Date.now() + MAX_SETTLE);
|
|
253
|
+
let quietSince = inFlight() === 0 ? Date.now() : 0;
|
|
254
|
+
while (Date.now() < settleUntil) {
|
|
255
|
+
if (inFlight() > 0) quietSince = 0;
|
|
256
|
+
else if (!quietSince) quietSince = Date.now();
|
|
257
|
+
else if (Date.now() - quietSince >= QUIET) break;
|
|
258
|
+
await page.waitForTimeout(100);
|
|
259
|
+
}
|
|
260
|
+
await page.evaluate(async () => {
|
|
261
|
+
await document.fonts?.ready;
|
|
262
|
+
await Promise.all([...document.images].filter((image) => !image.complete).map((image) => new Promise((done) => image.addEventListener("loadend", done, { once: true }))));
|
|
263
|
+
}).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
async function picture(browser, request, until) {
|
|
266
|
+
const { url, width, preview, image, dark } = request;
|
|
267
|
+
const page = await browser.newPage({
|
|
268
|
+
viewport: {
|
|
269
|
+
width,
|
|
270
|
+
height: 900
|
|
271
|
+
},
|
|
272
|
+
colorScheme: dark ? "dark" : "light"
|
|
273
|
+
});
|
|
274
|
+
let inFlight = 0;
|
|
275
|
+
page.on("request", () => inFlight++);
|
|
276
|
+
page.on("requestfinished", () => inFlight--);
|
|
277
|
+
page.on("requestfailed", () => inFlight--);
|
|
278
|
+
let crashed = false;
|
|
279
|
+
page.on("crash", () => {
|
|
280
|
+
crashed = true;
|
|
281
|
+
});
|
|
282
|
+
const left = () => Math.max(1e3, until - Date.now());
|
|
283
|
+
try {
|
|
284
|
+
if (image) await page.setContent(imagePage(url), {
|
|
285
|
+
waitUntil: "load",
|
|
286
|
+
timeout: left()
|
|
287
|
+
});
|
|
288
|
+
else await page.goto(url, {
|
|
289
|
+
waitUntil: "load",
|
|
290
|
+
timeout: left()
|
|
291
|
+
});
|
|
292
|
+
if (preview) {
|
|
293
|
+
await page.waitForFunction(() => typeof window.goodfellowPreview === "function", null, { timeout: left() });
|
|
294
|
+
const result = await page.evaluate((handed) => window.goodfellowPreview?.(handed), preview);
|
|
295
|
+
if (!result?.ok) throw new ToolError(`The draft couldn't be shown: ${result?.problem ?? "the site's preview page didn't answer"}`);
|
|
296
|
+
}
|
|
297
|
+
await settle(page, () => inFlight, until);
|
|
298
|
+
const height = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0));
|
|
299
|
+
return new Uint8Array(await page.screenshot({
|
|
300
|
+
fullPage: true,
|
|
301
|
+
clip: {
|
|
302
|
+
x: 0,
|
|
303
|
+
y: 0,
|
|
304
|
+
width,
|
|
305
|
+
height: Math.max(1, Math.min(height, MAX_SCREENSHOT_HEIGHT))
|
|
306
|
+
},
|
|
307
|
+
type: "png",
|
|
308
|
+
animations: "disabled",
|
|
309
|
+
caret: "hide",
|
|
310
|
+
timeout: left()
|
|
311
|
+
}));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error instanceof ToolError) throw error;
|
|
314
|
+
if (crashed || !browser.isConnected() || /closed|crash/i.test(error.message)) throw new BrowserClosed(error.message);
|
|
315
|
+
if (error.name === "TimeoutError") throw new ToolError(`${new URL(url).host} took too long to show the page, so there's no picture. It may be slow or not answering just now; try again in a moment.`);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
228
319
|
function localScreenshots(options = {}) {
|
|
229
|
-
return { async take(
|
|
320
|
+
return { async take(request) {
|
|
230
321
|
let chromium;
|
|
231
322
|
try {
|
|
232
323
|
({chromium} = await import("playwright-core"));
|
|
233
324
|
} catch {
|
|
234
325
|
throw new ToolError("Screenshots aren't available: this copy of the connector was installed without them.");
|
|
235
326
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
327
|
+
const until = Date.now() + SCREENSHOT_TIME;
|
|
328
|
+
const browsers = options.executablePath ? [{
|
|
329
|
+
name: "The browser",
|
|
330
|
+
launch: { executablePath: options.executablePath }
|
|
331
|
+
}] : [{
|
|
332
|
+
name: "Chrome",
|
|
333
|
+
launch: { channel: "chrome" }
|
|
334
|
+
}, {
|
|
335
|
+
name: "Microsoft Edge",
|
|
336
|
+
launch: { channel: "msedge" }
|
|
337
|
+
}];
|
|
338
|
+
const found = [];
|
|
339
|
+
const closed = [];
|
|
340
|
+
for (let attempt = 0; attempt < 2 && Date.now() < until; attempt++) {
|
|
341
|
+
const candidates = attempt === 0 ? browsers : found.length > 1 ? found.slice(1) : found;
|
|
342
|
+
let browser;
|
|
343
|
+
let name = "";
|
|
344
|
+
for (const candidate of candidates) {
|
|
345
|
+
browser = await chromium.launch({
|
|
346
|
+
...candidate.launch,
|
|
347
|
+
headless: true,
|
|
348
|
+
args: ["--disable-gpu"],
|
|
349
|
+
timeout: Math.max(1e3, until - Date.now())
|
|
350
|
+
}).catch(() => void 0);
|
|
351
|
+
if (browser) {
|
|
352
|
+
name = candidate.name;
|
|
353
|
+
if (attempt === 0) found.push(...browsers.slice(browsers.indexOf(candidate)));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!browser) break;
|
|
358
|
+
try {
|
|
359
|
+
return await picture(browser, request, until);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!(error instanceof BrowserClosed)) throw error;
|
|
362
|
+
const said = browserSaid(error.message);
|
|
363
|
+
closed.push(`${name} closed while showing the page${said ? `, saying: ${said}` : ""}.`);
|
|
364
|
+
} finally {
|
|
365
|
+
await browser.close().catch(() => {});
|
|
267
366
|
}
|
|
268
|
-
return new Uint8Array(await page.screenshot({
|
|
269
|
-
fullPage: true,
|
|
270
|
-
type: "png"
|
|
271
|
-
}));
|
|
272
|
-
} finally {
|
|
273
|
-
await browser.close();
|
|
274
367
|
}
|
|
368
|
+
if (closed.length > 0) throw new ToolError(`There's no picture: ${closed.join(" ")} Something on this computer may be stopping the browser running without a window, such as security software, or it may need restarting to finish an update.`);
|
|
369
|
+
throw new ToolError("Screenshots need Google Chrome or Microsoft Edge on this computer, or GOODFELLOW_BROWSER set to another Chromium-based browser.");
|
|
275
370
|
} };
|
|
276
371
|
}
|
|
277
372
|
//#endregion
|
|
278
|
-
export {
|
|
373
|
+
export { readLayout as _, ToolError as a, readSettings as b, entryLanguages as c, listMedia as d, listPages as f, readEntry as g, readCollection as h, localScreenshots as i, isMediaAddress as l, pageAddress as m, SCREENSHOT_TIME as n, contentFiles as o, mediaImage as p, browserSaid as r, entryAddresses as s, MAX_SCREENSHOT_HEIGHT as t, listCollections as u, readMenus as v, takenAddresses as x, readPage as y };
|
package/dist/screenshots.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ interface Screenshotter {
|
|
|
23
23
|
}
|
|
24
24
|
//#endregion
|
|
25
25
|
//#region src/screenshots.d.ts
|
|
26
|
+
/** How long one picture may take in all: Claude's apps give up on a tool after a minute. */
|
|
27
|
+
export declare const SCREENSHOT_TIME = 45000;
|
|
28
|
+
/** How tall a picture may be: taller pages are cut off there, since Claude shrinks taller images anyway. */
|
|
29
|
+
export declare const MAX_SCREENSHOT_HEIGHT = 8000;
|
|
30
|
+
/** What the browser itself said, which Playwright puts in its errors after "Browser logs:", without its own lines. */
|
|
31
|
+
export declare function browserSaid(message: string): string;
|
|
26
32
|
export declare function localScreenshots(options?: {
|
|
27
33
|
executablePath?: string;
|
|
28
34
|
}): Screenshotter;
|
package/dist/screenshots.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { localScreenshots };
|
|
1
|
+
import { i as localScreenshots, n as SCREENSHOT_TIME, r as browserSaid, t as MAX_SCREENSHOT_HEIGHT } from "./screenshots-DTrYohIM.js";
|
|
2
|
+
export { MAX_SCREENSHOT_HEIGHT, SCREENSHOT_TIME, browserSaid, localScreenshots };
|
package/dist/stdio.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { _ as
|
|
2
|
+
import { _ as readLayout, a as ToolError, b as readSettings, c as entryLanguages, d as listMedia, f as listPages, g as readEntry, h as readCollection, i as localScreenshots, l as isMediaAddress, m as pageAddress, o as contentFiles, p as mediaImage, s as entryAddresses, u as listCollections, v as readMenus, x as takenAddresses, y as readPage } from "./screenshots-DTrYohIM.js";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
4
|
import { bitbucket } from "@goodfellow-cms/bitbucket";
|
|
4
5
|
import { github } from "@goodfellow-cms/github";
|
|
5
6
|
import { gitlab } from "@goodfellow-cms/gitlab";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
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";
|
|
8
|
+
import { ConflictError, DraftError, FOOTER_FILE, GOODFELLOW_VERSION, HEADER_FILE, RateLimitError, SITE_DESCRIPTION_PATH, SITE_FILE, SignInError, describedConfig, entryFieldProblems, entryFileChange, entryPathIn, hasLanguages, isAddressSegment, isEmptyValue, isOtherLanguage, isReservedPagePath, keptDrafts, layoutFileChange, limitsContent, limitsFor, mainLanguage, menusFileChange, menusFileSchema, newDraftName, outsideLimits, pageFileChange, pageFileToPath, parseSiteDescription, readPermissions, reviewersFor, siteLanguages, siteSettingsFileChange, siteSettingsSchema, siteVersionProblem, writeChanges } from "@goodfellow-cms/core";
|
|
8
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
10
|
import { cleanProps, objectSchema, slotNames, toContent, toFlatBlocks } from "@goodfellow-cms/ai/schema";
|
|
10
11
|
import { z } from "zod";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { localFileStore } from "@goodfellow-cms/core/node";
|
|
11
15
|
//#region src/site.ts
|
|
12
16
|
/**
|
|
13
17
|
* Opening a site for the tools: its backend, its settings, and the
|
|
@@ -18,16 +22,40 @@ const VERSION_PROBLEMS = {
|
|
|
18
22
|
"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.`,
|
|
19
23
|
"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.`
|
|
20
24
|
};
|
|
21
|
-
|
|
25
|
+
/** The site's description, from its address, or `undefined` where it can't be read there. */
|
|
26
|
+
async function readDescription(context, url) {
|
|
27
|
+
const response = await (context.fetch ?? fetch)(`${url}/${SITE_DESCRIPTION_PATH}`, { headers: { accept: "application/json" } }).catch(() => void 0);
|
|
28
|
+
const result = response?.ok ? parseSiteDescription(await response.text()) : { ok: false };
|
|
29
|
+
return result.ok ? result.description : void 0;
|
|
30
|
+
}
|
|
31
|
+
/** Where the host's own Pages publishes the site: its custom domain first, then its address there, such as `name.gitlab.io`. */
|
|
32
|
+
async function pagesAddresses(backend) {
|
|
33
|
+
const found = await backend.pages?.site().catch(() => void 0);
|
|
34
|
+
return [found?.domain && `https://${found.domain}`, found?.url].filter((url) => Boolean(url));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Opens a site. Its blocks come from the description its build published, so
|
|
38
|
+
* it's read from the site's address in Site settings, or, where that's empty,
|
|
39
|
+
* from where the host's Pages publishes it. `addresses` remembers what was
|
|
40
|
+
* found, by site, so the host is asked once.
|
|
41
|
+
*/
|
|
42
|
+
async function openSite(context, id, addresses = /* @__PURE__ */ new Map()) {
|
|
22
43
|
const backend = await context.backend(id);
|
|
23
44
|
await backend.revision();
|
|
24
45
|
const settings = await readSettings(backend);
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
const own = (await context.served?.(id) ?? settings.url)?.replace(/\/+$/, "");
|
|
47
|
+
const known = addresses.get(id);
|
|
48
|
+
const candidates = own ? [own] : known ? [known] : (await pagesAddresses(backend)).map((url) => url.replace(/\/+$/, ""));
|
|
49
|
+
if (candidates.length === 0) throw new ToolError("Claude needs the site's address to see what blocks it has, and the site doesn't have one in Site settings, nor does its host say where it's published. Set it in the admin panel: Site settings, General → Site address.");
|
|
50
|
+
let url = candidates[0] ?? "";
|
|
51
|
+
let description;
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
description = await readDescription(context, candidate);
|
|
54
|
+
url = candidate;
|
|
55
|
+
if (description) break;
|
|
56
|
+
}
|
|
57
|
+
if (!description) throw new ToolError(`Claude couldn't read what blocks the site has from ${url}/${SITE_DESCRIPTION_PATH}. The site may not be online yet, or was built before Goodfellow could work with Claude: publish any change in the admin panel to rebuild it.`);
|
|
58
|
+
if (!own) addresses.set(id, url);
|
|
31
59
|
const problem = siteVersionProblem(description);
|
|
32
60
|
return {
|
|
33
61
|
id,
|
|
@@ -99,6 +127,7 @@ function explain(error) {
|
|
|
99
127
|
return failure(error.message);
|
|
100
128
|
}
|
|
101
129
|
if (error instanceof ConflictError) return failure("Someone changed the same part of the site while this was being saved. Read it again (with the draft), then make the change again.");
|
|
130
|
+
if (error instanceof RateLimitError) return failure(`The site's host is limiting how many requests Claude can make.${error.resetsAt ? ` It can carry on after ${error.resetsAt.toISOString().replace(/\.\d+Z$/, "Z")}` : " It can carry on in a few minutes"}: what's saved so far stays in the draft. Fewer, larger changes help, such as one save_page with every block rather than one for each.`);
|
|
102
131
|
if (error instanceof SignInError) return failure(error.problem === "cant-publish" ? "This sign-in can read the site but can't change it. Ask the site's owner to give you permission to edit it." : "Signing in to the site's host didn't work. Connect Claude to the site again.");
|
|
103
132
|
return failure(`Something went wrong: ${error.message ?? String(error)}`);
|
|
104
133
|
}
|
|
@@ -128,8 +157,10 @@ function registerTools(server, context) {
|
|
|
128
157
|
if (sites.length === 1 && only) return only.id;
|
|
129
158
|
throw new ToolError(sites.length === 0 ? "Claude isn't connected to any sites yet. Connect it to a site first." : `Say which site: ${sites.map((site) => site.id).join(", ")}.`);
|
|
130
159
|
}
|
|
160
|
+
/** Sites' addresses found on their hosts' Pages, where Site settings has none. */
|
|
161
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
131
162
|
async function open(given) {
|
|
132
|
-
return openSite(context, await siteId(given));
|
|
163
|
+
return openSite(context, await siteId(given), addresses);
|
|
133
164
|
}
|
|
134
165
|
async function draftOf(site, name) {
|
|
135
166
|
if (!site.backend.drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
|
|
@@ -139,13 +170,37 @@ function registerTools(server, context) {
|
|
|
139
170
|
async function changing(site, name) {
|
|
140
171
|
if (site.versionProblem) throw new ToolError(site.versionProblem);
|
|
141
172
|
if (name) return draftOf(site, name);
|
|
142
|
-
|
|
143
|
-
|
|
173
|
+
const drafts = site.backend.drafts;
|
|
174
|
+
if (!drafts) throw new ToolError("This site's host can't keep drafts, which Claude's changes need.");
|
|
175
|
+
try {
|
|
176
|
+
return await drafts.create(newDraftName(now()));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (!(error instanceof DraftError) || error.problem !== "exists") throw error;
|
|
179
|
+
const [open] = await drafts.list();
|
|
180
|
+
throw new ToolError(`There's a draft already, ${open ?? "started earlier"}: pass it as "draft" to add to it, or publish or discard it first.`);
|
|
181
|
+
}
|
|
144
182
|
}
|
|
145
183
|
/** Where to read from: a draft, or the live site. */
|
|
146
184
|
async function reading(site, name) {
|
|
147
185
|
return name ? (await draftOf(site, name)).store : site.backend;
|
|
148
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The draft's changes outside what an owner lets the connected person change
|
|
189
|
+
* (`content/permissions.json`), in words, or none where they have no limits.
|
|
190
|
+
* Only the files the changes touch are read.
|
|
191
|
+
*/
|
|
192
|
+
async function outsideTheirLimits(site, draft) {
|
|
193
|
+
const limits = limitsFor(await readPermissions(site.backend), site.backend.user.login);
|
|
194
|
+
if (!limits) return [];
|
|
195
|
+
const changes = await draft.changes();
|
|
196
|
+
return outsideLimits(await limitsContent(site.backend, changes), limits, changes).map((problem) => problem.kind === "page" ? `the page ${problem.address}` : problem.path);
|
|
197
|
+
}
|
|
198
|
+
/** Who's asked to review: the people the site's permissions name, else its owners where the sign-in can list them. */
|
|
199
|
+
async function reviewersOf(site) {
|
|
200
|
+
const owners = await site.backend.editors?.list().then((list) => list.editors.flatMap((editor) => editor.role === "owner" && editor.login ? [editor.login] : [])).catch(() => []);
|
|
201
|
+
const self = site.backend.user.login.toLowerCase();
|
|
202
|
+
return reviewersFor(await readPermissions(site.backend), owners ?? []).filter((login) => login.toLowerCase() !== self);
|
|
203
|
+
}
|
|
149
204
|
async function save(draft, changes, message) {
|
|
150
205
|
const store = draft.store;
|
|
151
206
|
await writeChanges(store, changes, {
|
|
@@ -610,6 +665,8 @@ function registerTools(server, context) {
|
|
|
610
665
|
if (site.versionProblem) throw new ToolError(site.versionProblem);
|
|
611
666
|
if (site.description.claude?.publish === false) throw new ToolError(NO_PUBLISHING);
|
|
612
667
|
const draft = await draftOf(site, name);
|
|
668
|
+
const outside = await outsideTheirLimits(site, draft);
|
|
669
|
+
if (outside.length > 0) throw new ToolError(`An owner has limited what you can change on this site, and this draft changes things outside it: ${outside.join(", ")}. Submit it for review instead (submit_for_review), and a reviewer publishes it.`);
|
|
613
670
|
try {
|
|
614
671
|
await draft.publish(message);
|
|
615
672
|
} catch (error) {
|
|
@@ -618,7 +675,7 @@ function registerTools(server, context) {
|
|
|
618
675
|
}
|
|
619
676
|
return text({
|
|
620
677
|
published: true,
|
|
621
|
-
note: "The site is being rebuilt with the changes, which usually takes a minute or two.",
|
|
678
|
+
note: site.id.startsWith("local:") ? "The changes are in the site's folder on this computer now. Publish them as you would any change to the site's files, such as by committing and pushing them." : "The site is being rebuilt with the changes, which usually takes a minute or two.",
|
|
622
679
|
address: site.url
|
|
623
680
|
});
|
|
624
681
|
});
|
|
@@ -628,11 +685,15 @@ function registerTools(server, context) {
|
|
|
628
685
|
title: z.string().describe("What the changes are, in a few words."),
|
|
629
686
|
description: z.string().optional().describe("More about the changes, for whoever reviews them.")
|
|
630
687
|
}, async ({ site: given, draft: name, title, description }) => {
|
|
688
|
+
const site = await open(given);
|
|
689
|
+
const draft = await draftOf(site, name);
|
|
690
|
+
const reviewers = await reviewersOf(site);
|
|
631
691
|
return text({
|
|
632
692
|
submitted: true,
|
|
633
|
-
review: (await
|
|
693
|
+
review: (await draft.submitForReview({
|
|
634
694
|
title,
|
|
635
|
-
...description && { description }
|
|
695
|
+
...description && { description },
|
|
696
|
+
...reviewers.length > 0 && { reviewers }
|
|
636
697
|
})).url
|
|
637
698
|
});
|
|
638
699
|
});
|
|
@@ -671,7 +732,144 @@ function createGoodfellowServer(context) {
|
|
|
671
732
|
return server;
|
|
672
733
|
}
|
|
673
734
|
//#endregion
|
|
735
|
+
//#region src/local.ts
|
|
736
|
+
/**
|
|
737
|
+
* Sites in a folder on this computer, such as a clone of the site's
|
|
738
|
+
* repository: Claude reads and writes its files there, as `goodfellow dev`'s
|
|
739
|
+
* admin panel does, and sees its blocks and takes screenshots through its
|
|
740
|
+
* development server. Changes wait in a draft, kept beside the site's
|
|
741
|
+
* packages, until they're published, which writes them into the folder, for
|
|
742
|
+
* the developer to commit as they would any change.
|
|
743
|
+
*/
|
|
744
|
+
/** Where a local site's draft is kept: beside its packages, which git leaves out. */
|
|
745
|
+
const LOCAL_DRAFT_FILE = "node_modules/.goodfellow/claude-draft.json";
|
|
746
|
+
/**
|
|
747
|
+
* Reads a site given as a folder: `local:C:\Sites\parish`, or the folder on its
|
|
748
|
+
* own where it's a full path, with `@` and the development server's address
|
|
749
|
+
* after it where that isn't the usual one. `undefined` where it isn't a folder.
|
|
750
|
+
*/
|
|
751
|
+
function parseLocalSite(entry) {
|
|
752
|
+
let rest = entry.trim();
|
|
753
|
+
if (rest.startsWith("local:")) rest = rest.slice(6);
|
|
754
|
+
else if (!/^(\/|[A-Za-z]:[\\/]|\\\\)/.test(rest)) return void 0;
|
|
755
|
+
const at = rest.search(/@https?:\/\//);
|
|
756
|
+
const folder = resolve(at === -1 ? rest : rest.slice(0, at));
|
|
757
|
+
const address = at === -1 ? void 0 : rest.slice(at + 1).replace(/\/+$/, "");
|
|
758
|
+
return {
|
|
759
|
+
id: `local:${folder}`,
|
|
760
|
+
folder,
|
|
761
|
+
...address && { address }
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** Whether a folder holds a Goodfellow site. */
|
|
765
|
+
function isSiteFolder(folder) {
|
|
766
|
+
return existsSync(join(folder, SITE_FILE));
|
|
767
|
+
}
|
|
768
|
+
/** Where a site's development server answers, by what the folder holds: `next dev`'s port, or `goodfellow dev`'s. */
|
|
769
|
+
function developmentAddresses(site) {
|
|
770
|
+
if (site.address) return [site.address];
|
|
771
|
+
return [
|
|
772
|
+
"next.config.ts",
|
|
773
|
+
"next.config.mjs",
|
|
774
|
+
"next.config.js"
|
|
775
|
+
].some((file) => existsSync(join(site.folder, file))) ? ["http://localhost:3000", "http://localhost:4321"] : ["http://localhost:4321", "http://localhost:3000"];
|
|
776
|
+
}
|
|
777
|
+
/** The first of the addresses whose server answers with a site's description. */
|
|
778
|
+
async function servedAt(site, fetcher = fetch) {
|
|
779
|
+
const addresses = developmentAddresses(site);
|
|
780
|
+
for (const address of addresses) if ((await fetcher(`${address}/${SITE_DESCRIPTION_PATH}`, { signal: AbortSignal.timeout(3e3) }).catch(() => void 0))?.ok) return address;
|
|
781
|
+
throw new ToolError(`Claude sees a site on this computer through its development server, which isn't running at ${addresses.join(" or ")}. Start it in ${site.folder} (npm run dev), then try again.${site.address ? "" : ` If it runs somewhere else, add @ and its address after the folder in the connector's settings, such as ${site.folder}@http://localhost:4400.`}`);
|
|
782
|
+
}
|
|
783
|
+
const toBase64 = (bytes) => Buffer.from(bytes).toString("base64");
|
|
784
|
+
const fromBase64 = (text) => new Uint8Array(Buffer.from(text, "base64"));
|
|
785
|
+
function storeChange(change) {
|
|
786
|
+
if ("bytes" in change) return {
|
|
787
|
+
path: change.path,
|
|
788
|
+
base64: toBase64(change.bytes)
|
|
789
|
+
};
|
|
790
|
+
return change;
|
|
791
|
+
}
|
|
792
|
+
function readChange(change) {
|
|
793
|
+
return "base64" in change ? {
|
|
794
|
+
path: change.path,
|
|
795
|
+
bytes: fromBase64(change.base64)
|
|
796
|
+
} : change;
|
|
797
|
+
}
|
|
798
|
+
/** Keeps a local site's draft in a file, so it lasts between conversations and restarts. */
|
|
799
|
+
function fileKeeper(folder) {
|
|
800
|
+
const file = join(folder, LOCAL_DRAFT_FILE);
|
|
801
|
+
return {
|
|
802
|
+
async load() {
|
|
803
|
+
const text = await readFile(file, "utf8").catch(() => void 0);
|
|
804
|
+
if (!text) return void 0;
|
|
805
|
+
const stored = JSON.parse(text);
|
|
806
|
+
return {
|
|
807
|
+
...stored,
|
|
808
|
+
changes: stored.changes.map(readChange),
|
|
809
|
+
originals: Object.fromEntries(Object.entries(stored.originals).map(([path, original]) => [path, "base64" in original ? { bytes: fromBase64(original.base64) } : original]))
|
|
810
|
+
};
|
|
811
|
+
},
|
|
812
|
+
async save(draft) {
|
|
813
|
+
if (!draft) {
|
|
814
|
+
await rm(file, { force: true });
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
await mkdir(dirname(file), { recursive: true });
|
|
818
|
+
const stored = {
|
|
819
|
+
...draft,
|
|
820
|
+
changes: draft.changes.map(storeChange),
|
|
821
|
+
originals: Object.fromEntries(Object.entries(draft.originals).map(([path, original]) => [path, "bytes" in original ? { base64: toBase64(original.bytes) } : original]))
|
|
822
|
+
};
|
|
823
|
+
await writeFile(file, `${JSON.stringify(stored)}\n`);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function notHere(what) {
|
|
828
|
+
throw new ToolError(`${what} isn't available for a site in a folder on this computer.`);
|
|
829
|
+
}
|
|
830
|
+
/** A site in a folder as a backend: its files, and one draft at a time, which publishing writes into the folder. */
|
|
831
|
+
function localBackend(folder) {
|
|
832
|
+
const store = localFileStore(folder);
|
|
833
|
+
return {
|
|
834
|
+
...store,
|
|
835
|
+
revision: () => store.revision(),
|
|
836
|
+
read: (path) => store.read(path),
|
|
837
|
+
readBytes: (path) => store.readBytes(path),
|
|
838
|
+
list: (dir) => store.list(dir),
|
|
839
|
+
write: (changes, options) => store.write(changes, options),
|
|
840
|
+
user: {
|
|
841
|
+
login: basename(folder),
|
|
842
|
+
name: "You"
|
|
843
|
+
},
|
|
844
|
+
changedPaths: async () => notHere("What changed between versions"),
|
|
845
|
+
deployStatus: async () => ({ state: "unknown" }),
|
|
846
|
+
history: async () => notHere("Version history"),
|
|
847
|
+
readAt: async () => notHere("Version history"),
|
|
848
|
+
filesAt: () => notHere("Version history"),
|
|
849
|
+
drafts: keptDrafts(store, fileKeeper(folder)),
|
|
850
|
+
signOut: () => {}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
//#endregion
|
|
674
854
|
//#region src/stdio.ts
|
|
855
|
+
/**
|
|
856
|
+
* Goodfellow's tools on the editor's computer, for Claude Desktop (as an
|
|
857
|
+
* extension) and Claude Code (`claude mcp add`). It signs in with tokens,
|
|
858
|
+
* which stay on this computer:
|
|
859
|
+
*
|
|
860
|
+
* GOODFELLOW_SITE=github:owner/site GOODFELLOW_TOKEN=… npx @goodfellow-cms/mcp
|
|
861
|
+
*
|
|
862
|
+
* `GOODFELLOW_SITE` can name several sites, separated by commas or new lines,
|
|
863
|
+
* and so can the arguments: GitHub repositories (`github:owner/site`), GitLab
|
|
864
|
+
* projects (`gitlab:group/site`, with `GITLAB_URL` for self-managed GitLab),
|
|
865
|
+
* Bitbucket repositories (`bitbucket:workspace/site`, with an access token or
|
|
866
|
+
* an API token after its account's email address, `email:token`) and folders
|
|
867
|
+
* on this computer (`local:C:\Sites\parish`, or the folder's full path), read
|
|
868
|
+
* through their development server. `GOODFELLOW_TOKEN` signs in to every host;
|
|
869
|
+
* `GOODFELLOW_GITHUB_TOKEN`, `GOODFELLOW_GITLAB_TOKEN` and
|
|
870
|
+
* `GOODFELLOW_BITBUCKET_TOKEN` sign in to one each. `GOODFELLOW_BROWSER` points
|
|
871
|
+
* screenshots at a browser other than Chrome or Edge.
|
|
872
|
+
*/
|
|
675
873
|
/** Sign-ins keep nothing on disk: the token comes from the environment every time. */
|
|
676
874
|
function memoryStorage() {
|
|
677
875
|
const items = /* @__PURE__ */ new Map();
|
|
@@ -685,38 +883,86 @@ function fail(message) {
|
|
|
685
883
|
process.stderr.write(`goodfellow-mcp: ${message}\n`);
|
|
686
884
|
process.exit(1);
|
|
687
885
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
886
|
+
/** The sites given, from the environment and the arguments, each once. */
|
|
887
|
+
function givenSites() {
|
|
888
|
+
const listed = [process.env.GOODFELLOW_SITE ?? "", ...process.argv.slice(2)].flatMap((value) => value.split(/[\n,]/)).map((value) => value.trim()).filter((value) => value && !value.startsWith("${"));
|
|
889
|
+
return [...new Set(listed)];
|
|
890
|
+
}
|
|
891
|
+
const HOST_TOKENS = {
|
|
892
|
+
github: "GOODFELLOW_GITHUB_TOKEN",
|
|
893
|
+
gitlab: "GOODFELLOW_GITLAB_TOKEN",
|
|
894
|
+
bitbucket: "GOODFELLOW_BITBUCKET_TOKEN"
|
|
697
895
|
};
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
896
|
+
function setting(name) {
|
|
897
|
+
const value = process.env[name]?.trim();
|
|
898
|
+
return value && !value.startsWith("${") ? value : void 0;
|
|
899
|
+
}
|
|
900
|
+
function tokenFor(host) {
|
|
901
|
+
return setting(HOST_TOKENS[host]) ?? setting("GOODFELLOW_TOKEN");
|
|
902
|
+
}
|
|
903
|
+
const given = givenSites();
|
|
904
|
+
if (given.length === 0) fail("Set GOODFELLOW_SITE to the site's repository, such as github:owner/site, gitlab:group/site or bitbucket:workspace/site, or the folder it's in on this computer. Separate several with commas.");
|
|
905
|
+
const sites = /* @__PURE__ */ new Map();
|
|
906
|
+
for (const entry of given) {
|
|
907
|
+
const local = parseLocalSite(entry);
|
|
908
|
+
if (local) {
|
|
909
|
+
if (!isSiteFolder(local.folder)) fail(`${local.folder} isn't a Goodfellow site: it has no content/site.json.`);
|
|
910
|
+
const backend = localBackend(local.folder);
|
|
911
|
+
sites.set(local.id, {
|
|
912
|
+
id: local.id,
|
|
913
|
+
label: basename(local.folder),
|
|
914
|
+
backend: async () => backend,
|
|
915
|
+
local
|
|
718
916
|
});
|
|
719
|
-
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const site = parseSiteId(entry);
|
|
920
|
+
if (!site) fail(`${entry} isn't a site. Write it as github:owner/site, gitlab:group/site or bitbucket:workspace/site, or the full path of a folder on this computer.`);
|
|
921
|
+
const token = tokenFor(site.host);
|
|
922
|
+
if (!token) fail(`Set GOODFELLOW_TOKEN, or ${HOST_TOKENS[site.host]}, to a token that can edit ${entry}.`);
|
|
923
|
+
const storage = {
|
|
924
|
+
local: memoryStorage(),
|
|
925
|
+
session: memoryStorage()
|
|
926
|
+
};
|
|
927
|
+
const host = site.host === "github" ? github({
|
|
928
|
+
repo: site.repo,
|
|
929
|
+
signIn: false,
|
|
930
|
+
storage
|
|
931
|
+
}) : site.host === "bitbucket" ? bitbucket({
|
|
932
|
+
repo: site.repo,
|
|
933
|
+
storage
|
|
934
|
+
}) : gitlab({
|
|
935
|
+
project: site.repo,
|
|
936
|
+
...setting("GITLAB_URL") && { url: setting("GITLAB_URL") },
|
|
937
|
+
storage
|
|
938
|
+
});
|
|
939
|
+
let signedIn;
|
|
940
|
+
sites.set(entry, {
|
|
941
|
+
id: entry,
|
|
942
|
+
label: entry,
|
|
943
|
+
backend: () => {
|
|
944
|
+
signedIn ??= host.signInWithToken(token, false).catch((error) => {
|
|
945
|
+
signedIn = void 0;
|
|
946
|
+
throw error;
|
|
947
|
+
});
|
|
948
|
+
return signedIn;
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function siteFor(id) {
|
|
953
|
+
const site = sites.get(id) ?? sites.get(parseLocalSite(id)?.id ?? "");
|
|
954
|
+
if (!site) throw new ToolError(`This connector edits ${[...sites.keys()].join(", ")}, not ${id}.`);
|
|
955
|
+
return site;
|
|
956
|
+
}
|
|
957
|
+
await createGoodfellowServer({
|
|
958
|
+
sites: async () => [...sites.values()].map((site) => ({
|
|
959
|
+
id: site.id,
|
|
960
|
+
label: site.label
|
|
961
|
+
})),
|
|
962
|
+
backend: (id) => siteFor(id).backend(),
|
|
963
|
+
served: async (id) => {
|
|
964
|
+
const { local } = siteFor(id);
|
|
965
|
+
return local ? servedAt(local) : void 0;
|
|
720
966
|
},
|
|
721
967
|
screenshots: localScreenshots(process.env.GOODFELLOW_BROWSER ? { executablePath: process.env.GOODFELLOW_BROWSER } : {})
|
|
722
968
|
}).connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,11 +37,11 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@goodfellow-cms/ai": "0.
|
|
41
|
-
"@goodfellow-cms/core": "0.
|
|
42
|
-
"@goodfellow-cms/bitbucket": "0.
|
|
43
|
-
"@goodfellow-cms/github": "0.
|
|
44
|
-
"@goodfellow-cms/gitlab": "0.
|
|
40
|
+
"@goodfellow-cms/ai": "0.8.0",
|
|
41
|
+
"@goodfellow-cms/core": "0.8.0",
|
|
42
|
+
"@goodfellow-cms/bitbucket": "0.8.0",
|
|
43
|
+
"@goodfellow-cms/github": "0.8.0",
|
|
44
|
+
"@goodfellow-cms/gitlab": "0.8.0",
|
|
45
45
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
46
46
|
"zod": "4.6.6"
|
|
47
47
|
},
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"playwright-core": "1.63.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@goodfellow-cms/blocks": "0.
|
|
53
|
-
"@goodfellow-cms/react": "0.
|
|
52
|
+
"@goodfellow-cms/blocks": "0.8.0",
|
|
53
|
+
"@goodfellow-cms/react": "0.8.0",
|
|
54
54
|
"@puckeditor/core": "0.23.0",
|
|
55
55
|
"@types/node": "^22.19.0",
|
|
56
56
|
"fflate": "0.8.3"
|