@abinnovision/payloadcms-mcpx 2.0.0-beta.10 → 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.
package/README.md CHANGED
@@ -103,6 +103,7 @@ Claude Desktop and the MCP Inspector are covered in
103
103
  | `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
104
104
  | `auth.resolve` | none | Replace or wrap the key lookup. |
105
105
  | `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
106
+ | `diagnostics` | `true` | Expose the server version plus repository and issue links. |
106
107
 
107
108
  The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
108
109
  checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
@@ -121,8 +122,8 @@ every check.
121
122
  | `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
122
123
  | `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
123
124
  | `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
124
- | `patchDocument` | Applies JSON Patch operations to the current draft. | `write` |
125
- | `createDocument` | Creates a draft from a seed. Not for upload collections. | `write` |
125
+ | `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write` |
126
+ | `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write` |
126
127
  | `validateDocument` | Lists what blocks publishing, without saving. | `write` |
127
128
  | `publishDocument` | Publishes the current draft. | `write`, `publish` |
128
129
 
@@ -163,6 +164,9 @@ To restrict that, use `apiKeys.overrideCollection` as shown in
163
164
  Blocker messages can name fields the user cannot read.
164
165
  - The request body is limited to 4 MB, a JSON-RPC batch to 10 messages, `patchDocument` to 500
165
166
  operations and `describeSchema` to 400 paths.
167
+ - A file is sent in a second step, a `PUT`, and downloaded with a `GET`, each with a short-lived,
168
+ single-use grant. Only collections with `upload.mimeTypes` take files. Downloads go through the
169
+ collection's own file access. See [uploads](./docs/security.md#uploads).
166
170
  - Custom tools are trusted code and must apply access control themselves.
167
171
 
168
172
  Open limitations: a filter through a relation into a readable collection ignores that collection's
@@ -1,4 +1,4 @@
1
- import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite } from "../capabilities.mjs";
1
+ import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabilities.mjs";
2
2
  //#region src/api-keys/capability-matrix.ts
3
3
  /**
4
4
  * The group's `admin.description`, rendered by the matrix in Payload's
@@ -25,12 +25,8 @@ import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite } from
25
25
  description: "Promote the current draft to what the public sees."
26
26
  }
27
27
  ];
28
- const UPLOAD_HINT = "Files are uploaded in the admin panel.";
29
28
  const LIVE_HINT = "Writes go live immediately.";
30
- const hintFor = (entity) => {
31
- const hints = [canWrite(entity) && !canCreate(entity) ? UPLOAD_HINT : void 0, isLiveWrite(entity) ? LIVE_HINT : void 0].filter((hint) => hint !== void 0);
32
- return hints.length > 0 ? { hint: hints.join(" ") } : {};
33
- };
29
+ const hintFor = (entity) => isLiveWrite(entity) ? { hint: LIVE_HINT } : {};
34
30
  const toRow = (entity) => ({
35
31
  fieldName: entity.fieldName,
36
32
  label: entity.slug,
@@ -22,6 +22,43 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
22
22
  return isId(auth["user"]["id"]) && auth["user"]["collection"] === options.userCollection && isId(auth["apiKeyId"]);
23
23
  };
24
24
  /**
25
+ * The checks a key passes once it is looked up: enabled, not expired, and its
26
+ * user exists, is verified and is not locked out. Yields the user it acts as,
27
+ * or `null`.
28
+ */ const checkApiKey = async (req, options, keyDoc) => {
29
+ const userId = relationId(keyDoc?.user);
30
+ if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
31
+ if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
32
+ const { payload } = req;
33
+ const userCollection = payload.collections[options.userCollection];
34
+ const lookup = {
35
+ collection: options.userCollection,
36
+ id: userId,
37
+ overrideAccess: true,
38
+ disableErrors: true
39
+ };
40
+ const [user, lock] = await Promise.all([payload.findByID({
41
+ ...lookup,
42
+ depth: userCollection?.config.auth.depth ?? 0
43
+ }), payload.findByID({
44
+ ...lookup,
45
+ depth: 0,
46
+ showHiddenFields: true,
47
+ select: { lockUntil: true }
48
+ })]);
49
+ const lockUntil = toTime(lock?.["lockUntil"]);
50
+ if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
51
+ return {
52
+ user: {
53
+ ...user,
54
+ collection: options.userCollection,
55
+ _strategy: "mcpx-api-key"
56
+ },
57
+ apiKeyId: keyDoc.id,
58
+ capabilities: keyDoc.capabilities
59
+ };
60
+ };
61
+ /**
25
62
  * Resolves the bearer key of a request to the user it acts as.
26
63
  *
27
64
  * The key is looked up by its HMAC index, the same way Payload resolves its own
@@ -47,27 +84,8 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
47
84
  }
48
85
  });
49
86
  const keyDoc = docs[0];
50
- const userId = relationId(keyDoc?.user);
51
- if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
52
- if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
53
- const userCollection = payload.collections[options.userCollection];
54
- const lookup = {
55
- collection: options.userCollection,
56
- id: userId,
57
- overrideAccess: true,
58
- disableErrors: true
59
- };
60
- const [user, lock] = await Promise.all([payload.findByID({
61
- ...lookup,
62
- depth: userCollection?.config.auth.depth ?? 0
63
- }), payload.findByID({
64
- ...lookup,
65
- depth: 0,
66
- showHiddenFields: true,
67
- select: { lockUntil: true }
68
- })]);
69
- const lockUntil = toTime(lock?.["lockUntil"]);
70
- if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
87
+ const auth = await checkApiKey(req, options, keyDoc);
88
+ if (!auth || !keyDoc) return null;
71
89
  const lastUsed = toTime(keyDoc.lastUsedAt);
72
90
  if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
73
91
  await payload.update({
@@ -84,15 +102,7 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
84
102
  msg: "Could not record the last use of an MCP API key"
85
103
  });
86
104
  }
87
- return {
88
- user: {
89
- ...user,
90
- collection: options.userCollection,
91
- _strategy: "mcpx-api-key"
92
- },
93
- apiKeyId: keyDoc.id,
94
- capabilities: keyDoc.capabilities
95
- };
105
+ return auth;
96
106
  };
97
107
  //#endregion
98
- export { isValidAuthResult, parseBearer, resolveApiKeyAuth };
108
+ export { checkApiKey, isValidAuthResult, parseBearer, resolveApiKeyAuth };
@@ -10,9 +10,9 @@ import { isPlainObject } from "./guards.mjs";
10
10
  * The config lets MCP change live content and a draft exists to promote.
11
11
  */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
12
12
  /**
13
- * An upload document is a file, and no tool here carries one. Its own fields
14
- * stay patchable; the first version is made in the admin panel.
15
- */ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
13
+ * Any writable collection. An upload collection also needs a file, which
14
+ * depends on the request, so the tools check it per call.
15
+ */ const canCreate = (entity) => canWrite(entity);
16
16
  /**
17
17
  * Without drafts there is no draft stage, so every write is live.
18
18
  */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
@@ -1,6 +1,7 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
2
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
3
3
  import { jsonRpcError } from "./errors.mjs";
4
+ import { uploadKvSlug } from "../upload/grant.mjs";
4
5
  import { createMcpServer } from "./server.mjs";
5
6
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
7
  //#region src/endpoint/handler.ts
@@ -34,6 +35,11 @@ const readBody = async (req) => {
34
35
  defaultLocale: localization.defaultLocale
35
36
  } : null,
36
37
  limits: options.limits,
38
+ uploads: options.auth?.resolve === void 0 && uploadKvSlug(req.payload.config) !== void 0,
39
+ diagnostics: options.diagnostics ? {
40
+ name: options.serverInfo.name,
41
+ version: options.serverInfo.version
42
+ } : null,
37
43
  exposure: {
38
44
  collections: options.collections,
39
45
  globals: options.globals
@@ -41,6 +47,22 @@ const readBody = async (req) => {
41
47
  };
42
48
  };
43
49
  /**
50
+ * Makes `req` act as the key: sets `req.user`, replacing any user Payload
51
+ * resolved from cookies, and the `req.context.mcpx` stamp the draft guard
52
+ * reads. Returns the scope the tools see.
53
+ */ const authenticateAs = (req, options, auth) => {
54
+ const capabilities = resolveCapabilities(options, auth.capabilities);
55
+ req.user = auth.user;
56
+ req.context = {
57
+ ...req.context,
58
+ mcpx: {
59
+ apiKeyId: auth.apiKeyId,
60
+ capabilities
61
+ }
62
+ };
63
+ return buildScope(req, options, capabilities);
64
+ };
65
+ /**
44
66
  * Answers GET and DELETE on the endpoint path. The server is stateless and
45
67
  * never streams, so only POST carries meaning.
46
68
  */ const methodNotAllowed = () => jsonRpcError({
@@ -69,15 +91,7 @@ const readBody = async (req) => {
69
91
  headers: { "www-authenticate": "Bearer" }
70
92
  });
71
93
  }
72
- const capabilities = resolveCapabilities(options, auth.capabilities);
73
- req.user = auth.user;
74
- req.context = {
75
- ...req.context,
76
- mcpx: {
77
- apiKeyId: auth.apiKeyId,
78
- capabilities
79
- }
80
- };
94
+ const scope = authenticateAs(req, options, auth);
81
95
  let parsedBody;
82
96
  try {
83
97
  const text = await readBody(req);
@@ -104,7 +118,7 @@ const readBody = async (req) => {
104
118
  code: -32600,
105
119
  message: `Invalid request: a batch may hold at most ${String(BATCH_MESSAGES_LIMIT)} messages.`
106
120
  });
107
- const server = createMcpServer(buildScope(req, options, capabilities), options);
121
+ const server = createMcpServer(scope, options);
108
122
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
109
123
  await server.connect(transport);
110
124
  const headers = new Headers(req.headers);
@@ -119,4 +133,4 @@ const readBody = async (req) => {
119
133
  }
120
134
  };
121
135
  //#endregion
122
- export { buildScope, createMcpxHandler, methodNotAllowed };
136
+ export { authenticateAs, buildScope, createMcpxHandler, methodNotAllowed };
@@ -1,5 +1,7 @@
1
1
  import { toToolError } from "./errors.mjs";
2
+ import { fileSlugs } from "../upload/file.mjs";
2
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";
@@ -11,6 +13,25 @@ const toolDescription = (tool, scope) => typeof tool.description === "function"
11
13
  /**
12
14
  * A tool that does not decide for itself is gated by its own checkbox.
13
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
+ };
14
35
  const WORKFLOW = [
15
36
  {
16
37
  tools: ["listCapabilities"],
@@ -24,10 +45,7 @@ const WORKFLOW = [
24
45
  tools: ["findDocuments", "getDocument"],
25
46
  does: "the content and its \"updatedAt\""
26
47
  },
27
- {
28
- tools: ["patchDocument", "createDocument"],
29
- does: "the write"
30
- },
48
+ WRITE_STEP,
31
49
  {
32
50
  tools: ["validateDocument"],
33
51
  does: "what blocks publishing"
@@ -37,6 +55,7 @@ const WORKFLOW = [
37
55
  does: "make the draft public"
38
56
  }
39
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.";
40
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.`;
41
60
  const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
42
61
  const DRAFTS_ONLY = "Every write is saved as a draft.";
@@ -44,9 +63,12 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
44
63
  * The workflow for the tools this key has, and what its writes reach.
45
64
  */ const serverInstructions = (scope) => {
46
65
  const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
47
- const steps = WORKFLOW.flatMap(({ tools, does }) => {
48
- const present = tools.filter((name) => enabled.has(name));
49
- return present.length === 0 ? [] : [`${present.join(" or ")}: ${does}.`];
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];
50
72
  }).map((step, index) => `${String(index + 1)}. ${step}`);
51
73
  const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
52
74
  const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
@@ -61,11 +83,11 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
61
83
  * so a failed call cannot roll back a write another call reported as done.
62
84
  */ const createMcpServer = (scope, options) => {
63
85
  const { req } = scope;
64
- const { logger } = req.payload;
65
86
  let queue = Promise.resolve();
66
87
  const server = new McpServer({
67
88
  name: options.serverInfo.name,
68
- version: options.serverInfo.version
89
+ version: options.serverInfo.version,
90
+ ...options.diagnostics ? { websiteUrl: MCPX_REPOSITORY_URL } : {}
69
91
  }, { instructions: serverInstructions(scope) });
70
92
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
71
93
  if (!isToolEnabled(tool, scope)) continue;
@@ -74,18 +96,7 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
74
96
  inputSchema: toolInputSchema(tool, scope),
75
97
  ...tool.annotations ? { annotations: tool.annotations } : {}
76
98
  }, (args, extra) => {
77
- const result = queue.then(async () => {
78
- try {
79
- return await tool.handler({
80
- args,
81
- scope,
82
- req,
83
- extra
84
- });
85
- } catch (error) {
86
- return toToolError(error, logger);
87
- }
88
- });
99
+ const result = queue.then(async () => await runTool(tool, args, scope, req, extra));
89
100
  queue = result.catch(() => void 0);
90
101
  return result;
91
102
  });
@@ -93,4 +104,4 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
93
104
  return server;
94
105
  };
95
106
  //#endregion
96
- export { createMcpServer, isToolEnabled, 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 };
package/dist/options.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from "./guards.mjs";
2
- import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
3
2
  import "./version.mjs";
3
+ import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
4
4
  import { InvalidConfiguration } from "payload";
5
5
  import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
6
6
  //#region src/options.ts
@@ -151,7 +151,8 @@ const normalizeLimits = (limits) => {
151
151
  serverInfo: {
152
152
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
153
153
  version: options.serverInfo?.version ?? "1.0.0"
154
- }
154
+ },
155
+ diagnostics: options.diagnostics ?? true
155
156
  };
156
157
  };
157
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,12 +1,13 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
3
4
  import { normalizeOptions } from "./options.mjs";
4
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
6
  import { definePlugin } from "payload";
6
7
  //#region src/plugin.ts
7
8
  /**
8
- * Mounts the MCP endpoint, adds the API key collection and installs the
9
- * 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.
10
11
  */ const mcpxPlugin = definePlugin({
11
12
  slug: "@abinnovision/payloadcms-mcpx",
12
13
  order: 100,
@@ -34,6 +35,16 @@ import { definePlugin } from "payload";
34
35
  path: normalized.endpointPath,
35
36
  method: "delete",
36
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)
37
48
  }
38
49
  ]
39
50
  };
@@ -2,10 +2,12 @@ import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { errorResult, jsonResult } from "../result.mjs";
3
3
  import { validateWriteValue } from "../schema/shape.mjs";
4
4
  import "../schema/index.mjs";
5
- import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
6
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
+ import { assertAcceptsFile, fileShape, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
7
+ import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
7
8
  import { readDraft, resolveEntity } from "./document.mjs";
8
9
  import { stripRowIds } from "../write/row-ids.mjs";
10
+ import { APIError } from "payload";
9
11
  import { z } from "zod";
10
12
  //#region src/tools/create-document.ts
11
13
  const uploadSentence = (scope) => {
@@ -16,8 +18,9 @@ const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only
16
18
 
17
19
  ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
18
20
  /**
19
- * Collection-only, because a global always exists. Upload collections are
20
- * excluded because a create there would need the file.
21
+ * Collection-only, because a global always exists. A create in an upload
22
+ * collection needs `file`: the call then returns an upload grant, and the PUT
23
+ * to it runs the call again with the bytes.
21
24
  *
22
25
  * The seed is checked against the collection's fields first, so an unknown key
23
26
  * is refused along with its valid siblings. Row ids in the seed are stripped
@@ -40,13 +43,17 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
40
43
  required: true,
41
44
  description: "Locale of the localized fields in \"data\"."
42
45
  }),
43
- data: z.record(z.string(), z.unknown()).describe("Field values by name, e.g. {\"title\":\"Home\",\"slug\":\"home\"}.")
46
+ data: z.record(z.string(), z.unknown()).describe("Field values by name, e.g. {\"title\":\"Home\",\"slug\":\"home\"}."),
47
+ ...fileShape(scope, (slugs) => `Required in ${slugs}. The call returns an "upload" for the bytes instead of writing.`)
44
48
  }),
45
49
  handler: async ({ args, scope }) => {
46
50
  const target = resolveEntity(scope, { collection: args.collection }, "create");
47
51
  if (target.kind === "global") throw new Error("createDocument resolved a global.");
48
52
  const { payload } = scope.req;
49
53
  const locale = localeOf(scope, args.locale);
54
+ const { file } = args;
55
+ if (file !== void 0) assertAcceptsFile(scope, target, file);
56
+ else if (scope.exposure.collections.some((entity) => entity.slug === target.slug && entity.isUpload)) throw new APIError(`"${target.slug}" stores files, so "file" is required.`, 400);
50
57
  if (Object.hasOwn(args.data, "id")) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
51
58
  const problems = validateWriteValue(payload.config, {
52
59
  pointer: "",
@@ -56,6 +63,21 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
56
63
  }
57
64
  }, args.data);
58
65
  if (problems.length > 0) return errorResult("Nothing was created.", { problems });
66
+ const bytes = uploadedFile(scope.req);
67
+ if (file !== void 0) {
68
+ const refusal = await fileWriteRefusal(scope, {
69
+ entity: target,
70
+ data: args.data
71
+ });
72
+ if (refusal) return refusal;
73
+ if (!bytes) return await uploadHandoff(scope, {
74
+ tool: "createDocument",
75
+ args: {
76
+ ...args,
77
+ file
78
+ }
79
+ });
80
+ }
59
81
  const created = await payload.create({
60
82
  collection: args.collection,
61
83
  data: stripRowIds(args.data),
@@ -63,7 +85,8 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
63
85
  draft: true,
64
86
  overrideAccess: false,
65
87
  req: scope.req,
66
- ...locale === void 0 ? {} : { locale }
88
+ ...locale === void 0 ? {} : { locale },
89
+ ...bytes === void 0 ? {} : { file: bytes }
67
90
  });
68
91
  const saved = await readDraft(scope, {
69
92
  target: {
@@ -1,6 +1,6 @@
1
1
  import { errorResult } from "../result.mjs";
2
- import { slugsFor } from "./shared.mjs";
3
2
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
3
+ import { slugsFor } from "./shared.mjs";
4
4
  import { APIError, Forbidden, NotFound } from "payload";
5
5
  //#region src/tools/document.ts
6
6
  /**
@@ -6,6 +6,7 @@ import { SchemaError } from "../schema/errors.mjs";
6
6
  import { lexicalOutline } from "../schema/outline.mjs";
7
7
  import { resolveDataPointer } from "../schema/pointer.mjs";
8
8
  import "../schema/index.mjs";
9
+ import { downloadHandoff, downloadSlugs } from "../upload/file.mjs";
9
10
  import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
10
11
  import { identityOf, resolveDocument } from "./document.mjs";
11
12
  import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
@@ -46,6 +47,18 @@ const versionShape = (scope) => {
46
47
  diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest version published in any locale. Returns {from, to, patch}, the JSON Patch from that version to what is read.")
47
48
  });
48
49
  };
50
+ const downloadShape = (scope) => {
51
+ const slugs = downloadSlugs(scope);
52
+ return widen(slugs.length === 0 ? {} : { download: z.literal(true).optional().describe(`Only for ${slugs.join(", ")}. Adds "download": GET its "url" with its "headers" once, within 5 minutes, for the file. Not with "path", "versionId" or "diffFrom".`) });
53
+ };
54
+ const assertDownloadArgs = (scope, target, args) => {
55
+ if (target.kind === "global" || !downloadSlugs(scope).includes(target.slug)) throw new APIError(`"${target.slug}" has no file to download.`, 400);
56
+ if (args.path !== void 0 || args.versionId !== void 0 || args.diffFrom !== void 0) throw new APIError("\"download\" cannot be combined with \"path\", \"versionId\" or \"diffFrom\".", 400);
57
+ };
58
+ const withDownload = async (scope, doc, target) => typeof doc["filename"] === "string" ? jsonResult({
59
+ ...doc,
60
+ download: await downloadHandoff(scope, target)
61
+ }) : errorResult("This document has no file to download.");
49
62
  /**
50
63
  * With `path` the handler returns the subtree plus the `id`, `_status` and
51
64
  * `updatedAt` a client needs to write back, so reading one branch still gives
@@ -69,11 +82,13 @@ const versionShape = (scope) => {
69
82
  }),
70
83
  draft: z.boolean().optional().describe("Default true. false reads the published version."),
71
84
  outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
72
- ...versionShape(scope)
85
+ ...versionShape(scope),
86
+ ...downloadShape(scope)
73
87
  }),
74
88
  handler: async ({ args, scope }) => {
75
89
  const target = resolveDocument(scope, args, "read");
76
90
  assertVersionArgs(scope, target, args);
91
+ if (args.download) assertDownloadArgs(scope, target, args);
77
92
  const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
78
93
  if (prototyped !== void 0) return errorResult(prototyped);
79
94
  let pointer;
@@ -96,7 +111,12 @@ const versionShape = (scope) => {
96
111
  }, doc);
97
112
  if (pointer === void 0) {
98
113
  if (args.outline) return errorResult(OUTLINE_ERROR);
99
- return jsonResult(doc);
114
+ return args.download && args.id !== void 0 ? await withDownload(scope, doc, {
115
+ collection: target.slug,
116
+ id: args.id,
117
+ draft: args.draft ?? true,
118
+ locale: read.locale ?? null
119
+ }) : jsonResult(doc);
100
120
  }
101
121
  const path = pointer.toString();
102
122
  const value = pointer.get(doc);
@@ -1,10 +1,12 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
- import { canCreate } from "../capabilities.mjs";
3
2
  import { jsonResult } from "../result.mjs";
4
3
  import { translateStatic, translatorFor } from "../i18n.mjs";
4
+ import { slugsFor } from "./shared.mjs";
5
+ import { MCPX_ISSUES_URL, MCPX_REPOSITORY_URL } from "../version.mjs";
5
6
  import { hasDraftValidationEnabled } from "payload/shared";
6
7
  //#region src/tools/list-capabilities.ts
7
8
  const DESCRIPTION = `Lists what this key may do. Call it first. Returns each collection and global the key can read or write, with whether it can create, publish, has drafts and exposes versions to findVersions, and a collection's "idType". Also returns the locales, the "limits" on page size and depth, and the enabled custom tools.`;
9
+ const DIAGNOSTICS = ` Its "server" block holds the server version with repository and issue links, for a bug report.`;
8
10
  const translateLabel = (scope, label, fallback) => {
9
11
  const { i18n, t } = scope.req;
10
12
  const resolved = typeof label === "function" ? label({
@@ -21,7 +23,7 @@ const translateLabel = (scope, label, fallback) => {
21
23
  * grows.
22
24
  */ const listCapabilities = defineMcpxTool({
23
25
  name: "listCapabilities",
24
- description: DESCRIPTION,
26
+ description: (scope) => scope.diagnostics ? `${DESCRIPTION}${DIAGNOSTICS}` : DESCRIPTION,
25
27
  annotations: {
26
28
  readOnlyHint: true,
27
29
  openWorldHint: false
@@ -31,6 +33,7 @@ const translateLabel = (scope, label, fallback) => {
31
33
  handler: ({ scope }) => {
32
34
  const { payload } = scope.req;
33
35
  const translate = translatorFor(scope.req.i18n);
36
+ const creatable = slugsFor(scope, "create").collections;
34
37
  const collections = scope.exposure.collections.flatMap((entry) => {
35
38
  const capability = scope.capabilities.collections[entry.slug];
36
39
  const collection = payload.collections[entry.slug];
@@ -46,7 +49,7 @@ const translateLabel = (scope, label, fallback) => {
46
49
  ...description === void 0 ? {} : { description },
47
50
  read: capability.read,
48
51
  write: capability.write,
49
- create: capability.write && canCreate(entry),
52
+ create: creatable.includes(entry.slug),
50
53
  publish: capability.publish,
51
54
  drafts: entry.hasDrafts,
52
55
  versions: entry.hasVersions,
@@ -79,7 +82,13 @@ const translateLabel = (scope, label, fallback) => {
79
82
  default: scope.localization.defaultLocale
80
83
  } : null,
81
84
  limits: scope.limits,
82
- tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
85
+ tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name),
86
+ ...scope.diagnostics ? { server: {
87
+ name: scope.diagnostics.name,
88
+ version: scope.diagnostics.version,
89
+ repository: MCPX_REPOSITORY_URL,
90
+ issues: MCPX_ISSUES_URL
91
+ } } : {}
83
92
  });
84
93
  }
85
94
  });
@@ -3,13 +3,14 @@ import { isPlainObject } from "../guards.mjs";
3
3
  import { errorResult, jsonResult } from "../result.mjs";
4
4
  import { JSON_POINTER_PATTERN } from "../schema/path.mjs";
5
5
  import "../schema/index.mjs";
6
- import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
7
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
+ import { assertAcceptsFile, fileShape, fileSlugs, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
8
+ import { entityShape, idShape, liveWriteSentence, localeOf, localeShape } from "./shared.mjs";
8
9
  import { identityOf, readDraft, resolveDocument, staleReadResult } from "./document.mjs";
9
10
  import { applyPatchOperations, isElementPointer } from "../write/patch.mjs";
10
11
  import { withTransaction } from "../write/transaction.mjs";
11
12
  import { buildWriteData } from "../write/write-data.mjs";
12
- import { Forbidden, NotFound } from "payload";
13
+ import { APIError, Forbidden, NotFound } from "payload";
13
14
  import { z } from "zod";
14
15
  import { Pointer } from "rfc6902";
15
16
  //#region src/tools/patch-document.ts
@@ -75,7 +76,8 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
75
76
  * document before writing anything, runs the write in a transaction, then
76
77
  * re-reads the saved document to report which pointers survived and what still
77
78
  * blocks publishing. The draft guard, not this tool, decides where the write
78
- * lands.
79
+ * lands. With `file`, the patches and the file land as one update, and the
80
+ * transaction commits right after it because Payload has moved files by then.
79
81
  */ const patchDocument = defineMcpxTool({
80
82
  name: "patchDocument",
81
83
  description: DESCRIPTION,
@@ -93,27 +95,72 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
93
95
  required: true,
94
96
  description: "Localized fields are written in this locale only."
95
97
  }),
96
- patches: z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT),
97
- expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since.")
98
+ patches: fileSlugs(scope).length === 0 ? z.array(PATCH_OPERATION_SCHEMA).min(1).max(PATCHES_LIMIT) : z.array(PATCH_OPERATION_SCHEMA).max(PATCHES_LIMIT).describe("May be empty when \"file\" is given."),
99
+ expectedUpdatedAt: z.string().optional().describe("\"updatedAt\" from your last read. Refused if the document changed since."),
100
+ ...fileShape(scope, (slugs) => `Replaces the file of a document in ${slugs}. The call returns an "upload" for the bytes instead of writing.`)
98
101
  }),
99
102
  handler: async ({ args, scope }) => {
100
103
  const target = resolveDocument(scope, args, "write");
101
104
  const { payload } = scope.req;
102
105
  const locale = localeOf(scope, args.locale);
103
106
  const patches = args.patches;
104
- return await withTransaction(scope.req, async () => {
107
+ const { file } = args;
108
+ const bytes = uploadedFile(scope.req);
109
+ if (file !== void 0) assertAcceptsFile(scope, target, file);
110
+ if (patches.length === 0 && file === void 0) throw new APIError("\"patches\" may be empty only when \"file\" is given.", 400);
111
+ const report = async (next) => {
112
+ const saved = await readDraft(scope, {
113
+ target,
114
+ locale,
115
+ privileged: true
116
+ });
117
+ const readable = await readDraft(scope, {
118
+ target,
119
+ locale
120
+ }).catch((error) => {
121
+ if (error instanceof NotFound || error instanceof Forbidden) return;
122
+ throw error;
123
+ });
124
+ const notApplied = readable ? notAppliedPointers(patches, next, readable) : [];
125
+ const validation = await collectPublishBlockers(scope.req, {
126
+ doc: saved,
127
+ entity: target
128
+ });
129
+ return jsonResult({
130
+ ...identityOf(target, saved["id"]),
131
+ status: saved["_status"],
132
+ updatedAt: saved["updatedAt"],
133
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
134
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {},
135
+ ...notApplied.length > 0 ? { notApplied } : {}
136
+ });
137
+ };
138
+ const outcome = await withTransaction(scope.req, async () => {
105
139
  const doc = await readDraft(scope, {
106
140
  target,
107
141
  locale
108
142
  });
109
143
  const stale = staleReadResult(doc, args.expectedUpdatedAt, "The document changed since you read it. Read it again and re-apply the patch.");
110
- if (stale) return stale;
144
+ if (stale) return { done: stale };
111
145
  const applied = applyPatchOperations(payload.config, {
112
146
  doc,
113
147
  patches,
114
148
  ref: target
115
149
  });
116
- if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
150
+ if ("problems" in applied) return { done: errorResult("No operation was applied.", { problems: applied.problems }) };
151
+ if (file !== void 0 && target.kind === "collection") {
152
+ const refusal = await fileWriteRefusal(scope, {
153
+ entity: target,
154
+ id: target.id,
155
+ data: applied.next
156
+ });
157
+ if (refusal) return { done: refusal };
158
+ if (!bytes) return { handoff: {
159
+ ...args,
160
+ file,
161
+ expectedUpdatedAt: doc["updatedAt"]
162
+ } };
163
+ }
117
164
  const write = {
118
165
  data: buildWriteData(payload.config, target.config, applied.next),
119
166
  depth: 0,
@@ -125,39 +172,22 @@ const notAppliedPointers = (patches, intended, saved) => patches.flatMap((operat
125
172
  if (target.kind === "collection") await payload.update({
126
173
  ...write,
127
174
  collection: target.slug,
128
- id: target.id
175
+ id: target.id,
176
+ ...bytes === void 0 ? {} : { file: bytes }
129
177
  });
130
178
  else await payload.updateGlobal({
131
179
  ...write,
132
180
  fallbackLocale: false,
133
181
  slug: target.slug
134
182
  });
135
- const saved = await readDraft(scope, {
136
- target,
137
- locale,
138
- privileged: true
139
- });
140
- const readable = await readDraft(scope, {
141
- target,
142
- locale
143
- }).catch((error) => {
144
- if (error instanceof NotFound || error instanceof Forbidden) return;
145
- throw error;
146
- });
147
- const notApplied = readable ? notAppliedPointers(patches, applied.next, readable) : [];
148
- const validation = await collectPublishBlockers(scope.req, {
149
- doc: saved,
150
- entity: target
151
- });
152
- return jsonResult({
153
- ...identityOf(target, saved["id"]),
154
- status: saved["_status"],
155
- updatedAt: saved["updatedAt"],
156
- ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
157
- ...validation.unavailable ? { publishBlockersUnavailable: true } : {},
158
- ...notApplied.length > 0 ? { notApplied } : {}
159
- });
183
+ return bytes === void 0 ? { done: await report(applied.next) } : { written: applied.next };
184
+ });
185
+ if ("done" in outcome) return outcome.done;
186
+ if ("handoff" in outcome) return await uploadHandoff(scope, {
187
+ tool: "patchDocument",
188
+ args: outcome.handoff
160
189
  });
190
+ return await report(outcome.written);
161
191
  }
162
192
  });
163
193
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { canCreate, isLiveWrite } from "../capabilities.mjs";
2
+ import { acceptsFiles } from "../upload/file.mjs";
2
3
  import { z } from "zod";
3
4
  //#region src/tools/shared.ts
4
5
  /**
@@ -17,19 +18,19 @@ const slugsWhere = (scope, predicate, allowed) => {
17
18
  return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
18
19
  };
19
20
  /**
20
- * Slugs this key may write but never create in, because their documents are
21
- * files. Collection-only, since nothing creates a global either way.
22
- */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
23
- collections: scope.collections.writable,
24
- globals: []
25
- });
21
+ * Collections this key may write but not create in: upload collections whose
22
+ * files MCP does not accept. Nothing creates a global either way.
23
+ */ const patchOnlySlugs = (scope) => {
24
+ const creatable = slugsFor(scope, "create").collections;
25
+ return scope.collections.writable.filter((slug) => !creatable.includes(slug));
26
+ };
26
27
  const widen = (branch) => branch;
27
28
  /**
28
29
  * The one list the shape helpers and {@link resolveEntity} both read.
29
30
  */ const slugsFor = (scope, operation) => {
30
31
  switch (operation) {
31
32
  case "create": return {
32
- collections: slugsWhere(scope, canCreate, {
33
+ collections: slugsWhere(scope, (entity) => canCreate(entity) && (!entity.isUpload || acceptsFiles(scope, entity.slug)), {
33
34
  collections: scope.collections.writable,
34
35
  globals: []
35
36
  }),
@@ -1,7 +1,7 @@
1
1
  import { defineMcpxTool } from "../define-tool.mjs";
2
2
  import { jsonResult } from "../result.mjs";
3
- import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
4
3
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
4
+ import { entityShape, idShape, localeOf, localeShape } from "./shared.mjs";
5
5
  import { collectLocaleBlockers, identityOf, readDraft, resolveDocument } from "./document.mjs";
6
6
  /**
7
7
  * Gated on write rather than read, because publish blockers only mean something
package/dist/types.d.mts CHANGED
@@ -34,9 +34,9 @@ export interface McpxCollectionOptions {
34
34
  */
35
35
  read?: boolean;
36
36
  /**
37
- * Expose `patchDocument`, `validateDocument` and, unless this is an upload
38
- * collection, `createDocument`. Without drafts a write changes live
39
- * content. Default `true`.
37
+ * Expose `patchDocument`, `validateDocument` and `createDocument`. An upload
38
+ * collection is created in only where MCP accepts its files. Without drafts
39
+ * a write changes live content. Default `true`.
40
40
  */
41
41
  write?: boolean;
42
42
  /**
@@ -66,7 +66,7 @@ export interface McpxExposedEntity {
66
66
  */
67
67
  hasVersions: boolean;
68
68
  /**
69
- * An upload document is a file, and no tool here can supply one.
69
+ * An upload document is a file, which only an upload grant can supply.
70
70
  */
71
71
  isUpload: boolean;
72
72
  /**
@@ -101,6 +101,18 @@ export interface McpxToolScope {
101
101
  maxLimit: number;
102
102
  maxDepth: number;
103
103
  };
104
+ /**
105
+ * Whether files can be uploaded at all: the KV is database-backed and no
106
+ * custom `auth.resolve` is configured.
107
+ */
108
+ uploads: boolean;
109
+ /**
110
+ * `null` when `diagnostics` is off.
111
+ */
112
+ diagnostics: null | {
113
+ name: string;
114
+ version: string;
115
+ };
104
116
  exposure: {
105
117
  collections: McpxExposedEntity[];
106
118
  globals: McpxExposedEntity[];
@@ -224,6 +236,10 @@ export type McpxPluginOptions = {
224
236
  name?: string;
225
237
  version?: string;
226
238
  };
239
+ /**
240
+ * Expose the server version plus repository and issue links to clients. Default `true`.
241
+ */
242
+ diagnostics?: boolean;
227
243
  };
228
244
  /**
229
245
  * What a key may do with one entity. Globals reuse this shape.
@@ -0,0 +1,166 @@
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { issueGrant, uploadKvSlug } from "./grant.mjs";
3
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
4
+ import { APIError } from "payload";
5
+ import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
6
+ import { z } from "zod";
7
+ //#region src/upload/file.ts
8
+ const MAX_BYTES = 26214400;
9
+ const FILENAME = /^[^/\\\p{Cc}]+$/u;
10
+ const MIME_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
11
+ const uploadedFiles = /* @__PURE__ */ new WeakMap();
12
+ const fileSchema = (maxBytes) => z.strictObject({
13
+ filename: z.string().max(255).regex(FILENAME),
14
+ mimeType: z.string().max(255).regex(MIME_TYPE),
15
+ size: z.number().int().min(1).max(maxBytes)
16
+ });
17
+ /**
18
+ * The largest file accepted: Payload's `upload.limits.fileSize` if set, and
19
+ * never more than 25 MB, since the body is buffered.
20
+ */ const uploadMaxBytes = (config) => Math.min(config.upload.limits?.fileSize ?? MAX_BYTES, MAX_BYTES);
21
+ const allowedMimeTypes = (scope, slug) => {
22
+ if (!scope.uploads || !scope.collections.writable.includes(slug)) return [];
23
+ const entity = scope.exposure.collections.find((candidate) => candidate.slug === slug);
24
+ const config = scope.req.payload.config.collections.find((candidate) => candidate.slug === slug);
25
+ return entity?.isUpload === true && Array.isArray(config?.upload.mimeTypes) ? config.upload.mimeTypes : [];
26
+ };
27
+ /**
28
+ * Whether this key may send a file to `slug`: a writable upload collection
29
+ * that sets `upload.mimeTypes`.
30
+ */ const acceptsFiles = (scope, slug) => allowedMimeTypes(scope, slug).length > 0;
31
+ /**
32
+ * The collections this key may send files to.
33
+ */ const fileSlugs = (scope) => scope.collections.writable.filter((slug) => acceptsFiles(scope, slug));
34
+ /**
35
+ * The `file` argument, present only when the key may send a file somewhere.
36
+ */ const fileShape = (scope, description) => {
37
+ const slugs = fileSlugs(scope);
38
+ return slugs.length === 0 ? {} : { file: fileSchema(uploadMaxBytes(scope.req.payload.config)).optional().describe(description(slugs.join(", "))) };
39
+ };
40
+ /**
41
+ * Throws for a `file` sent to an entity that does not accept one, or declared
42
+ * with a type outside its `upload.mimeTypes`. Payload stores the declared
43
+ * type, so it is held to the same list as the content.
44
+ */ const assertAcceptsFile = (scope, target, file) => {
45
+ const mimeTypes = target.kind === "global" ? [] : allowedMimeTypes(scope, target.slug);
46
+ if (mimeTypes.length === 0) throw new APIError(`"${target.slug}" does not accept files through MCP: it is not an upload collection, or it sets no upload.mimeTypes.`, 400);
47
+ if (!validateMimeType(file.mimeType, mimeTypes)) throw new APIError(`"${target.slug}" accepts only ${mimeTypes.join(", ")}, not "${file.mimeType}".`, 400);
48
+ };
49
+ /**
50
+ * Attaches the claimed bytes to the request the tool runs on.
51
+ */ const setUploadedFile = (req, file) => {
52
+ uploadedFiles.set(req, file);
53
+ };
54
+ /**
55
+ * The bytes for this call, or `undefined` before they were sent.
56
+ */ const uploadedFile = (req) => uploadedFiles.get(req);
57
+ const sharesPublishedFile = async (req, entity, id) => {
58
+ if (!hasDraftsEnabled(entity.config)) return false;
59
+ const read = {
60
+ collection: entity.slug,
61
+ id,
62
+ depth: 0,
63
+ overrideAccess: true,
64
+ disableErrors: true,
65
+ select: {
66
+ filename: true,
67
+ _status: true
68
+ },
69
+ req
70
+ };
71
+ const [latest, published] = await Promise.all([req.payload.findByID({
72
+ ...read,
73
+ draft: true
74
+ }), req.payload.findByID({
75
+ ...read,
76
+ draft: false
77
+ })]);
78
+ return latest?.["_status"] === "draft" && published?.["_status"] === "published" && latest["filename"] === published["filename"];
79
+ };
80
+ /**
81
+ * Refuses a file write Payload would turn into data loss: a replace that
82
+ * deletes the published file, or a write that fails validation after the old
83
+ * file is gone. Payload validates only after it moves files, so the merged
84
+ * data is validated here first wherever Payload will validate it.
85
+ */ const fileWriteRefusal = async (scope, target) => {
86
+ const { entity, id } = target;
87
+ if (id !== void 0 && await sharesPublishedFile(scope.req, entity, id)) return errorResult("The file was not replaced: the latest draft still uses the published file, which the replace would delete. Publish or discard the draft first.");
88
+ if (hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
89
+ const { blockers } = await collectPublishBlockers(scope.req, {
90
+ doc: {
91
+ ...target.data,
92
+ ...id === void 0 ? {} : { id }
93
+ },
94
+ entity
95
+ });
96
+ return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
97
+ };
98
+ const handoffUrl = (req, path) => {
99
+ const request = new URL(req.url ?? "");
100
+ const url = new URL(req.payload.config.serverURL || request.origin);
101
+ url.pathname = `${request.pathname.replace(/\/+$/, "")}/${path}`;
102
+ return url.toString();
103
+ };
104
+ const grantContext = (req) => {
105
+ const slug = uploadKvSlug(req.payload.config);
106
+ const apiKeyId = req.context.mcpx?.apiKeyId;
107
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A handoff was offered without a database KV or key.");
108
+ return {
109
+ slug,
110
+ apiKeyId
111
+ };
112
+ };
113
+ /**
114
+ * Stores the call as a grant and returns where to PUT its file. Nothing else
115
+ * is written: the PUT runs the same call again with the bytes attached.
116
+ */ const uploadHandoff = async (scope, call) => {
117
+ const { req } = scope;
118
+ const { payload } = req;
119
+ const { slug, apiKeyId } = grantContext(req);
120
+ const url = handoffUrl(req, "upload");
121
+ const { grantId, exp } = await issueGrant(payload, slug, {
122
+ kind: "upload",
123
+ apiKeyId,
124
+ tool: call.tool,
125
+ args: call.args,
126
+ locale: req.locale ?? null,
127
+ fallbackLocale: req.fallbackLocale ?? null
128
+ });
129
+ return jsonResult({ upload: {
130
+ url,
131
+ method: "PUT",
132
+ headers: {
133
+ "content-type": call.args.file.mimeType,
134
+ "x-mcpx-grant": grantId
135
+ },
136
+ expiresAt: new Date(exp).toISOString(),
137
+ maxBytes: uploadMaxBytes(payload.config)
138
+ } });
139
+ };
140
+ /**
141
+ * The upload collections whose files this key may download. A collection
142
+ * with its own top-level `download` field is left out, since the handoff
143
+ * would overwrite it in the result.
144
+ */ const downloadSlugs = (scope) => scope.uploads ? scope.collections.readable.filter((slug) => scope.exposure.collections.find((candidate) => candidate.slug === slug)?.isUpload === true && !scope.req.payload.collections[slug]?.config.flattenedFields.some((field) => field.name === "download")) : [];
145
+ /**
146
+ * Stores a grant for the file of a document just read and returns where to
147
+ * GET it.
148
+ */ const downloadHandoff = async (scope, target) => {
149
+ const { req } = scope;
150
+ const { slug, apiKeyId } = grantContext(req);
151
+ const url = handoffUrl(req, "file");
152
+ const { grantId, exp } = await issueGrant(req.payload, slug, {
153
+ kind: "download",
154
+ apiKeyId,
155
+ ...target,
156
+ fallbackLocale: req.fallbackLocale ?? null
157
+ });
158
+ return {
159
+ url,
160
+ method: "GET",
161
+ headers: { "x-mcpx-grant": grantId },
162
+ expiresAt: new Date(exp).toISOString()
163
+ };
164
+ };
165
+ //#endregion
166
+ export { acceptsFiles, assertAcceptsFile, downloadHandoff, downloadSlugs, fileShape, fileSlugs, fileWriteRefusal, setUploadedFile, uploadHandoff, uploadMaxBytes, uploadedFile };
@@ -0,0 +1,96 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { hashApiKey } from "../api-keys/key.mjs";
3
+ import { APIError, ValidationError } from "payload";
4
+ import crypto from "node:crypto";
5
+ //#region src/upload/grant.ts
6
+ const GRANT_TTL_MS = 3e5;
7
+ const GRANTS_PER_KEY = 10;
8
+ const GRANT_ID = /^[\w-]{43}$/;
9
+ const NO_REQ = {};
10
+ const grantPrefix = (apiKeyId) => `mcpx-grant:${String(apiKeyId)}:`;
11
+ const usedPrefix = (apiKeyId) => `mcpx-grant-used:${String(apiKeyId)}:`;
12
+ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ? data["exp"] : 0;
13
+ /**
14
+ * The KV collection slug, when Payload's database KV adapter backs it with a
15
+ * unique `key`, which the single-use claim relies on. `undefined` otherwise.
16
+ */ const uploadKvSlug = (config) => {
17
+ const kv = config.kv;
18
+ const key = kv?.kvCollection?.fields.find((field) => "name" in field && field.name === "key");
19
+ return key && "unique" in key && key.unique ? kv?.kvCollection?.slug : void 0;
20
+ };
21
+ const findRows = async (payload, slug, fragments) => {
22
+ const { docs } = await payload.db.find({
23
+ collection: slug,
24
+ where: { or: fragments.map((fragment) => ({ key: { like: fragment } })) },
25
+ limit: 0,
26
+ pagination: false,
27
+ req: NO_REQ
28
+ });
29
+ return docs;
30
+ };
31
+ /**
32
+ * Stores a grant and returns its id, the only copy of which goes to the
33
+ * client. The row key holds the HMAC of the id. Expired rows of the same key
34
+ * are purged first, and a key may hold at most {@link GRANTS_PER_KEY} grants.
35
+ */ const issueGrant = async (payload, slug, grant) => {
36
+ const now = Date.now();
37
+ const prefixes = [grantPrefix(grant.apiKeyId), usedPrefix(grant.apiKeyId)];
38
+ const rows = (await findRows(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
39
+ const expired = rows.filter((row) => expOf(row.data) <= now);
40
+ if (expired.length > 0) await payload.db.deleteMany({
41
+ collection: slug,
42
+ where: { key: { in: expired.map((row) => row.key) } },
43
+ req: NO_REQ
44
+ });
45
+ if (rows.filter((row) => row.key.startsWith(prefixes[0]) && expOf(row.data) > now).length >= GRANTS_PER_KEY) throw new APIError(`This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`, 429);
46
+ const grantId = crypto.randomBytes(32).toString("base64url");
47
+ const exp = now + GRANT_TTL_MS;
48
+ await payload.db.create({
49
+ collection: slug,
50
+ data: {
51
+ key: `${grantPrefix(grant.apiKeyId)}${hashApiKey(payload.secret, grantId)}`,
52
+ data: {
53
+ ...grant,
54
+ exp
55
+ }
56
+ },
57
+ req: NO_REQ
58
+ });
59
+ return {
60
+ grantId,
61
+ exp
62
+ };
63
+ };
64
+ /**
65
+ * Loads the grant for `grantId` and marks it used, or returns `undefined` for
66
+ * a malformed, unknown, expired or used one. The marker is a unique-key
67
+ * insert, so of two concurrent claims exactly one succeeds. A malformed id
68
+ * is refused before any database read.
69
+ */ const claimGrant = async (payload, slug, grantId) => {
70
+ if (!GRANT_ID.test(grantId)) return;
71
+ const hash = hashApiKey(payload.secret, grantId);
72
+ const row = (await findRows(payload, slug, [hash])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${hash}`));
73
+ const grant = row?.data;
74
+ if (!row || !isPlainObject(grant) || row.key !== `${grantPrefix(grant.apiKeyId)}${hash}` || expOf(grant) <= Date.now()) return;
75
+ try {
76
+ await payload.db.create({
77
+ collection: slug,
78
+ data: {
79
+ key: `${usedPrefix(grant.apiKeyId)}${hash}`,
80
+ data: { exp: grant.exp }
81
+ },
82
+ req: NO_REQ
83
+ });
84
+ } catch (error) {
85
+ if (error instanceof ValidationError) return;
86
+ throw error;
87
+ }
88
+ await payload.db.deleteOne({
89
+ collection: slug,
90
+ where: { key: { equals: row.key } },
91
+ req: NO_REQ
92
+ });
93
+ return grant;
94
+ };
95
+ //#endregion
96
+ export { claimGrant, issueGrant, uploadKvSlug };
package/dist/version.mjs CHANGED
@@ -2,5 +2,7 @@
2
2
  /**
3
3
  * Package version injected at build time; sources under Vitest report "dev".
4
4
  */ const MCPX_VERSION = "1.0.0";
5
+ const MCPX_REPOSITORY_URL = "https://github.com/abinnovision/payloadcms-commons/tree/main/packages/mcpx";
6
+ const MCPX_ISSUES_URL = "https://github.com/abinnovision/payloadcms-commons/issues";
5
7
  //#endregion
6
- export { MCPX_VERSION };
8
+ export { MCPX_ISSUES_URL, MCPX_REPOSITORY_URL, MCPX_VERSION };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "2.0.0-beta.10",
4
+ "version": "2.0.0-beta.12",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "prettier": "@abinnovision/prettier-config",
59
59
  "dependencies": {
60
- "@modelcontextprotocol/sdk": "^1.30.0",
60
+ "@modelcontextprotocol/sdk": "^1.30.1",
61
61
  "rfc6902": "^5.3.0",
62
62
  "zod": "^4.6.5"
63
63
  },
@@ -66,15 +66,15 @@
66
66
  "@abinnovision/prettier-config": "^2.2.0",
67
67
  "@arethetypeswrong/core": "^0.18.5",
68
68
  "@internal/test-utils": "0.0.0",
69
- "@payloadcms/db-sqlite": "3.89.0",
70
- "@payloadcms/richtext-lexical": "3.89.0",
71
- "@payloadcms/ui": "3.89.0",
69
+ "@payloadcms/db-sqlite": "3.90.2",
70
+ "@payloadcms/richtext-lexical": "3.90.2",
71
+ "@payloadcms/ui": "3.90.2",
72
72
  "@swc/core": "^1.16.2",
73
73
  "@types/node": "^26.5.1",
74
74
  "@types/react": "^19.3.0",
75
75
  "@vitest/coverage-v8": "^5.0.0",
76
76
  "eslint": "^10.10.0",
77
- "payload": "3.89.0",
77
+ "payload": "3.90.2",
78
78
  "prettier": "^3.9.5",
79
79
  "publint": "^0.3.24",
80
80
  "react": "^19.3.0",