@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
package/README.md
CHANGED
|
@@ -103,6 +103,7 @@ Claude Desktop and the MCP Inspector are covered in
|
|
|
103
103
|
| `tools` | `[]` | Custom tools, see [`docs/custom-tools.md`](./docs/custom-tools.md). |
|
|
104
104
|
| `auth.resolve` | none | Replace or wrap the key lookup. |
|
|
105
105
|
| `serverInfo` | `payloadcms-mcpx` and the package version | `{ name, version }` reported to clients. |
|
|
106
|
+
| `diagnostics` | `true` | Expose the server version plus repository and issue links. |
|
|
106
107
|
|
|
107
108
|
The config only takes capabilities away: `true` exposes everything the entity supports, and a key's
|
|
108
109
|
checkboxes decide what each key may do. `publish: false` keeps MCP writes as drafts. On an entity
|
|
@@ -121,8 +122,8 @@ every check.
|
|
|
121
122
|
| `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
|
|
122
123
|
| `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
|
|
123
124
|
| `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
|
|
124
|
-
| `patchDocument` | Applies JSON Patch operations to the current draft.
|
|
125
|
-
| `createDocument` | Creates a draft from a seed
|
|
125
|
+
| `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write` |
|
|
126
|
+
| `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write` |
|
|
126
127
|
| `validateDocument` | Lists what blocks publishing, without saving. | `write` |
|
|
127
128
|
| `publishDocument` | Publishes the current draft. | `write`, `publish` |
|
|
128
129
|
|
|
@@ -163,6 +164,9 @@ To restrict that, use `apiKeys.overrideCollection` as shown in
|
|
|
163
164
|
Blocker messages can name fields the user cannot read.
|
|
164
165
|
- The request body is limited to 4 MB, a JSON-RPC batch to 10 messages, `patchDocument` to 500
|
|
165
166
|
operations and `describeSchema` to 400 paths.
|
|
167
|
+
- A file is sent in a second step, a `PUT`, and downloaded with a `GET`, each with a short-lived,
|
|
168
|
+
single-use grant. Only collections with `upload.mimeTypes` take files. Downloads go through the
|
|
169
|
+
collection's own file access. See [uploads](./docs/security.md#uploads).
|
|
166
170
|
- Custom tools are trusted code and must apply access control themselves.
|
|
167
171
|
|
|
168
172
|
Open limitations: a filter through a relation into a readable collection ignores that collection's
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAPABILITIES_FIELD,
|
|
1
|
+
import { CAPABILITIES_FIELD, canPublish, canWrite, isLiveWrite } from "../capabilities.mjs";
|
|
2
2
|
//#region src/api-keys/capability-matrix.ts
|
|
3
3
|
/**
|
|
4
4
|
* The group's `admin.description`, rendered by the matrix in Payload's
|
|
@@ -25,12 +25,8 @@ import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite } from
|
|
|
25
25
|
description: "Promote the current draft to what the public sees."
|
|
26
26
|
}
|
|
27
27
|
];
|
|
28
|
-
const UPLOAD_HINT = "Files are uploaded in the admin panel.";
|
|
29
28
|
const LIVE_HINT = "Writes go live immediately.";
|
|
30
|
-
const hintFor = (entity) => {
|
|
31
|
-
const hints = [canWrite(entity) && !canCreate(entity) ? UPLOAD_HINT : void 0, isLiveWrite(entity) ? LIVE_HINT : void 0].filter((hint) => hint !== void 0);
|
|
32
|
-
return hints.length > 0 ? { hint: hints.join(" ") } : {};
|
|
33
|
-
};
|
|
29
|
+
const hintFor = (entity) => isLiveWrite(entity) ? { hint: LIVE_HINT } : {};
|
|
34
30
|
const toRow = (entity) => ({
|
|
35
31
|
fieldName: entity.fieldName,
|
|
36
32
|
label: entity.slug,
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -22,6 +22,43 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
22
22
|
return isId(auth["user"]["id"]) && auth["user"]["collection"] === options.userCollection && isId(auth["apiKeyId"]);
|
|
23
23
|
};
|
|
24
24
|
/**
|
|
25
|
+
* The checks a key passes once it is looked up: enabled, not expired, and its
|
|
26
|
+
* user exists, is verified and is not locked out. Yields the user it acts as,
|
|
27
|
+
* or `null`.
|
|
28
|
+
*/ const checkApiKey = async (req, options, keyDoc) => {
|
|
29
|
+
const userId = relationId(keyDoc?.user);
|
|
30
|
+
if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
|
|
31
|
+
if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
|
|
32
|
+
const { payload } = req;
|
|
33
|
+
const userCollection = payload.collections[options.userCollection];
|
|
34
|
+
const lookup = {
|
|
35
|
+
collection: options.userCollection,
|
|
36
|
+
id: userId,
|
|
37
|
+
overrideAccess: true,
|
|
38
|
+
disableErrors: true
|
|
39
|
+
};
|
|
40
|
+
const [user, lock] = await Promise.all([payload.findByID({
|
|
41
|
+
...lookup,
|
|
42
|
+
depth: userCollection?.config.auth.depth ?? 0
|
|
43
|
+
}), payload.findByID({
|
|
44
|
+
...lookup,
|
|
45
|
+
depth: 0,
|
|
46
|
+
showHiddenFields: true,
|
|
47
|
+
select: { lockUntil: true }
|
|
48
|
+
})]);
|
|
49
|
+
const lockUntil = toTime(lock?.["lockUntil"]);
|
|
50
|
+
if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
|
|
51
|
+
return {
|
|
52
|
+
user: {
|
|
53
|
+
...user,
|
|
54
|
+
collection: options.userCollection,
|
|
55
|
+
_strategy: "mcpx-api-key"
|
|
56
|
+
},
|
|
57
|
+
apiKeyId: keyDoc.id,
|
|
58
|
+
capabilities: keyDoc.capabilities
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
25
62
|
* Resolves the bearer key of a request to the user it acts as.
|
|
26
63
|
*
|
|
27
64
|
* The key is looked up by its HMAC index, the same way Payload resolves its own
|
|
@@ -47,27 +84,8 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
47
84
|
}
|
|
48
85
|
});
|
|
49
86
|
const keyDoc = docs[0];
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
|
|
53
|
-
const userCollection = payload.collections[options.userCollection];
|
|
54
|
-
const lookup = {
|
|
55
|
-
collection: options.userCollection,
|
|
56
|
-
id: userId,
|
|
57
|
-
overrideAccess: true,
|
|
58
|
-
disableErrors: true
|
|
59
|
-
};
|
|
60
|
-
const [user, lock] = await Promise.all([payload.findByID({
|
|
61
|
-
...lookup,
|
|
62
|
-
depth: userCollection?.config.auth.depth ?? 0
|
|
63
|
-
}), payload.findByID({
|
|
64
|
-
...lookup,
|
|
65
|
-
depth: 0,
|
|
66
|
-
showHiddenFields: true,
|
|
67
|
-
select: { lockUntil: true }
|
|
68
|
-
})]);
|
|
69
|
-
const lockUntil = toTime(lock?.["lockUntil"]);
|
|
70
|
-
if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
|
|
87
|
+
const auth = await checkApiKey(req, options, keyDoc);
|
|
88
|
+
if (!auth || !keyDoc) return null;
|
|
71
89
|
const lastUsed = toTime(keyDoc.lastUsedAt);
|
|
72
90
|
if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
|
|
73
91
|
await payload.update({
|
|
@@ -84,15 +102,7 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
|
84
102
|
msg: "Could not record the last use of an MCP API key"
|
|
85
103
|
});
|
|
86
104
|
}
|
|
87
|
-
return
|
|
88
|
-
user: {
|
|
89
|
-
...user,
|
|
90
|
-
collection: options.userCollection,
|
|
91
|
-
_strategy: "mcpx-api-key"
|
|
92
|
-
},
|
|
93
|
-
apiKeyId: keyDoc.id,
|
|
94
|
-
capabilities: keyDoc.capabilities
|
|
95
|
-
};
|
|
105
|
+
return auth;
|
|
96
106
|
};
|
|
97
107
|
//#endregion
|
|
98
|
-
export { isValidAuthResult, parseBearer, resolveApiKeyAuth };
|
|
108
|
+
export { checkApiKey, isValidAuthResult, parseBearer, resolveApiKeyAuth };
|
package/dist/capabilities.mjs
CHANGED
|
@@ -10,9 +10,9 @@ import { isPlainObject } from "./guards.mjs";
|
|
|
10
10
|
* The config lets MCP change live content and a draft exists to promote.
|
|
11
11
|
*/ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
12
12
|
/**
|
|
13
|
-
* An upload
|
|
14
|
-
*
|
|
15
|
-
*/ const canCreate = (entity) => canWrite(entity)
|
|
13
|
+
* Any writable collection. An upload collection also needs a file, which
|
|
14
|
+
* depends on the request, so the tools check it per call.
|
|
15
|
+
*/ const canCreate = (entity) => canWrite(entity);
|
|
16
16
|
/**
|
|
17
17
|
* Without drafts there is no draft stage, so every write is live.
|
|
18
18
|
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
|
|
2
2
|
import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
3
3
|
import { jsonRpcError } from "./errors.mjs";
|
|
4
|
+
import { uploadKvSlug } from "../upload/grant.mjs";
|
|
4
5
|
import { createMcpServer } from "./server.mjs";
|
|
5
6
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
7
|
//#region src/endpoint/handler.ts
|
|
@@ -34,6 +35,11 @@ const readBody = async (req) => {
|
|
|
34
35
|
defaultLocale: localization.defaultLocale
|
|
35
36
|
} : null,
|
|
36
37
|
limits: options.limits,
|
|
38
|
+
uploads: options.auth?.resolve === void 0 && uploadKvSlug(req.payload.config) !== void 0,
|
|
39
|
+
diagnostics: options.diagnostics ? {
|
|
40
|
+
name: options.serverInfo.name,
|
|
41
|
+
version: options.serverInfo.version
|
|
42
|
+
} : null,
|
|
37
43
|
exposure: {
|
|
38
44
|
collections: options.collections,
|
|
39
45
|
globals: options.globals
|
|
@@ -41,6 +47,22 @@ const readBody = async (req) => {
|
|
|
41
47
|
};
|
|
42
48
|
};
|
|
43
49
|
/**
|
|
50
|
+
* Makes `req` act as the key: sets `req.user`, replacing any user Payload
|
|
51
|
+
* resolved from cookies, and the `req.context.mcpx` stamp the draft guard
|
|
52
|
+
* reads. Returns the scope the tools see.
|
|
53
|
+
*/ const authenticateAs = (req, options, auth) => {
|
|
54
|
+
const capabilities = resolveCapabilities(options, auth.capabilities);
|
|
55
|
+
req.user = auth.user;
|
|
56
|
+
req.context = {
|
|
57
|
+
...req.context,
|
|
58
|
+
mcpx: {
|
|
59
|
+
apiKeyId: auth.apiKeyId,
|
|
60
|
+
capabilities
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return buildScope(req, options, capabilities);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
44
66
|
* Answers GET and DELETE on the endpoint path. The server is stateless and
|
|
45
67
|
* never streams, so only POST carries meaning.
|
|
46
68
|
*/ const methodNotAllowed = () => jsonRpcError({
|
|
@@ -69,15 +91,7 @@ const readBody = async (req) => {
|
|
|
69
91
|
headers: { "www-authenticate": "Bearer" }
|
|
70
92
|
});
|
|
71
93
|
}
|
|
72
|
-
const
|
|
73
|
-
req.user = auth.user;
|
|
74
|
-
req.context = {
|
|
75
|
-
...req.context,
|
|
76
|
-
mcpx: {
|
|
77
|
-
apiKeyId: auth.apiKeyId,
|
|
78
|
-
capabilities
|
|
79
|
-
}
|
|
80
|
-
};
|
|
94
|
+
const scope = authenticateAs(req, options, auth);
|
|
81
95
|
let parsedBody;
|
|
82
96
|
try {
|
|
83
97
|
const text = await readBody(req);
|
|
@@ -104,7 +118,7 @@ const readBody = async (req) => {
|
|
|
104
118
|
code: -32600,
|
|
105
119
|
message: `Invalid request: a batch may hold at most ${String(BATCH_MESSAGES_LIMIT)} messages.`
|
|
106
120
|
});
|
|
107
|
-
const server = createMcpServer(
|
|
121
|
+
const server = createMcpServer(scope, options);
|
|
108
122
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
109
123
|
await server.connect(transport);
|
|
110
124
|
const headers = new Headers(req.headers);
|
|
@@ -119,4 +133,4 @@ const readBody = async (req) => {
|
|
|
119
133
|
}
|
|
120
134
|
};
|
|
121
135
|
//#endregion
|
|
122
|
-
export { buildScope, createMcpxHandler, methodNotAllowed };
|
|
136
|
+
export { authenticateAs, buildScope, createMcpxHandler, methodNotAllowed };
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { toToolError } from "./errors.mjs";
|
|
2
|
+
import { fileSlugs } from "../upload/file.mjs";
|
|
2
3
|
import { liveWriteSlugs } from "../tools/shared.mjs";
|
|
4
|
+
import { MCPX_REPOSITORY_URL } from "../version.mjs";
|
|
3
5
|
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
4
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
7
|
import { z } from "zod";
|
|
@@ -11,6 +13,25 @@ const toolDescription = (tool, scope) => typeof tool.description === "function"
|
|
|
11
13
|
/**
|
|
12
14
|
* A tool that does not decide for itself is gated by its own checkbox.
|
|
13
15
|
*/ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
16
|
+
/**
|
|
17
|
+
* Runs one tool call. Payload's public errors become error results; anything
|
|
18
|
+
* else is logged and reported as an internal error.
|
|
19
|
+
*/ const runTool = async (tool, args, scope, req, extra) => {
|
|
20
|
+
try {
|
|
21
|
+
return await tool.handler({
|
|
22
|
+
args,
|
|
23
|
+
scope,
|
|
24
|
+
req,
|
|
25
|
+
extra
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return toToolError(error, req.payload.logger);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const WRITE_STEP = {
|
|
32
|
+
tools: ["patchDocument", "createDocument"],
|
|
33
|
+
does: "the write"
|
|
34
|
+
};
|
|
14
35
|
const WORKFLOW = [
|
|
15
36
|
{
|
|
16
37
|
tools: ["listCapabilities"],
|
|
@@ -24,10 +45,7 @@ const WORKFLOW = [
|
|
|
24
45
|
tools: ["findDocuments", "getDocument"],
|
|
25
46
|
does: "the content and its \"updatedAt\""
|
|
26
47
|
},
|
|
27
|
-
|
|
28
|
-
tools: ["patchDocument", "createDocument"],
|
|
29
|
-
does: "the write"
|
|
30
|
-
},
|
|
48
|
+
WRITE_STEP,
|
|
31
49
|
{
|
|
32
50
|
tools: ["validateDocument"],
|
|
33
51
|
does: "what blocks publishing"
|
|
@@ -37,6 +55,7 @@ const WORKFLOW = [
|
|
|
37
55
|
does: "make the draft public"
|
|
38
56
|
}
|
|
39
57
|
];
|
|
58
|
+
const UPLOAD_STEP = "A write that returns \"upload\": PUT the file bytes to its \"url\" with its \"headers\". The response is the result of the write.";
|
|
40
59
|
const PATHS = `describeSchema takes and returns schema paths. Every other "path", and "from" in a patch operation, is a pointer into a document, with a 0-based index where a schema path has "*" for an array element or a block slug: "/layout/sections/sectionWrapper/identifier" is "/layout/sections/0/identifier". In rich text a pointer enters the rich text state: "/content/block/callout/tone" is "/content/root/children/3/fields/tone". getDocument "outline" gives the index.`;
|
|
41
60
|
const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
|
|
42
61
|
const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
@@ -44,9 +63,12 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
|
44
63
|
* The workflow for the tools this key has, and what its writes reach.
|
|
45
64
|
*/ const serverInstructions = (scope) => {
|
|
46
65
|
const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
const uploads = fileSlugs(scope).length > 0;
|
|
67
|
+
const steps = WORKFLOW.flatMap((step) => {
|
|
68
|
+
const present = step.tools.filter((name) => enabled.has(name));
|
|
69
|
+
if (present.length === 0) return [];
|
|
70
|
+
const line = `${present.join(" or ")}: ${step.does}.`;
|
|
71
|
+
return step === WRITE_STEP && uploads ? [line, UPLOAD_STEP] : [line];
|
|
50
72
|
}).map((step, index) => `${String(index + 1)}. ${step}`);
|
|
51
73
|
const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
|
|
52
74
|
const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
|
|
@@ -61,11 +83,11 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
|
61
83
|
* so a failed call cannot roll back a write another call reported as done.
|
|
62
84
|
*/ const createMcpServer = (scope, options) => {
|
|
63
85
|
const { req } = scope;
|
|
64
|
-
const { logger } = req.payload;
|
|
65
86
|
let queue = Promise.resolve();
|
|
66
87
|
const server = new McpServer({
|
|
67
88
|
name: options.serverInfo.name,
|
|
68
|
-
version: options.serverInfo.version
|
|
89
|
+
version: options.serverInfo.version,
|
|
90
|
+
...options.diagnostics ? { websiteUrl: MCPX_REPOSITORY_URL } : {}
|
|
69
91
|
}, { instructions: serverInstructions(scope) });
|
|
70
92
|
for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
|
|
71
93
|
if (!isToolEnabled(tool, scope)) continue;
|
|
@@ -74,18 +96,7 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
|
74
96
|
inputSchema: toolInputSchema(tool, scope),
|
|
75
97
|
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
76
98
|
}, (args, extra) => {
|
|
77
|
-
const result = queue.then(async () =>
|
|
78
|
-
try {
|
|
79
|
-
return await tool.handler({
|
|
80
|
-
args,
|
|
81
|
-
scope,
|
|
82
|
-
req,
|
|
83
|
-
extra
|
|
84
|
-
});
|
|
85
|
-
} catch (error) {
|
|
86
|
-
return toToolError(error, logger);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
99
|
+
const result = queue.then(async () => await runTool(tool, args, scope, req, extra));
|
|
89
100
|
queue = result.catch(() => void 0);
|
|
90
101
|
return result;
|
|
91
102
|
});
|
|
@@ -93,4 +104,4 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
|
93
104
|
return server;
|
|
94
105
|
};
|
|
95
106
|
//#endregion
|
|
96
|
-
export { createMcpServer, isToolEnabled, toolInputSchema };
|
|
107
|
+
export { createMcpServer, isToolEnabled, runTool, toolInputSchema };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { checkApiKey } from "../auth/resolve.mjs";
|
|
2
|
+
import { claimGrant, uploadKvSlug } from "../upload/grant.mjs";
|
|
3
|
+
import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
|
|
4
|
+
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
5
|
+
import { isToolEnabled, runTool, toolInputSchema } from "./server.mjs";
|
|
6
|
+
import { authenticateAs } from "./handler.mjs";
|
|
7
|
+
//#region src/endpoint/upload.ts
|
|
8
|
+
const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
|
|
9
|
+
const respond = (status, body) => Response.json(body, { status });
|
|
10
|
+
const refused = () => respond(403, { error: "The request was refused: the grant is unknown, expired or used, or the key no longer allows it. Call the tool again for a new one." });
|
|
11
|
+
const readExactly = async (req, size) => {
|
|
12
|
+
const declared = req.headers.get("content-length");
|
|
13
|
+
if (declared !== null && Number(declared) !== size) return null;
|
|
14
|
+
if (!req.body) return null;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let total = 0;
|
|
17
|
+
for await (const chunk of req.body) {
|
|
18
|
+
total += chunk.byteLength;
|
|
19
|
+
if (total > size) return null;
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
}
|
|
22
|
+
return total === size ? Buffer.concat(chunks) : null;
|
|
23
|
+
};
|
|
24
|
+
const statusOf = (result, data) => {
|
|
25
|
+
if (result.isError !== true) return 200;
|
|
26
|
+
if ("updatedAt" in data || data["status"] === 423) return 409;
|
|
27
|
+
if (data["status"] === 401 || data["status"] === 403) return 403;
|
|
28
|
+
return data["error"] === "Internal error" ? 500 : 422;
|
|
29
|
+
};
|
|
30
|
+
const parseResult = (result) => {
|
|
31
|
+
const [first] = result.content;
|
|
32
|
+
return first?.type === "text" ? JSON.parse(first.text) : {};
|
|
33
|
+
};
|
|
34
|
+
const NO_SESSION = {
|
|
35
|
+
signal: new AbortController().signal,
|
|
36
|
+
requestId: "upload",
|
|
37
|
+
sendNotification: () => Promise.resolve(),
|
|
38
|
+
sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
|
|
39
|
+
};
|
|
40
|
+
const reauthenticate = async (req, options, grant) => {
|
|
41
|
+
const keyDoc = await req.payload.findByID({
|
|
42
|
+
collection: options.apiKeysSlug,
|
|
43
|
+
id: grant.apiKeyId,
|
|
44
|
+
depth: 0,
|
|
45
|
+
overrideAccess: true,
|
|
46
|
+
disableErrors: true,
|
|
47
|
+
select: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
user: true,
|
|
50
|
+
capabilities: true,
|
|
51
|
+
expiresAt: true
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return await checkApiKey(req, options, keyDoc ?? void 0);
|
|
55
|
+
};
|
|
56
|
+
const claimAs = async (req, options, kind) => {
|
|
57
|
+
const slug = uploadKvSlug(req.payload.config);
|
|
58
|
+
const grantId = req.headers.get("x-mcpx-grant");
|
|
59
|
+
if (slug === void 0 || options.auth?.resolve || grantId === null) return;
|
|
60
|
+
const grant = await claimGrant(req.payload, slug, grantId);
|
|
61
|
+
if (grant?.kind !== kind) return;
|
|
62
|
+
const auth = await reauthenticate(req, options, grant);
|
|
63
|
+
if (!auth) return;
|
|
64
|
+
const scope = authenticateAs(req, options, auth);
|
|
65
|
+
req.query = {};
|
|
66
|
+
for (const name of [...req.searchParams.keys()]) req.searchParams.delete(name);
|
|
67
|
+
req.locale = grant.locale;
|
|
68
|
+
req.fallbackLocale = grant.fallbackLocale;
|
|
69
|
+
return {
|
|
70
|
+
grant,
|
|
71
|
+
scope
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Completes a call that returned an `upload`: claims the grant, acts as its
|
|
76
|
+
* key again, then runs the same tool with the same arguments and the body as
|
|
77
|
+
* the file. The response body is the tool's result. The grant header is never
|
|
78
|
+
* logged.
|
|
79
|
+
*/ const createUploadHandler = (options) => async (req) => {
|
|
80
|
+
const { payload } = req;
|
|
81
|
+
const claimed = await claimAs(req, options, "upload");
|
|
82
|
+
const file = claimed?.grant.args["file"];
|
|
83
|
+
if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !file) return refused();
|
|
84
|
+
const { grant, scope } = claimed;
|
|
85
|
+
const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
|
|
86
|
+
if (!tool || !isToolEnabled(tool, scope)) return refused();
|
|
87
|
+
const args = toolInputSchema(tool, scope).safeParse(grant.args);
|
|
88
|
+
if (!args.success) return refused();
|
|
89
|
+
if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
|
|
90
|
+
const data = await readExactly(req, file.size);
|
|
91
|
+
if (data === null) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
|
|
92
|
+
setUploadedFile(req, {
|
|
93
|
+
data,
|
|
94
|
+
mimetype: file.mimeType,
|
|
95
|
+
name: file.filename,
|
|
96
|
+
size: data.length
|
|
97
|
+
});
|
|
98
|
+
const result = await runTool(tool, args.data, scope, req, NO_SESSION);
|
|
99
|
+
const body = parseResult(result);
|
|
100
|
+
return respond(statusOf(result, body), body);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Serves the file of a `getDocument` call that returned a `download`: claims
|
|
104
|
+
* the grant, acts as its key again, reloads the document as its user and
|
|
105
|
+
* hands the request to the collection's own `/file/:filename` endpoint, so
|
|
106
|
+
* Payload's file access, storage handlers and response headers apply.
|
|
107
|
+
*/ const createDownloadHandler = (options) => async (req) => {
|
|
108
|
+
const claimed = await claimAs(req, options, "download");
|
|
109
|
+
if (!claimed || !downloadSlugs(claimed.scope).includes(claimed.grant.collection)) return refused();
|
|
110
|
+
const { collection, id, draft } = claimed.grant;
|
|
111
|
+
const doc = await req.payload.findByID({
|
|
112
|
+
collection,
|
|
113
|
+
id,
|
|
114
|
+
depth: 0,
|
|
115
|
+
draft,
|
|
116
|
+
overrideAccess: false,
|
|
117
|
+
disableErrors: true,
|
|
118
|
+
select: {
|
|
119
|
+
filename: true,
|
|
120
|
+
prefix: true
|
|
121
|
+
},
|
|
122
|
+
req
|
|
123
|
+
});
|
|
124
|
+
const { endpoints } = req.payload.collections[collection]?.config ?? {};
|
|
125
|
+
const endpoint = Array.isArray(endpoints) ? endpoints.find((candidate) => candidate.method === "get" && candidate.path === "/file/:filename") : void 0;
|
|
126
|
+
if (typeof doc?.filename !== "string" || !endpoint) return refused();
|
|
127
|
+
if (typeof doc.prefix === "string") req.searchParams.set("prefix", doc.prefix);
|
|
128
|
+
req.routeParams = {
|
|
129
|
+
collection,
|
|
130
|
+
filename: doc.filename
|
|
131
|
+
};
|
|
132
|
+
return await endpoint.handler(req);
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
export { createDownloadHandler, createUploadHandler };
|
package/dist/options.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from "./guards.mjs";
|
|
2
|
-
import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
|
|
3
2
|
import "./version.mjs";
|
|
3
|
+
import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
|
|
4
4
|
import { InvalidConfiguration } from "payload";
|
|
5
5
|
import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
|
|
6
6
|
//#region src/options.ts
|
|
@@ -151,7 +151,8 @@ const normalizeLimits = (limits) => {
|
|
|
151
151
|
serverInfo: {
|
|
152
152
|
name: options.serverInfo?.name ?? "payloadcms-mcpx",
|
|
153
153
|
version: options.serverInfo?.version ?? "1.0.0"
|
|
154
|
-
}
|
|
154
|
+
},
|
|
155
|
+
diagnostics: options.diagnostics ?? true
|
|
155
156
|
};
|
|
156
157
|
};
|
|
157
158
|
//#endregion
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { McpxPluginOptions } from "./types.mjs";
|
|
2
2
|
//#region src/plugin.d.ts
|
|
3
3
|
/**
|
|
4
|
-
* Mounts the MCP
|
|
5
|
-
* draft guard on every collection and global.
|
|
4
|
+
* Mounts the MCP, upload and download endpoints, adds the API key collection
|
|
5
|
+
* and installs the draft guard on every collection and global.
|
|
6
6
|
*/
|
|
7
7
|
export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
|
|
8
8
|
//#endregion
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createApiKeysCollection } from "./api-keys/collection.mjs";
|
|
2
2
|
import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
|
|
3
|
+
import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
|
|
3
4
|
import { normalizeOptions } from "./options.mjs";
|
|
4
5
|
import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
|
|
5
6
|
import { definePlugin } from "payload";
|
|
6
7
|
//#region src/plugin.ts
|
|
7
8
|
/**
|
|
8
|
-
* Mounts the MCP
|
|
9
|
-
* draft guard on every collection and global.
|
|
9
|
+
* Mounts the MCP, upload and download endpoints, adds the API key collection
|
|
10
|
+
* and installs the draft guard on every collection and global.
|
|
10
11
|
*/ const mcpxPlugin = definePlugin({
|
|
11
12
|
slug: "@abinnovision/payloadcms-mcpx",
|
|
12
13
|
order: 100,
|
|
@@ -34,6 +35,16 @@ import { definePlugin } from "payload";
|
|
|
34
35
|
path: normalized.endpointPath,
|
|
35
36
|
method: "delete",
|
|
36
37
|
handler: methodNotAllowed
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: `${normalized.endpointPath}/upload`,
|
|
41
|
+
method: "put",
|
|
42
|
+
handler: createUploadHandler(normalized)
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
path: `${normalized.endpointPath}/file`,
|
|
46
|
+
method: "get",
|
|
47
|
+
handler: createDownloadHandler(normalized)
|
|
37
48
|
}
|
|
38
49
|
]
|
|
39
50
|
};
|
|
@@ -2,10 +2,12 @@ import { defineMcpxTool } from "../define-tool.mjs";
|
|
|
2
2
|
import { errorResult, jsonResult } from "../result.mjs";
|
|
3
3
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
4
4
|
import "../schema/index.mjs";
|
|
5
|
-
import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
|
|
6
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
|
+
import { assertAcceptsFile, fileShape, fileWriteRefusal, uploadHandoff, uploadedFile } from "../upload/file.mjs";
|
|
7
|
+
import { liveWriteSentence, localeOf, localeShape, patchOnlySlugs, slugEnum, slugsFor } from "./shared.mjs";
|
|
7
8
|
import { readDraft, resolveEntity } from "./document.mjs";
|
|
8
9
|
import { stripRowIds } from "../write/row-ids.mjs";
|
|
10
|
+
import { APIError } from "payload";
|
|
9
11
|
import { z } from "zod";
|
|
10
12
|
//#region src/tools/create-document.ts
|
|
11
13
|
const uploadSentence = (scope) => {
|
|
@@ -16,8 +18,9 @@ const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only
|
|
|
16
18
|
|
|
17
19
|
${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
18
20
|
/**
|
|
19
|
-
* Collection-only, because a global always exists.
|
|
20
|
-
*
|
|
21
|
+
* Collection-only, because a global always exists. A create in an upload
|
|
22
|
+
* collection needs `file`: the call then returns an upload grant, and the PUT
|
|
23
|
+
* to it runs the call again with the bytes.
|
|
21
24
|
*
|
|
22
25
|
* The seed is checked against the collection's fields first, so an unknown key
|
|
23
26
|
* is refused along with its valid siblings. Row ids in the seed are stripped
|
|
@@ -40,13 +43,17 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
|
40
43
|
required: true,
|
|
41
44
|
description: "Locale of the localized fields in \"data\"."
|
|
42
45
|
}),
|
|
43
|
-
data: z.record(z.string(), z.unknown()).describe("Field values by name, e.g. {\"title\":\"Home\",\"slug\":\"home\"}.")
|
|
46
|
+
data: z.record(z.string(), z.unknown()).describe("Field values by name, e.g. {\"title\":\"Home\",\"slug\":\"home\"}."),
|
|
47
|
+
...fileShape(scope, (slugs) => `Required in ${slugs}. The call returns an "upload" for the bytes instead of writing.`)
|
|
44
48
|
}),
|
|
45
49
|
handler: async ({ args, scope }) => {
|
|
46
50
|
const target = resolveEntity(scope, { collection: args.collection }, "create");
|
|
47
51
|
if (target.kind === "global") throw new Error("createDocument resolved a global.");
|
|
48
52
|
const { payload } = scope.req;
|
|
49
53
|
const locale = localeOf(scope, args.locale);
|
|
54
|
+
const { file } = args;
|
|
55
|
+
if (file !== void 0) assertAcceptsFile(scope, target, file);
|
|
56
|
+
else if (scope.exposure.collections.some((entity) => entity.slug === target.slug && entity.isUpload)) throw new APIError(`"${target.slug}" stores files, so "file" is required.`, 400);
|
|
50
57
|
if (Object.hasOwn(args.data, "id")) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
|
|
51
58
|
const problems = validateWriteValue(payload.config, {
|
|
52
59
|
pointer: "",
|
|
@@ -56,6 +63,21 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
|
56
63
|
}
|
|
57
64
|
}, args.data);
|
|
58
65
|
if (problems.length > 0) return errorResult("Nothing was created.", { problems });
|
|
66
|
+
const bytes = uploadedFile(scope.req);
|
|
67
|
+
if (file !== void 0) {
|
|
68
|
+
const refusal = await fileWriteRefusal(scope, {
|
|
69
|
+
entity: target,
|
|
70
|
+
data: args.data
|
|
71
|
+
});
|
|
72
|
+
if (refusal) return refusal;
|
|
73
|
+
if (!bytes) return await uploadHandoff(scope, {
|
|
74
|
+
tool: "createDocument",
|
|
75
|
+
args: {
|
|
76
|
+
...args,
|
|
77
|
+
file
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
59
81
|
const created = await payload.create({
|
|
60
82
|
collection: args.collection,
|
|
61
83
|
data: stripRowIds(args.data),
|
|
@@ -63,7 +85,8 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
|
|
|
63
85
|
draft: true,
|
|
64
86
|
overrideAccess: false,
|
|
65
87
|
req: scope.req,
|
|
66
|
-
...locale === void 0 ? {} : { locale }
|
|
88
|
+
...locale === void 0 ? {} : { locale },
|
|
89
|
+
...bytes === void 0 ? {} : { file: bytes }
|
|
67
90
|
});
|
|
68
91
|
const saved = await readDraft(scope, {
|
|
69
92
|
target: {
|
package/dist/tools/document.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { errorResult } from "../result.mjs";
|
|
2
|
-
import { slugsFor } from "./shared.mjs";
|
|
3
2
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
3
|
+
import { slugsFor } from "./shared.mjs";
|
|
4
4
|
import { APIError, Forbidden, NotFound } from "payload";
|
|
5
5
|
//#region src/tools/document.ts
|
|
6
6
|
/**
|