@abinnovision/payloadcms-mcpx 2.0.0-beta.10 → 2.0.0-beta.13

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.
@@ -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,185 @@
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { SchemaError } from "../schema/errors.mjs";
3
+ import { resolveDataPointer } from "../schema/pointer.mjs";
4
+ import "../schema/index.mjs";
5
+ import { issueGrant, uploadKvSlug } from "./grant.mjs";
6
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
+ import { APIError } from "payload";
8
+ import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
9
+ import { z } from "zod";
10
+ //#region src/upload/file.ts
11
+ const MAX_BYTES = 26214400;
12
+ const FILENAME = /^[^/\\\p{Cc}]+$/u;
13
+ const MIME_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
14
+ const uploadedFiles = /* @__PURE__ */ new WeakMap();
15
+ const fileSchema = (maxBytes) => z.strictObject({
16
+ filename: z.string().max(255).regex(FILENAME),
17
+ mimeType: z.string().max(255).regex(MIME_TYPE),
18
+ size: z.number().int().min(1).max(maxBytes)
19
+ });
20
+ /**
21
+ * The largest file accepted: Payload's `upload.limits.fileSize` if set, and
22
+ * never more than 25 MB, since the body is buffered.
23
+ */ const uploadMaxBytes = (config) => Math.min(config.upload.limits?.fileSize ?? MAX_BYTES, MAX_BYTES);
24
+ const allowedMimeTypes = (scope, slug) => {
25
+ if (!scope.uploads || !scope.collections.writable.includes(slug)) return [];
26
+ const entity = scope.exposure.collections.find((candidate) => candidate.slug === slug);
27
+ const config = scope.req.payload.config.collections.find((candidate) => candidate.slug === slug);
28
+ return entity?.isUpload === true && Array.isArray(config?.upload.mimeTypes) ? config.upload.mimeTypes : [];
29
+ };
30
+ /**
31
+ * Whether this key may send a file to `slug`: a writable upload collection
32
+ * that sets `upload.mimeTypes`.
33
+ */ const acceptsFiles = (scope, slug) => allowedMimeTypes(scope, slug).length > 0;
34
+ /**
35
+ * The collections this key may send files to.
36
+ */ const fileSlugs = (scope) => scope.collections.writable.filter((slug) => acceptsFiles(scope, slug));
37
+ /**
38
+ * The `file` argument, present only when the key may send a file somewhere.
39
+ */ const fileShape = (scope, description) => {
40
+ const slugs = fileSlugs(scope);
41
+ return slugs.length === 0 ? {} : { file: fileSchema(uploadMaxBytes(scope.req.payload.config)).optional().describe(description(slugs.join(", "))) };
42
+ };
43
+ /**
44
+ * Throws for a `file` sent to an entity that does not accept one, or declared
45
+ * with a type outside its `upload.mimeTypes`. Payload stores the declared
46
+ * type, so it is held to the same list as the content.
47
+ */ const assertAcceptsFile = (scope, target, file) => {
48
+ const mimeTypes = target.kind === "global" ? [] : allowedMimeTypes(scope, target.slug);
49
+ 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);
50
+ if (!validateMimeType(file.mimeType, mimeTypes)) throw new APIError(`"${target.slug}" accepts only ${mimeTypes.join(", ")}, not "${file.mimeType}".`, 400);
51
+ };
52
+ /**
53
+ * Attaches the claimed bytes to the request the tool runs on.
54
+ */ const setUploadedFile = (req, file) => {
55
+ uploadedFiles.set(req, file);
56
+ };
57
+ /**
58
+ * The bytes for this call, or `undefined` before they were sent.
59
+ */ const uploadedFile = (req) => uploadedFiles.get(req);
60
+ const sharesPublishedFile = async (req, entity, id) => {
61
+ if (!hasDraftsEnabled(entity.config)) return false;
62
+ const read = {
63
+ collection: entity.slug,
64
+ id,
65
+ depth: 0,
66
+ overrideAccess: true,
67
+ disableErrors: true,
68
+ select: {
69
+ filename: true,
70
+ _status: true
71
+ },
72
+ req
73
+ };
74
+ const [latest, published] = await Promise.all([req.payload.findByID({
75
+ ...read,
76
+ draft: true
77
+ }), req.payload.findByID({
78
+ ...read,
79
+ draft: false
80
+ })]);
81
+ return latest?.["_status"] === "draft" && published?.["_status"] === "published" && latest["filename"] === published["filename"];
82
+ };
83
+ const isClientWritable = (scope, target, path) => {
84
+ try {
85
+ const resolution = resolveDataPointer(scope.req.payload.config, {
86
+ ref: target.entity,
87
+ doc: target.data,
88
+ pointer: path
89
+ });
90
+ return resolution.readOnly !== true && resolution.descriptor?.readOnly !== true;
91
+ } catch (error) {
92
+ if (error instanceof SchemaError) return false;
93
+ throw error;
94
+ }
95
+ };
96
+ /**
97
+ * Refuses a file write Payload would turn into data loss: a replace that
98
+ * deletes the published file, or a write that fails validation after the old
99
+ * file is gone. Payload validates only after it moves files, so the merged
100
+ * data is validated here first wherever Payload will validate it, keeping only
101
+ * failures the client can fix. Once the bytes are attached, Payload validates
102
+ * with every hook and the file.
103
+ */ const fileWriteRefusal = async (scope, target) => {
104
+ const { entity, id } = target;
105
+ 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.");
106
+ if (uploadedFile(scope.req) !== void 0 || hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
107
+ const { blockers: found } = await collectPublishBlockers(scope.req, {
108
+ doc: {
109
+ ...target.data,
110
+ ...id === void 0 ? {} : { id }
111
+ },
112
+ entity
113
+ });
114
+ const blockers = found.filter((blocker) => isClientWritable(scope, target, blocker.path));
115
+ return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
116
+ };
117
+ const handoffUrl = (req, path) => {
118
+ const request = new URL(req.url ?? "");
119
+ const url = new URL(req.payload.config.serverURL || request.origin);
120
+ url.pathname = `${request.pathname.replace(/\/+$/, "")}/${path}`;
121
+ return url.toString();
122
+ };
123
+ const grantContext = (req) => {
124
+ const slug = uploadKvSlug(req.payload.config);
125
+ const apiKeyId = req.context.mcpx?.apiKeyId;
126
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A handoff was offered without a database KV or key.");
127
+ return {
128
+ slug,
129
+ apiKeyId
130
+ };
131
+ };
132
+ /**
133
+ * Stores the call as a grant and returns where to PUT its file. Nothing else
134
+ * is written: the PUT runs the same call again with the bytes attached.
135
+ */ const uploadHandoff = async (scope, call) => {
136
+ const { req } = scope;
137
+ const { payload } = req;
138
+ const { slug, apiKeyId } = grantContext(req);
139
+ const url = handoffUrl(req, "upload");
140
+ const { grantId, exp } = await issueGrant(payload, slug, {
141
+ kind: "upload",
142
+ apiKeyId,
143
+ tool: call.tool,
144
+ args: call.args,
145
+ locale: req.locale ?? null,
146
+ fallbackLocale: req.fallbackLocale ?? null
147
+ });
148
+ return jsonResult({ upload: {
149
+ url,
150
+ method: "PUT",
151
+ headers: {
152
+ "content-type": call.args.file.mimeType,
153
+ "x-mcpx-grant": grantId
154
+ },
155
+ expiresAt: new Date(exp).toISOString(),
156
+ maxBytes: uploadMaxBytes(payload.config)
157
+ } });
158
+ };
159
+ /**
160
+ * The upload collections whose files this key may download. A collection
161
+ * with its own top-level `download` field is left out, since the handoff
162
+ * would overwrite it in the result.
163
+ */ 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")) : [];
164
+ /**
165
+ * Stores a grant for the file of a document just read and returns where to
166
+ * GET it.
167
+ */ const downloadHandoff = async (scope, target) => {
168
+ const { req } = scope;
169
+ const { slug, apiKeyId } = grantContext(req);
170
+ const url = handoffUrl(req, "file");
171
+ const { grantId, exp } = await issueGrant(req.payload, slug, {
172
+ kind: "download",
173
+ apiKeyId,
174
+ ...target,
175
+ fallbackLocale: req.fallbackLocale ?? null
176
+ });
177
+ return {
178
+ url,
179
+ method: "GET",
180
+ headers: { "x-mcpx-grant": grantId },
181
+ expiresAt: new Date(exp).toISOString()
182
+ };
183
+ };
184
+ //#endregion
185
+ 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 };