@abinnovision/payloadcms-mcpx 1.1.0-beta.5 → 2.0.0-beta.7
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 +141 -448
- package/dist/{client → admin}/capability-matrix.d.mts +3 -4
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +1 -1
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +6 -6
- package/dist/api-keys/capability-matrix.mjs +15 -85
- package/dist/api-keys/fields.mjs +15 -24
- package/dist/auth/resolve.mjs +23 -5
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
- package/dist/capabilities.mjs +34 -42
- package/dist/define-tool.d.mts +19 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +49 -19
- package/dist/endpoint/server.mjs +65 -19
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +5 -0
- package/dist/i18n.mjs +8 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +51 -43
- package/dist/plugin.mjs +0 -1
- 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 +39 -92
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +22 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +35 -45
- package/dist/tools/builtin.mjs +6 -4
- package/dist/tools/create-document.mjs +26 -23
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +84 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +74 -0
- package/dist/tools/get-document.mjs +80 -49
- package/dist/tools/list-capabilities.mjs +25 -16
- package/dist/tools/patch-document.mjs +74 -50
- package/dist/tools/publish-document.mjs +17 -28
- package/dist/tools/query-paths.mjs +55 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +65 -96
- package/dist/tools/validate-document.mjs +16 -29
- package/dist/tools/versions.mjs +127 -0
- package/dist/types.d.mts +55 -55
- package/dist/write/draft-guard.mjs +17 -37
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +4 -4
- package/dist/write/row-ids.mjs +44 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +4 -4
- 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/api-keys/fields.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const checkbox = (name, description) => ({
|
|
|
16
16
|
defaultValue: false,
|
|
17
17
|
admin: { description }
|
|
18
18
|
});
|
|
19
|
-
|
|
19
|
+
const SETUP_GUIDE_FIELD = "setupGuide";
|
|
20
20
|
/**
|
|
21
21
|
* Fields every key carries. Key generation and the HMAC index live in the
|
|
22
22
|
* collection-level `beforeChange` hook (see `collection.ts`), because sibling
|
|
@@ -58,13 +58,11 @@ const checkbox = (name, description) => ({
|
|
|
58
58
|
}
|
|
59
59
|
];
|
|
60
60
|
/**
|
|
61
|
-
* Wraps the key fields and the setup guide in unnamed tabs, so
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* off the document root, which capability resolution reads.
|
|
61
|
+
* Wraps the key fields and the setup guide in unnamed tabs, so wide snippets
|
|
62
|
+
* get the full form width. Named tabs would nest the data and move
|
|
63
|
+
* `capabilities` off the document root, which capability resolution reads.
|
|
65
64
|
*
|
|
66
|
-
* The guide tab
|
|
67
|
-
* key to hand out, and a tab leading to an empty panel is worse than no tab.
|
|
65
|
+
* The guide tab only shows on update: on create there is no key to hand out.
|
|
68
66
|
*/ const withSetupGuideTab = (keyFields, options) => {
|
|
69
67
|
if (!options.setupGuide) return keyFields;
|
|
70
68
|
return [{
|
|
@@ -81,7 +79,7 @@ const checkbox = (name, description) => ({
|
|
|
81
79
|
admin: {
|
|
82
80
|
disableListColumn: true,
|
|
83
81
|
components: { Field: {
|
|
84
|
-
path: "@abinnovision/payloadcms-mcpx/
|
|
82
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
85
83
|
exportName: "McpxSetupGuide",
|
|
86
84
|
clientProps: { endpointPath: options.endpointPath }
|
|
87
85
|
} }
|
|
@@ -91,21 +89,14 @@ const checkbox = (name, description) => ({
|
|
|
91
89
|
}];
|
|
92
90
|
};
|
|
93
91
|
/**
|
|
94
|
-
* One checkbox per
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* field behind it. Only operations the plugin config exposes get a checkbox, so
|
|
98
|
-
* a key can never enable more than the config allows. Everything defaults to
|
|
99
|
-
* off, which is why a key issued before a capability existed stays closed to it.
|
|
92
|
+
* One checkbox per operation the config exposes, grouped per collection, global
|
|
93
|
+
* and custom tool, built from {@link createCapabilityMatrix}. Everything
|
|
94
|
+
* defaults to off, so a key issued before a capability existed stays closed.
|
|
100
95
|
*
|
|
101
|
-
* An entity without
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* The group renders as a matrix rather than as nested boxes; see
|
|
106
|
-
* `src/client/capability-matrix.tsx`. The descriptions below therefore reach
|
|
107
|
-
* the screen on the matrix column headers, and survive here for a consumer who
|
|
108
|
-
* replaces the component through `apiKeys.overrideCollection`.
|
|
96
|
+
* An entity without drafts gets no `publish` checkbox, even where
|
|
97
|
+
* writes are on: the write is already live, so the box would make `write` a
|
|
98
|
+
* dead setting. The admin renders the group as a matrix; the checkboxes serve
|
|
99
|
+
* consumers who replace that component through `apiKeys.overrideCollection`.
|
|
109
100
|
*/ const createCapabilityFields = (options) => {
|
|
110
101
|
const matrix = createCapabilityMatrix(options);
|
|
111
102
|
const entityGroups = (rows) => rows.map((row) => ({
|
|
@@ -141,7 +132,7 @@ const checkbox = (name, description) => ({
|
|
|
141
132
|
admin: {
|
|
142
133
|
description: CAPABILITIES_DESCRIPTION,
|
|
143
134
|
components: { Field: {
|
|
144
|
-
path: "@abinnovision/payloadcms-mcpx/
|
|
135
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
145
136
|
exportName: "McpxCapabilityMatrix",
|
|
146
137
|
clientProps: {
|
|
147
138
|
matrix,
|
|
@@ -153,4 +144,4 @@ const checkbox = (name, description) => ({
|
|
|
153
144
|
}];
|
|
154
145
|
};
|
|
155
146
|
//#endregion
|
|
156
|
-
export {
|
|
147
|
+
export { createCapabilityFields, createKeyFields, withSetupGuideTab };
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "../guards.mjs";
|
|
1
2
|
import { hashApiKey } from "../api-keys/key.mjs";
|
|
2
3
|
//#region src/auth/resolve.ts
|
|
3
4
|
const BEARER = /^Bearer\s+(\S+)\s*$/i;
|
|
@@ -10,6 +11,14 @@ const parseBearer = (headers) => {
|
|
|
10
11
|
if (!header) return null;
|
|
11
12
|
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
12
13
|
};
|
|
14
|
+
const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
15
|
+
/**
|
|
16
|
+
* Whether a resolved auth, from the default or a custom resolver, may become
|
|
17
|
+
* `req.user`: a user of the configured user collection with an id, and a key id.
|
|
18
|
+
*/ const isValidAuthResult = (auth, options) => {
|
|
19
|
+
if (!isPlainObject(auth) || !isPlainObject(auth["user"])) return false;
|
|
20
|
+
return isId(auth["user"]["id"]) && auth["user"]["collection"] === options.userCollection && isId(auth["apiKeyId"]);
|
|
21
|
+
};
|
|
13
22
|
/**
|
|
14
23
|
* Resolves the bearer key of a request to the user it acts as.
|
|
15
24
|
*
|
|
@@ -37,14 +46,23 @@ const parseBearer = (headers) => {
|
|
|
37
46
|
const userId = relationId(keyDoc?.user);
|
|
38
47
|
if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
|
|
39
48
|
const userCollection = payload.collections[options.userCollection];
|
|
40
|
-
const
|
|
49
|
+
const lookup = {
|
|
41
50
|
collection: options.userCollection,
|
|
42
51
|
id: userId,
|
|
43
|
-
depth: userCollection?.config.auth.depth ?? 0,
|
|
44
52
|
overrideAccess: true,
|
|
45
53
|
disableErrors: true
|
|
46
|
-
}
|
|
47
|
-
const
|
|
54
|
+
};
|
|
55
|
+
const [user, lock] = await Promise.all([payload.findByID({
|
|
56
|
+
...lookup,
|
|
57
|
+
depth: userCollection?.config.auth.depth ?? 0
|
|
58
|
+
}), payload.findByID({
|
|
59
|
+
...lookup,
|
|
60
|
+
depth: 0,
|
|
61
|
+
showHiddenFields: true,
|
|
62
|
+
select: { lockUntil: true }
|
|
63
|
+
})]);
|
|
64
|
+
const storedLock = lock?.["lockUntil"];
|
|
65
|
+
const lockUntil = typeof storedLock === "string" || storedLock instanceof Date ? new Date(storedLock).getTime() : NaN;
|
|
48
66
|
if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
|
|
49
67
|
return {
|
|
50
68
|
user: {
|
|
@@ -57,4 +75,4 @@ const parseBearer = (headers) => {
|
|
|
57
75
|
};
|
|
58
76
|
};
|
|
59
77
|
//#endregion
|
|
60
|
-
export { parseBearer, resolveApiKeyAuth };
|
|
78
|
+
export { isValidAuthResult, parseBearer, resolveApiKeyAuth };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region src/
|
|
1
|
+
//#region src/builtin-tool-names.ts
|
|
2
2
|
/**
|
|
3
3
|
* Names of the builtin tools. Custom tools must not reuse them.
|
|
4
4
|
*/ const BUILTIN_TOOL_NAMES = [
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"describeSchema",
|
|
7
7
|
"findDocuments",
|
|
8
8
|
"getDocument",
|
|
9
|
+
"findVersions",
|
|
9
10
|
"patchDocument",
|
|
10
11
|
"createDocument",
|
|
11
12
|
"validateDocument",
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,62 +1,54 @@
|
|
|
1
|
+
import { isPlainObject } from "./guards.mjs";
|
|
1
2
|
//#region src/capabilities.ts
|
|
2
3
|
/** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
|
|
3
|
-
/**
|
|
4
|
-
/** The config lets MCP change live content and
|
|
4
|
+
/** Covers draft and live writes; {@link isLiveWrite} separates them. */ const canWrite = (entity) => entity.write !== false;
|
|
5
|
+
/** The config lets MCP change live content and a draft exists to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
5
6
|
/**
|
|
6
7
|
* An upload document is a file, and no tool here carries one. Its own fields
|
|
7
8
|
* stay patchable; the first version is made in the admin panel.
|
|
8
9
|
*/ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
* write at all and every write is live.
|
|
11
|
+
* Without drafts there is no draft stage, so every write is live.
|
|
12
12
|
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
/**
|
|
16
|
-
* Publishing is an extension of writing, never a capability of its own: a key
|
|
17
|
-
* that may publish may also edit the draft it publishes. Both checkboxes are
|
|
18
|
-
* therefore required, on top of the config exposing publishing at all.
|
|
19
|
-
*/ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
|
|
13
|
+
const flag = (group, name) => isPlainObject(group) && group[name] === true;
|
|
14
|
+
const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
|
|
20
15
|
/**
|
|
21
16
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
22
17
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
23
18
|
* issued before a capability existed stay closed.
|
|
24
19
|
*/ const resolveCapabilities = (options, keyCapabilities) => {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
|
|
40
|
-
globals[global.slug] = {
|
|
41
|
-
read: global.read && flag(group, "read"),
|
|
42
|
-
write: canWrite(global) && flag(group, "write"),
|
|
43
|
-
publish: publishFlag(global, group)
|
|
44
|
-
};
|
|
45
|
-
}
|
|
20
|
+
const groupOf = (name) => isPlainObject(keyCapabilities) ? keyCapabilities[name] : void 0;
|
|
21
|
+
const resolveEntities = (entities, namespaceGroup) => {
|
|
22
|
+
const resolved = {};
|
|
23
|
+
for (const entity of entities) {
|
|
24
|
+
const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
|
|
25
|
+
resolved[entity.slug] = {
|
|
26
|
+
read: entity.read && flag(group, "read"),
|
|
27
|
+
write: canWrite(entity) && flag(group, "write"),
|
|
28
|
+
publish: publishFlag(entity, group)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
};
|
|
33
|
+
const toolsGroup = groupOf("tools");
|
|
46
34
|
const tools = {};
|
|
47
35
|
for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
|
|
48
36
|
return {
|
|
49
|
-
collections,
|
|
50
|
-
globals,
|
|
37
|
+
collections: resolveEntities(options.collections, groupOf("collections")),
|
|
38
|
+
globals: resolveEntities(options.globals, groupOf("globals")),
|
|
51
39
|
tools
|
|
52
40
|
};
|
|
53
41
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The slugs tools use to narrow their enums. Taken from the output of
|
|
44
|
+
* {@link resolveCapabilities}, so config and checkbox are already applied.
|
|
45
|
+
*/ const scopeSlugs = (entries) => {
|
|
46
|
+
const slugsWith = (operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
|
|
47
|
+
return {
|
|
48
|
+
readable: slugsWith("read"),
|
|
49
|
+
writable: slugsWith("write"),
|
|
50
|
+
publishable: slugsWith("publish")
|
|
51
|
+
};
|
|
52
|
+
};
|
|
61
53
|
//#endregion
|
|
62
|
-
export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite,
|
|
54
|
+
export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, resolveCapabilities, scopeSlugs };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/define-tool.d.ts
|
|
4
|
+
/** Fixed shape; arguments inferred from it. */
|
|
5
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
6
|
+
inputSchema?: Shape;
|
|
7
|
+
}): McpxTool<Shape>;
|
|
8
|
+
/** Per-request shape returned as an object literal; arguments inferred from it. */
|
|
9
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
10
|
+
inputSchema: (scope: McpxToolScope) => Shape;
|
|
11
|
+
}): McpxAnyTool;
|
|
12
|
+
/**
|
|
13
|
+
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
14
|
+
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
17
|
+
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
18
|
+
}): McpxAnyTool;
|
|
19
|
+
//#endregion
|
package/dist/endpoint/errors.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorResult } from "../result.mjs";
|
|
2
|
-
import { pointerFromPayloadPath } from "../schema/
|
|
2
|
+
import { pointerFromPayloadPath } from "../schema/path.mjs";
|
|
3
3
|
import "../schema/index.mjs";
|
|
4
4
|
import { APIError, ValidationError } from "payload";
|
|
5
5
|
//#region src/endpoint/errors.ts
|
|
@@ -22,11 +22,10 @@ import { APIError, ValidationError } from "payload";
|
|
|
22
22
|
});
|
|
23
23
|
};
|
|
24
24
|
/**
|
|
25
|
-
* Payload's public errors keep their message and status
|
|
26
|
-
* also surfaces its per-field detail, with each
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* message leaks to the client.
|
|
25
|
+
* Payload's public errors keep their message and status. A `ValidationError`
|
|
26
|
+
* also surfaces its per-field detail, with each path as a JSON Pointer like
|
|
27
|
+
* every other path this plugin reports. Anything else is logged and reported as
|
|
28
|
+
* an internal error, so no stack or driver message reaches the client.
|
|
30
29
|
*/ const toToolError = (error, logger) => {
|
|
31
30
|
if (error instanceof ValidationError) return errorResult(error.message, {
|
|
32
31
|
status: error.status,
|
|
@@ -1,22 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
|
|
2
|
+
import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
2
3
|
import { jsonRpcError } from "./errors.mjs";
|
|
3
|
-
import { resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
4
4
|
import { createMcpServer } from "./server.mjs";
|
|
5
5
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
6
|
//#region src/endpoint/handler.ts
|
|
7
|
-
const
|
|
7
|
+
const BODY_BYTES_LIMIT = 4194304;
|
|
8
|
+
const BATCH_MESSAGES_LIMIT = 10;
|
|
9
|
+
const readBody = async (req) => {
|
|
10
|
+
if (Number(req.headers.get("content-length")) > BODY_BYTES_LIMIT) return null;
|
|
11
|
+
if (!req.body) return;
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
let text = "";
|
|
14
|
+
let size = 0;
|
|
15
|
+
for await (const chunk of req.body) {
|
|
16
|
+
size += chunk.byteLength;
|
|
17
|
+
if (size > BODY_BYTES_LIMIT) return null;
|
|
18
|
+
text += decoder.decode(chunk, { stream: true });
|
|
19
|
+
}
|
|
20
|
+
return text + decoder.decode();
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The scope the tools of one request see: the key's resolved capabilities, the
|
|
24
|
+
* slugs they reach, and the locales and limits of the config.
|
|
25
|
+
*/ const buildScope = (req, options, capabilities) => {
|
|
8
26
|
const { localization } = req.payload.config;
|
|
9
27
|
return {
|
|
10
28
|
req,
|
|
11
29
|
capabilities,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
locales: localization ? localization.localeCodes : null,
|
|
19
|
-
defaultLocale: localization ? localization.defaultLocale : null,
|
|
30
|
+
collections: scopeSlugs(capabilities.collections),
|
|
31
|
+
globals: scopeSlugs(capabilities.globals),
|
|
32
|
+
localization: localization ? {
|
|
33
|
+
locales: localization.localeCodes,
|
|
34
|
+
defaultLocale: localization.defaultLocale
|
|
35
|
+
} : null,
|
|
20
36
|
limits: options.limits,
|
|
21
37
|
exposure: {
|
|
22
38
|
collections: options.collections,
|
|
@@ -44,12 +60,15 @@ const buildScope = (req, options, capabilities) => {
|
|
|
44
60
|
req,
|
|
45
61
|
resolveDefault
|
|
46
62
|
}) : await resolveDefault();
|
|
47
|
-
if (!auth)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
if (!isValidAuthResult(auth, options)) {
|
|
64
|
+
if (auth) req.payload.logger.error("[payloadcms-mcpx] auth.resolve returned an invalid result: it needs a user of the user collection with an id, and an apiKeyId.");
|
|
65
|
+
return jsonRpcError({
|
|
66
|
+
status: 401,
|
|
67
|
+
code: -32001,
|
|
68
|
+
message: "Unauthorized: a valid API key is required.",
|
|
69
|
+
headers: { "www-authenticate": "Bearer" }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
53
72
|
const capabilities = resolveCapabilities(options, auth.capabilities);
|
|
54
73
|
req.user = auth.user;
|
|
55
74
|
req.context = {
|
|
@@ -61,7 +80,13 @@ const buildScope = (req, options, capabilities) => {
|
|
|
61
80
|
};
|
|
62
81
|
let parsedBody;
|
|
63
82
|
try {
|
|
64
|
-
|
|
83
|
+
const text = await readBody(req);
|
|
84
|
+
if (text === null) return jsonRpcError({
|
|
85
|
+
status: 413,
|
|
86
|
+
code: -32e3,
|
|
87
|
+
message: `Request body too large: the limit is ${String(BODY_BYTES_LIMIT / 1024 / 1024)} MB.`
|
|
88
|
+
});
|
|
89
|
+
parsedBody = text === void 0 ? void 0 : JSON.parse(text);
|
|
65
90
|
} catch {
|
|
66
91
|
return jsonRpcError({
|
|
67
92
|
status: 400,
|
|
@@ -74,6 +99,11 @@ const buildScope = (req, options, capabilities) => {
|
|
|
74
99
|
code: -32600,
|
|
75
100
|
message: "Invalid request: a JSON body is required."
|
|
76
101
|
});
|
|
102
|
+
if (Array.isArray(parsedBody) && parsedBody.length > BATCH_MESSAGES_LIMIT) return jsonRpcError({
|
|
103
|
+
status: 400,
|
|
104
|
+
code: -32600,
|
|
105
|
+
message: `Invalid request: a batch may hold at most ${String(BATCH_MESSAGES_LIMIT)} messages.`
|
|
106
|
+
});
|
|
77
107
|
const server = createMcpServer(buildScope(req, options, capabilities), options);
|
|
78
108
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
79
109
|
await server.connect(transport);
|
|
@@ -89,4 +119,4 @@ const buildScope = (req, options, capabilities) => {
|
|
|
89
119
|
}
|
|
90
120
|
};
|
|
91
121
|
//#endregion
|
|
92
|
-
export { createMcpxHandler, methodNotAllowed };
|
|
122
|
+
export { buildScope, createMcpxHandler, methodNotAllowed };
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,44 +1,90 @@
|
|
|
1
1
|
import { toToolError } from "./errors.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { liveWriteSlugs } from "../tools/shared.mjs";
|
|
3
3
|
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
//#region src/endpoint/server.ts
|
|
7
|
-
/** Strict, so an unknown argument is rejected by name
|
|
8
|
-
|
|
7
|
+
/** Strict, so an unknown argument is rejected by name instead of stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
8
|
+
const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
9
9
|
/** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
10
|
+
const WORKFLOW = [
|
|
11
|
+
{
|
|
12
|
+
tools: ["listCapabilities"],
|
|
13
|
+
does: "what this key may access"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
tools: ["describeSchema"],
|
|
17
|
+
does: "the fields"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
tools: ["findDocuments", "getDocument"],
|
|
21
|
+
does: "the content and its \"updatedAt\""
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
tools: ["patchDocument", "createDocument"],
|
|
25
|
+
does: "the write"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
tools: ["validateDocument"],
|
|
29
|
+
does: "what blocks publishing"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
tools: ["publishDocument"],
|
|
33
|
+
does: "make the draft public"
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
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.`;
|
|
37
|
+
const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
|
|
38
|
+
const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
39
|
+
/** The workflow for the tools this key has, and what its writes reach. */ const serverInstructions = (scope) => {
|
|
40
|
+
const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
|
|
41
|
+
const steps = WORKFLOW.flatMap(({ tools, does }) => {
|
|
42
|
+
const present = tools.filter((name) => enabled.has(name));
|
|
43
|
+
return present.length === 0 ? [] : [`${present.join(" or ")}: ${does}.`];
|
|
44
|
+
}).map((step, index) => `${String(index + 1)}. ${step}`);
|
|
45
|
+
const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
|
|
46
|
+
const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
|
|
47
|
+
const writes = enabled.has("patchDocument") ? `\n\n${drafts}${publishing}` : "";
|
|
48
|
+
const paths = enabled.has("describeSchema") ? `\n\n${PATHS}` : "";
|
|
49
|
+
return `Call the tools in this order:\n${steps.join("\n")}${paths}${writes}`;
|
|
50
|
+
};
|
|
10
51
|
/**
|
|
11
52
|
* One server per request. Builtin and configured tools take the same route,
|
|
12
53
|
* each registered against the key's capabilities, so `tools/list` shows exactly
|
|
13
|
-
* what the key may call.
|
|
54
|
+
* what the key may call. The tool handlers of one JSON-RPC batch run one at a time,
|
|
55
|
+
* so a failed call cannot roll back a write another call reported as done.
|
|
14
56
|
*/ const createMcpServer = (scope, options) => {
|
|
15
57
|
const { req } = scope;
|
|
16
58
|
const { logger } = req.payload;
|
|
59
|
+
let queue = Promise.resolve();
|
|
17
60
|
const server = new McpServer({
|
|
18
61
|
name: options.serverInfo.name,
|
|
19
62
|
version: options.serverInfo.version
|
|
20
|
-
}, { instructions:
|
|
21
|
-
const guarded = (run) => async () => {
|
|
22
|
-
try {
|
|
23
|
-
return await run();
|
|
24
|
-
} catch (error) {
|
|
25
|
-
return toToolError(error, logger);
|
|
26
|
-
}
|
|
27
|
-
};
|
|
63
|
+
}, { instructions: serverInstructions(scope) });
|
|
28
64
|
for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
|
|
29
65
|
if (!isToolEnabled(tool, scope)) continue;
|
|
30
66
|
server.registerTool(tool.name, {
|
|
31
67
|
description: toolDescription(tool, scope),
|
|
32
68
|
inputSchema: toolInputSchema(tool, scope),
|
|
33
69
|
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
34
|
-
}, (args, extra) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
70
|
+
}, (args, extra) => {
|
|
71
|
+
const result = queue.then(async () => {
|
|
72
|
+
try {
|
|
73
|
+
return await tool.handler({
|
|
74
|
+
args,
|
|
75
|
+
scope,
|
|
76
|
+
req,
|
|
77
|
+
extra
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return toToolError(error, logger);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
queue = result.catch(() => void 0);
|
|
84
|
+
return result;
|
|
85
|
+
});
|
|
40
86
|
}
|
|
41
87
|
return server;
|
|
42
88
|
};
|
|
43
89
|
//#endregion
|
|
44
|
-
export { createMcpServer, isToolEnabled,
|
|
90
|
+
export { createMcpServer, isToolEnabled, toolInputSchema };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "payload";
|
|
2
|
+
//#region src/entity.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The id type follows the database adapter and a collection's own `id` field,
|
|
5
|
+
* so one project can have both. Payload's `DefaultDocumentIDType` narrows to
|
|
6
|
+
* the project default, so it cannot be used here.
|
|
7
|
+
*/
|
|
8
|
+
export type DocumentId = number | string;
|
|
9
|
+
//#endregion
|
package/dist/guards.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/guards.ts
|
|
2
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
/** An own property only, so a client-chosen key never reads the prototype. */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { isPlainObject, ownValue };
|
package/dist/i18n.mjs
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
//#region src/i18n.ts
|
|
2
2
|
const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
3
|
-
|
|
3
|
+
const pick = (record, language) => {
|
|
4
4
|
for (const code of Array.isArray(language) ? language : [language]) {
|
|
5
5
|
const entry = record[code];
|
|
6
6
|
if (entry !== void 0 && entry.trim() !== "") return entry;
|
|
7
7
|
}
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
* Resolves a static label or `admin.description` to
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Resolves a static label or `admin.description` to one string: the request's
|
|
11
|
+
* language, then the configured fallback language, then the first entry the
|
|
12
|
+
* record declares.
|
|
13
13
|
*
|
|
14
14
|
* Anything that is not a string or a string-valued record is dropped. A
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* function or React component description may reach client-only i18n, so it is
|
|
16
|
+
* never invoked.
|
|
17
17
|
*/ const translateStatic = (value, language) => {
|
|
18
18
|
if (typeof value === "string") return value.trim() === "" ? void 0 : value;
|
|
19
19
|
const record = stringRecord(value);
|
|
20
20
|
if (!record) return;
|
|
21
21
|
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
/** For callers with no request
|
|
23
|
+
const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
24
|
+
/** For callers with no request. Both languages miss, so the first entry wins. */ const translateAny = translatorFor({
|
|
25
25
|
fallbackLanguage: "",
|
|
26
26
|
language: ""
|
|
27
27
|
});
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DocumentId } from "./entity.mjs";
|
|
2
|
+
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode } from "./types.mjs";
|
|
3
|
+
import { defineMcpxTool } from "./define-tool.mjs";
|
|
2
4
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
3
5
|
import { isMcpxRequest } from "./request.mjs";
|
|
4
6
|
import { errorResult, jsonResult } from "./result.mjs";
|
|
5
|
-
|
|
7
|
+
import { mcpxReadRequest } from "./tools/read-request.mjs";
|
|
8
|
+
export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, type McpxWriteMode, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { defineMcpxTool } from "./define-tool.mjs";
|
|
1
2
|
import { errorResult, jsonResult } from "./result.mjs";
|
|
2
|
-
import {
|
|
3
|
+
import { mcpxReadRequest } from "./tools/read-request.mjs";
|
|
3
4
|
import { isMcpxRequest } from "./request.mjs";
|
|
4
5
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
5
|
-
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
|
6
|
+
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
|