@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.
Files changed (76) hide show
  1. package/README.md +144 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +14 -86
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +71 -23
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +44 -46
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +73 -29
  18. package/dist/endpoint/server.mjs +85 -22
  19. package/dist/endpoint/upload.mjs +135 -0
  20. package/dist/entity.d.mts +9 -0
  21. package/dist/guards.mjs +7 -0
  22. package/dist/i18n.mjs +10 -8
  23. package/dist/index.d.mts +5 -2
  24. package/dist/index.mjs +3 -2
  25. package/dist/options.mjs +54 -47
  26. package/dist/plugin.d.mts +2 -2
  27. package/dist/plugin.mjs +13 -3
  28. package/dist/request.d.mts +2 -3
  29. package/dist/request.mjs +2 -3
  30. package/dist/result.d.mts +2 -3
  31. package/dist/result.mjs +3 -3
  32. package/dist/schema/admin-hidden.mjs +91 -0
  33. package/dist/schema/describe.mjs +17 -33
  34. package/dist/schema/errors.mjs +8 -0
  35. package/dist/schema/index.mjs +5 -2
  36. package/dist/schema/lexical-pointer.mjs +18 -20
  37. package/dist/schema/lexical.mjs +44 -93
  38. package/dist/schema/outline.mjs +5 -15
  39. package/dist/schema/path.mjs +28 -0
  40. package/dist/schema/pointer.mjs +18 -25
  41. package/dist/schema/shape.mjs +25 -71
  42. package/dist/schema/walk.mjs +43 -45
  43. package/dist/tools/builtin.mjs +4 -4
  44. package/dist/tools/create-document.mjs +50 -24
  45. package/dist/tools/describe-schema.mjs +38 -34
  46. package/dist/tools/document.mjs +124 -0
  47. package/dist/tools/find-documents.mjs +32 -19
  48. package/dist/tools/find-versions.mjs +22 -18
  49. package/dist/tools/get-document.mjs +60 -33
  50. package/dist/tools/list-capabilities.mjs +35 -19
  51. package/dist/tools/patch-document.mjs +122 -66
  52. package/dist/tools/publish-document.mjs +26 -32
  53. package/dist/tools/query-paths.mjs +57 -0
  54. package/dist/tools/read-request.d.mts +10 -0
  55. package/dist/tools/read-request.mjs +43 -0
  56. package/dist/tools/shared.mjs +74 -105
  57. package/dist/tools/validate-document.mjs +18 -30
  58. package/dist/tools/versions.mjs +37 -20
  59. package/dist/types.d.mts +135 -75
  60. package/dist/upload/file.mjs +166 -0
  61. package/dist/upload/grant.mjs +96 -0
  62. package/dist/version.mjs +3 -1
  63. package/dist/write/draft-guard.mjs +23 -41
  64. package/dist/write/patch.mjs +27 -183
  65. package/dist/write/publish-blockers.mjs +7 -14
  66. package/dist/write/publish-intent.mjs +6 -4
  67. package/dist/write/row-ids.mjs +46 -0
  68. package/dist/write/transaction.mjs +5 -8
  69. package/dist/write/write-data.mjs +64 -0
  70. package/package.json +11 -10
  71. package/dist/client/capability-matrix.d.mts +0 -26
  72. package/dist/client/capability-matrix.mjs +0 -349
  73. package/dist/endpoint/index.mjs +0 -4
  74. package/dist/tools/target.mjs +0 -45
  75. /package/dist/{client → admin}/index.d.mts +0 -0
  76. /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;
@@ -16,7 +16,7 @@ const checkbox = (name, description) => ({
16
16
  defaultValue: false,
17
17
  admin: { description }
18
18
  });
19
- /** Name of the `ui` field the "Connect a client" tab renders. */ const SETUP_GUIDE_FIELD = "setupGuide";
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 the wide
62
- * snippets get the full form width without pushing the key itself out of view.
63
- * Unnamed on purpose: named tabs would nest the data and move `capabilities`
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 is conditioned on the update operation. On create there is no
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/client",
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 exposed operation, grouped per collection, per global and
95
- * per custom tool, generated from the same {@link createCapabilityMatrix}
96
- * descriptor the admin component draws, so a cell can never appear without a
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
- * 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`.
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/client",
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 { SETUP_GUIDE_FIELD, createCapabilityFields, createKeyFields, withSetupGuideTab };
168
+ export { createCapabilityFields, createKeyFields, withSetupGuideTab };
@@ -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 userId = relationId(keyDoc?.user);
38
- if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
39
- const userCollection = payload.collections[options.userCollection];
40
- const user = await payload.findByID({
41
- collection: options.userCollection,
42
- id: userId,
43
- depth: userCollection?.config.auth.depth ?? 0,
44
- overrideAccess: true,
45
- disableErrors: true
46
- });
47
- const lockUntil = typeof user?.["lockUntil"] === "string" ? Date.parse(user["lockUntil"]) : NaN;
48
- if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
49
- return {
50
- user: {
51
- ...user,
52
- collection: options.userCollection,
53
- _strategy: "mcpx-api-key"
54
- },
55
- apiKeyId: keyDoc.id,
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 };
@@ -1,4 +1,4 @@
1
- //#region src/tools/names.ts
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 = [
@@ -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
- * An upload document is a file, and no tool here carries one. Its own fields
7
- * stay patchable; the first version is made in the admin panel.
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
- * With no versions there is no draft to land on, so `write: "live"` permits the
11
- * write at all and every write is live.
12
- */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
13
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
- const flag = (group, name) => isRecord(group) && group[name] === true;
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
- * 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
+ * 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 collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
26
- const globalsGroup = isRecord(keyCapabilities) ? keyCapabilities["globals"] : void 0;
27
- const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
28
- const collections = {};
29
- for (const collection of options.collections) {
30
- const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
31
- collections[collection.slug] = {
32
- read: collection.read && flag(group, "read"),
33
- write: canWrite(collection) && flag(group, "write"),
34
- publish: publishFlag(collection, group)
35
- };
36
- }
37
- const globals = {};
38
- for (const global of options.globals) {
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
- const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
55
- const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
56
- const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
57
- const publishableSlugs = (capabilities) => pick(capabilities.collections, "publish");
58
- const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
59
- const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
60
- const publishableGlobalSlugs = (capabilities) => pick(capabilities.globals, "publish");
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, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
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
@@ -1,4 +1,4 @@
1
- //#region src/types.ts
1
+ //#region src/define-tool.ts
2
2
  function defineMcpxTool(tool) {
3
3
  return tool;
4
4
  }
@@ -1,5 +1,5 @@
1
1
  import { errorResult } from "../result.mjs";
2
- import { pointerFromPayloadPath } from "../schema/walk.mjs";
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; a `ValidationError`
26
- * also surfaces its per-field detail, with each field's path restated as a
27
- * JSON Pointer so it reads like every other path this plugin reports. Anything
28
- * else is logged and reported as an internal error so no stack or driver
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 { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
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
+ 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 buildScope = (req, options, capabilities) => {
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
- readable: readableSlugs(capabilities),
13
- writable: writableSlugs(capabilities),
14
- publishable: publishableSlugs(capabilities),
15
- readableGlobals: readableGlobalSlugs(capabilities),
16
- writableGlobals: writableGlobalSlugs(capabilities),
17
- publishableGlobals: publishableGlobalSlugs(capabilities),
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) return jsonRpcError({
48
- status: 401,
49
- code: -32001,
50
- message: "Unauthorized: a valid API key is required.",
51
- headers: { "www-authenticate": "Bearer" }
52
- });
53
- const capabilities = resolveCapabilities(options, auth.capabilities);
54
- req.user = auth.user;
55
- req.context = {
56
- ...req.context,
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
- parsedBody = await req.json?.();
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
- const server = createMcpServer(buildScope(req, options, capabilities), options);
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 };