@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +17 -7
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +6 -4
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +12 -10
  15. package/dist/api-keys/capability-matrix.mjs +17 -37
  16. package/dist/api-keys/collection.mjs +18 -19
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +67 -53
  19. package/dist/auth/resolve.mjs +79 -30
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +69 -15
  22. package/dist/define-tool.d.mts +10 -6
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +52 -27
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/grants/confirmation.mjs +75 -0
  30. package/dist/grants/grant.mjs +137 -0
  31. package/dist/guards.mjs +11 -2
  32. package/dist/i18n.mjs +3 -1
  33. package/dist/index.d.mts +2 -2
  34. package/dist/index.mjs +1 -1
  35. package/dist/options.mjs +73 -62
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +21 -15
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +13 -4
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +39 -51
  49. package/dist/schema/walk.mjs +68 -21
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +147 -21
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +60 -54
  62. package/dist/tools/publish-document.mjs +23 -30
  63. package/dist/tools/query-paths.mjs +4 -2
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +66 -50
  66. package/dist/tools/validate-document.mjs +10 -11
  67. package/dist/tools/versions.mjs +24 -10
  68. package/dist/types.d.mts +228 -60
  69. package/dist/upload/file.mjs +177 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +46 -37
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +25 -11
  74. package/dist/write/row-ids.mjs +6 -2
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +15 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
package/dist/types.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DocumentId } from "./entity.mjs";
2
- import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
3
2
  import { z } from "zod";
3
+ import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
4
4
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
5
5
  import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
6
6
  //#region src/types.d.ts
@@ -12,29 +12,21 @@ declare module "payload" {
12
12
  "@abinnovision/payloadcms-mcpx": McpxPluginOptions;
13
13
  }
14
14
  }
15
- /**
16
- * How far an exposed entity lets MCP writes reach.
17
- *
18
- * - `false`: no write tool touches it.
19
- * - `"draft"`: writes land as drafts and nothing MCP does changes what the
20
- * public sees. Requires `versions.drafts`.
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.
24
- */
25
- export type McpxWriteMode = "draft" | "live" | false;
26
15
  /**
27
16
  * What an entity exposes. The config names the entities that are reachable and
28
17
  * only takes capabilities away; a key's checkboxes decide per key. `true` is
29
18
  * shorthand for `{}`, which exposes everything the entity supports.
30
19
  */
31
20
  export interface McpxCollectionOptions {
32
- /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
21
+ /**
22
+ * Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`.
23
+ */
33
24
  read?: boolean;
34
25
  /**
35
- * Expose `patchDocument`, `validateDocument` and, unless this is an upload
36
- * collection, `createDocument`. Without drafts a write changes live
37
- * content. Default `true`.
26
+ * Expose `patchDocument`, `validateDocument`, `createDocument` and
27
+ * `duplicateDocument`. An upload collection is created in only where MCP
28
+ * accepts its files, and is never duplicated. Without drafts
29
+ * a write changes live content. Needs `read`. Default `true` while `read` is.
38
30
  */
39
31
  write?: boolean;
40
32
  /**
@@ -43,39 +35,67 @@ export interface McpxCollectionOptions {
43
35
  * `false` keeps writes as drafts and is refused where there are none.
44
36
  */
45
37
  publish?: boolean;
38
+ /**
39
+ * Expose `deleteDocument`, which deletes one document once the key's user
40
+ * approved the call in the admin panel. On a collection with `trash` it
41
+ * moves the document to trash. `"unattended"` also lets a key set to it move
42
+ * documents to trash without approval; it needs `trash`. Needs `read`.
43
+ * Default `false`.
44
+ */
45
+ delete?: boolean | "unattended";
46
46
  }
47
- /** The same options. A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
48
- export type McpxGlobalOptions = McpxCollectionOptions;
47
+ /**
48
+ * The same options without `delete`. A singleton, so neither `findDocuments`,
49
+ * `createDocument` nor `deleteDocument` reaches one.
50
+ */
51
+ export type McpxGlobalOptions = Omit<McpxCollectionOptions, "delete">;
49
52
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
50
- /** What the config exposes, before an API key's checkboxes narrow it. */
51
- export interface McpxExposedEntity {
53
+ /**
54
+ * What the config exposes, before an API key's checkboxes narrow it. The
55
+ * operations are the config's side of {@link McpxEntityCapabilities}: `write`
56
+ * covers draft and live writes, and `publish` is only true where drafts exist.
57
+ */
58
+ export interface McpxExposedEntity extends McpxEntityCapabilities {
52
59
  slug: string;
53
- read: boolean;
54
- write: McpxWriteMode;
55
60
  hasDrafts: boolean;
56
61
  /**
57
62
  * The entity has Payload `versions`, with or without drafts, and is
58
63
  * readable. Says nothing about drafts; see `hasDrafts`.
59
64
  */
60
65
  hasVersions: boolean;
61
- /** An upload document is a file, and no tool here can supply one. */
66
+ /**
67
+ * Writes go live immediately, as the entity keeps no drafts.
68
+ */
69
+ liveWrite: boolean;
70
+ /**
71
+ * An upload document is a file, which only an upload grant can supply.
72
+ */
62
73
  isUpload: boolean;
63
- /** Name of the capability group on the key document. */
74
+ /**
75
+ * Name of the capability group on the key document.
76
+ */
64
77
  fieldName: string;
65
78
  }
66
- /** The slugs a key may read, write and publish. */
79
+ /**
80
+ * The slugs a key may read, write, publish and delete.
81
+ */
67
82
  export interface McpxScopeSlugs {
68
83
  readable: string[];
69
84
  writable: string[];
70
85
  publishable: string[];
86
+ deletable: string[];
71
87
  }
72
- /** What a tool knows about the current request. */
88
+ /**
89
+ * What a tool knows about the current request.
90
+ */
73
91
  export interface McpxToolScope {
74
92
  req: PayloadRequest;
75
93
  capabilities: McpxResolvedCapabilities;
76
94
  collections: McpxScopeSlugs;
77
95
  globals: McpxScopeSlugs;
78
- /** `null` when localization is off. */
96
+ /**
97
+ * `null` when localization is off.
98
+ */
79
99
  localization: null | {
80
100
  locales: string[];
81
101
  defaultLocale: string;
@@ -84,20 +104,34 @@ export interface McpxToolScope {
84
104
  maxLimit: number;
85
105
  maxDepth: number;
86
106
  };
107
+ /**
108
+ * Whether files can be uploaded at all: the KV is database-backed and no
109
+ * custom `auth.resolve` is configured. Confirmations need the same.
110
+ */
111
+ uploads: boolean;
112
+ /**
113
+ * `null` when `diagnostics` is off.
114
+ */
115
+ diagnostics: null | {
116
+ name: string;
117
+ version: string;
118
+ };
87
119
  exposure: {
88
120
  collections: McpxExposedEntity[];
89
121
  globals: McpxExposedEntity[];
90
122
  };
91
123
  }
92
124
  /**
93
- * A tool, builtin or custom. Runs with `req.user` resolved from the key and
94
- * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
95
- * per request, leaving no static shape to infer from.
125
+ * What every tool declares, apart from `confirm` and the handler.
96
126
  */
97
- export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
98
- /** camelCase, unique, not one of the builtin tool names. */
127
+ interface McpxToolBase<Shape extends z.ZodRawShape> {
128
+ /**
129
+ * camelCase, unique, not one of the builtin tool names.
130
+ */
99
131
  name: string;
100
- /** Built per request so it can state what this key's writes do. */
132
+ /**
133
+ * Built per request so it can state what this key's writes do.
134
+ */
101
135
  description: string | ((scope: McpxToolScope) => string);
102
136
  annotations?: ToolAnnotations;
103
137
  /**
@@ -112,21 +146,117 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
112
146
  * instead of stripped.
113
147
  */
114
148
  inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
115
- handler(ctx: {
116
- args: Args;
117
- scope: McpxToolScope;
118
- /** Shorthand for `scope.req`. */
119
- req: PayloadRequest;
120
- extra: McpxToolExtra;
121
- }): CallToolResult | Promise<CallToolResult>;
122
149
  }
123
- /** Argument type erased, so a registry can hold tools of differing shapes. */
150
+ /**
151
+ * What a handler receives.
152
+ */
153
+ export interface McpxToolContext<Args> {
154
+ args: Args;
155
+ scope: McpxToolScope;
156
+ /**
157
+ * Shorthand for `scope.req`.
158
+ */
159
+ req: PayloadRequest;
160
+ extra: McpxToolExtra;
161
+ }
162
+ /**
163
+ * Handed to the handler of a tool with `confirm`. Internal for now.
164
+ */
165
+ export interface McpxConfirmation {
166
+ /**
167
+ * `true` when `runConfirmed` runs an approved call.
168
+ */
169
+ confirmed: boolean;
170
+ /**
171
+ * Stores the call with `args` for the key's user to approve and returns
172
+ * the result that hands its id and URL to the client. Throws in a
173
+ * confirmed run.
174
+ */
175
+ request: (args: Record<string, unknown>) => Promise<CallToolResult>;
176
+ }
177
+ /**
178
+ * A tool as `defineMcpxTool` takes it. `Confirm` is inferred from `confirm`,
179
+ * so only a tool that sets it gets `confirmation` in its handler.
180
+ */
181
+ export type McpxToolDefinition<Shape extends z.ZodRawShape, Args, Confirm extends McpxToolConfirm | undefined> = McpxToolBase<Shape> & {
182
+ /**
183
+ * Internal: only builtin tools are confirmable for now. The handler gets
184
+ * `confirmation`: a call outside a confirmed run stores itself through
185
+ * `confirmation.request`, the key's user approves it in the admin panel,
186
+ * and `runConfirmed` runs the handler again with `confirmation.confirmed`
187
+ * set.
188
+ */
189
+ confirm?: Confirm;
190
+ handler(ctx: McpxToolContext<Args> & (Confirm extends McpxToolConfirm ? {
191
+ confirmation: McpxConfirmation;
192
+ } : unknown)): CallToolResult | Promise<CallToolResult>;
193
+ };
194
+ /**
195
+ * A tool, builtin or custom. Runs with `req.user` resolved from the key and
196
+ * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
197
+ * per request, leaving no static shape to infer from.
198
+ */
199
+ export type McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> = McpxToolDefinition<Shape, Args, undefined> | (McpxToolDefinition<Shape, Args, McpxToolConfirm> & {
200
+ confirm: McpxToolConfirm;
201
+ });
202
+ /**
203
+ * How the admin panel shows one stored call of a confirmable tool.
204
+ */
205
+ export interface McpxConfirmationSummary {
206
+ /**
207
+ * What the call acts on, such as a collection's label.
208
+ */
209
+ label: string;
210
+ /**
211
+ * `undefined` when the target cannot be read.
212
+ */
213
+ title?: string;
214
+ /**
215
+ * The target's `_status`, where it has drafts.
216
+ */
217
+ status?: string;
218
+ id?: string;
219
+ /**
220
+ * Admin path that opens the target.
221
+ */
222
+ href?: string;
223
+ /**
224
+ * The call cannot be undone.
225
+ */
226
+ permanent: boolean;
227
+ /**
228
+ * Written by the client, so shown as such.
229
+ */
230
+ reason?: string;
231
+ }
232
+ /**
233
+ * Internal for now; see {@link McpxTool.confirm}.
234
+ */
235
+ export interface McpxToolConfirm {
236
+ /**
237
+ * Heading of the tool's group in the admin panel.
238
+ */
239
+ label: string;
240
+ /**
241
+ * Read at view time with the access of the key's user.
242
+ */
243
+ summarize(call: {
244
+ args: Record<string, unknown>;
245
+ }, req: PayloadRequest): Promise<McpxConfirmationSummary>;
246
+ }
247
+ /**
248
+ * Argument type erased, so a registry can hold tools of differing shapes.
249
+ */
124
250
  export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
125
251
  export interface McpxAuthResult {
126
- /** Must carry `collection`. */
252
+ /**
253
+ * Must carry `collection`.
254
+ */
127
255
  user: TypedUser;
128
256
  apiKeyId: DocumentId;
129
- /** The `capabilities` group as stored on the key document. */
257
+ /**
258
+ * The `capabilities` group as stored on the key document.
259
+ */
130
260
  capabilities: unknown;
131
261
  }
132
262
  /**
@@ -136,31 +266,48 @@ export interface McpxAuthResult {
136
266
  * to `Record<string, unknown>`, which interfaces do not satisfy.
137
267
  */
138
268
  export type McpxPluginOptions = {
139
- /** Allow-list of collections. `true` is shorthand for `{}`. */
269
+ /**
270
+ * Allow-list of collections. `true` is shorthand for `{}`.
271
+ */
140
272
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
141
- /** Allow-list of globals. `true` is shorthand for `{}`. */
273
+ /**
274
+ * Allow-list of globals. `true` is shorthand for `{}`.
275
+ */
142
276
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
143
- /** Collection the keys act as. Default `config.admin.user`, then `users`. */
277
+ /**
278
+ * Collection the keys act as. Default `config.admin.user`, then `users`.
279
+ */
144
280
  userCollection?: CollectionSlug;
145
281
  apiKeys?: {
146
- /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
282
+ /**
283
+ * Slug of the generated API key collection. Default `mcpx-api-keys`.
284
+ */
147
285
  slug?: string;
148
286
  /**
149
- * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
150
- * client config. Default `true`. The snippets contain the key in full.
287
+ * Add a "Connect a client" button to saved keys, opening a drawer with
288
+ * ready-to-paste MCP client config. Default `true`. The snippets contain
289
+ * the key in full.
151
290
  */
152
291
  setupGuide?: boolean;
153
- /** Final override applied to the generated collection. */
292
+ /**
293
+ * Final override applied to the generated collection.
294
+ */
154
295
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
155
296
  };
156
297
  endpoint?: {
157
- /** Endpoint path below the API route. Default `/mcpx`. */
298
+ /**
299
+ * Endpoint path below the API route. Default `/mcpx`.
300
+ */
158
301
  path?: string;
159
302
  };
160
303
  limits?: {
161
- /** Upper bound for `findDocuments.limit`. Default 25. */
304
+ /**
305
+ * Upper bound for `findDocuments.limit`. Default 25.
306
+ */
162
307
  maxLimit?: number;
163
- /** Upper bound for `depth` on reads. Default 1. */
308
+ /**
309
+ * Upper bound for `depth` on reads. Default 1.
310
+ */
164
311
  maxDepth?: number;
165
312
  };
166
313
  tools?: McpxAnyTool[];
@@ -179,21 +326,42 @@ export type McpxPluginOptions = {
179
326
  name?: string;
180
327
  version?: string;
181
328
  };
329
+ /**
330
+ * Expose the server version plus repository and issue links to clients. Default `true`.
331
+ */
332
+ diagnostics?: boolean;
182
333
  };
183
- /** What a key may do with one entity. Globals reuse this shape. */
184
- export interface McpxCollectionCapabilities {
334
+ /**
335
+ * One entity's operations, as the config exposes them or as a key may use
336
+ * them. Globals reuse this shape.
337
+ */
338
+ export interface McpxEntityCapabilities {
185
339
  read: boolean;
186
340
  write: boolean;
187
- /** Only ever true where the config lets writes publish and drafts exist. */
341
+ /**
342
+ * Only ever true where the config lets writes publish and drafts exist.
343
+ */
188
344
  publish: boolean;
345
+ /**
346
+ * Always `false` on a global.
347
+ */
348
+ delete: boolean;
349
+ /**
350
+ * Moves to trash without approval. Only ever true where `delete` is.
351
+ */
352
+ deleteUnattended: boolean;
189
353
  }
190
- /** In force for one request: the plugin config and the key checkboxes together. */
354
+ /**
355
+ * In force for one request: the plugin config and the key checkboxes together.
356
+ */
191
357
  export interface McpxResolvedCapabilities {
192
- collections: Record<string, McpxCollectionCapabilities>;
193
- globals: Record<string, McpxCollectionCapabilities>;
358
+ collections: Record<string, McpxEntityCapabilities>;
359
+ globals: Record<string, McpxEntityCapabilities>;
194
360
  tools: Record<string, boolean>;
195
361
  }
196
- /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
362
+ /**
363
+ * Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
364
+ */
197
365
  export interface McpxRequestContext {
198
366
  apiKeyId: DocumentId;
199
367
  capabilities: McpxResolvedCapabilities;
@@ -0,0 +1,177 @@
1
+ import { grantContext, issueGrant } from "../grants/grant.mjs";
2
+ import { publicOrigin } from "../request.mjs";
3
+ import { errorResult, jsonResult } from "../result.mjs";
4
+ import { SchemaError } from "../schema/errors.mjs";
5
+ import { resolveDataPointer } from "../schema/pointer.mjs";
6
+ import "../schema/index.mjs";
7
+ import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
+ import { z } from "zod";
9
+ import { APIError } from "payload";
10
+ import { hasDraftValidationEnabled, hasDraftsEnabled, validateMimeType } from "payload/shared";
11
+ //#region src/upload/file.ts
12
+ const MAX_BYTES = 26214400;
13
+ const FILENAME = /^[^/\\\p{Cc}]+$/u;
14
+ const MIME_TYPE = /^[\w.+-]+\/[\w.+-]+$/;
15
+ const uploadedFiles = /* @__PURE__ */ new WeakMap();
16
+ const fileSchema = (maxBytes) => z.strictObject({
17
+ filename: z.string().max(255).regex(FILENAME),
18
+ mimeType: z.string().max(255).regex(MIME_TYPE),
19
+ size: z.number().int().min(1).max(maxBytes)
20
+ });
21
+ /**
22
+ * The largest file accepted: Payload's `upload.limits.fileSize` if set, and
23
+ * never more than 25 MB, since the body is buffered.
24
+ */ const uploadMaxBytes = (config) => Math.min(config.upload.limits?.fileSize ?? MAX_BYTES, MAX_BYTES);
25
+ const allowedMimeTypes = (scope, slug) => {
26
+ if (!scope.uploads || !scope.collections.writable.includes(slug)) return [];
27
+ const entity = scope.exposure.collections.find((candidate) => candidate.slug === slug);
28
+ const config = scope.req.payload.collections[slug]?.config;
29
+ return entity?.isUpload === true && Array.isArray(config?.upload.mimeTypes) ? config.upload.mimeTypes : [];
30
+ };
31
+ /**
32
+ * Whether this key may send a file to `slug`: a writable upload collection
33
+ * that sets `upload.mimeTypes`.
34
+ */ const acceptsFiles = (scope, slug) => allowedMimeTypes(scope, slug).length > 0;
35
+ /**
36
+ * The collections this key may send files to.
37
+ */ const fileSlugs = (scope) => scope.collections.writable.filter((slug) => acceptsFiles(scope, slug));
38
+ /**
39
+ * The `file` argument, present only when the key may send a file somewhere.
40
+ */ const fileShape = (scope, description) => {
41
+ const slugs = fileSlugs(scope);
42
+ return slugs.length === 0 ? {} : { file: fileSchema(uploadMaxBytes(scope.req.payload.config)).optional().describe(description(slugs.join(", "))) };
43
+ };
44
+ /**
45
+ * Throws for a `file` sent to an entity that does not accept one, or declared
46
+ * with a type outside its `upload.mimeTypes`. Payload stores the declared
47
+ * type, so it is held to the same list as the content.
48
+ */ const assertAcceptsFile = (scope, target, file) => {
49
+ const mimeTypes = target.kind === "global" ? [] : allowedMimeTypes(scope, target.slug);
50
+ 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);
51
+ if (!validateMimeType(file.mimeType, mimeTypes)) throw new APIError(`"${target.slug}" accepts only ${mimeTypes.join(", ")}, not "${file.mimeType}".`, 400);
52
+ };
53
+ /**
54
+ * Attaches the claimed bytes to the request the tool runs on.
55
+ */ const setUploadedFile = (req, file) => {
56
+ uploadedFiles.set(req, file);
57
+ };
58
+ /**
59
+ * The bytes for this call, or `undefined` before they were sent.
60
+ */ const uploadedFile = (req) => uploadedFiles.get(req);
61
+ const sharesPublishedFile = async (req, entity, id) => {
62
+ if (!hasDraftsEnabled(entity.config)) return false;
63
+ const read = {
64
+ collection: entity.slug,
65
+ id,
66
+ depth: 0,
67
+ overrideAccess: true,
68
+ disableErrors: true,
69
+ select: {
70
+ filename: true,
71
+ _status: true
72
+ },
73
+ req
74
+ };
75
+ const [latest, published] = await Promise.all([req.payload.findByID({
76
+ ...read,
77
+ draft: true
78
+ }), req.payload.findByID({
79
+ ...read,
80
+ draft: false
81
+ })]);
82
+ return latest?.["_status"] === "draft" && published?.["_status"] === "published" && latest["filename"] === published["filename"];
83
+ };
84
+ const isClientWritable = (scope, target, path) => {
85
+ try {
86
+ const resolution = resolveDataPointer(scope.req.payload.config, {
87
+ ref: target.entity,
88
+ doc: target.data,
89
+ pointer: path
90
+ });
91
+ return resolution.readOnly !== true && resolution.descriptor?.readOnly !== true;
92
+ } catch (error) {
93
+ if (error instanceof SchemaError) return false;
94
+ throw error;
95
+ }
96
+ };
97
+ /**
98
+ * Refuses a file write Payload would turn into data loss: a replace that
99
+ * deletes the published file, or a write that fails validation after the old
100
+ * file is gone. Payload validates only after it moves files, so the merged
101
+ * data is validated here first wherever Payload will validate it, keeping only
102
+ * failures the client can fix. Once the bytes are attached, Payload validates
103
+ * with every hook and the file.
104
+ */ const fileWriteRefusal = async (scope, target) => {
105
+ const { entity, id } = target;
106
+ 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.");
107
+ if (uploadedFile(scope.req) !== void 0 || hasDraftsEnabled(entity.config) && !hasDraftValidationEnabled(entity.config)) return;
108
+ const { blockers: found } = await collectPublishBlockers(scope.req, {
109
+ doc: {
110
+ ...target.data,
111
+ ...id === void 0 ? {} : { id }
112
+ },
113
+ entity
114
+ });
115
+ const blockers = found.filter((blocker) => isClientWritable(scope, target, blocker.path));
116
+ return blockers.length === 0 ? void 0 : errorResult("Nothing was written: the document fails validation.", { validationErrors: blockers });
117
+ };
118
+ const handoffUrl = (req, path) => {
119
+ const url = new URL(publicOrigin(req));
120
+ url.pathname = `${new URL(req.url ?? "").pathname.replace(/\/+$/, "")}/${path}`;
121
+ return url.toString();
122
+ };
123
+ /**
124
+ * Stores the call as a grant and returns where to PUT its file. Nothing else
125
+ * is written: the PUT runs the same call again with the bytes attached.
126
+ */ const uploadHandoff = async (scope, call) => {
127
+ const { req } = scope;
128
+ const { payload } = req;
129
+ const { slug, apiKeyId } = grantContext(req);
130
+ const url = handoffUrl(req, "upload");
131
+ const { id, exp } = await issueGrant(payload, slug, {
132
+ kind: "upload",
133
+ apiKeyId,
134
+ tool: call.tool,
135
+ args: call.args,
136
+ file: call.args.file,
137
+ locale: req.locale ?? null,
138
+ fallbackLocale: req.fallbackLocale ?? null
139
+ });
140
+ return jsonResult({ upload: {
141
+ url,
142
+ method: "PUT",
143
+ headers: {
144
+ "content-type": call.args.file.mimeType,
145
+ "x-mcpx-grant": id
146
+ },
147
+ expiresAt: new Date(exp).toISOString(),
148
+ maxBytes: uploadMaxBytes(payload.config)
149
+ } });
150
+ };
151
+ /**
152
+ * The upload collections whose files this key may download. A collection
153
+ * with its own top-level `download` field is left out, since the handoff
154
+ * would overwrite it in the result.
155
+ */ 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")) : [];
156
+ /**
157
+ * Stores a grant for the file of a document just read and returns where to
158
+ * GET it.
159
+ */ const downloadHandoff = async (scope, target) => {
160
+ const { req } = scope;
161
+ const { slug, apiKeyId } = grantContext(req);
162
+ const url = handoffUrl(req, "file");
163
+ const { id, exp } = await issueGrant(req.payload, slug, {
164
+ kind: "download",
165
+ apiKeyId,
166
+ ...target,
167
+ fallbackLocale: req.fallbackLocale ?? null
168
+ });
169
+ return {
170
+ url,
171
+ method: "GET",
172
+ headers: { "x-mcpx-grant": id },
173
+ expiresAt: new Date(exp).toISOString()
174
+ };
175
+ };
176
+ //#endregion
177
+ export { acceptsFiles, assertAcceptsFile, downloadHandoff, downloadSlugs, fileShape, fileSlugs, fileWriteRefusal, setUploadedFile, uploadHandoff, uploadMaxBytes, uploadedFile };
package/dist/version.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  //#region src/version.ts
2
2
  /**
3
3
  * Package version injected at build time; sources under Vitest report "dev".
4
- */ const MCPX_VERSION = "1.0.0";
4
+ */ const MCPX_VERSION = "2.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 };