@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 1.0.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,8 +13,9 @@ resolved server-side against the real config and the real document, so unknown
13
13
  fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
14
14
  valid alternatives listed, never silently dropped.
15
15
 
16
- Writes are RFC 6902 patches that always land as drafts; publishing stays a
17
- human action in the admin panel. Every write returns the publish
16
+ Writes are RFC 6902 patches that land as drafts, with `allowLiveWrites` as the
17
+ one documented exception; publishing stays a human action in the admin panel.
18
+ Every write returns the publish
18
19
  blockers: the validation failures that still prevent a human from publishing
19
20
  the draft. Capabilities are declared twice: the plugin config decides what
20
21
  can exist, a checkbox on each API key decides what does, and a missing checkbox
@@ -153,7 +154,7 @@ only the slugs the key may touch.
153
154
  | `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
154
155
  | `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
155
156
  | `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
156
- | `validateDocument` | Publish blockers without writing. | `collection` + `id` \| `global`, `locale` |
157
+ | `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
157
158
 
158
159
  Rules the tools enforce and explain in their own descriptions:
159
160
 
@@ -266,6 +267,12 @@ Collections with `versions.drafts.validate: true` refuse invalid drafts
266
267
  outright; those failures come back as `validationErrors`. Both carry pointers,
267
268
  restated from the dotted paths Payload reports internally.
268
269
 
270
+ `publishBlockersUnavailable` marks a check that could not complete, which is
271
+ not the same answer as a document with nothing wrong with it. `validateDocument`
272
+ runs the same traversal without saving anything, so it is not free of side
273
+ effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
274
+ `readOnlyHint` for that reason.
275
+
269
276
  Writes also report `notApplied`: pointers whose value Payload kept unchanged,
270
277
  which happens when field-level access denies the update.
271
278
 
@@ -377,6 +384,11 @@ results shaped like a builtin's.
377
384
  | `auth.resolve` | none | Replace or wrap the default key resolution. |
378
385
  | `serverInfo` | package name and version | Reported to MCP clients. |
379
386
 
387
+ `allowLiveWrites` is the only way an MCP write reaches live content. Where it is
388
+ set, the server instructions and the `patchDocument` and `createDocument`
389
+ descriptions name those slugs for the key in question, so a client is never told
390
+ its writes are drafts while they are not.
391
+
380
392
  Misconfiguration (unknown slugs, write on a collection without drafts, upload
381
393
  collections exposed for write, tool name collisions) fails at startup with
382
394
  `InvalidConfiguration`. Auth collections cannot be exposed at all, read
@@ -107,7 +107,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
107
107
  label: global.slug,
108
108
  fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
109
109
  }));
110
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
110
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
111
111
  const groups = [
112
112
  ...collectionGroups.length > 0 ? [{
113
113
  name: "collections",
@@ -1,6 +1,8 @@
1
+ import { errorResult } from "../result.mjs";
1
2
  import { pointerFromPayloadPath } from "../schema/walk.mjs";
3
+ import "../schema/index.mjs";
2
4
  import { APIError, ValidationError } from "payload";
3
- //#region src/endpoint/result.ts
5
+ //#region src/endpoint/errors.ts
4
6
  /**
5
7
  * A JSON-RPC error response for failures that happen before the MCP server
6
8
  * is involved (auth, method, body parsing).
@@ -20,25 +22,6 @@ import { APIError, ValidationError } from "payload";
20
22
  });
21
23
  };
22
24
  /**
23
- * A successful tool result carrying `value` as JSON text.
24
- */ const jsonResult = (value) => ({ content: [{
25
- type: "text",
26
- text: JSON.stringify(value)
27
- }] });
28
- /**
29
- * A failed tool result. `extras` travel alongside the message so the client
30
- * can act on them (problems, validation errors, the current `updatedAt`).
31
- */ const errorResult = (message, extras = {}) => ({
32
- content: [{
33
- type: "text",
34
- text: JSON.stringify({
35
- error: message,
36
- ...extras
37
- })
38
- }],
39
- isError: true
40
- });
41
- /**
42
25
  * Maps an exception thrown by a tool to a result the client can read.
43
26
  *
44
27
  * Payload's public errors keep their message and status; a `ValidationError`
@@ -62,4 +45,4 @@ import { APIError, ValidationError } from "payload";
62
45
  return errorResult("Internal error");
63
46
  };
64
47
  //#endregion
65
- export { errorResult, jsonResult, jsonRpcError, toToolError };
48
+ export { jsonRpcError, toToolError };
@@ -1,5 +1,5 @@
1
- import { jsonRpcError } from "./result.mjs";
2
1
  import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
+ import { jsonRpcError } from "./errors.mjs";
3
3
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
5
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -0,0 +1,4 @@
1
+ import { jsonRpcError, toToolError } from "./errors.mjs";
2
+ import { createMcpServer, isToolEnabled, toolDescription, toolInputSchema } from "./server.mjs";
3
+ import { createMcpxHandler, methodNotAllowed } from "./handler.mjs";
4
+ export { createMcpServer, createMcpxHandler, isToolEnabled, jsonRpcError, methodNotAllowed, toToolError, toolDescription, toolInputSchema };
@@ -1,5 +1,6 @@
1
- import { toToolError } from "./result.mjs";
2
- import { BUILTIN_TOOLS } from "../tools/index.mjs";
1
+ import { toToolError } from "./errors.mjs";
2
+ import { draftSentence } from "../tools/shared.mjs";
3
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { z } from "zod";
5
6
  //#region src/endpoint/server.ts
@@ -10,6 +11,10 @@ import { z } from "zod";
10
11
  * scope to narrow enums to what the key may touch.
11
12
  */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
12
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
+ /**
13
18
  * Whether the key may call the tool. A tool that does not decide for itself is
14
19
  * gated by its own checkbox on the key, which is how the tools from
15
20
  * `options.tools` work; the builtins derive it from the key's collection and
@@ -26,7 +31,7 @@ import { z } from "zod";
26
31
  const server = new McpServer({
27
32
  name: options.serverInfo.name,
28
33
  version: options.serverInfo.version
29
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
34
+ }, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
30
35
  const guarded = (run) => async () => {
31
36
  try {
32
37
  return await run();
@@ -37,7 +42,7 @@ import { z } from "zod";
37
42
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
38
43
  if (!isToolEnabled(tool, scope)) continue;
39
44
  server.registerTool(tool.name, {
40
- description: tool.description,
45
+ description: toolDescription(tool, scope),
41
46
  inputSchema: toolInputSchema(tool, scope),
42
47
  ...tool.annotations ? { annotations: tool.annotations } : {}
43
48
  }, (args, extra) => guarded(() => tool.handler({
@@ -50,4 +55,4 @@ import { z } from "zod";
50
55
  return server;
51
56
  };
52
57
  //#endregion
53
- export { createMcpServer, isToolEnabled, toolInputSchema };
58
+ export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,5 @@
1
- import { errorResult, jsonResult } from "./endpoint/result.mjs";
2
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool } from "./types.mjs";
3
2
  import { mcpxPlugin } from "./plugin.mjs";
4
- import { isMcpxRequest } from "./write/draft-guard.mjs";
5
- import { PublishBlocker } from "./write/publish-blockers.mjs";
6
- export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type McpxToolScope, type PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
3
+ import { isMcpxRequest } from "./request.mjs";
4
+ import { errorResult, jsonResult } from "./result.mjs";
5
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { errorResult, jsonResult } from "./endpoint/result.mjs";
1
+ import { errorResult, jsonResult } from "./result.mjs";
2
2
  import { defineMcpxTool } from "./types.mjs";
3
- import { isMcpxRequest } from "./write/draft-guard.mjs";
3
+ import { isMcpxRequest } from "./request.mjs";
4
4
  import { mcpxPlugin } from "./plugin.mjs";
5
5
  export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import "./endpoint/index.mjs";
3
4
  import { normalizeOptions } from "./options.mjs";
4
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
6
  import { definePlugin } from "payload";
@@ -1,5 +1,5 @@
1
- import { CollectionConfig, PayloadRequest } from "payload";
2
- //#region src/write/draft-guard.d.ts
1
+ import { PayloadRequest } from "payload";
2
+ //#region src/request.d.ts
3
3
  /**
4
4
  * Whether a request originated from the MCP endpoint. The endpoint stamps
5
5
  * `req.context.mcpx`, which travels into every local API call made with the
@@ -0,0 +1,8 @@
1
+ //#region src/request.ts
2
+ /**
3
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
4
+ * `req.context.mcpx`, which travels into every local API call made with the
5
+ * same `req`, including those made by custom tools.
6
+ */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
7
+ //#endregion
8
+ export { isMcpxRequest };
@@ -1,6 +1,5 @@
1
- import "payload";
2
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
- //#region src/endpoint/result.d.ts
2
+ //#region src/result.d.ts
4
3
  /**
5
4
  * A successful tool result carrying `value` as JSON text.
6
5
  */
@@ -0,0 +1,22 @@
1
+ //#region src/result.ts
2
+ /**
3
+ * A successful tool result carrying `value` as JSON text.
4
+ */ const jsonResult = (value) => ({ content: [{
5
+ type: "text",
6
+ text: JSON.stringify(value)
7
+ }] });
8
+ /**
9
+ * A failed tool result. `extras` travel alongside the message so the client
10
+ * can act on them (problems, validation errors, the current `updatedAt`).
11
+ */ const errorResult = (message, extras = {}) => ({
12
+ content: [{
13
+ type: "text",
14
+ text: JSON.stringify({
15
+ error: message,
16
+ ...extras
17
+ })
18
+ }],
19
+ isError: true
20
+ });
21
+ //#endregion
22
+ export { errorResult, jsonResult };
@@ -0,0 +1,6 @@
1
+ import { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
+ import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
+ import { resolveDataPointer } from "./pointer.mjs";
5
+ import { validateWriteValue } from "./shape.mjs";
6
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
@@ -43,7 +43,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
43
43
  if (isSubtreePrefix(descriptors, segments)) return {
44
44
  ...blockType === void 0 ? {} : { blockType },
45
45
  fields,
46
- prefix: segments
46
+ prefix: segments.map((segment) => isIndexSegment(segment) ? "*" : segment)
47
47
  };
48
48
  throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
49
49
  }
@@ -83,7 +83,7 @@ const withRows = (descriptor, field) => ({
83
83
  * Whether a descriptor stands for a construct that only holds other fields.
84
84
  *
85
85
  * These describe a position rather than a value, so everything that resolves a
86
- * path to something writable skips them; only {@link describeNode} reports
86
+ * path to something writable skips them; only {@link nodeDescriber} reports
87
87
  * them, to carry what the container itself declares.
88
88
  */ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
89
89
  /**
@@ -133,29 +133,25 @@ const withRows = (descriptor, field) => ({
133
133
  /**
134
134
  * The descriptors that address a value, which is what every walk resolving a
135
135
  * path against a document needs. A container describes a position rather than
136
- * a value, so only {@link describeNode} reports one.
136
+ * a value, so only {@link nodeDescriber} reports one.
137
137
  */ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
138
138
  /**
139
- * Locates the blocks field that a resolved descriptor path refers to.
140
- */ const findBlocksField = (fields, path) => {
139
+ * Locates the field of `type` that a resolved descriptor path refers to.
140
+ */ const findFieldAt = (fields, path, type) => {
141
141
  for (const field of fields) {
142
142
  if (!("name" in field) || field.name !== path[0]) continue;
143
- if (field.type === "blocks" && path.length === 1) return field;
144
- if (field.type === "tab" || field.type === "group") return findBlocksField(field.flattenedFields, path.slice(1));
145
- if (field.type === "array" && path[1] === "*") return findBlocksField(field.flattenedFields, path.slice(2));
143
+ if (field.type === type && path.length === 1) return field;
144
+ if (field.type === "tab" || field.type === "group") return findFieldAt(field.flattenedFields, path.slice(1), type);
145
+ if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
146
146
  }
147
147
  };
148
148
  /**
149
+ * Locates the blocks field that a resolved descriptor path refers to.
150
+ */ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
151
+ /**
149
152
  * Locates the rich text field that a resolved descriptor path refers to, so
150
153
  * its editor can be introspected for the fields its nodes carry.
151
- */ const findRichTextField = (fields, path) => {
152
- for (const field of fields) {
153
- if (!("name" in field) || field.name !== path[0]) continue;
154
- if (field.type === "richText" && path.length === 1) return field;
155
- if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
156
- if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
157
- }
158
- };
154
+ */ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
159
155
  const targetOf = (config, ref) => {
160
156
  const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
161
157
  if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
@@ -5,7 +5,7 @@ import { getDocument } from "./get-document.mjs";
5
5
  import { listCapabilities } from "./list-capabilities.mjs";
6
6
  import { patchDocument } from "./patch-document.mjs";
7
7
  import { validateDocument } from "./validate-document.mjs";
8
- //#region src/tools/index.ts
8
+ //#region src/tools/builtin.ts
9
9
  /**
10
10
  * The builtin tools in registration order. They are ordinary {@link McpxTool}s
11
11
  * that ship with the plugin and register through the same loop as the tools
@@ -1,15 +1,19 @@
1
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
- import { resolveTarget } from "./target.mjs";
1
+ import { errorResult, jsonResult } from "../result.mjs";
4
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
+ import "../schema/index.mjs";
4
+ import { draftSentence, localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
5
+ import { resolveTarget } from "./target.mjs";
5
6
  import { defineMcpxTool } from "../types.mjs";
6
7
  import { stripRowIds } from "../write/patch.mjs";
7
8
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
8
9
  import { z } from "zod";
9
10
  //#region src/tools/create-document.ts
11
+ 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
+
13
+ ${draftSentence(scope)}`;
10
14
  const createDocument = defineMcpxTool({
11
15
  name: "createDocument",
12
- description: `Creates a new document as a draft from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings. The draft may be incomplete: the response lists "publishBlockers", which patchDocument can then work through. Use this when no document exists yet; prefer patching an existing draft otherwise.`,
16
+ description: DESCRIPTION,
13
17
  annotations: {
14
18
  readOnlyHint: false,
15
19
  destructiveHint: false,
@@ -29,18 +33,18 @@ const createDocument = defineMcpxTool({
29
33
  const target = resolveTarget(scope, { collection: args.collection }, "write");
30
34
  const { payload } = scope.req;
31
35
  const locale = localeOf(scope, args.locale);
32
- const { id: _ignored, ...seed } = args.data;
36
+ if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
33
37
  const problems = validateWriteValue(payload.config, {
34
38
  pointer: "",
35
39
  resolution: {
36
40
  fields: target.config.flattenedFields,
37
41
  prefix: []
38
42
  }
39
- }, seed);
43
+ }, args.data);
40
44
  if (problems.length > 0) return errorResult("Nothing was created.", { problems });
41
45
  const created = await payload.create({
42
46
  collection: args.collection,
43
- data: stripRowIds(seed),
47
+ data: stripRowIds(args.data),
44
48
  depth: 0,
45
49
  draft: true,
46
50
  overrideAccess: false,
@@ -53,7 +57,7 @@ const createDocument = defineMcpxTool({
53
57
  locale,
54
58
  privileged: true
55
59
  });
56
- const publishBlockers = await collectPublishBlockers(scope.req, {
60
+ const validation = await collectPublishBlockers(scope.req, {
57
61
  doc: saved,
58
62
  entity: target
59
63
  });
@@ -61,7 +65,8 @@ const createDocument = defineMcpxTool({
61
65
  id: saved["id"],
62
66
  status: saved["_status"],
63
67
  updatedAt: saved["updatedAt"],
64
- ...publishBlockers.length > 0 ? { publishBlockers } : {}
68
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
69
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
65
70
  });
66
71
  }
67
72
  });
@@ -1,11 +1,11 @@
1
+ import { jsonResult } from "../result.mjs";
1
2
  import { translatorFor } from "../i18n.mjs";
2
- import { jsonResult } from "../endpoint/result.mjs";
3
+ import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
4
+ import "../schema/index.mjs";
3
5
  import { targetShape } from "./shared.mjs";
4
6
  import { refOf, resolveTarget } from "./target.mjs";
5
7
  import { defineMcpxTool } from "../types.mjs";
6
- import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
7
8
  import { z } from "zod";
8
- //#region src/tools/describe-schema.ts
9
9
  const describeSchema = defineMcpxTool({
10
10
  name: "describeSchema",
11
11
  description: `Describes the writable shape of a document, one node at a time.
@@ -1,9 +1,8 @@
1
- import { jsonResult } from "../endpoint/result.mjs";
1
+ import { jsonResult } from "../result.mjs";
2
2
  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
- //#region src/tools/find-documents.ts
7
6
  const findDocuments = defineMcpxTool({
8
7
  name: "findDocuments",
9
8
  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.`,
@@ -1,11 +1,11 @@
1
+ import { errorResult, jsonResult } from "../result.mjs";
1
2
  import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
3
+ import "../schema/index.mjs";
3
4
  import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
5
  import { requireIdFor, resolveTarget } from "./target.mjs";
5
6
  import { defineMcpxTool } from "../types.mjs";
6
7
  import { z } from "zod";
7
8
  import { Pointer } from "rfc6902";
8
- //#region src/tools/get-document.ts
9
9
  const getDocument = defineMcpxTool({
10
10
  name: "getDocument",
11
11
  description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
@@ -1,9 +1,8 @@
1
+ import { jsonResult } from "../result.mjs";
1
2
  import { translatorFor } from "../i18n.mjs";
2
- import { jsonResult } from "../endpoint/result.mjs";
3
3
  import { translateLabel } from "./shared.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { hasDraftValidationEnabled } from "payload/shared";
6
- //#region src/tools/list-capabilities.ts
7
6
  const listCapabilities = defineMcpxTool({
8
7
  name: "listCapabilities",
9
8
  description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
@@ -1,24 +1,24 @@
1
- import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
- import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
1
+ import { errorResult, jsonResult } from "../result.mjs";
2
+ import { draftSentence, idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
3
  import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
- import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
5
+ import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
6
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
7
  import { withTransaction } from "../write/transaction.mjs";
8
8
  import { z } from "zod";
9
9
  import { Pointer } from "rfc6902";
10
10
  //#region src/tools/patch-document.ts
11
- const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
11
+ const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
12
12
 
13
13
  Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
14
14
 
15
- The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
15
+ ${draftSentence(scope)}
16
16
 
17
17
  Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
18
18
 
19
19
  Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so a concurrent edit is refused rather than overwritten.
20
20
 
21
- A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update.`;
21
+ A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
22
22
  const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
23
23
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
24
24
  /**
@@ -48,7 +48,7 @@ const patchDocument = defineMcpxTool({
48
48
  description: DESCRIPTION,
49
49
  annotations: {
50
50
  readOnlyHint: false,
51
- destructiveHint: false,
51
+ destructiveHint: true,
52
52
  idempotentHint: false,
53
53
  openWorldHint: false
54
54
  },
@@ -79,13 +79,11 @@ const patchDocument = defineMcpxTool({
79
79
  locale
80
80
  });
81
81
  if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
82
- const problems = findPatchProblems(payload.config, {
82
+ const applied = applyPatchOperations(payload.config, {
83
83
  doc,
84
84
  patches,
85
85
  ref: refOf(target)
86
86
  });
87
- if (problems.length > 0) return errorResult("No operation was applied.", { problems });
88
- const applied = applyPatchToCopy(doc, patches);
89
87
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
90
88
  const write = {
91
89
  data: buildWriteData(payload.config, target.config, applied.next),
@@ -111,7 +109,7 @@ const patchDocument = defineMcpxTool({
111
109
  privileged: true
112
110
  });
113
111
  const notApplied = notAppliedPointers(patches, applied.next, saved);
114
- const publishBlockers = await collectPublishBlockers(scope.req, {
112
+ const validation = await collectPublishBlockers(scope.req, {
115
113
  doc: saved,
116
114
  entity: target
117
115
  });
@@ -119,7 +117,8 @@ const patchDocument = defineMcpxTool({
119
117
  ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
120
118
  status: saved["_status"],
121
119
  updatedAt: saved["updatedAt"],
122
- ...publishBlockers.length > 0 ? { publishBlockers } : {},
120
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
121
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {},
123
122
  ...notApplied.length > 0 ? { notApplied } : {}
124
123
  });
125
124
  });
@@ -5,6 +5,21 @@ import { z } from "zod";
5
5
  const slugEnum = (slugs) => z.enum(slugs);
6
6
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
7
7
  /**
8
+ * Slugs this key may write whose writes land live rather than as a draft,
9
+ * which is what `allowLiveWrites` permits for an entity without versions.
10
+ * Empty for every key that can only write drafts.
11
+ */ const liveWriteSlugs = (scope) => {
12
+ const live = (entities, writable) => entities.filter((entity) => writable.includes(entity.slug) && entity.allowLiveWrites && !entity.hasDrafts).map((entity) => entity.slug);
13
+ return [...live(scope.exposure.collections, scope.writable), ...live(scope.exposure.globals, scope.writableGlobals)];
14
+ };
15
+ /**
16
+ * The sentence the write tools and the server instructions end on: what a
17
+ * write actually does for this key.
18
+ */ const draftSentence = (scope) => {
19
+ const live = liveWriteSlugs(scope);
20
+ return live.length === 0 ? "Every write lands as a draft and is never published; publishing stays a human action in the admin panel." : `Writes land as drafts and are never published, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately. Publishing anything else stays a human action in the admin panel.`;
21
+ };
22
+ /**
8
23
  * Widens one branch to the superset a handler sees. The widening itself is
9
24
  * unchecked — the runtime shape really does vary — so `Branch` checks what it
10
25
  * can around it.
@@ -99,4 +114,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
99
114
  return translateStatic(resolved, i18n) ?? fallback;
100
115
  };
101
116
  //#endregion
102
- export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
117
+ export { depthShape, draftSentence, idSchema, idShape, liveWriteSlugs, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
@@ -1,18 +1,16 @@
1
- import { jsonResult } from "../endpoint/result.mjs";
1
+ import { jsonResult } from "../result.mjs";
2
2
  import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
3
  import { requireIdFor, resolveTarget } from "./target.mjs";
4
4
  import { defineMcpxTool } from "../types.mjs";
5
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
- //#region src/tools/validate-document.ts
7
6
  const validateDocument = defineMcpxTool({
8
7
  name: "validateDocument",
9
8
  description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
10
9
 
11
- Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
12
- annotations: {
13
- readOnlyHint: true,
14
- openWorldHint: false
15
- },
10
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
11
+
12
+ Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
13
+ annotations: { openWorldHint: false },
16
14
  isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
17
15
  inputSchema: (scope) => ({
18
16
  ...targetShape(scope, "write", {
@@ -40,7 +38,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
40
38
  locale,
41
39
  privileged: true
42
40
  });
43
- const publishBlockers = await collectPublishBlockers(scope.req, {
41
+ const validation = await collectPublishBlockers(scope.req, {
44
42
  doc,
45
43
  entity: target
46
44
  });
@@ -48,7 +46,8 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
48
46
  ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
49
47
  status: doc["_status"],
50
48
  updatedAt: doc["updatedAt"],
51
- publishBlockers
49
+ publishBlockers: validation.blockers,
50
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
52
51
  });
53
52
  }
54
53
  });
package/dist/types.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
2
  import { z } from "zod";
3
- import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
4
3
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
+ import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
5
5
  //#region src/types.d.ts
6
6
  declare module "payload" {
7
7
  interface RequestContext {
@@ -103,7 +103,11 @@ interface McpxToolScope {
103
103
  interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
104
104
  /** camelCase, unique, not one of the builtin tool names. */
105
105
  name: string;
106
- description: string;
106
+ /**
107
+ * Fixed text, or text built per request so it can state what this key's
108
+ * writes actually do.
109
+ */
110
+ description: string | ((scope: McpxToolScope) => string);
107
111
  annotations?: ToolAnnotations;
108
112
  /**
109
113
  * Whether this key may call the tool; a tool that is not enabled never
@@ -223,5 +227,15 @@ interface McpxRequestContext {
223
227
  apiKeyId: number | string;
224
228
  capabilities: McpxResolvedCapabilities;
225
229
  }
230
+ /**
231
+ * One reason a human could not publish the draft as it stands.
232
+ */
233
+ interface PublishBlocker {
234
+ /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
235
+ field?: string;
236
+ message: string;
237
+ /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
238
+ path: string;
239
+ }
226
240
  //#endregion
227
- export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool };
241
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool };
@@ -1,3 +1,4 @@
1
+ import { isMcpxRequest } from "../request.mjs";
1
2
  import { APIError } from "payload";
2
3
  import { hasDraftsEnabled } from "payload/shared";
3
4
  //#region src/write/draft-guard.ts
@@ -14,11 +15,6 @@ import { hasDraftsEnabled } from "payload/shared";
14
15
  "overwriteExistingFiles"
15
16
  ]);
16
17
  /**
17
- * Whether a request originated from the MCP endpoint. The endpoint stamps
18
- * `req.context.mcpx`, which travels into every local API call made with the
19
- * same `req`, including those made by custom tools.
20
- */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
21
- /**
22
18
  * Forces every MCP write into a draft save.
23
19
  *
24
20
  * `draft` alone is not enough: Payload's update path only saves a draft when
@@ -110,4 +106,4 @@ const refusePublish = ({ collection, data, req }) => {
110
106
  }
111
107
  }));
112
108
  //#endregion
113
- export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
109
+ export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
@@ -1,24 +1,45 @@
1
- import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
2
- import { validateWriteValue } from "../schema/shape.mjs";
1
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
3
2
  import { resolveDataPointer } from "../schema/pointer.mjs";
3
+ import { validateWriteValue } from "../schema/shape.mjs";
4
+ import "../schema/index.mjs";
4
5
  import { z } from "zod";
5
6
  import { Pointer, applyPatch } from "rfc6902";
6
7
  //#region src/write/patch.ts
8
+ const POINTER = z.string().regex(JSON_POINTER_PATTERN);
7
9
  /**
8
- * One RFC 6902 operation as accepted by `patchDocument`.
9
- */ const PATCH_OPERATION_SCHEMA = z.object({
10
- from: z.string().regex(JSON_POINTER_PATTERN).optional(),
11
- op: z.enum([
12
- "add",
13
- "copy",
14
- "move",
15
- "remove",
16
- "replace",
17
- "test"
18
- ]),
19
- path: z.string().regex(JSON_POINTER_PATTERN),
20
- value: z.unknown().optional()
21
- }).describe("An RFC 6902 operation.");
10
+ * One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
11
+ * so an operation carries only the members RFC 6902 defines for it.
12
+ */ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
13
+ z.strictObject({
14
+ op: z.literal("add"),
15
+ path: POINTER,
16
+ value: z.unknown()
17
+ }),
18
+ z.strictObject({
19
+ op: z.literal("remove"),
20
+ path: POINTER
21
+ }),
22
+ z.strictObject({
23
+ op: z.literal("replace"),
24
+ path: POINTER,
25
+ value: z.unknown()
26
+ }),
27
+ z.strictObject({
28
+ from: POINTER,
29
+ op: z.literal("move"),
30
+ path: POINTER
31
+ }),
32
+ z.strictObject({
33
+ from: POINTER,
34
+ op: z.literal("copy"),
35
+ path: POINTER
36
+ }),
37
+ z.strictObject({
38
+ op: z.literal("test"),
39
+ path: POINTER,
40
+ value: z.unknown()
41
+ })
42
+ ]).describe("An RFC 6902 operation.");
22
43
  /**
23
44
  * Whether a pointer touches a field Payload maintains.
24
45
  */ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
@@ -87,59 +108,112 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
87
108
  return next;
88
109
  };
89
110
  /**
90
- * Applies a patch to a deep copy of the document, so a failing operation
91
- * leaves the original untouched and nothing partial is ever written.
92
- */ const applyPatchToCopy = (doc, patches) => {
93
- const next = structuredClone(doc);
94
- const prepared = patches.map((operation) => {
95
- const cloned = "value" in operation ? {
96
- ...operation,
97
- value: structuredClone(operation.value)
98
- } : operation;
99
- if (cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(next) === void 0) return {
100
- ...cloned,
101
- op: "add"
102
- };
103
- return cloned;
104
- });
105
- const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
106
- if (problems.length > 0) return { problems };
107
- reconcileRowIds(next, doc);
108
- return { next };
111
+ * The operation as it is applied: values are cloned so the written document
112
+ * never shares references with the caller's operations, and a `replace` of a
113
+ * field the target locale has no value for becomes an `add`, which is what
114
+ * RFC 6902 requires when nothing is there to replace.
115
+ */ const prepare = (operation, doc) => {
116
+ const cloned = "value" in operation ? {
117
+ ...operation,
118
+ value: structuredClone(operation.value)
119
+ } : operation;
120
+ return cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(doc) === void 0 ? {
121
+ ...cloned,
122
+ op: "add"
123
+ } : cloned;
109
124
  };
110
125
  /**
111
- * Checks every operation against the schema before any is applied.
112
- *
113
- * A partially applied batch is worse than a refused one, so this returns all
114
- * problems and the caller applies nothing unless the list is empty.
115
- */ const findPatchProblems = (config, target) => target.patches.flatMap((operation, index) => {
116
- const at = `patches[${String(index)}]`;
117
- const pointers = [operation.path, ..."from" in operation && operation.from ? [operation.from] : []];
118
- if (pointers.includes("")) return [`${at}: an empty pointer addresses the whole document. Address a field instead.`];
126
+ * The value an operation writes at its path: the one it carries, or the one it
127
+ * takes from `from`. A `remove` writes nothing.
128
+ */ const effectiveValue = (operation, doc) => {
129
+ if ("value" in operation) return operation.value;
130
+ return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
131
+ };
132
+ /**
133
+ * Whether a resolved pointer lands in a read-only field. A pointer that stops
134
+ * short of one addresses a subtree, and the fields beneath it decide.
135
+ */ const resolvesReadOnly = (resolution) => {
136
+ if (resolution.descriptor) return resolution.descriptor.readOnly === true;
137
+ const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
138
+ return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
139
+ };
140
+ /**
141
+ * Whether the pointer addresses something read-only. An element carries no
142
+ * descriptor of its own, so the field it belongs to is read one segment up.
143
+ */ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
144
+ doc: target.doc,
145
+ pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
146
+ ref: target.ref
147
+ }));
148
+ /**
149
+ * Checks one operation against the schema, in the state the document is in
150
+ * when that operation runs. Both pointers must resolve, whatever the operation
151
+ * writes at its path must pass write validation, and what it drops must not sit
152
+ * in a read-only field.
153
+ */ const findOperationProblems = (config, target) => {
154
+ const { doc, operation, ref } = target;
155
+ const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
156
+ if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
119
157
  const reserved = pointers.find(isReservedPointer);
120
- if (reserved !== void 0) return [`${at}: "${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
158
+ if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
121
159
  const dropped = droppedPointer(operation);
122
- if (dropped !== void 0 && !isElementPointer(dropped)) return [`${at}: "${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
123
- const value = "value" in operation ? operation.value : void 0;
160
+ if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
124
161
  try {
125
- const moved = "from" in operation && operation.from ? Pointer.fromJSON(operation.from).get(target.doc) : void 0;
162
+ const value = effectiveValue(operation, doc);
163
+ if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
164
+ doc,
165
+ pointer: operation.path,
166
+ ref
167
+ })) return [`"${operation.path}" is read-only and cannot be written.`];
168
+ if (dropped !== void 0 && isReadOnlyPointer(config, {
169
+ doc,
170
+ pointer: dropped,
171
+ ref
172
+ })) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
126
173
  for (const pointer of pointers) {
127
174
  const resolution = resolveDataPointer(config, {
128
- addedValue: value ?? moved,
129
- doc: target.doc,
175
+ addedValue: value,
176
+ doc,
130
177
  pointer,
131
- ref: target.ref
178
+ ref
132
179
  });
133
- if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
134
- pointer,
135
- resolution
136
- }, value).map((problem) => `${at}: ${problem}`);
180
+ if (pointer === operation.path && value !== void 0) {
181
+ const problems = validateWriteValue(config, {
182
+ pointer,
183
+ resolution
184
+ }, value);
185
+ if (problems.length > 0) return problems;
186
+ }
137
187
  }
138
188
  return [];
139
189
  } catch (error) {
140
- return [`${at}: ${error instanceof Error ? error.message : "invalid"}`];
190
+ return [error instanceof Error ? error.message : "invalid"];
191
+ }
192
+ };
193
+ /**
194
+ * Validates and applies every operation against one evolving copy of the
195
+ * document, so an operation that depends on an earlier one resolves against
196
+ * the shape it actually modifies.
197
+ *
198
+ * The copy means a failing operation leaves the original untouched, and the
199
+ * caller writes nothing unless the whole batch came back applied, so a
200
+ * partially applied batch is never persisted.
201
+ */ const applyPatchOperations = (config, target) => {
202
+ const next = structuredClone(target.doc);
203
+ for (const [index, operation] of target.patches.entries()) {
204
+ const at = `patches[${String(index)}]`;
205
+ const problems = findOperationProblems(config, {
206
+ doc: next,
207
+ operation,
208
+ ref: target.ref
209
+ });
210
+ if (problems.length > 0) return { problems: problems.map((problem) => `${at}: ${problem}`) };
211
+ const [error] = applyPatch(next, [prepare(operation, next)]);
212
+ if (error) return { problems: [`${at}: ${error.message}`] };
141
213
  }
142
- });
214
+ reconcileRowIds(next, target.doc);
215
+ return { next };
216
+ };
143
217
  /**
144
218
  * Keys Payload manages on a row that travel back into the write unchanged.
145
219
  */ const ROW_KEYS = /* @__PURE__ */ new Set([
@@ -216,4 +290,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
216
290
  });
217
291
  };
218
292
  //#endregion
219
- export { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, droppedPointer, findPatchProblems, isElementPointer, isReservedPointer, stripRowIds };
293
+ export { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, droppedPointer, isElementPointer, isReservedPointer, stripRowIds };
@@ -1,4 +1,5 @@
1
1
  import { pointerFromPayloadPath } from "../schema/walk.mjs";
2
+ import "../schema/index.mjs";
2
3
  import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
3
4
  //#region src/write/publish-blockers.ts
4
5
  /**
@@ -18,6 +19,9 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
18
19
  *
19
20
  * Limits: only the locale the doc was read in is checked, and field-level
20
21
  * `beforeChange` hooks run again, which is safe only for pure ones.
22
+ *
23
+ * `unavailable` marks a traversal that threw, which is not the same answer as
24
+ * a document with nothing wrong with it.
21
25
  */ const collectPublishBlockers = async (req, target) => {
22
26
  const { doc, entity } = target;
23
27
  const id = doc["id"];
@@ -62,13 +66,16 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
62
66
  });
63
67
  } catch (error) {
64
68
  req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
65
- return [];
69
+ return {
70
+ blockers: [],
71
+ unavailable: true
72
+ };
66
73
  }
67
- return errors.map((error) => ({
74
+ return { blockers: errors.map((error) => ({
68
75
  message: error.message,
69
76
  path: pointerFromPayloadPath(error.path),
70
77
  ...typeof error.label === "string" ? { field: error.label } : {}
71
- }));
78
+ })) };
72
79
  };
73
80
  //#endregion
74
81
  export { collectPublishBlockers };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.11",
4
+ "version": "1.0.0-beta.12",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",
package/dist/i18n.d.mts DELETED
@@ -1 +0,0 @@
1
- import { PayloadRequest } from "payload";
@@ -1 +0,0 @@
1
- import "payload";
@@ -1,3 +0,0 @@
1
- import "./lexical.mjs";
2
- import "../i18n.mjs";
3
- import "payload";
@@ -1,3 +0,0 @@
1
- import "../types.mjs";
2
- import "../schema/walk.mjs";
3
- import "payload";
@@ -1,15 +0,0 @@
1
- import "../tools/target.mjs";
2
- import { PayloadRequest } from "payload";
3
- //#region src/write/publish-blockers.d.ts
4
- /**
5
- * One reason a human could not publish the draft as it stands.
6
- */
7
- interface PublishBlocker {
8
- /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
9
- field?: string;
10
- message: string;
11
- /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
12
- path: string;
13
- }
14
- //#endregion
15
- export type { PublishBlocker };