@abinnovision/payloadcms-mcpx 1.0.0-beta.13 → 1.0.0-beta.14

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.
@@ -4,27 +4,13 @@ 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
10
  /**
8
- * Builds a tool's input schema as a strict object, so an unknown argument is
9
- * rejected with its name instead of being silently stripped and the tool
10
- * answering as if it had not been passed. A tool may build its shape from the
11
- * scope to narrow enums to what the key may touch.
12
- */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
13
- /**
14
- * A tool's description, which may be built from the scope so it can name the
15
- * targets this key writes live.
16
- */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
17
- /**
18
- * Whether the key may call the tool. A tool that does not decide for itself is
19
- * gated by its own checkbox on the key, which is how the tools from
20
- * `options.tools` work; the builtins derive it from the key's collection and
21
- * global capabilities instead.
22
- */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
23
- /**
24
- * Builds the MCP server for one request. Builtin and configured tools take the
25
- * same route: each is registered against the key's capabilities, so
26
- * `tools/list` shows exactly what the key may call and every `collection` enum
27
- * is limited to what it may touch.
11
+ * One server per request. Builtin and configured tools take the same route,
12
+ * each registered against the key's capabilities, so `tools/list` shows exactly
13
+ * what the key may call.
28
14
  */ const createMcpServer = (scope, options) => {
29
15
  const { req } = scope;
30
16
  const { logger } = req.payload;
package/dist/i18n.mjs CHANGED
@@ -1,11 +1,6 @@
1
1
  //#region src/i18n.ts
2
- /**
3
- * A locale-keyed record, once it is known to hold nothing but strings.
4
- */ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
5
- /**
6
- * Picks the entry a language addresses, treating an empty value as absent so
7
- * the chain continues rather than yielding a useless string.
8
- */ const pick = (record, language) => {
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) => {
9
4
  for (const code of Array.isArray(language) ? language : [language]) {
10
5
  const entry = record[code];
11
6
  if (entry !== void 0 && entry.trim() !== "") return entry;
@@ -25,14 +20,8 @@
25
20
  if (!record) return;
26
21
  return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
27
22
  };
28
- /**
29
- * Binds {@link translateStatic} to a request's language, so a walk that
30
- * resolves many descriptions carries no request of its own.
31
- */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
32
- /**
33
- * Translator for callers with no request in hand. Both language keys miss, so
34
- * the chain degrades to the record's first entry.
35
- */ const translateAny = translatorFor({
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({
36
25
  fallbackLanguage: "",
37
26
  language: ""
38
27
  });
package/dist/options.mjs CHANGED
@@ -11,23 +11,16 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
11
11
  const fail = (message) => {
12
12
  throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
13
13
  };
14
+ /** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
14
15
  /**
15
- * Lower camel case of a slug, the same transform the stock MCP plugin applies
16
- * to derive field names from collection slugs.
17
- */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
18
- /**
19
- * Refuses collections that must never be reachable through MCP, read included.
20
16
  * Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
21
- * read, and email or lockout state is PII either way.
17
+ * read, and email or lockout state is PII either way. Refused for read too.
22
18
  */ const assertExposable = (collection, apiKeysSlug) => {
23
19
  const { slug } = collection;
24
20
  if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
25
21
  if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
26
22
  };
27
- /**
28
- * The write mode, checked at runtime as well as in the type. JS callers get no
29
- * type checking, and a typo reading as "no write" would be a silent downgrade.
30
- */ const normalizeWriteMode = (kind, slug, value) => {
23
+ /** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
31
24
  if (value === void 0 || value === false) return false;
32
25
  if (value === "draft" || value === "live") return value;
33
26
  return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
@@ -43,15 +36,11 @@ const fail = (message) => {
43
36
  };
44
37
  const assertWritable = (collection, options) => {
45
38
  const { slug } = collection;
46
- if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
47
39
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
48
40
  if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
49
41
  if (options.write === "live") assertPublishable("Collection", collection);
50
42
  };
51
- /**
52
- * Refuses globals that must never be reachable. Globals cannot be auth or
53
- * upload entities, so only Payload's own reserved namespace is left to guard.
54
- */ const assertGlobalExposable = (global) => {
43
+ /** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
55
44
  if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
56
45
  };
57
46
  /**
@@ -77,6 +66,7 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
77
66
  read: settings.read ?? true,
78
67
  write: normalizeWriteMode("Collection", slug, settings.write),
79
68
  hasDrafts,
69
+ isUpload: Boolean(collection.upload),
80
70
  fieldName: toCamelCase(slug)
81
71
  };
82
72
  if (normalized.write !== false) assertWritable(collection, normalized);
@@ -100,6 +90,7 @@ const normalizeGlobals = (config, options) => {
100
90
  read: settings.read ?? true,
101
91
  write: normalizeWriteMode("Global", slug, settings.write),
102
92
  hasDrafts,
93
+ isUpload: false,
103
94
  fieldName: toCamelCase(slug)
104
95
  };
105
96
  if (normalized.write !== false) assertGlobalWritable(global, normalized);
@@ -132,11 +123,7 @@ const normalizeLimits = (limits) => {
132
123
  maxDepth
133
124
  };
134
125
  };
135
- /**
136
- * Validates the plugin options against the incoming config and fills in
137
- * defaults. Every problem is an `InvalidConfiguration` so misconfiguration
138
- * fails at startup instead of at request time.
139
- */ const normalizeOptions = (config, options) => {
126
+ /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
140
127
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
141
128
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
142
129
  if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
package/dist/result.d.mts CHANGED
@@ -1,12 +1,10 @@
1
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  //#region src/result.d.ts
3
- /**
4
- * A successful tool result carrying `value` as JSON text.
5
- */
3
+ /** `value` as JSON text. */
6
4
  declare const jsonResult: (value: unknown) => CallToolResult;
7
5
  /**
8
- * A failed tool result. `extras` travel alongside the message so the client
9
- * can act on them (problems, validation errors, the current `updatedAt`).
6
+ * `extras` travel alongside the message so the client can act on them:
7
+ * problems, validation errors, the current `updatedAt`.
10
8
  */
11
9
  declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
12
10
  //#endregion
package/dist/result.mjs CHANGED
@@ -1,13 +1,11 @@
1
1
  //#region src/result.ts
2
- /**
3
- * A successful tool result carrying `value` as JSON text.
4
- */ const jsonResult = (value) => ({ content: [{
2
+ /** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
5
3
  type: "text",
6
4
  text: JSON.stringify(value)
7
5
  }] });
8
6
  /**
9
- * A failed tool result. `extras` travel alongside the message so the client
10
- * can act on them (problems, validation errors, the current `updatedAt`).
7
+ * `extras` travel alongside the message so the client can act on them:
8
+ * problems, validation errors, the current `updatedAt`.
11
9
  */ const errorResult = (message, extras = {}) => ({
12
10
  content: [{
13
11
  type: "text",
@@ -2,13 +2,8 @@ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
2
  import { translateAny } from "../i18n.mjs";
3
3
  import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
4
4
  //#region src/schema/describe.ts
5
- /**
6
- * The longest descriptor path that is a prefix of `remaining`. Blocks and rich
7
- * text fields are both leaves of the walk, so at most one can match.
8
- */ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
9
- /**
10
- * Walks one step of a schema path through a blocks field.
11
- */ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
5
+ /** Blocks and rich text are both leaves of the walk, so at most one matches. */ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
6
+ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
12
7
  const field = findBlocksField(fields, match);
13
8
  if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
14
9
  const slug = remaining.at(match.length);
@@ -22,8 +17,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
22
17
  };
23
18
  };
24
19
  /**
25
- * Walks one step of a schema path into a Lexical node's own fields.
26
- *
27
20
  * A node that picks a block by slug takes one segment more, so `/content/block`
28
21
  * addresses the choice and `/content/block/callout` the definition. Everything
29
22
  * else, a link node being the usual case, resolves in a single segment.
@@ -52,8 +45,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
52
45
  };
53
46
  };
54
47
  /**
55
- * Walks a schema path to the field list it addresses.
56
- *
57
48
  * A schema path alternates a blocks field's own path with the slug of one of
58
49
  * the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
59
50
  * reaches `hero` as it exists under `pages` specifically. The slug sits where
@@ -114,11 +105,8 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
114
105
  }));
115
106
  };
116
107
  /**
117
- * Describes a collection or global root, one block reached through a schema
118
- * path, or the fields a Lexical node carries.
119
- *
120
108
  * Curried on the translator that resolves each `admin.description`, so a
121
- * request binds its language once and the walk itself stays request-free.
109
+ * request binds its language once and the walk stays request-free.
122
110
  */ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
123
111
  const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
124
112
  const descriptors = describeFields(fields, translate);
@@ -1,8 +1,6 @@
1
1
  import { flattenAllFields } from "payload";
2
2
  //#region src/schema/lexical.ts
3
3
  /**
4
- * Node types Lexical registers itself.
5
- *
6
4
  * `editorConfig.features.nodes` lists only what a feature contributed, so a
7
5
  * field whose editor enables nothing but text formatting reports none at all.
8
6
  */ const LEXICAL_CORE_NODES = [
@@ -13,26 +11,17 @@ import { flattenAllFields } from "payload";
13
11
  "tab"
14
12
  ];
15
13
  /**
16
- * Node types whose sub-fields exist but cannot be addressed by a schema path.
17
- *
18
- * Asked without a node, `upload` answers with every enabled collection's
19
- * upload fields concatenated, so the result describes no single position. It
20
- * would need addressing by `relationTo` to mean anything.
14
+ * Sub-fields exist but cannot be addressed by a schema path. Asked without a
15
+ * node, `upload` answers with every enabled collection's upload fields
16
+ * concatenated, so the result describes no single position.
21
17
  */ const OPAQUE_NODE_TYPES = /* @__PURE__ */ new Set(["upload"]);
22
18
  /**
23
- * Sub-schemas per rich text field, keyed by node type. `null` records a node
24
- * type that was asked and has nothing to describe, so it is asked only once.
25
- *
26
- * Worth caching because the describe and validate paths resolve the same field
27
- * repeatedly, and because the block features build their answer from scratch on
28
- * every call. Keyed weakly on the sanitized field, which lives as long as the
29
- * config does.
19
+ * `null` records a node type that was asked and has nothing to describe, so it
20
+ * is asked only once. Worth caching because describe and validate resolve the
21
+ * same field repeatedly and the block features rebuild their answer every call.
30
22
  */ const subSchemaCache = /* @__PURE__ */ new WeakMap();
31
23
  const featuresOf = (field) => field.editor?.editorConfig?.features;
32
- /**
33
- * Node types a rich text field accepts. Editors other than Lexical report
34
- * only the core nodes.
35
- */ const allowedNodeTypes = (field) => {
24
+ /** Editors other than Lexical report only the core nodes. */ const allowedNodeTypes = (field) => {
36
25
  const registered = (featuresOf(field)?.nodes ?? []).flatMap((entry) => {
37
26
  const type = entry.node?.getType?.();
38
27
  return type ? [type] : [];
@@ -53,10 +42,7 @@ const resolveSubSchema = (field, nodeType) => {
53
42
  kind: "fields"
54
43
  };
55
44
  };
56
- /**
57
- * The sub-schema behind one node type of a rich text field, or `undefined`
58
- * when that node carries no addressable fields.
59
- */ const lexicalSubSchema = (field, nodeType) => {
45
+ const lexicalSubSchema = (field, nodeType) => {
60
46
  let cached = subSchemaCache.get(field);
61
47
  if (!cached) {
62
48
  cached = /* @__PURE__ */ new Map();
@@ -65,13 +51,8 @@ const resolveSubSchema = (field, nodeType) => {
65
51
  if (!cached.has(nodeType)) cached.set(nodeType, resolveSubSchema(field, nodeType));
66
52
  return cached.get(nodeType) ?? void 0;
67
53
  };
54
+ /** In the order their features registered them. */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
68
55
  /**
69
- * Node types of a rich text field that have a sub-schema, in the order their
70
- * features registered them.
71
- */ const subSchemaNodeTypes = (field) => [...featuresOf(field)?.getSubFields?.keys() ?? []].filter((nodeType) => lexicalSubSchema(field, nodeType) !== void 0);
72
- /**
73
- * Node properties worth reporting and enforcing.
74
- *
75
56
  * Only properties a feature narrows and Lexical does not check on its own
76
57
  * belong here. Everything else a feature restricts is already visible: a
77
58
  * link's targets through its sub-schema, a block node's choices through the
@@ -9,10 +9,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
9
9
  descriptor,
10
10
  parts: splitPath(descriptor.path)
11
11
  })).filter(({ parts }) => parts.every((part, offset) => partMatches(part, segments[offset]))).sort((left, right) => right.consumed - left.consumed)[0];
12
- /**
13
- * Whether the segments stop part-way through some descriptor's path, which
14
- * means they address a subtree rather than a field.
15
- */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
12
+ /** Stopping part-way through a descriptor's path means a subtree, not a field. */ const isSubtreePrefix = (descriptors, segments) => descriptors.some((descriptor) => {
16
13
  const parts = splitPath(descriptor.path);
17
14
  return parts.length > segments.length && segments.every((segment, offset) => {
18
15
  const part = parts[offset];
@@ -20,15 +17,12 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
20
17
  });
21
18
  });
22
19
  /**
23
- * Reads the value the given pointer segments address. Unlike a descriptor
24
- * path, the segments carry real indices, so intervening array fields are
25
- * descended through rather than skipped.
20
+ * Unlike a descriptor path, the segments carry real indices, so intervening
21
+ * array fields are descended through rather than skipped.
26
22
  */ const valueAtSegments = (data, segments) => segments.reduce((current, segment) => current === null || typeof current !== "object" ? void 0 : current[segment], data);
27
23
  /**
28
- * Resolves a JSON Pointer against the schema, using the stored document to
29
- * choose a branch at every blocks element.
30
- *
31
- * The document is required rather than optional: `/layout/sections/3/modules/1`
24
+ * The stored document chooses the branch at every blocks element, and is
25
+ * required rather than optional: `/layout/sections/3/modules/1`
32
26
  * can only be resolved by reading `blockType` off `sections[3]`, since a blocks
33
27
  * field admits many shapes at the same index.
34
28
  */ const resolveDataPointer = (config, target) => {
@@ -1,20 +1,15 @@
1
1
  import { lexicalSubSchema } from "./lexical.mjs";
2
2
  import { blockOf, blockSlugsOf, describeAddressableFields, findBlocksField, findRichTextField, splitPath } from "./walk.mjs";
3
3
  //#region src/schema/shape.ts
4
- /**
5
- * Keys Payload manages on a row that a client may echo back harmlessly.
6
- */ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
4
+ const TOLERATED_VALUE_KEYS = /* @__PURE__ */ new Set([
7
5
  "blockName",
8
6
  "blockType",
9
7
  "id"
10
8
  ]);
11
9
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
10
  /**
13
- * Checks the fields a single Lexical node carries against the schema its
14
- * feature declares for that node type.
15
- *
16
- * A node with nothing to declare, and one whose sub-fields cannot be named at
17
- * a position, are both left alone.
11
+ * A node with nothing to declare, and one whose sub-fields cannot be named at a
12
+ * position, are both left alone.
18
13
  */ const checkNodeFields = (scope, field, node) => {
19
14
  const sub = lexicalSubSchema(field, node.type);
20
15
  if (!sub) return;
@@ -47,10 +42,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
47
42
  }, data);
48
43
  };
49
44
  /**
50
- * Checks an editor state against what the field's editor can actually
51
- * produce: every node type, and the fields each node carries.
52
- *
53
- * Payload does not: the Lexical validator runs node validations only for the
45
+ * Payload does not check this: the Lexical validator runs node validations only for the
54
46
  * few node types that register one, so a `heading` inside a field whose
55
47
  * editor has no heading feature is stored without complaint and only fails
56
48
  * later, at render or when the document is reopened in the admin editor. A key
@@ -126,8 +118,7 @@ const checkLeafValue = (scope, descriptor, value) => {
126
118
  });
127
119
  };
128
120
  /**
129
- * Walks an incoming value against the schema, reporting every shape problem
130
- * rather than the first.
121
+ * Reports every shape problem rather than the first.
131
122
  *
132
123
  * Shape only: unknown field names, unknown block slugs, read-only fields, and
133
124
  * rich text nodes or node properties the field's editor cannot produce.
@@ -2,50 +2,37 @@ import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
2
  import { translateAny } from "../i18n.mjs";
3
3
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
4
4
  //#region src/schema/walk.ts
5
- /**
6
- * Fields Payload maintains, which a client may neither address nor supply.
7
- */ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
5
+ const RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
8
6
  "_status",
9
7
  "createdAt",
10
8
  "deletedAt",
11
9
  "id",
12
10
  "updatedAt"
13
11
  ]);
12
+ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
13
+ /** No segments is the root pointer, `""`. */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
14
+ /** Unescapes `~1` and `~0`. The root pointer yields no segments. */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
14
15
  /**
15
- * Shape a JSON Pointer must have to be parseable at all.
16
- */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
17
- /**
18
- * Joins segments into a JSON Pointer, so the segments `items`, `*`, `title`
19
- * read as one path to a subfield of every element of `items`. No segments is
20
- * the root pointer, `""`.
21
- */ const joinPath = (parts) => parts.map((part) => `/${part.replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
22
- /**
23
- * Splits a JSON Pointer into its segments, unescaping `~1` and `~0`. The root
24
- * pointer yields no segments.
25
- */ const splitPath = (path) => path.split("/").slice(1).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
26
- /**
27
- * Restates a path Payload reports on a validation error (`layout.0.title`) as
28
- * a JSON Pointer, so everything this plugin hands back addresses documents the
29
- * same way. Payload's path already carries real indices, so it maps directly.
16
+ * A path Payload reports on a validation error (`layout.0.title`) as a JSON
17
+ * Pointer, so everything handed back addresses documents the same way. The
18
+ * path already carries real indices, so it maps directly.
30
19
  */ const pointerFromPayloadPath = (path) => path ? joinPath(path.split(".")) : "";
20
+ /** On a flattened field, whichever of `blockReferences` and `blocks` was declared. */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
31
21
  /**
32
- * Blocks a blocks field accepts, by slug. On a flattened field, whichever of
33
- * `blockReferences` and `blocks` was declared carries the definitions.
34
- */ const blockSlugsOf = (field) => [...new Set((field.blockReferences ?? field.blocks).map((block) => typeof block === "string" ? block : block.slug))];
35
- /**
36
- * Resolves one of a blocks field's slugs to its definition.
37
- *
38
- * A definition inlined on the field wins over the shared registry. A block's
39
- * own fields are identical wherever it appears, but the blocks its children
40
- * accept are not, so an inline definition has to be read at its position.
41
- * The registry (`config.blocks`) is the fallback for slugs referenced by name.
22
+ * An inlined definition wins over the registry (`config.blocks`). A block's own
23
+ * fields are identical wherever it appears, but the blocks its children accept
24
+ * are not, so an inline definition has to be read at its position.
42
25
  */ const blockOf = (config, field, slug) => {
43
26
  const declared = field.blockReferences ?? field.blocks;
44
27
  const inline = declared.find((block) => typeof block !== "string" && block.slug === slug);
45
28
  if (inline) return inline;
46
29
  return declared.includes(slug) ? config.blocks?.find((block) => block.slug === slug) : void 0;
47
30
  };
48
- const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || fieldIsHiddenOrDisabled(field);
31
+ /**
32
+ * Payload's `fieldIsHiddenOrDisabled` reads `hidden` and `admin.disabled`, not
33
+ * `admin.hidden`, which is what its own upload base fields carry.
34
+ */ const isAdminHidden = (field) => "admin" in field && field.admin.hidden === true;
35
+ /** A field kept out of the admin panel is kept out of the MCP surface too. */ const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || isAdminHidden(field) || fieldIsHiddenOrDisabled(field);
49
36
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
50
37
  const describeBase = (field, { path, readOnly, translate }) => {
51
38
  const description = translate("admin" in field ? field.admin.description : void 0);
@@ -80,11 +67,9 @@ const withRows = (descriptor, field) => ({
80
67
  ...field.maxRows === void 0 ? {} : { maxRows: field.maxRows }
81
68
  });
82
69
  /**
83
- * Whether a descriptor stands for a construct that only holds other fields.
84
- *
85
- * These describe a position rather than a value, so everything that resolves a
86
- * path to something writable skips them; only {@link nodeDescriber} reports
87
- * them, to carry what the container itself declares.
70
+ * A container describes a position rather than a value, so everything resolving
71
+ * a path to something writable skips it. Only {@link nodeDescriber} reports one,
72
+ * to carry what the container itself declares.
88
73
  */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
89
74
  /**
90
75
  * Whether a container declares anything a client could not infer from the
@@ -93,20 +78,16 @@ const withRows = (descriptor, field) => ({
93
78
  /**
94
79
  * Flattens a field list into descriptors addressed relative to the node.
95
80
  *
96
- * The input is Payload's own flattened shape, which has already merged every
97
- * construct that exists only in the admin UI (unnamed tabs, unnamed groups,
98
- * `row`, `collapsible`) and dropped `ui` fields. Named tabs, groups and
99
- * arrays contribute a path segment, and are described in their own right when
100
- * they declare something of their own: an array always, since its row counts
101
- * live nowhere else, a group or tab only when it carries a description or a
102
- * constraint. The walk stops at every blocks field and names the slugs instead
103
- * of descending, which keeps a node proportional to the number of blocks it
104
- * allows rather than to the size of their definitions.
81
+ * The input is Payload's own flattened shape, so the admin-only constructs
82
+ * (unnamed tabs and groups, `row`, `collapsible`, `ui`) are already gone. Named
83
+ * tabs, groups and arrays contribute a path segment, and are described in their
84
+ * own right when they declare something of their own: an array always, since
85
+ * its row counts live nowhere else, a group or tab only when it carries a
86
+ * description or a constraint. The walk stops at every blocks field and names
87
+ * the slugs, which keeps a node proportional to the number of blocks it allows
88
+ * rather than to the size of their definitions.
105
89
  *
106
- * `translate` resolves each `admin.description` to the request's language.
107
- * Callers that walk for paths alone leave it out and get the language-agnostic
108
- * default, so a missing argument costs language selection, never the
109
- * description itself.
90
+ * Omitting `translate` costs language selection, never the description itself.
110
91
  */ const describeFields = (fields, translate = translateAny) => {
111
92
  const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
112
93
  if (isSkipped(field)) return [];
@@ -135,9 +116,7 @@ const withRows = (descriptor, field) => ({
135
116
  * path against a document needs. A container describes a position rather than
136
117
  * a value, so only {@link nodeDescriber} reports one.
137
118
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
138
- /**
139
- * Locates the field of `type` that a resolved descriptor path refers to.
140
- */ const findFieldAt = (fields, path, type) => {
119
+ const findFieldAt = (fields, path, type) => {
141
120
  for (const field of fields) {
142
121
  if (!("name" in field) || field.name !== path[0]) continue;
143
122
  if (field.type === type && path.length === 1) return field;
@@ -145,14 +124,17 @@ const withRows = (descriptor, field) => ({
145
124
  if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
146
125
  }
147
126
  };
148
- /**
149
- * Locates the blocks field that a resolved descriptor path refers to.
150
- */ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
127
+ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
151
128
  /**
152
129
  * Locates the rich text field that a resolved descriptor path refers to, so
153
130
  * its editor can be introspected for the fields its nodes carry.
154
131
  */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
155
- const targetOf = (config, ref) => {
132
+ /**
133
+ * Looks up the sanitized config for a collection or global, as a
134
+ * {@link SchemaTarget}. Throws on an unknown slug rather than returning
135
+ * undefined, because a reference reaching here has already been checked against
136
+ * the key's capabilities and a miss means the config changed underneath it.
137
+ */ const targetOf = (config, ref) => {
156
138
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
157
139
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
158
140
  return found;
@@ -8,12 +8,10 @@ import { publishDocument } from "./publish-document.mjs";
8
8
  import { validateDocument } from "./validate-document.mjs";
9
9
  //#region src/tools/builtin.ts
10
10
  /**
11
- * The builtin tools in registration order. They are ordinary {@link McpxTool}s
12
- * that ship with the plugin and register through the same loop as the tools
13
- * from `options.tools`; only their `isEnabled` differs, deriving from the
14
- * key's collection and global capabilities rather than a checkbox of their
15
- * own. The surface is fixed: adding a collection, block or field never
16
- * changes it.
11
+ * The builtin tools, in registration order. Fixed: adding a collection, block
12
+ * or field never changes the surface. They differ from a custom tool only in
13
+ * `isEnabled`, which derives from the key's capabilities rather than a
14
+ * checkbox of their own.
17
15
  */ const BUILTIN_TOOLS = [
18
16
  listCapabilities,
19
17
  describeSchema,
@@ -1,17 +1,30 @@
1
1
  import { errorResult, jsonResult } from "../result.mjs";
2
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
3
  import "../schema/index.mjs";
4
- import { draftSentence, localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
4
+ import { draftSentence, localeOf, localeShape, patchOnlySlugs, readTarget, slugEnum, slugsFor } from "./shared.mjs";
5
5
  import { resolveTarget } from "./target.mjs";
6
6
  import { defineMcpxTool } from "../types.mjs";
7
7
  import { stripRowIds } from "../write/patch.mjs";
8
8
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
9
9
  import { z } from "zod";
10
10
  //#region src/tools/create-document.ts
11
+ /** Names the writable slugs this tool leaves out, so the gap reads as intent. */ const uploadSentence = (scope) => {
12
+ const slugs = patchOnlySlugs(scope);
13
+ return slugs.length === 0 ? "" : `\n\nLeft out of "collection" on purpose: ${slugs.join(", ")}. Those documents are files, and no tool here carries one. Upload the file in the admin panel, then edit its fields with patchDocument.`;
14
+ };
11
15
  const DESCRIPTION = (scope) => `Creates a new document from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings, and "id" is Payload's to assign. The document may be incomplete: the response lists "publishBlockers", which patchDocument can then work through, and "publishBlockersUnavailable" when that check itself failed. Use this when no document exists yet; prefer patching an existing draft otherwise.
12
16
 
13
- ${draftSentence(scope)}`;
14
- const createDocument = defineMcpxTool({
17
+ ${draftSentence(scope)}${uploadSentence(scope)}`;
18
+ /**
19
+ * Collection-only, because a global always exists, and never reaches an upload
20
+ * collection, because a create there would have to carry the file.
21
+ *
22
+ * The seed is checked against the collection's fields before the create, so an
23
+ * unknown key is refused with its valid siblings rather than dropped. Row ids
24
+ * in the seed are stripped and a top-level `id` is refused outright. The new
25
+ * document is re-read privileged afterwards to collect publish blockers, which
26
+ * is why an incomplete seed still succeeds and comes back with a checklist.
27
+ */ const createDocument = defineMcpxTool({
15
28
  name: "createDocument",
16
29
  description: DESCRIPTION,
17
30
  annotations: {
@@ -20,9 +33,9 @@ const createDocument = defineMcpxTool({
20
33
  idempotentHint: false,
21
34
  openWorldHint: false
22
35
  },
23
- isEnabled: (scope) => scope.writable.length > 0,
36
+ isEnabled: (scope) => slugsFor(scope, "create").collections.length > 0,
24
37
  inputSchema: (scope) => ({
25
- collection: slugEnum(scope.writable).describe("Collection to create the document in."),
38
+ collection: slugEnum(slugsFor(scope, "create").collections).describe("Collection to create the document in."),
26
39
  ...localeShape(scope, {
27
40
  required: true,
28
41
  description: "Locale the localized fields of the seed belong to."
@@ -30,7 +43,7 @@ const createDocument = defineMcpxTool({
30
43
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
31
44
  }),
32
45
  handler: async ({ args, scope }) => {
33
- const target = resolveTarget(scope, { collection: args.collection }, "write");
46
+ const target = resolveTarget(scope, { collection: args.collection }, "create");
34
47
  const { payload } = scope.req;
35
48
  const locale = localeOf(scope, args.locale);
36
49
  if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
@@ -6,7 +6,13 @@ import { targetShape } from "./shared.mjs";
6
6
  import { refOf, resolveTarget } from "./target.mjs";
7
7
  import { defineMcpxTool } from "../types.mjs";
8
8
  import { z } from "zod";
9
- const describeSchema = defineMcpxTool({
9
+ /**
10
+ * Describes each requested path independently and returns a per
11
+ * path error object instead of failing the call, so a client exploring several
12
+ * branches at once keeps the nodes that did resolve. `expand` swaps the
13
+ * requested paths for every node reachable from the root and appends a
14
+ * truncation notice past {@link REACHABLE_PATHS_LIMIT}.
15
+ */ const describeSchema = defineMcpxTool({
10
16
  name: "describeSchema",
11
17
  description: `Describes the writable shape of a document, one node at a time.
12
18
 
@@ -3,7 +3,14 @@ import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
3
  import { resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { z } from "zod";
6
- const findDocuments = defineMcpxTool({
6
+ /**
7
+ * Collection-only: a global is a singleton, so there is nothing to list.
8
+ *
9
+ * The query goes to Payload with `overrideAccess: false`, so the collection's
10
+ * own access control decides what comes back. `limit` and `depth` are bounded
11
+ * by the configured limits in the schema itself, which puts the ceiling in
12
+ * front of the client rather than silently clamping behind it.
13
+ */ const findDocuments = defineMcpxTool({
7
14
  name: "findDocuments",
8
15
  description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
9
16
  annotations: {