@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.
- package/README.md +6 -2
- package/dist/api-keys/capability-matrix.mjs +2 -6
- package/dist/auth/resolve.mjs +41 -31
- package/dist/capabilities.mjs +3 -3
- package/dist/endpoint/handler.mjs +25 -11
- package/dist/endpoint/server.mjs +33 -22
- package/dist/endpoint/upload.mjs +135 -0
- package/dist/options.mjs +3 -2
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.mjs +13 -2
- package/dist/tools/create-document.mjs +28 -5
- package/dist/tools/document.mjs +1 -1
- package/dist/tools/get-document.mjs +22 -2
- package/dist/tools/list-capabilities.mjs +13 -4
- package/dist/tools/patch-document.mjs +64 -34
- package/dist/tools/shared.mjs +8 -7
- package/dist/tools/validate-document.mjs +1 -1
- package/dist/types.d.mts +20 -4
- package/dist/upload/file.mjs +185 -0
- package/dist/upload/grant.mjs +96 -0
- package/dist/version.mjs +3 -1
- package/package.json +6 -6
|
@@ -6,6 +6,7 @@ import { SchemaError } from "../schema/errors.mjs";
|
|
|
6
6
|
import { lexicalOutline } from "../schema/outline.mjs";
|
|
7
7
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
8
8
|
import "../schema/index.mjs";
|
|
9
|
+
import { downloadHandoff, downloadSlugs } from "../upload/file.mjs";
|
|
9
10
|
import { READ_LOCALE_DESCRIPTION, depthShape, entityShape, idSchema, idShape, localeOf, localeShape, slugsFor, widen } from "./shared.mjs";
|
|
10
11
|
import { identityOf, resolveDocument } from "./document.mjs";
|
|
11
12
|
import { diffDocuments, loadPublished, loadVersion, readLive } from "./versions.mjs";
|
|
@@ -46,6 +47,18 @@ const versionShape = (scope) => {
|
|
|
46
47
|
diffFrom: idSchema.optional().describe("Version id, or \"published\" for the newest version published in any locale. Returns {from, to, patch}, the JSON Patch from that version to what is read.")
|
|
47
48
|
});
|
|
48
49
|
};
|
|
50
|
+
const downloadShape = (scope) => {
|
|
51
|
+
const slugs = downloadSlugs(scope);
|
|
52
|
+
return widen(slugs.length === 0 ? {} : { download: z.literal(true).optional().describe(`Only for ${slugs.join(", ")}. Adds "download": GET its "url" with its "headers" once, within 5 minutes, for the file. Not with "path", "versionId" or "diffFrom".`) });
|
|
53
|
+
};
|
|
54
|
+
const assertDownloadArgs = (scope, target, args) => {
|
|
55
|
+
if (target.kind === "global" || !downloadSlugs(scope).includes(target.slug)) throw new APIError(`"${target.slug}" has no file to download.`, 400);
|
|
56
|
+
if (args.path !== void 0 || args.versionId !== void 0 || args.diffFrom !== void 0) throw new APIError("\"download\" cannot be combined with \"path\", \"versionId\" or \"diffFrom\".", 400);
|
|
57
|
+
};
|
|
58
|
+
const withDownload = async (scope, doc, target) => typeof doc["filename"] === "string" ? jsonResult({
|
|
59
|
+
...doc,
|
|
60
|
+
download: await downloadHandoff(scope, target)
|
|
61
|
+
}) : errorResult("This document has no file to download.");
|
|
49
62
|
/**
|
|
50
63
|
* With `path` the handler returns the subtree plus the `id`, `_status` and
|
|
51
64
|
* `updatedAt` a client needs to write back, so reading one branch still gives
|
|
@@ -69,11 +82,13 @@ const versionShape = (scope) => {
|
|
|
69
82
|
}),
|
|
70
83
|
draft: z.boolean().optional().describe("Default true. false reads the published version."),
|
|
71
84
|
outline: z.boolean().optional().describe("With \"path\" at a rich text field: list each node's pointer, type, \"version\" and text."),
|
|
72
|
-
...versionShape(scope)
|
|
85
|
+
...versionShape(scope),
|
|
86
|
+
...downloadShape(scope)
|
|
73
87
|
}),
|
|
74
88
|
handler: async ({ args, scope }) => {
|
|
75
89
|
const target = resolveDocument(scope, args, "read");
|
|
76
90
|
assertVersionArgs(scope, target, args);
|
|
91
|
+
if (args.download) assertDownloadArgs(scope, target, args);
|
|
77
92
|
const prototyped = args.path ? prototypeSegmentProblem(args.path) : void 0;
|
|
78
93
|
if (prototyped !== void 0) return errorResult(prototyped);
|
|
79
94
|
let pointer;
|
|
@@ -96,7 +111,12 @@ const versionShape = (scope) => {
|
|
|
96
111
|
}, doc);
|
|
97
112
|
if (pointer === void 0) {
|
|
98
113
|
if (args.outline) return errorResult(OUTLINE_ERROR);
|
|
99
|
-
return
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
* files
|
|
22
|
-
*/ const patchOnlySlugs = (scope) =>
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
38
|
-
* collection
|
|
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,
|
|
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 };
|