@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -470
- package/dist/admin/capability-matrix.d.mts +33 -0
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +4 -2
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +12 -8
- package/dist/api-keys/capability-matrix.mjs +14 -86
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +36 -24
- package/dist/auth/resolve.mjs +71 -23
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +44 -46
- package/dist/define-tool.d.mts +23 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +73 -29
- package/dist/endpoint/server.mjs +85 -22
- package/dist/endpoint/upload.mjs +135 -0
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +7 -0
- package/dist/i18n.mjs +10 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +54 -47
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.mjs +13 -3
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +44 -93
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +28 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +43 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +50 -24
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +124 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +60 -33
- package/dist/tools/list-capabilities.mjs +35 -19
- package/dist/tools/patch-document.mjs +122 -66
- package/dist/tools/publish-document.mjs +26 -32
- package/dist/tools/query-paths.mjs +57 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +74 -105
- package/dist/tools/validate-document.mjs +18 -30
- package/dist/tools/versions.mjs +37 -20
- package/dist/types.d.mts +135 -75
- package/dist/upload/file.mjs +166 -0
- package/dist/upload/grant.mjs +96 -0
- package/dist/version.mjs +3 -1
- package/dist/write/draft-guard.mjs +23 -41
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +6 -4
- package/dist/write/row-ids.mjs +46 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +11 -10
- package/dist/client/capability-matrix.d.mts +0 -26
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
|
@@ -5,7 +5,8 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
|
|
|
5
5
|
* Generates the key on create and keeps the lookup index in step with it.
|
|
6
6
|
* Runs after field `beforeValidate` fallbacks, so on update `data.apiKey` is the
|
|
7
7
|
* decrypted plaintext of the stored key.
|
|
8
|
-
*/ const keyBeforeChange = ({ data, operation, req }) => {
|
|
8
|
+
*/ const keyBeforeChange = ({ context, data, operation, req }) => {
|
|
9
|
+
if (context["mcpxTouch"]) return data;
|
|
9
10
|
if (operation === "create" && typeof data["apiKey"] !== "string") data["apiKey"] = generateApiKey();
|
|
10
11
|
if (typeof data["apiKey"] === "string") data["apiKeyIndex"] = hashApiKey(req.payload.secret, data["apiKey"]);
|
|
11
12
|
return data;
|
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
|
|
@@ -34,6 +34,27 @@ const checkbox = (name, description) => ({
|
|
|
34
34
|
defaultValue: true,
|
|
35
35
|
admin: { description: "Disabled keys are refused without revoking them." }
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
name: "expiresAt",
|
|
39
|
+
type: "date",
|
|
40
|
+
admin: {
|
|
41
|
+
date: { pickerAppearance: "dayAndTime" },
|
|
42
|
+
description: "Refused from this time on. Leave empty for no expiry."
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "lastUsedAt",
|
|
47
|
+
type: "date",
|
|
48
|
+
access: {
|
|
49
|
+
create: () => false,
|
|
50
|
+
update: () => false
|
|
51
|
+
},
|
|
52
|
+
admin: {
|
|
53
|
+
readOnly: true,
|
|
54
|
+
date: { pickerAppearance: "dayAndTime" },
|
|
55
|
+
description: "Updated at most once an hour."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
37
58
|
{
|
|
38
59
|
name: "apiKey",
|
|
39
60
|
type: "text",
|
|
@@ -58,13 +79,11 @@ const checkbox = (name, description) => ({
|
|
|
58
79
|
}
|
|
59
80
|
];
|
|
60
81
|
/**
|
|
61
|
-
* Wraps the key fields and the setup guide in unnamed tabs, so
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* off the document root, which capability resolution reads.
|
|
82
|
+
* Wraps the key fields and the setup guide in unnamed tabs, so wide snippets
|
|
83
|
+
* get the full form width. Named tabs would nest the data and move
|
|
84
|
+
* `capabilities` off the document root, which capability resolution reads.
|
|
65
85
|
*
|
|
66
|
-
* The guide tab
|
|
67
|
-
* key to hand out, and a tab leading to an empty panel is worse than no tab.
|
|
86
|
+
* The guide tab only shows on update: on create there is no key to hand out.
|
|
68
87
|
*/ const withSetupGuideTab = (keyFields, options) => {
|
|
69
88
|
if (!options.setupGuide) return keyFields;
|
|
70
89
|
return [{
|
|
@@ -81,7 +100,7 @@ const checkbox = (name, description) => ({
|
|
|
81
100
|
admin: {
|
|
82
101
|
disableListColumn: true,
|
|
83
102
|
components: { Field: {
|
|
84
|
-
path: "@abinnovision/payloadcms-mcpx/
|
|
103
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
85
104
|
exportName: "McpxSetupGuide",
|
|
86
105
|
clientProps: { endpointPath: options.endpointPath }
|
|
87
106
|
} }
|
|
@@ -91,21 +110,14 @@ const checkbox = (name, description) => ({
|
|
|
91
110
|
}];
|
|
92
111
|
};
|
|
93
112
|
/**
|
|
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.
|
|
100
|
-
*
|
|
101
|
-
* An entity without versions gets no `publish` checkbox even under
|
|
102
|
-
* `write: "live"`: there is no draft to promote there, the write itself is the
|
|
103
|
-
* live change, and a second checkbox would only make `write` a dead setting.
|
|
113
|
+
* One checkbox per operation the config exposes, grouped per collection, global
|
|
114
|
+
* and custom tool, built from {@link createCapabilityMatrix}. Everything
|
|
115
|
+
* defaults to off, so a key issued before a capability existed stays closed.
|
|
104
116
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
117
|
+
* An entity without drafts gets no `publish` checkbox, even where
|
|
118
|
+
* writes are on: the write is already live, so the box would make `write` a
|
|
119
|
+
* dead setting. The admin renders the group as a matrix; the checkboxes serve
|
|
120
|
+
* consumers who replace that component through `apiKeys.overrideCollection`.
|
|
109
121
|
*/ const createCapabilityFields = (options) => {
|
|
110
122
|
const matrix = createCapabilityMatrix(options);
|
|
111
123
|
const entityGroups = (rows) => rows.map((row) => ({
|
|
@@ -141,7 +153,7 @@ const checkbox = (name, description) => ({
|
|
|
141
153
|
admin: {
|
|
142
154
|
description: CAPABILITIES_DESCRIPTION,
|
|
143
155
|
components: { Field: {
|
|
144
|
-
path: "@abinnovision/payloadcms-mcpx/
|
|
156
|
+
path: "@abinnovision/payloadcms-mcpx/admin",
|
|
145
157
|
exportName: "McpxCapabilityMatrix",
|
|
146
158
|
clientProps: {
|
|
147
159
|
matrix,
|
|
@@ -153,4 +165,4 @@ const checkbox = (name, description) => ({
|
|
|
153
165
|
}];
|
|
154
166
|
};
|
|
155
167
|
//#endregion
|
|
156
|
-
export {
|
|
168
|
+
export { createCapabilityFields, createKeyFields, withSetupGuideTab };
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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;
|
|
5
|
+
const LAST_USED_INTERVAL_MS = 36e5;
|
|
6
|
+
const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
|
|
4
7
|
const relationId = (value) => {
|
|
5
8
|
if (typeof value === "string" || typeof value === "number") return value;
|
|
6
9
|
if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
@@ -10,6 +13,51 @@ const parseBearer = (headers) => {
|
|
|
10
13
|
if (!header) return null;
|
|
11
14
|
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
12
15
|
};
|
|
16
|
+
const isId = (value) => typeof value === "string" || typeof value === "number";
|
|
17
|
+
/**
|
|
18
|
+
* Whether a resolved auth, from the default or a custom resolver, may become
|
|
19
|
+
* `req.user`: a user of the configured user collection with an id, and a key id.
|
|
20
|
+
*/ const isValidAuthResult = (auth, options) => {
|
|
21
|
+
if (!isPlainObject(auth) || !isPlainObject(auth["user"])) return false;
|
|
22
|
+
return isId(auth["user"]["id"]) && auth["user"]["collection"] === options.userCollection && isId(auth["apiKeyId"]);
|
|
23
|
+
};
|
|
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
|
+
};
|
|
13
61
|
/**
|
|
14
62
|
* Resolves the bearer key of a request to the user it acts as.
|
|
15
63
|
*
|
|
@@ -30,31 +78,31 @@ const parseBearer = (headers) => {
|
|
|
30
78
|
select: {
|
|
31
79
|
enabled: true,
|
|
32
80
|
user: true,
|
|
33
|
-
capabilities: true
|
|
81
|
+
capabilities: true,
|
|
82
|
+
expiresAt: true,
|
|
83
|
+
lastUsedAt: true
|
|
34
84
|
}
|
|
35
85
|
});
|
|
36
86
|
const keyDoc = docs[0];
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
capabilities: keyDoc.capabilities
|
|
57
|
-
};
|
|
87
|
+
const auth = await checkApiKey(req, options, keyDoc);
|
|
88
|
+
if (!auth || !keyDoc) return null;
|
|
89
|
+
const lastUsed = toTime(keyDoc.lastUsedAt);
|
|
90
|
+
if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
|
|
91
|
+
await payload.update({
|
|
92
|
+
collection: options.apiKeysSlug,
|
|
93
|
+
id: keyDoc.id,
|
|
94
|
+
data: { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
95
|
+
overrideAccess: true,
|
|
96
|
+
depth: 0,
|
|
97
|
+
context: { mcpxTouch: true }
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
payload.logger.warn({
|
|
101
|
+
err: error,
|
|
102
|
+
msg: "Could not record the last use of an MCP API key"
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return auth;
|
|
58
106
|
};
|
|
59
107
|
//#endregion
|
|
60
|
-
export { parseBearer, resolveApiKeyAuth };
|
|
108
|
+
export { checkApiKey, isValidAuthResult, parseBearer, resolveApiKeyAuth };
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,62 +1,60 @@
|
|
|
1
|
+
import { isPlainObject } from "./guards.mjs";
|
|
1
2
|
//#region src/capabilities.ts
|
|
2
|
-
/** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
|
|
3
|
-
/** Whatever the write lands on; {@link isLiveWrite} tells the two apart. */ const canWrite = (entity) => entity.write !== false;
|
|
4
|
-
/** The config lets MCP change live content and there is a draft to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
*/ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
|
|
4
|
+
* Group field holding the capability checkboxes on an API key document.
|
|
5
|
+
*/ const CAPABILITIES_FIELD = "capabilities";
|
|
9
6
|
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
7
|
+
* Covers draft and live writes; {@link isLiveWrite} separates them.
|
|
8
|
+
*/ const canWrite = (entity) => entity.write !== false;
|
|
9
|
+
/**
|
|
10
|
+
* The config lets MCP change live content and a draft exists to promote.
|
|
11
|
+
*/ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
15
12
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
/**
|
|
17
|
+
* Without drafts there is no draft stage, so every write is live.
|
|
18
|
+
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
19
|
+
const flag = (group, name) => isPlainObject(group) && group[name] === true;
|
|
20
|
+
const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
|
|
20
21
|
/**
|
|
21
22
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
22
23
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
23
24
|
* issued before a capability existed stay closed.
|
|
24
25
|
*/ 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
|
-
}
|
|
26
|
+
const groupOf = (name) => isPlainObject(keyCapabilities) ? keyCapabilities[name] : void 0;
|
|
27
|
+
const resolveEntities = (entities, namespaceGroup) => {
|
|
28
|
+
const resolved = {};
|
|
29
|
+
for (const entity of entities) {
|
|
30
|
+
const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
|
|
31
|
+
resolved[entity.slug] = {
|
|
32
|
+
read: entity.read && flag(group, "read"),
|
|
33
|
+
write: canWrite(entity) && flag(group, "write"),
|
|
34
|
+
publish: publishFlag(entity, group)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
};
|
|
39
|
+
const toolsGroup = groupOf("tools");
|
|
46
40
|
const tools = {};
|
|
47
41
|
for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
|
|
48
42
|
return {
|
|
49
|
-
collections,
|
|
50
|
-
globals,
|
|
43
|
+
collections: resolveEntities(options.collections, groupOf("collections")),
|
|
44
|
+
globals: resolveEntities(options.globals, groupOf("globals")),
|
|
51
45
|
tools
|
|
52
46
|
};
|
|
53
47
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The slugs tools use to narrow their enums. Taken from the output of
|
|
50
|
+
* {@link resolveCapabilities}, so config and checkbox are already applied.
|
|
51
|
+
*/ const scopeSlugs = (entries) => {
|
|
52
|
+
const slugsWith = (operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
|
|
53
|
+
return {
|
|
54
|
+
readable: slugsWith("read"),
|
|
55
|
+
writable: slugsWith("write"),
|
|
56
|
+
publishable: slugsWith("publish")
|
|
57
|
+
};
|
|
58
|
+
};
|
|
61
59
|
//#endregion
|
|
62
|
-
export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite,
|
|
60
|
+
export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, resolveCapabilities, scopeSlugs };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpxAnyTool, McpxTool, McpxToolScope } from "./types.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/define-tool.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Fixed shape; arguments inferred from it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
8
|
+
inputSchema?: Shape;
|
|
9
|
+
}): McpxTool<Shape>;
|
|
10
|
+
/**
|
|
11
|
+
* Per-request shape returned as an object literal; arguments inferred from it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
|
|
14
|
+
inputSchema: (scope: McpxToolScope) => Shape;
|
|
15
|
+
}): McpxAnyTool;
|
|
16
|
+
/**
|
|
17
|
+
* Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
|
|
18
|
+
* builtins do. Nothing to infer from, so state the arguments instead.
|
|
19
|
+
*/
|
|
20
|
+
export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
|
|
21
|
+
inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
|
|
22
|
+
}): McpxAnyTool;
|
|
23
|
+
//#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,23 +1,45 @@
|
|
|
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 {
|
|
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
|
|
7
|
-
const
|
|
8
|
+
const BODY_BYTES_LIMIT = 4194304;
|
|
9
|
+
const BATCH_MESSAGES_LIMIT = 10;
|
|
10
|
+
const readBody = async (req) => {
|
|
11
|
+
if (Number(req.headers.get("content-length")) > BODY_BYTES_LIMIT) return null;
|
|
12
|
+
if (!req.body) return;
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
let text = "";
|
|
15
|
+
let size = 0;
|
|
16
|
+
for await (const chunk of req.body) {
|
|
17
|
+
size += chunk.byteLength;
|
|
18
|
+
if (size > BODY_BYTES_LIMIT) return null;
|
|
19
|
+
text += decoder.decode(chunk, { stream: true });
|
|
20
|
+
}
|
|
21
|
+
return text + decoder.decode();
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The scope the tools of one request see: the key's resolved capabilities, the
|
|
25
|
+
* slugs they reach, and the locales and limits of the config.
|
|
26
|
+
*/ const buildScope = (req, options, capabilities) => {
|
|
8
27
|
const { localization } = req.payload.config;
|
|
9
28
|
return {
|
|
10
29
|
req,
|
|
11
30
|
capabilities,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
locales: localization ? localization.localeCodes : null,
|
|
19
|
-
defaultLocale: localization ? localization.defaultLocale : null,
|
|
31
|
+
collections: scopeSlugs(capabilities.collections),
|
|
32
|
+
globals: scopeSlugs(capabilities.globals),
|
|
33
|
+
localization: localization ? {
|
|
34
|
+
locales: localization.localeCodes,
|
|
35
|
+
defaultLocale: localization.defaultLocale
|
|
36
|
+
} : null,
|
|
20
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,
|
|
21
43
|
exposure: {
|
|
22
44
|
collections: options.collections,
|
|
23
45
|
globals: options.globals
|
|
@@ -25,6 +47,22 @@ const buildScope = (req, options, capabilities) => {
|
|
|
25
47
|
};
|
|
26
48
|
};
|
|
27
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
|
+
/**
|
|
28
66
|
* Answers GET and DELETE on the endpoint path. The server is stateless and
|
|
29
67
|
* never streams, so only POST carries meaning.
|
|
30
68
|
*/ const methodNotAllowed = () => jsonRpcError({
|
|
@@ -44,24 +82,25 @@ const buildScope = (req, options, capabilities) => {
|
|
|
44
82
|
req,
|
|
45
83
|
resolveDefault
|
|
46
84
|
}) : await resolveDefault();
|
|
47
|
-
if (!auth)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
mcpx: {
|
|
58
|
-
apiKeyId: auth.apiKeyId,
|
|
59
|
-
capabilities
|
|
60
|
-
}
|
|
61
|
-
};
|
|
85
|
+
if (!isValidAuthResult(auth, options)) {
|
|
86
|
+
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.");
|
|
87
|
+
return jsonRpcError({
|
|
88
|
+
status: 401,
|
|
89
|
+
code: -32001,
|
|
90
|
+
message: "Unauthorized: a valid API key is required.",
|
|
91
|
+
headers: { "www-authenticate": "Bearer" }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const scope = authenticateAs(req, options, auth);
|
|
62
95
|
let parsedBody;
|
|
63
96
|
try {
|
|
64
|
-
|
|
97
|
+
const text = await readBody(req);
|
|
98
|
+
if (text === null) return jsonRpcError({
|
|
99
|
+
status: 413,
|
|
100
|
+
code: -32e3,
|
|
101
|
+
message: `Request body too large: the limit is ${String(BODY_BYTES_LIMIT / 1024 / 1024)} MB.`
|
|
102
|
+
});
|
|
103
|
+
parsedBody = text === void 0 ? void 0 : JSON.parse(text);
|
|
65
104
|
} catch {
|
|
66
105
|
return jsonRpcError({
|
|
67
106
|
status: 400,
|
|
@@ -74,7 +113,12 @@ const buildScope = (req, options, capabilities) => {
|
|
|
74
113
|
code: -32600,
|
|
75
114
|
message: "Invalid request: a JSON body is required."
|
|
76
115
|
});
|
|
77
|
-
|
|
116
|
+
if (Array.isArray(parsedBody) && parsedBody.length > BATCH_MESSAGES_LIMIT) return jsonRpcError({
|
|
117
|
+
status: 400,
|
|
118
|
+
code: -32600,
|
|
119
|
+
message: `Invalid request: a batch may hold at most ${String(BATCH_MESSAGES_LIMIT)} messages.`
|
|
120
|
+
});
|
|
121
|
+
const server = createMcpServer(scope, options);
|
|
78
122
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
79
123
|
await server.connect(transport);
|
|
80
124
|
const headers = new Headers(req.headers);
|
|
@@ -89,4 +133,4 @@ const buildScope = (req, options, capabilities) => {
|
|
|
89
133
|
}
|
|
90
134
|
};
|
|
91
135
|
//#endregion
|
|
92
|
-
export { createMcpxHandler, methodNotAllowed };
|
|
136
|
+
export { authenticateAs, buildScope, createMcpxHandler, methodNotAllowed };
|