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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +144 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +14 -86
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +71 -23
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +44 -46
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +73 -29
  18. package/dist/endpoint/server.mjs +85 -22
  19. package/dist/endpoint/upload.mjs +135 -0
  20. package/dist/entity.d.mts +9 -0
  21. package/dist/guards.mjs +7 -0
  22. package/dist/i18n.mjs +10 -8
  23. package/dist/index.d.mts +5 -2
  24. package/dist/index.mjs +3 -2
  25. package/dist/options.mjs +54 -47
  26. package/dist/plugin.d.mts +2 -2
  27. package/dist/plugin.mjs +13 -3
  28. package/dist/request.d.mts +2 -3
  29. package/dist/request.mjs +2 -3
  30. package/dist/result.d.mts +2 -3
  31. package/dist/result.mjs +3 -3
  32. package/dist/schema/admin-hidden.mjs +91 -0
  33. package/dist/schema/describe.mjs +17 -33
  34. package/dist/schema/errors.mjs +8 -0
  35. package/dist/schema/index.mjs +5 -2
  36. package/dist/schema/lexical-pointer.mjs +18 -20
  37. package/dist/schema/lexical.mjs +44 -93
  38. package/dist/schema/outline.mjs +5 -15
  39. package/dist/schema/path.mjs +28 -0
  40. package/dist/schema/pointer.mjs +18 -25
  41. package/dist/schema/shape.mjs +25 -71
  42. package/dist/schema/walk.mjs +43 -45
  43. package/dist/tools/builtin.mjs +4 -4
  44. package/dist/tools/create-document.mjs +50 -24
  45. package/dist/tools/describe-schema.mjs +38 -34
  46. package/dist/tools/document.mjs +124 -0
  47. package/dist/tools/find-documents.mjs +32 -19
  48. package/dist/tools/find-versions.mjs +22 -18
  49. package/dist/tools/get-document.mjs +60 -33
  50. package/dist/tools/list-capabilities.mjs +35 -19
  51. package/dist/tools/patch-document.mjs +122 -66
  52. package/dist/tools/publish-document.mjs +26 -32
  53. package/dist/tools/query-paths.mjs +57 -0
  54. package/dist/tools/read-request.d.mts +10 -0
  55. package/dist/tools/read-request.mjs +43 -0
  56. package/dist/tools/shared.mjs +74 -105
  57. package/dist/tools/validate-document.mjs +18 -30
  58. package/dist/tools/versions.mjs +37 -20
  59. package/dist/types.d.mts +135 -75
  60. package/dist/upload/file.mjs +166 -0
  61. package/dist/upload/grant.mjs +96 -0
  62. package/dist/version.mjs +3 -1
  63. package/dist/write/draft-guard.mjs +23 -41
  64. package/dist/write/patch.mjs +27 -183
  65. package/dist/write/publish-blockers.mjs +7 -14
  66. package/dist/write/publish-intent.mjs +6 -4
  67. package/dist/write/row-ids.mjs +46 -0
  68. package/dist/write/transaction.mjs +5 -8
  69. package/dist/write/write-data.mjs +64 -0
  70. package/package.json +11 -10
  71. package/dist/client/capability-matrix.d.mts +0 -26
  72. package/dist/client/capability-matrix.mjs +0 -349
  73. package/dist/endpoint/index.mjs +0 -4
  74. package/dist/tools/target.mjs +0 -45
  75. /package/dist/{client → admin}/index.d.mts +0 -0
  76. /package/dist/{client → admin}/index.mjs +0 -0
package/dist/types.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DocumentId } from "./entity.mjs";
1
2
  import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
3
  import { z } from "zod";
3
4
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
@@ -17,63 +18,101 @@ declare module "payload" {
17
18
  * - `false`: no write tool touches it.
18
19
  * - `"draft"`: writes land as drafts and nothing MCP does changes what the
19
20
  * public sees. Requires `versions.drafts`.
20
- * - `"live"`: MCP may change live content. On an entity with drafts that means
21
- * `publishDocument` is exposed; on one without, where there is no draft to
22
- * land on, it means the write itself is permitted and lands live.
21
+ * - `"live"`: MCP may change live content. On an entity with drafts that
22
+ * exposes `publishDocument`. On one without, there is no draft, so the write
23
+ * itself is permitted and lands live.
23
24
  */
24
25
  export type McpxWriteMode = "draft" | "live" | false;
25
- /** A key can only enable what the config exposes here. */
26
+ /**
27
+ * What an entity exposes. The config names the entities that are reachable and
28
+ * only takes capabilities away; a key's checkboxes decide per key. `true` is
29
+ * shorthand for `{}`, which exposes everything the entity supports.
30
+ */
26
31
  export interface McpxCollectionOptions {
27
- /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
28
- read?: boolean;
29
32
  /**
30
- * Expose `patchDocument`, `validateDocument` and, unless this is an upload
31
- * collection, `createDocument`, and how far those writes reach. Default
32
- * `false`.
33
+ * Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`.
33
34
  */
34
- write?: McpxWriteMode;
35
- }
36
- /** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
37
- export interface McpxGlobalOptions {
38
- /** Expose `describeSchema` and `getDocument`. Default `true`. */
39
35
  read?: boolean;
40
36
  /**
41
- * Expose `patchDocument` and `validateDocument`, and how far those writes
42
- * reach. Default `false`.
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
+ */
41
+ write?: boolean;
42
+ /**
43
+ * Expose `publishDocument`, which promotes a draft to live content. Needs
44
+ * `versions.drafts` and `write`. Default `true` where the entity has drafts.
45
+ * `false` keeps writes as drafts and is refused where there are none.
43
46
  */
44
- write?: McpxWriteMode;
47
+ publish?: boolean;
45
48
  }
49
+ /**
50
+ * The same options. A singleton, so neither `findDocuments` nor
51
+ * `createDocument` reaches one.
52
+ */
53
+ export type McpxGlobalOptions = McpxCollectionOptions;
46
54
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
47
- /** What the config exposes, before an API key's checkboxes narrow it. */
55
+ /**
56
+ * What the config exposes, before an API key's checkboxes narrow it.
57
+ */
48
58
  export interface McpxExposedEntity {
49
59
  slug: string;
50
60
  read: boolean;
51
61
  write: McpxWriteMode;
52
62
  hasDrafts: boolean;
53
- /** Payload keeps a version history, with or without drafts. */
63
+ /**
64
+ * The entity has Payload `versions`, with or without drafts, and is
65
+ * readable. Says nothing about drafts; see `hasDrafts`.
66
+ */
54
67
  hasVersions: boolean;
55
- /** An upload document is a file, and no tool here can supply one. */
68
+ /**
69
+ * An upload document is a file, which only an upload grant can supply.
70
+ */
56
71
  isUpload: boolean;
57
- /** Name of the capability group on the key document. */
72
+ /**
73
+ * Name of the capability group on the key document.
74
+ */
58
75
  fieldName: string;
59
76
  }
60
- /** What a tool knows about the current request. */
61
- export interface McpxToolScope {
62
- req: PayloadRequest;
63
- capabilities: McpxResolvedCapabilities;
77
+ /**
78
+ * The slugs a key may read, write and publish.
79
+ */
80
+ export interface McpxScopeSlugs {
64
81
  readable: string[];
65
82
  writable: string[];
66
83
  publishable: string[];
67
- readableGlobals: string[];
68
- writableGlobals: string[];
69
- publishableGlobals: string[];
70
- /** `null` when localization is off. */
71
- locales: null | string[];
72
- defaultLocale: null | string;
84
+ }
85
+ /**
86
+ * What a tool knows about the current request.
87
+ */
88
+ export interface McpxToolScope {
89
+ req: PayloadRequest;
90
+ capabilities: McpxResolvedCapabilities;
91
+ collections: McpxScopeSlugs;
92
+ globals: McpxScopeSlugs;
93
+ /**
94
+ * `null` when localization is off.
95
+ */
96
+ localization: null | {
97
+ locales: string[];
98
+ defaultLocale: string;
99
+ };
73
100
  limits: {
74
101
  maxLimit: number;
75
102
  maxDepth: number;
76
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
+ };
77
116
  exposure: {
78
117
  collections: McpxExposedEntity[];
79
118
  globals: McpxExposedEntity[];
@@ -85,9 +124,13 @@ export interface McpxToolScope {
85
124
  * per request, leaving no static shape to infer from.
86
125
  */
87
126
  export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
88
- /** camelCase, unique, not one of the builtin tool names. */
127
+ /**
128
+ * camelCase, unique, not one of the builtin tool names.
129
+ */
89
130
  name: string;
90
- /** Built per request so it can state what this key's writes actually do. */
131
+ /**
132
+ * Built per request so it can state what this key's writes do.
133
+ */
91
134
  description: string | ((scope: McpxToolScope) => string);
92
135
  annotations?: ToolAnnotations;
93
136
  /**
@@ -99,39 +142,32 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
99
142
  /**
100
143
  * Built per request so enums can be narrowed to what the key may touch.
101
144
  * Registered strictly either way: an unknown argument is rejected by name
102
- * rather than stripped.
145
+ * instead of stripped.
103
146
  */
104
147
  inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
105
148
  handler(ctx: {
106
149
  args: Args;
107
150
  scope: McpxToolScope;
108
- /** Shorthand for `scope.req`. */
151
+ /**
152
+ * Shorthand for `scope.req`.
153
+ */
109
154
  req: PayloadRequest;
110
155
  extra: McpxToolExtra;
111
156
  }): CallToolResult | Promise<CallToolResult>;
112
157
  }
113
- /** Argument type erased, so a registry can hold tools of differing shapes. */
114
- export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
115
- /** Fixed shape; arguments inferred from it. */
116
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
117
- inputSchema?: Shape;
118
- }): McpxTool<Shape>;
119
- /** Per-request shape returned as an object literal; arguments inferred from it. */
120
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
121
- inputSchema: (scope: McpxToolScope) => Shape;
122
- }): McpxAnyTool;
123
158
  /**
124
- * Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
125
- * builtins do. Nothing to infer from, so state the arguments instead.
159
+ * Argument type erased, so a registry can hold tools of differing shapes.
126
160
  */
127
- export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
128
- inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
129
- }): McpxAnyTool;
161
+ export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
130
162
  export interface McpxAuthResult {
131
- /** Must carry `collection`. */
163
+ /**
164
+ * Must carry `collection`.
165
+ */
132
166
  user: TypedUser;
133
- apiKeyId: number | string;
134
- /** The `capabilities` group as stored on the key document. */
167
+ apiKeyId: DocumentId;
168
+ /**
169
+ * The `capabilities` group as stored on the key document.
170
+ */
135
171
  capabilities: unknown;
136
172
  }
137
173
  /**
@@ -141,36 +177,56 @@ export interface McpxAuthResult {
141
177
  * to `Record<string, unknown>`, which interfaces do not satisfy.
142
178
  */
143
179
  export type McpxPluginOptions = {
144
- /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
180
+ /**
181
+ * Allow-list of collections. `true` is shorthand for `{}`.
182
+ */
145
183
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
146
- /** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
184
+ /**
185
+ * Allow-list of globals. `true` is shorthand for `{}`.
186
+ */
147
187
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
148
- /** Collection the keys act as. Default `config.admin.user`, then `users`. */
188
+ /**
189
+ * Collection the keys act as. Default `config.admin.user`, then `users`.
190
+ */
149
191
  userCollection?: CollectionSlug;
150
192
  apiKeys?: {
151
- /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
193
+ /**
194
+ * Slug of the generated API key collection. Default `mcpx-api-keys`.
195
+ */
152
196
  slug?: string;
153
197
  /**
154
198
  * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
155
199
  * client config. Default `true`. The snippets contain the key in full.
156
200
  */
157
201
  setupGuide?: boolean;
158
- /** Final override applied to the generated collection. */
202
+ /**
203
+ * Final override applied to the generated collection.
204
+ */
159
205
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
160
206
  };
161
207
  endpoint?: {
162
- /** Endpoint path below the API route. Default `/mcpx`. */
208
+ /**
209
+ * Endpoint path below the API route. Default `/mcpx`.
210
+ */
163
211
  path?: string;
164
212
  };
165
213
  limits?: {
166
- /** Upper bound for `findDocuments.limit`. Default 25. */
214
+ /**
215
+ * Upper bound for `findDocuments.limit`. Default 25.
216
+ */
167
217
  maxLimit?: number;
168
- /** Upper bound for `depth` on reads. Default 1. */
218
+ /**
219
+ * Upper bound for `depth` on reads. Default 1.
220
+ */
169
221
  maxDepth?: number;
170
222
  };
171
223
  tools?: McpxAnyTool[];
172
224
  auth?: {
173
- /** Replace or wrap the default key resolution. Return `null` for 401. */
225
+ /**
226
+ * Replace or wrap the default key resolution. Return `null` for 401. A
227
+ * result whose user has no `id` or whose `collection` is not the user
228
+ * collection, or that has no `apiKeyId`, is also answered with 401.
229
+ */
174
230
  resolve?: (args: {
175
231
  req: PayloadRequest;
176
232
  resolveDefault: () => Promise<McpxAuthResult | null>;
@@ -180,31 +236,35 @@ export type McpxPluginOptions = {
180
236
  name?: string;
181
237
  version?: string;
182
238
  };
239
+ /**
240
+ * Expose the server version plus repository and issue links to clients. Default `true`.
241
+ */
242
+ diagnostics?: boolean;
183
243
  };
184
- /** What a key may do with one entity. Globals reuse this shape. */
244
+ /**
245
+ * What a key may do with one entity. Globals reuse this shape.
246
+ */
185
247
  export interface McpxCollectionCapabilities {
186
248
  read: boolean;
187
249
  write: boolean;
188
- /** Only ever true where the config sets `write: "live"` and drafts exist. */
250
+ /**
251
+ * Only ever true where the config lets writes publish and drafts exist.
252
+ */
189
253
  publish: boolean;
190
254
  }
191
- /** In force for one request: plugin config AND key checkboxes. */
255
+ /**
256
+ * In force for one request: the plugin config and the key checkboxes together.
257
+ */
192
258
  export interface McpxResolvedCapabilities {
193
259
  collections: Record<string, McpxCollectionCapabilities>;
194
260
  globals: Record<string, McpxCollectionCapabilities>;
195
261
  tools: Record<string, boolean>;
196
262
  }
197
- /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
263
+ /**
264
+ * Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
265
+ */
198
266
  export interface McpxRequestContext {
199
- apiKeyId: number | string;
267
+ apiKeyId: DocumentId;
200
268
  capabilities: McpxResolvedCapabilities;
201
269
  }
202
- /** One reason a human could not publish the draft as it stands. */
203
- export interface PublishBlocker {
204
- /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
205
- field?: string;
206
- message: string;
207
- /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
208
- path: string;
209
- }
210
270
  //#endregion
@@ -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 };