@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.
- package/README.md +144 -470
- package/dist/admin/capability-matrix.d.mts +33 -0
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +4 -2
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +12 -8
- package/dist/api-keys/capability-matrix.mjs +14 -86
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +36 -24
- package/dist/auth/resolve.mjs +71 -23
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +44 -46
- package/dist/define-tool.d.mts +23 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +73 -29
- package/dist/endpoint/server.mjs +85 -22
- package/dist/endpoint/upload.mjs +135 -0
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +7 -0
- package/dist/i18n.mjs +10 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +54 -47
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.mjs +13 -3
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +44 -93
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +28 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +43 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +50 -24
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +124 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +60 -33
- package/dist/tools/list-capabilities.mjs +35 -19
- package/dist/tools/patch-document.mjs +122 -66
- package/dist/tools/publish-document.mjs +26 -32
- package/dist/tools/query-paths.mjs +57 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +74 -105
- package/dist/tools/validate-document.mjs +18 -30
- package/dist/tools/versions.mjs +37 -20
- package/dist/types.d.mts +135 -75
- package/dist/upload/file.mjs +166 -0
- package/dist/upload/grant.mjs +96 -0
- package/dist/version.mjs +3 -1
- package/dist/write/draft-guard.mjs +23 -41
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +6 -4
- package/dist/write/row-ids.mjs +46 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +11 -10
- package/dist/client/capability-matrix.d.mts +0 -26
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /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
|
|
21
|
-
* `publishDocument
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
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 `
|
|
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
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* An upload document is a file, which only an upload grant can supply.
|
|
70
|
+
*/
|
|
56
71
|
isUpload: boolean;
|
|
57
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Name of the capability group on the key document.
|
|
74
|
+
*/
|
|
58
75
|
fieldName: string;
|
|
59
76
|
}
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* camelCase, unique, not one of the builtin tool names.
|
|
129
|
+
*/
|
|
89
130
|
name: string;
|
|
90
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
128
|
-
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
129
|
-
}): McpxAnyTool;
|
|
161
|
+
export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
|
|
130
162
|
export interface McpxAuthResult {
|
|
131
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Must carry `collection`.
|
|
165
|
+
*/
|
|
132
166
|
user: TypedUser;
|
|
133
|
-
apiKeyId:
|
|
134
|
-
/**
|
|
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
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Allow-list of collections. `true` is shorthand for `{}`.
|
|
182
|
+
*/
|
|
145
183
|
collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
|
|
146
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Allow-list of globals. `true` is shorthand for `{}`.
|
|
186
|
+
*/
|
|
147
187
|
globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
|
|
148
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Collection the keys act as. Default `config.admin.user`, then `users`.
|
|
190
|
+
*/
|
|
149
191
|
userCollection?: CollectionSlug;
|
|
150
192
|
apiKeys?: {
|
|
151
|
-
/**
|
|
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
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Final override applied to the generated collection.
|
|
204
|
+
*/
|
|
159
205
|
overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
|
|
160
206
|
};
|
|
161
207
|
endpoint?: {
|
|
162
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Endpoint path below the API route. Default `/mcpx`.
|
|
210
|
+
*/
|
|
163
211
|
path?: string;
|
|
164
212
|
};
|
|
165
213
|
limits?: {
|
|
166
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Upper bound for `findDocuments.limit`. Default 25.
|
|
216
|
+
*/
|
|
167
217
|
maxLimit?: number;
|
|
168
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Only ever true where the config lets writes publish and drafts exist.
|
|
252
|
+
*/
|
|
189
253
|
publish: boolean;
|
|
190
254
|
}
|
|
191
|
-
/**
|
|
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
|
-
/**
|
|
263
|
+
/**
|
|
264
|
+
* Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
|
|
265
|
+
*/
|
|
198
266
|
export interface McpxRequestContext {
|
|
199
|
-
apiKeyId:
|
|
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 };
|