@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.12

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.
Files changed (76) hide show
  1. package/README.md +144 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +14 -86
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +71 -23
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +44 -46
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +73 -29
  18. package/dist/endpoint/server.mjs +85 -22
  19. package/dist/endpoint/upload.mjs +135 -0
  20. package/dist/entity.d.mts +9 -0
  21. package/dist/guards.mjs +7 -0
  22. package/dist/i18n.mjs +10 -8
  23. package/dist/index.d.mts +5 -2
  24. package/dist/index.mjs +3 -2
  25. package/dist/options.mjs +54 -47
  26. package/dist/plugin.d.mts +2 -2
  27. package/dist/plugin.mjs +13 -3
  28. package/dist/request.d.mts +2 -3
  29. package/dist/request.mjs +2 -3
  30. package/dist/result.d.mts +2 -3
  31. package/dist/result.mjs +3 -3
  32. package/dist/schema/admin-hidden.mjs +91 -0
  33. package/dist/schema/describe.mjs +17 -33
  34. package/dist/schema/errors.mjs +8 -0
  35. package/dist/schema/index.mjs +5 -2
  36. package/dist/schema/lexical-pointer.mjs +18 -20
  37. package/dist/schema/lexical.mjs +44 -93
  38. package/dist/schema/outline.mjs +5 -15
  39. package/dist/schema/path.mjs +28 -0
  40. package/dist/schema/pointer.mjs +18 -25
  41. package/dist/schema/shape.mjs +25 -71
  42. package/dist/schema/walk.mjs +43 -45
  43. package/dist/tools/builtin.mjs +4 -4
  44. package/dist/tools/create-document.mjs +50 -24
  45. package/dist/tools/describe-schema.mjs +38 -34
  46. package/dist/tools/document.mjs +124 -0
  47. package/dist/tools/find-documents.mjs +32 -19
  48. package/dist/tools/find-versions.mjs +22 -18
  49. package/dist/tools/get-document.mjs +60 -33
  50. package/dist/tools/list-capabilities.mjs +35 -19
  51. package/dist/tools/patch-document.mjs +122 -66
  52. package/dist/tools/publish-document.mjs +26 -32
  53. package/dist/tools/query-paths.mjs +57 -0
  54. package/dist/tools/read-request.d.mts +10 -0
  55. package/dist/tools/read-request.mjs +43 -0
  56. package/dist/tools/shared.mjs +74 -105
  57. package/dist/tools/validate-document.mjs +18 -30
  58. package/dist/tools/versions.mjs +37 -20
  59. package/dist/types.d.mts +135 -75
  60. package/dist/upload/file.mjs +166 -0
  61. package/dist/upload/grant.mjs +96 -0
  62. package/dist/version.mjs +3 -1
  63. package/dist/write/draft-guard.mjs +23 -41
  64. package/dist/write/patch.mjs +27 -183
  65. package/dist/write/publish-blockers.mjs +7 -14
  66. package/dist/write/publish-intent.mjs +6 -4
  67. package/dist/write/row-ids.mjs +46 -0
  68. package/dist/write/transaction.mjs +5 -8
  69. package/dist/write/write-data.mjs +64 -0
  70. package/package.json +11 -10
  71. package/dist/client/capability-matrix.d.mts +0 -26
  72. package/dist/client/capability-matrix.mjs +0 -349
  73. package/dist/endpoint/index.mjs +0 -4
  74. package/dist/tools/target.mjs +0 -45
  75. /package/dist/{client → admin}/index.d.mts +0 -0
  76. /package/dist/{client → admin}/index.mjs +0 -0
@@ -1,44 +1,107 @@
1
1
  import { toToolError } from "./errors.mjs";
2
- import { draftSentence } from "../tools/shared.mjs";
2
+ import { fileSlugs } from "../upload/file.mjs";
3
+ import { liveWriteSlugs } from "../tools/shared.mjs";
4
+ import { MCPX_REPOSITORY_URL } from "../version.mjs";
3
5
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
4
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
7
  import { z } from "zod";
6
8
  //#region src/endpoint/server.ts
7
- /** Strict, so an unknown argument is rejected by name rather than stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
8
- /** May be built from the scope, to name the targets this key writes live. */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
9
- /** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
9
+ /**
10
+ * Strict, so an unknown argument is rejected by name instead of stripped.
11
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
12
+ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
13
+ /**
14
+ * A tool that does not decide for itself is gated by its own checkbox.
15
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
16
+ /**
17
+ * Runs one tool call. Payload's public errors become error results; anything
18
+ * else is logged and reported as an internal error.
19
+ */ const runTool = async (tool, args, scope, req, extra) => {
20
+ try {
21
+ return await tool.handler({
22
+ args,
23
+ scope,
24
+ req,
25
+ extra
26
+ });
27
+ } catch (error) {
28
+ return toToolError(error, req.payload.logger);
29
+ }
30
+ };
31
+ const WRITE_STEP = {
32
+ tools: ["patchDocument", "createDocument"],
33
+ does: "the write"
34
+ };
35
+ const WORKFLOW = [
36
+ {
37
+ tools: ["listCapabilities"],
38
+ does: "what this key may access"
39
+ },
40
+ {
41
+ tools: ["describeSchema"],
42
+ does: "the fields"
43
+ },
44
+ {
45
+ tools: ["findDocuments", "getDocument"],
46
+ does: "the content and its \"updatedAt\""
47
+ },
48
+ WRITE_STEP,
49
+ {
50
+ tools: ["validateDocument"],
51
+ does: "what blocks publishing"
52
+ },
53
+ {
54
+ tools: ["publishDocument"],
55
+ does: "make the draft public"
56
+ }
57
+ ];
58
+ const UPLOAD_STEP = "A write that returns \"upload\": PUT the file bytes to its \"url\" with its \"headers\". The response is the result of the write.";
59
+ const PATHS = `describeSchema takes and returns schema paths. Every other "path", and "from" in a patch operation, is a pointer into a document, with a 0-based index where a schema path has "*" for an array element or a block slug: "/layout/sections/sectionWrapper/identifier" is "/layout/sections/0/identifier". In rich text a pointer enters the rich text state: "/content/block/callout/tone" is "/content/root/children/3/fields/tone". getDocument "outline" gives the index.`;
60
+ const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
61
+ const DRAFTS_ONLY = "Every write is saved as a draft.";
62
+ /**
63
+ * The workflow for the tools this key has, and what its writes reach.
64
+ */ const serverInstructions = (scope) => {
65
+ const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
66
+ const uploads = fileSlugs(scope).length > 0;
67
+ const steps = WORKFLOW.flatMap((step) => {
68
+ const present = step.tools.filter((name) => enabled.has(name));
69
+ if (present.length === 0) return [];
70
+ const line = `${present.join(" or ")}: ${step.does}.`;
71
+ return step === WRITE_STEP && uploads ? [line, UPLOAD_STEP] : [line];
72
+ }).map((step, index) => `${String(index + 1)}. ${step}`);
73
+ const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
74
+ const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
75
+ const writes = enabled.has("patchDocument") ? `\n\n${drafts}${publishing}` : "";
76
+ const paths = enabled.has("describeSchema") ? `\n\n${PATHS}` : "";
77
+ return `Call the tools in this order:\n${steps.join("\n")}${paths}${writes}`;
78
+ };
10
79
  /**
11
80
  * One server per request. Builtin and configured tools take the same route,
12
81
  * each registered against the key's capabilities, so `tools/list` shows exactly
13
- * what the key may call.
82
+ * what the key may call. The tool handlers of one JSON-RPC batch run one at a time,
83
+ * so a failed call cannot roll back a write another call reported as done.
14
84
  */ const createMcpServer = (scope, options) => {
15
85
  const { req } = scope;
16
- const { logger } = req.payload;
86
+ let queue = Promise.resolve();
17
87
  const server = new McpServer({
18
88
  name: options.serverInfo.name,
19
- version: options.serverInfo.version
20
- }, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
21
- const guarded = (run) => async () => {
22
- try {
23
- return await run();
24
- } catch (error) {
25
- return toToolError(error, logger);
26
- }
27
- };
89
+ version: options.serverInfo.version,
90
+ ...options.diagnostics ? { websiteUrl: MCPX_REPOSITORY_URL } : {}
91
+ }, { instructions: serverInstructions(scope) });
28
92
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
29
93
  if (!isToolEnabled(tool, scope)) continue;
30
94
  server.registerTool(tool.name, {
31
95
  description: toolDescription(tool, scope),
32
96
  inputSchema: toolInputSchema(tool, scope),
33
97
  ...tool.annotations ? { annotations: tool.annotations } : {}
34
- }, (args, extra) => guarded(() => tool.handler({
35
- args,
36
- scope,
37
- req,
38
- extra
39
- }))());
98
+ }, (args, extra) => {
99
+ const result = queue.then(async () => await runTool(tool, args, scope, req, extra));
100
+ queue = result.catch(() => void 0);
101
+ return result;
102
+ });
40
103
  }
41
104
  return server;
42
105
  };
43
106
  //#endregion
44
- export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
107
+ export { createMcpServer, isToolEnabled, runTool, toolInputSchema };
@@ -0,0 +1,135 @@
1
+ import { checkApiKey } from "../auth/resolve.mjs";
2
+ import { claimGrant, uploadKvSlug } from "../upload/grant.mjs";
3
+ import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
4
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
5
+ import { isToolEnabled, runTool, toolInputSchema } from "./server.mjs";
6
+ import { authenticateAs } from "./handler.mjs";
7
+ //#region src/endpoint/upload.ts
8
+ const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
9
+ const respond = (status, body) => Response.json(body, { status });
10
+ const refused = () => respond(403, { error: "The request was refused: the grant is unknown, expired or used, or the key no longer allows it. Call the tool again for a new one." });
11
+ const readExactly = async (req, size) => {
12
+ const declared = req.headers.get("content-length");
13
+ if (declared !== null && Number(declared) !== size) return null;
14
+ if (!req.body) return null;
15
+ const chunks = [];
16
+ let total = 0;
17
+ for await (const chunk of req.body) {
18
+ total += chunk.byteLength;
19
+ if (total > size) return null;
20
+ chunks.push(chunk);
21
+ }
22
+ return total === size ? Buffer.concat(chunks) : null;
23
+ };
24
+ const statusOf = (result, data) => {
25
+ if (result.isError !== true) return 200;
26
+ if ("updatedAt" in data || data["status"] === 423) return 409;
27
+ if (data["status"] === 401 || data["status"] === 403) return 403;
28
+ return data["error"] === "Internal error" ? 500 : 422;
29
+ };
30
+ const parseResult = (result) => {
31
+ const [first] = result.content;
32
+ return first?.type === "text" ? JSON.parse(first.text) : {};
33
+ };
34
+ const NO_SESSION = {
35
+ signal: new AbortController().signal,
36
+ requestId: "upload",
37
+ sendNotification: () => Promise.resolve(),
38
+ sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
39
+ };
40
+ const reauthenticate = async (req, options, grant) => {
41
+ const keyDoc = await req.payload.findByID({
42
+ collection: options.apiKeysSlug,
43
+ id: grant.apiKeyId,
44
+ depth: 0,
45
+ overrideAccess: true,
46
+ disableErrors: true,
47
+ select: {
48
+ enabled: true,
49
+ user: true,
50
+ capabilities: true,
51
+ expiresAt: true
52
+ }
53
+ });
54
+ return await checkApiKey(req, options, keyDoc ?? void 0);
55
+ };
56
+ const claimAs = async (req, options, kind) => {
57
+ const slug = uploadKvSlug(req.payload.config);
58
+ const grantId = req.headers.get("x-mcpx-grant");
59
+ if (slug === void 0 || options.auth?.resolve || grantId === null) return;
60
+ const grant = await claimGrant(req.payload, slug, grantId);
61
+ if (grant?.kind !== kind) return;
62
+ const auth = await reauthenticate(req, options, grant);
63
+ if (!auth) return;
64
+ const scope = authenticateAs(req, options, auth);
65
+ req.query = {};
66
+ for (const name of [...req.searchParams.keys()]) req.searchParams.delete(name);
67
+ req.locale = grant.locale;
68
+ req.fallbackLocale = grant.fallbackLocale;
69
+ return {
70
+ grant,
71
+ scope
72
+ };
73
+ };
74
+ /**
75
+ * Completes a call that returned an `upload`: claims the grant, acts as its
76
+ * key again, then runs the same tool with the same arguments and the body as
77
+ * the file. The response body is the tool's result. The grant header is never
78
+ * logged.
79
+ */ const createUploadHandler = (options) => async (req) => {
80
+ const { payload } = req;
81
+ const claimed = await claimAs(req, options, "upload");
82
+ const file = claimed?.grant.args["file"];
83
+ if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !file) return refused();
84
+ const { grant, scope } = claimed;
85
+ const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
86
+ if (!tool || !isToolEnabled(tool, scope)) return refused();
87
+ const args = toolInputSchema(tool, scope).safeParse(grant.args);
88
+ if (!args.success) return refused();
89
+ if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
90
+ const data = await readExactly(req, file.size);
91
+ if (data === null) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
92
+ setUploadedFile(req, {
93
+ data,
94
+ mimetype: file.mimeType,
95
+ name: file.filename,
96
+ size: data.length
97
+ });
98
+ const result = await runTool(tool, args.data, scope, req, NO_SESSION);
99
+ const body = parseResult(result);
100
+ return respond(statusOf(result, body), body);
101
+ };
102
+ /**
103
+ * Serves the file of a `getDocument` call that returned a `download`: claims
104
+ * the grant, acts as its key again, reloads the document as its user and
105
+ * hands the request to the collection's own `/file/:filename` endpoint, so
106
+ * Payload's file access, storage handlers and response headers apply.
107
+ */ const createDownloadHandler = (options) => async (req) => {
108
+ const claimed = await claimAs(req, options, "download");
109
+ if (!claimed || !downloadSlugs(claimed.scope).includes(claimed.grant.collection)) return refused();
110
+ const { collection, id, draft } = claimed.grant;
111
+ const doc = await req.payload.findByID({
112
+ collection,
113
+ id,
114
+ depth: 0,
115
+ draft,
116
+ overrideAccess: false,
117
+ disableErrors: true,
118
+ select: {
119
+ filename: true,
120
+ prefix: true
121
+ },
122
+ req
123
+ });
124
+ const { endpoints } = req.payload.collections[collection]?.config ?? {};
125
+ const endpoint = Array.isArray(endpoints) ? endpoints.find((candidate) => candidate.method === "get" && candidate.path === "/file/:filename") : void 0;
126
+ if (typeof doc?.filename !== "string" || !endpoint) return refused();
127
+ if (typeof doc.prefix === "string") req.searchParams.set("prefix", doc.prefix);
128
+ req.routeParams = {
129
+ collection,
130
+ filename: doc.filename
131
+ };
132
+ return await endpoint.handler(req);
133
+ };
134
+ //#endregion
135
+ export { createDownloadHandler, createUploadHandler };
@@ -0,0 +1,9 @@
1
+ import "payload";
2
+ //#region src/entity.d.ts
3
+ /**
4
+ * The id type follows the database adapter and a collection's own `id` field,
5
+ * so one project can have both. Payload's `DefaultDocumentIDType` narrows to
6
+ * the project default, so it cannot be used here.
7
+ */
8
+ export type DocumentId = number | string;
9
+ //#endregion
@@ -0,0 +1,7 @@
1
+ //#region src/guards.ts
2
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ /**
4
+ * An own property only, so a client-chosen key never reads the prototype.
5
+ */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
6
+ //#endregion
7
+ export { isPlainObject, ownValue };
package/dist/i18n.mjs CHANGED
@@ -1,27 +1,29 @@
1
1
  //#region src/i18n.ts
2
2
  const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
3
- /** Treats an empty value as absent, so the fallback chain continues. */ const pick = (record, language) => {
3
+ const pick = (record, language) => {
4
4
  for (const code of Array.isArray(language) ? language : [language]) {
5
5
  const entry = record[code];
6
6
  if (entry !== void 0 && entry.trim() !== "") return entry;
7
7
  }
8
8
  };
9
9
  /**
10
- * Resolves a static label or `admin.description` to the one string a client
11
- * can use: the request's language, then the fallback language configured for
12
- * the deployment, then whichever entry the record declares first.
10
+ * Resolves a static label or `admin.description` to one string: the request's
11
+ * language, then the configured fallback language, then the first entry the
12
+ * record declares.
13
13
  *
14
14
  * Anything that is not a string or a string-valued record is dropped. A
15
- * description written as a function or a React component is an admin-UI
16
- * construct that may reach client-only i18n, so it is never invoked here.
15
+ * function or React component description may reach client-only i18n, so it is
16
+ * never invoked.
17
17
  */ const translateStatic = (value, language) => {
18
18
  if (typeof value === "string") return value.trim() === "" ? void 0 : value;
19
19
  const record = stringRecord(value);
20
20
  if (!record) return;
21
21
  return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
22
22
  };
23
- /** Bound to one request's language, so a walk carries no request of its own. */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
- /** For callers with no request: both keys miss, so the first entry wins. */ const translateAny = translatorFor({
23
+ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
+ /**
25
+ * For callers with no request. Both languages miss, so the first entry wins.
26
+ */ const translateAny = translatorFor({
25
27
  fallbackLanguage: "",
26
28
  language: ""
27
29
  });
package/dist/index.d.mts CHANGED
@@ -1,5 +1,8 @@
1
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
1
+ import { DocumentId } from "./entity.mjs";
2
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode } from "./types.mjs";
3
+ import { defineMcpxTool } from "./define-tool.mjs";
2
4
  import { mcpxPlugin } from "./plugin.mjs";
3
5
  import { isMcpxRequest } from "./request.mjs";
4
6
  import { errorResult, jsonResult } from "./result.mjs";
5
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
7
+ import { mcpxReadRequest } from "./tools/read-request.mjs";
8
+ export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, type McpxWriteMode, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
+ import { defineMcpxTool } from "./define-tool.mjs";
1
2
  import { errorResult, jsonResult } from "./result.mjs";
2
- import { defineMcpxTool } from "./types.mjs";
3
+ import { mcpxReadRequest } from "./tools/read-request.mjs";
3
4
  import { isMcpxRequest } from "./request.mjs";
4
5
  import { mcpxPlugin } from "./plugin.mjs";
5
- export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
6
+ export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
package/dist/options.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
1
+ import { isPlainObject } from "./guards.mjs";
2
2
  import "./version.mjs";
3
+ import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
3
4
  import { InvalidConfiguration } from "payload";
4
5
  import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
5
6
  //#region src/options.ts
@@ -11,45 +12,55 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
11
12
  const fail = (message) => {
12
13
  throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
13
14
  };
14
- /** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
15
15
  /**
16
- * Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
17
- * read, and email or lockout state is PII either way. Refused for read too.
18
- */ const assertExposable = (collection, apiKeysSlug) => {
16
+ * The same transform the stock MCP plugin uses to derive field names.
17
+ */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
18
+ const assertExposable = (collection, apiKeysSlug) => {
19
19
  const { slug } = collection;
20
20
  if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
21
21
  if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
22
22
  };
23
- /** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
24
- if (value === void 0 || value === false) return false;
25
- if (value === "draft" || value === "live") return value;
26
- return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
23
+ const OPTION_NAMES = [
24
+ "read",
25
+ "write",
26
+ "publish"
27
+ ];
28
+ const REMOVED_WRITE_MODES = {
29
+ draft: "Use { publish: false } instead.",
30
+ live: "Writes are on by default, so remove it."
27
31
  };
28
- /**
29
- * `localizeStatus` makes `_status` a localized field, which flips Payload's
30
- * `publishAllLocales` default to false and turns `_status` into a locale-keyed
31
- * object. Publishing would then cover one locale while reporting success, and
32
- * the tool responses model `_status` as a string. Refused until both are
33
- * handled.
34
- */ const assertPublishable = (kind, config) => {
35
- if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
32
+ const readFlag = (kind, slug, name, value) => {
33
+ if (value === void 0 || typeof value === "boolean") return value;
34
+ const moved = name === "write" && typeof value === "string" ? REMOVED_WRITE_MODES[value] : void 0;
35
+ return fail(`${kind} "${slug}" has ${name}: ${JSON.stringify(value)}. ${moved ?? "Use true or false."}`);
36
36
  };
37
- const assertWritable = (collection, options) => {
38
- const { slug } = collection;
39
- if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
40
- if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
41
- if (options.write === "live") assertPublishable("Collection", collection);
37
+ const assertWritable = (kind, config, write) => {
38
+ if (write === "live" && hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which live writes do not support yet. Set publish: false or write: false.`);
42
39
  };
43
- /** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
44
- if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
40
+ const normalizeCapabilities = (kind, config, raw) => {
41
+ const { slug } = config;
42
+ if (raw === false) fail(`${kind} "${slug}" is set to false. Remove the entry to hide the ${kind.toLowerCase()}.`);
43
+ if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${OPTION_NAMES.join(", ")}.`);
44
+ const settings = isPlainObject(raw) ? raw : {};
45
+ const unknownKey = Object.keys(settings).find((key) => !OPTION_NAMES.includes(key));
46
+ if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${OPTION_NAMES.join(", ")}.`);
47
+ const flag = (name) => readFlag(kind, slug, name, settings[name]);
48
+ const read = flag("read") ?? true;
49
+ const write = flag("write") ?? true;
50
+ const publish = flag("publish");
51
+ const hasDrafts = hasDraftsEnabled(config);
52
+ if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
53
+ if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
54
+ if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
55
+ return {
56
+ read,
57
+ write: write ? publish === false ? "draft" : "live" : false,
58
+ hasDrafts,
59
+ hasVersions: read && Boolean(config.versions)
60
+ };
45
61
  };
46
- /**
47
- * `GlobalConfig` has no `timestamps` option and `sanitizeGlobal` always appends
48
- * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
49
- * for is always available here. Drafts are the only requirement left.
50
- */ const assertGlobalWritable = (global, options) => {
51
- if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
52
- if (options.write === "live") assertPublishable("Global", global);
62
+ const assertGlobalExposable = (global) => {
63
+ if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
53
64
  };
54
65
  const normalizeCollections = (config, options, apiKeysSlug) => {
55
66
  const collections = config.collections ?? [];
@@ -59,18 +70,16 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
59
70
  const collection = collections.find((candidate) => candidate.slug === slug);
60
71
  if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
61
72
  assertExposable(collection, apiKeysSlug);
62
- const settings = raw === true ? {} : raw;
63
- const hasDrafts = hasDraftsEnabled(collection);
64
73
  const normalized = {
65
74
  slug,
66
- read: settings.read ?? true,
67
- write: normalizeWriteMode("Collection", slug, settings.write),
68
- hasDrafts,
69
- hasVersions: Boolean(collection.versions),
75
+ ...normalizeCapabilities("Collection", collection, raw),
70
76
  isUpload: Boolean(collection.upload),
71
77
  fieldName: toCamelCase(slug)
72
78
  };
73
- if (normalized.write !== false) assertWritable(collection, normalized);
79
+ if (normalized.write !== false) {
80
+ if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
81
+ assertWritable("Collection", collection, normalized.write);
82
+ }
74
83
  if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
75
84
  fieldNames.add(normalized.fieldName);
76
85
  return [normalized];
@@ -84,18 +93,13 @@ const normalizeGlobals = (config, options) => {
84
93
  const global = globals.find((candidate) => candidate.slug === slug);
85
94
  if (!global) return fail(`Exposed global "${slug}" does not exist.`);
86
95
  assertGlobalExposable(global);
87
- const settings = raw === true ? {} : raw;
88
- const hasDrafts = hasDraftsEnabled(global);
89
96
  const normalized = {
90
97
  slug,
91
- read: settings.read ?? true,
92
- write: normalizeWriteMode("Global", slug, settings.write),
93
- hasDrafts,
94
- hasVersions: Boolean(global.versions),
98
+ ...normalizeCapabilities("Global", global, raw),
95
99
  isUpload: false,
96
100
  fieldName: toCamelCase(slug)
97
101
  };
98
- if (normalized.write !== false) assertGlobalWritable(global, normalized);
102
+ if (normalized.write !== false) assertWritable("Global", global, normalized.write);
99
103
  if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
100
104
  fieldNames.add(normalized.fieldName);
101
105
  return [normalized];
@@ -125,7 +129,9 @@ const normalizeLimits = (limits) => {
125
129
  maxDepth
126
130
  };
127
131
  };
128
- /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
132
+ /**
133
+ * Every problem is an `InvalidConfiguration`, so it fails at startup.
134
+ */ const normalizeOptions = (config, options) => {
129
135
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
130
136
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
131
137
  if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
@@ -145,7 +151,8 @@ const normalizeLimits = (limits) => {
145
151
  serverInfo: {
146
152
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
147
153
  version: options.serverInfo?.version ?? "1.0.0"
148
- }
154
+ },
155
+ diagnostics: options.diagnostics ?? true
149
156
  };
150
157
  };
151
158
  //#endregion
package/dist/plugin.d.mts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { McpxPluginOptions } from "./types.mjs";
2
2
  //#region src/plugin.d.ts
3
3
  /**
4
- * Mounts the MCP endpoint, adds the API key collection and installs the
5
- * draft guard on every collection and global.
4
+ * Mounts the MCP, upload and download endpoints, adds the API key collection
5
+ * and installs the draft guard on every collection and global.
6
6
  */
7
7
  export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
8
8
  //#endregion
package/dist/plugin.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
- import "./endpoint/index.mjs";
3
+ import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
4
4
  import { normalizeOptions } from "./options.mjs";
5
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
6
6
  import { definePlugin } from "payload";
7
7
  //#region src/plugin.ts
8
8
  /**
9
- * Mounts the MCP endpoint, adds the API key collection and installs the
10
- * draft guard on every collection and global.
9
+ * Mounts the MCP, upload and download endpoints, adds the API key collection
10
+ * and installs the draft guard on every collection and global.
11
11
  */ const mcpxPlugin = definePlugin({
12
12
  slug: "@abinnovision/payloadcms-mcpx",
13
13
  order: 100,
@@ -35,6 +35,16 @@ import { definePlugin } from "payload";
35
35
  path: normalized.endpointPath,
36
36
  method: "delete",
37
37
  handler: methodNotAllowed
38
+ },
39
+ {
40
+ path: `${normalized.endpointPath}/upload`,
41
+ method: "put",
42
+ handler: createUploadHandler(normalized)
43
+ },
44
+ {
45
+ path: `${normalized.endpointPath}/file`,
46
+ method: "get",
47
+ handler: createDownloadHandler(normalized)
38
48
  }
39
49
  ]
40
50
  };
@@ -1,9 +1,8 @@
1
1
  import { PayloadRequest } from "payload";
2
2
  //#region src/request.d.ts
3
3
  /**
4
- * Whether a request originated from the MCP endpoint. The endpoint stamps
5
- * `req.context.mcpx`, which travels into every local API call made with the
6
- * same `req`, including those made by custom tools.
4
+ * Whether `req` came through the MCP endpoint, which stamps `req.context.mcpx`.
5
+ * The stamp follows `req` into every local API call, including custom tools.
7
6
  */
8
7
  export declare const isMcpxRequest: (req: PayloadRequest) => boolean;
9
8
  //#endregion
package/dist/request.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  //#region src/request.ts
2
2
  /**
3
- * Whether a request originated from the MCP endpoint. The endpoint stamps
4
- * `req.context.mcpx`, which travels into every local API call made with the
5
- * same `req`, including those made by custom tools.
3
+ * Whether `req` came through the MCP endpoint, which stamps `req.context.mcpx`.
4
+ * The stamp follows `req` into every local API call, including custom tools.
6
5
  */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
7
6
  //#endregion
8
7
  export { isMcpxRequest };
package/dist/result.d.mts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  //#region src/result.d.ts
3
- /** `value` as JSON text. */
4
3
  export declare const jsonResult: (value: unknown) => CallToolResult;
5
4
  /**
6
- * `extras` travel alongside the message so the client can act on them:
7
- * problems, validation errors, the current `updatedAt`.
5
+ * `extras` carries data the client can act on, such as problems, validation
6
+ * errors or the current `updatedAt`.
8
7
  */
9
8
  export declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
10
9
  //#endregion
package/dist/result.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  //#region src/result.ts
2
- /** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
2
+ const jsonResult = (value) => ({ content: [{
3
3
  type: "text",
4
4
  text: JSON.stringify(value)
5
5
  }] });
6
6
  /**
7
- * `extras` travel alongside the message so the client can act on them:
8
- * problems, validation errors, the current `updatedAt`.
7
+ * `extras` carries data the client can act on, such as problems, validation
8
+ * errors or the current `updatedAt`.
9
9
  */ const errorResult = (message, extras = {}) => ({
10
10
  content: [{
11
11
  type: "text",