@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.10

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 (71) hide show
  1. package/README.md +140 -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 +17 -85
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +44 -6
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +41 -43
  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 +49 -19
  18. package/dist/endpoint/server.mjs +72 -20
  19. package/dist/entity.d.mts +9 -0
  20. package/dist/guards.mjs +7 -0
  21. package/dist/i18n.mjs +10 -8
  22. package/dist/index.d.mts +5 -2
  23. package/dist/index.mjs +3 -2
  24. package/dist/options.mjs +52 -46
  25. package/dist/plugin.mjs +0 -1
  26. package/dist/request.d.mts +2 -3
  27. package/dist/request.mjs +2 -3
  28. package/dist/result.d.mts +2 -3
  29. package/dist/result.mjs +3 -3
  30. package/dist/schema/admin-hidden.mjs +91 -0
  31. package/dist/schema/describe.mjs +17 -33
  32. package/dist/schema/errors.mjs +8 -0
  33. package/dist/schema/index.mjs +5 -2
  34. package/dist/schema/lexical-pointer.mjs +18 -20
  35. package/dist/schema/lexical.mjs +44 -93
  36. package/dist/schema/outline.mjs +5 -15
  37. package/dist/schema/path.mjs +28 -0
  38. package/dist/schema/pointer.mjs +18 -25
  39. package/dist/schema/shape.mjs +25 -71
  40. package/dist/schema/walk.mjs +43 -45
  41. package/dist/tools/builtin.mjs +4 -4
  42. package/dist/tools/create-document.mjs +26 -23
  43. package/dist/tools/describe-schema.mjs +38 -34
  44. package/dist/tools/document.mjs +124 -0
  45. package/dist/tools/find-documents.mjs +32 -19
  46. package/dist/tools/find-versions.mjs +22 -18
  47. package/dist/tools/get-document.mjs +39 -32
  48. package/dist/tools/list-capabilities.mjs +23 -16
  49. package/dist/tools/patch-document.mjs +75 -49
  50. package/dist/tools/publish-document.mjs +26 -32
  51. package/dist/tools/query-paths.mjs +57 -0
  52. package/dist/tools/read-request.d.mts +10 -0
  53. package/dist/tools/read-request.mjs +43 -0
  54. package/dist/tools/shared.mjs +67 -99
  55. package/dist/tools/validate-document.mjs +18 -30
  56. package/dist/tools/versions.mjs +37 -20
  57. package/dist/types.d.mts +118 -74
  58. package/dist/write/draft-guard.mjs +23 -41
  59. package/dist/write/patch.mjs +27 -183
  60. package/dist/write/publish-blockers.mjs +7 -14
  61. package/dist/write/publish-intent.mjs +6 -4
  62. package/dist/write/row-ids.mjs +46 -0
  63. package/dist/write/transaction.mjs +5 -8
  64. package/dist/write/write-data.mjs +64 -0
  65. package/package.json +6 -5
  66. package/dist/client/capability-matrix.d.mts +0 -26
  67. package/dist/client/capability-matrix.mjs +0 -349
  68. package/dist/endpoint/index.mjs +0 -4
  69. package/dist/tools/target.mjs +0 -45
  70. /package/dist/{client → admin}/index.d.mts +0 -0
  71. /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,14 @@ 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
+ };
13
24
  /**
14
25
  * Resolves the bearer key of a request to the user it acts as.
15
26
  *
@@ -30,22 +41,49 @@ const parseBearer = (headers) => {
30
41
  select: {
31
42
  enabled: true,
32
43
  user: true,
33
- capabilities: true
44
+ capabilities: true,
45
+ expiresAt: true,
46
+ lastUsedAt: true
34
47
  }
35
48
  });
36
49
  const keyDoc = docs[0];
37
50
  const userId = relationId(keyDoc?.user);
38
51
  if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
52
+ if (toTime(keyDoc.expiresAt) <= Date.now()) return null;
39
53
  const userCollection = payload.collections[options.userCollection];
40
- const user = await payload.findByID({
54
+ const lookup = {
41
55
  collection: options.userCollection,
42
56
  id: userId,
43
- depth: userCollection?.config.auth.depth ?? 0,
44
57
  overrideAccess: true,
45
58
  disableErrors: true
46
- });
47
- const lockUntil = typeof user?.["lockUntil"] === "string" ? Date.parse(user["lockUntil"]) : NaN;
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"]);
48
70
  if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
71
+ const lastUsed = toTime(keyDoc.lastUsedAt);
72
+ if (Number.isNaN(lastUsed) || Date.now() - lastUsed > LAST_USED_INTERVAL_MS) try {
73
+ await payload.update({
74
+ collection: options.apiKeysSlug,
75
+ id: keyDoc.id,
76
+ data: { lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() },
77
+ overrideAccess: true,
78
+ depth: 0,
79
+ context: { mcpxTouch: true }
80
+ });
81
+ } catch (error) {
82
+ payload.logger.warn({
83
+ err: error,
84
+ msg: "Could not record the last use of an MCP API key"
85
+ });
86
+ }
49
87
  return {
50
88
  user: {
51
89
  ...user,
@@ -57,4 +95,4 @@ const parseBearer = (headers) => {
57
95
  };
58
96
  };
59
97
  //#endregion
60
- export { parseBearer, resolveApiKeyAuth };
98
+ export { 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;
3
+ /**
4
+ * Group field holding the capability checkboxes on an API key document.
5
+ */ const CAPABILITIES_FIELD = "capabilities";
6
+ /**
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;
5
12
  /**
6
13
  * An upload document is a file, and no tool here carries one. Its own fields
7
14
  * stay patchable; the first version is made in the admin panel.
8
15
  */ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
9
16
  /**
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.
17
+ * Without drafts there is no draft stage, so every write is live.
12
18
  */ 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;
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");
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,22 +1,38 @@
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
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 buildScope = (req, options, capabilities) => {
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
- 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,
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) return jsonRpcError({
48
- status: 401,
49
- code: -32001,
50
- message: "Unauthorized: a valid API key is required.",
51
- headers: { "www-authenticate": "Bearer" }
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
- parsedBody = await req.json?.();
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 };
@@ -1,44 +1,96 @@
1
1
  import { toToolError } from "./errors.mjs";
2
- import { draftSentence } from "../tools/shared.mjs";
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 rather than stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
8
- /** May be built from the scope, to name the targets this key writes live. */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
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;
7
+ /**
8
+ * Strict, so an unknown argument is rejected by name instead of stripped.
9
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
10
+ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
11
+ /**
12
+ * A tool that does not decide for itself is gated by its own checkbox.
13
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
14
+ const WORKFLOW = [
15
+ {
16
+ tools: ["listCapabilities"],
17
+ does: "what this key may access"
18
+ },
19
+ {
20
+ tools: ["describeSchema"],
21
+ does: "the fields"
22
+ },
23
+ {
24
+ tools: ["findDocuments", "getDocument"],
25
+ does: "the content and its \"updatedAt\""
26
+ },
27
+ {
28
+ tools: ["patchDocument", "createDocument"],
29
+ does: "the write"
30
+ },
31
+ {
32
+ tools: ["validateDocument"],
33
+ does: "what blocks publishing"
34
+ },
35
+ {
36
+ tools: ["publishDocument"],
37
+ does: "make the draft public"
38
+ }
39
+ ];
40
+ 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
+ const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
42
+ const DRAFTS_ONLY = "Every write is saved as a draft.";
43
+ /**
44
+ * The workflow for the tools this key has, and what its writes reach.
45
+ */ const serverInstructions = (scope) => {
46
+ const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
47
+ const steps = WORKFLOW.flatMap(({ tools, does }) => {
48
+ const present = tools.filter((name) => enabled.has(name));
49
+ return present.length === 0 ? [] : [`${present.join(" or ")}: ${does}.`];
50
+ }).map((step, index) => `${String(index + 1)}. ${step}`);
51
+ const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
52
+ const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
53
+ const writes = enabled.has("patchDocument") ? `\n\n${drafts}${publishing}` : "";
54
+ const paths = enabled.has("describeSchema") ? `\n\n${PATHS}` : "";
55
+ return `Call the tools in this order:\n${steps.join("\n")}${paths}${writes}`;
56
+ };
10
57
  /**
11
58
  * One server per request. Builtin and configured tools take the same route,
12
59
  * each registered against the key's capabilities, so `tools/list` shows exactly
13
- * what the key may call.
60
+ * what the key may call. The tool handlers of one JSON-RPC batch run one at a time,
61
+ * so a failed call cannot roll back a write another call reported as done.
14
62
  */ const createMcpServer = (scope, options) => {
15
63
  const { req } = scope;
16
64
  const { logger } = req.payload;
65
+ let queue = Promise.resolve();
17
66
  const server = new McpServer({
18
67
  name: options.serverInfo.name,
19
68
  version: options.serverInfo.version
20
- }, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
21
- const guarded = (run) => async () => {
22
- try {
23
- return await run();
24
- } catch (error) {
25
- return toToolError(error, logger);
26
- }
27
- };
69
+ }, { instructions: serverInstructions(scope) });
28
70
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
29
71
  if (!isToolEnabled(tool, scope)) continue;
30
72
  server.registerTool(tool.name, {
31
73
  description: toolDescription(tool, scope),
32
74
  inputSchema: toolInputSchema(tool, scope),
33
75
  ...tool.annotations ? { annotations: tool.annotations } : {}
34
- }, (args, extra) => guarded(() => tool.handler({
35
- args,
36
- scope,
37
- req,
38
- extra
39
- }))());
76
+ }, (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
+ });
89
+ queue = result.catch(() => void 0);
90
+ return result;
91
+ });
40
92
  }
41
93
  return server;
42
94
  };
43
95
  //#endregion
44
- export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
96
+ 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
@@ -0,0 +1,7 @@
1
+ //#region src/guards.ts
2
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ /**
4
+ * An own property only, so a client-chosen key never reads the prototype.
5
+ */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
6
+ //#endregion
7
+ export { isPlainObject, ownValue };