@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.
Files changed (69) hide show
  1. package/README.md +141 -448
  2. package/dist/{client → admin}/capability-matrix.d.mts +3 -4
  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 +1 -1
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +6 -6
  8. package/dist/api-keys/capability-matrix.mjs +15 -85
  9. package/dist/api-keys/fields.mjs +15 -24
  10. package/dist/auth/resolve.mjs +23 -5
  11. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +2 -1
  12. package/dist/capabilities.mjs +34 -42
  13. package/dist/define-tool.d.mts +19 -0
  14. package/dist/{types.mjs → define-tool.mjs} +1 -1
  15. package/dist/endpoint/errors.mjs +5 -6
  16. package/dist/endpoint/handler.mjs +49 -19
  17. package/dist/endpoint/server.mjs +65 -19
  18. package/dist/entity.d.mts +9 -0
  19. package/dist/guards.mjs +5 -0
  20. package/dist/i18n.mjs +8 -8
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.mjs +3 -2
  23. package/dist/options.mjs +51 -43
  24. package/dist/plugin.mjs +0 -1
  25. package/dist/request.d.mts +2 -3
  26. package/dist/request.mjs +2 -3
  27. package/dist/result.d.mts +2 -3
  28. package/dist/result.mjs +3 -3
  29. package/dist/schema/admin-hidden.mjs +91 -0
  30. package/dist/schema/describe.mjs +17 -33
  31. package/dist/schema/errors.mjs +8 -0
  32. package/dist/schema/index.mjs +5 -2
  33. package/dist/schema/lexical-pointer.mjs +18 -20
  34. package/dist/schema/lexical.mjs +39 -92
  35. package/dist/schema/outline.mjs +5 -15
  36. package/dist/schema/path.mjs +22 -0
  37. package/dist/schema/pointer.mjs +18 -25
  38. package/dist/schema/shape.mjs +25 -71
  39. package/dist/schema/walk.mjs +35 -45
  40. package/dist/tools/builtin.mjs +6 -4
  41. package/dist/tools/create-document.mjs +26 -23
  42. package/dist/tools/describe-schema.mjs +38 -34
  43. package/dist/tools/document.mjs +84 -0
  44. package/dist/tools/find-documents.mjs +32 -19
  45. package/dist/tools/find-versions.mjs +74 -0
  46. package/dist/tools/get-document.mjs +80 -49
  47. package/dist/tools/list-capabilities.mjs +25 -16
  48. package/dist/tools/patch-document.mjs +74 -50
  49. package/dist/tools/publish-document.mjs +17 -28
  50. package/dist/tools/query-paths.mjs +55 -0
  51. package/dist/tools/read-request.d.mts +10 -0
  52. package/dist/tools/read-request.mjs +43 -0
  53. package/dist/tools/shared.mjs +65 -96
  54. package/dist/tools/validate-document.mjs +16 -29
  55. package/dist/tools/versions.mjs +127 -0
  56. package/dist/types.d.mts +55 -55
  57. package/dist/write/draft-guard.mjs +17 -37
  58. package/dist/write/patch.mjs +27 -183
  59. package/dist/write/publish-blockers.mjs +7 -14
  60. package/dist/write/publish-intent.mjs +4 -4
  61. package/dist/write/row-ids.mjs +44 -0
  62. package/dist/write/transaction.mjs +5 -8
  63. package/dist/write/write-data.mjs +64 -0
  64. package/package.json +4 -4
  65. package/dist/client/capability-matrix.mjs +0 -349
  66. package/dist/endpoint/index.mjs +0 -4
  67. package/dist/tools/target.mjs +0 -45
  68. /package/dist/{client → admin}/index.d.mts +0 -0
  69. /package/dist/{client → admin}/index.mjs +0 -0
@@ -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
@@ -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 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.
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 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.
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/client",
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 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.
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 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.
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/client",
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 { SETUP_GUIDE_FIELD, createCapabilityFields, createKeyFields, withSetupGuideTab };
147
+ export { createCapabilityFields, createKeyFields, withSetupGuideTab };
@@ -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 user = await payload.findByID({
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 lockUntil = typeof user?.["lockUntil"] === "string" ? Date.parse(user["lockUntil"]) : NaN;
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/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 = [
@@ -6,6 +6,7 @@
6
6
  "describeSchema",
7
7
  "findDocuments",
8
8
  "getDocument",
9
+ "findVersions",
9
10
  "patchDocument",
10
11
  "createDocument",
11
12
  "validateDocument",
@@ -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
- /** 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;
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
- * With no versions there is no draft to land on, so `write: "live"` permits the
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 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");
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 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
- }
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
- 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");
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, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
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
@@ -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,90 @@
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;
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: `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
- };
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) => guarded(() => tool.handler({
35
- args,
36
- scope,
37
- req,
38
- extra
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, toolDescription, toolInputSchema };
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
@@ -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
- /** Treats an empty value as absent, so the fallback chain continues. */ const pick = (record, language) => {
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 the one string a client
11
- * can use: the request's language, then the fallback language configured for
12
- * the deployment, then whichever entry the record declares first.
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
- * description written as a function or a React component is an admin-UI
16
- * construct that may reach client-only i18n, so it is never invoked here.
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
- /** Bound to one request's language, so a walk carries no request of its own. */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
- /** For callers with no request: both keys miss, so the first entry wins. */ const translateAny = translatorFor({
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 { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
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
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
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 { defineMcpxTool } from "./types.mjs";
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 };