@abinnovision/payloadcms-mcpx 2.0.0-beta.8 → 2.0.0

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +17 -7
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +6 -4
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +12 -10
  15. package/dist/api-keys/capability-matrix.mjs +17 -37
  16. package/dist/api-keys/collection.mjs +18 -19
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +67 -53
  19. package/dist/auth/resolve.mjs +79 -30
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +69 -15
  22. package/dist/define-tool.d.mts +10 -6
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +52 -27
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/grants/confirmation.mjs +75 -0
  30. package/dist/grants/grant.mjs +137 -0
  31. package/dist/guards.mjs +11 -2
  32. package/dist/i18n.mjs +3 -1
  33. package/dist/index.d.mts +2 -2
  34. package/dist/index.mjs +1 -1
  35. package/dist/options.mjs +73 -62
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +21 -15
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +13 -4
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +39 -51
  49. package/dist/schema/walk.mjs +68 -21
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +147 -21
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +60 -54
  62. package/dist/tools/publish-document.mjs +23 -30
  63. package/dist/tools/query-paths.mjs +4 -2
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +66 -50
  66. package/dist/tools/validate-document.mjs +10 -11
  67. package/dist/tools/versions.mjs +24 -10
  68. package/dist/types.d.mts +228 -60
  69. package/dist/upload/file.mjs +177 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +46 -37
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +25 -11
  74. package/dist/write/row-ids.mjs +6 -2
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +15 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
@@ -1,27 +1,28 @@
1
- import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
1
+ import { propOf } from "../guards.mjs";
2
2
  import { isMcpxRequest } from "../request.mjs";
3
+ import { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
3
4
  import { APIError } from "payload";
4
5
  import { hasDraftsEnabled } from "payload/shared";
5
6
  //#region src/write/draft-guard.ts
6
7
  const STRIPPED_ARGS = /* @__PURE__ */ new Set([
7
8
  "where",
8
9
  "publishAllLocales",
9
- "publishSpecificLocale",
10
10
  "unpublishAllLocales",
11
11
  "duplicateFromID",
12
12
  "selectedLocales",
13
13
  "overwriteExistingFiles"
14
14
  ]);
15
- const scrubWriteArgs = (args, publishing) => {
16
- const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
15
+ const scrubWriteArgs = (args, intent) => {
16
+ const publishing = intent === "publish";
17
+ const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
17
18
  if (next["data"] && typeof next["data"] === "object") {
18
- const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
19
+ const { _status: _ignoredStatus, deletedAt, ...data } = next["data"];
19
20
  next["data"] = publishing ? {
20
21
  ...data,
21
22
  _status: "published"
22
- } : data;
23
+ } : intent === "trash" ? withTrashIntent({ deletedAt }) : data;
23
24
  }
24
- next["draft"] = !publishing;
25
+ next["draft"] = intent === void 0;
25
26
  next["autosave"] = false;
26
27
  next["overrideLock"] = false;
27
28
  next["trash"] = false;
@@ -30,26 +31,35 @@ const scrubWriteArgs = (args, publishing) => {
30
31
  const forceDraftWrite = (hookArgs) => {
31
32
  const { args, operation, req } = hookArgs;
32
33
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
33
- const publishing = operation === "update" && hasPublishIntent(args.data);
34
- return scrubWriteArgs(args, publishing);
34
+ if (operation === "create" && takeDuplicateIntent(args.data)) {
35
+ const next = scrubWriteArgs(args, void 0);
36
+ next["duplicateFromID"] = args.duplicateFromID;
37
+ return next;
38
+ }
39
+ const intent = operation !== "update" ? void 0 : hasPublishIntent(args.data) ? "publish" : hasTrashIntent(args.data) ? "trash" : void 0;
40
+ return scrubWriteArgs(args, intent);
35
41
  };
36
42
  /**
37
- * The global counterpart of {@link forceDraftWrite}. `updateGlobal` destructures
38
- * `draft` and the publish arguments before it runs `beforeOperation` and
39
- * re-reads only `data` afterwards, so setting them here has no effect. Only
40
- * `data` with `_status` stripped lands, so {@link refusePublishGlobal} is what
41
- * enforces the guard. `publishDocument` therefore passes `draft: false` at the
42
- * call site, and this hook restores `_status` instead of stripping it.
43
+ * The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
44
+ * `draft` and the publish arguments before this hook runs, so only changes to
45
+ * `data` take effect. A write to a global with drafts that did not ask for a
46
+ * draft save is therefore refused here, and `_status` is restored for a publish.
43
47
  */ const forceDraftWriteGlobal = (hookArgs) => {
44
- const { operation, req } = hookArgs;
48
+ const { global, operation, req } = hookArgs;
45
49
  const args = hookArgs.args;
46
50
  if (!isMcpxRequest(req) || operation !== "update") return args;
47
- return scrubWriteArgs(args, hasPublishIntent(args["data"]));
51
+ const publishing = hasPublishIntent(args["data"]);
52
+ if (!publishing && args["draft"] !== true && hasDraftsEnabled(global)) {
53
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
54
+ throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
55
+ }
56
+ return scrubWriteArgs(args, publishing ? "publish" : void 0);
48
57
  };
49
58
  const refuseUnlessExpected = (req, slug, data) => {
50
59
  const publishing = takePublishIntent(data);
51
- if (!isMcpxRequest(req)) return;
52
- const status = data._status;
60
+ const trashing = takeTrashIntent(data);
61
+ if (!isMcpxRequest(req) || trashing) return;
62
+ const status = propOf(data, "_status");
53
63
  const expected = publishing ? "published" : "draft";
54
64
  if (status === expected) return;
55
65
  req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
@@ -67,30 +77,29 @@ const refusePublishGlobal = ({ data, global, req }) => {
67
77
  refuseUnlessExpected(req, global.slug, next);
68
78
  return next;
69
79
  };
70
- /**
71
- * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
72
- * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
73
- * exist. It runs on the final collection list, so no collection escapes it, and
74
- * both hooks are appended last, so a user hook cannot override them.
75
- */ const installDraftGuards = (collections) => collections.map((collection) => ({
76
- ...collection,
80
+ const appendGuards = (entities, guards) => entities.map((entity) => ({
81
+ ...entity,
77
82
  hooks: {
78
- ...collection.hooks,
79
- beforeOperation: [...collection.hooks?.beforeOperation ?? [], forceDraftWrite],
80
- ...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
83
+ ...entity.hooks,
84
+ beforeOperation: [...entity.hooks?.beforeOperation ?? [], guards.beforeOperation],
85
+ ...hasDraftsEnabled(entity) ? { beforeChange: [...entity.hooks?.beforeChange ?? [], guards.beforeChange] } : {}
81
86
  }
82
87
  }));
83
88
  /**
89
+ * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
90
+ * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
91
+ * exist. It runs on the final collection list, so no collection escapes it.
92
+ */ const installDraftGuards = (collections) => appendGuards(collections, {
93
+ beforeChange: refusePublish,
94
+ beforeOperation: forceDraftWrite
95
+ });
96
+ /**
84
97
  * Attaches the guard to every global, exposed or not: a custom tool running on
85
98
  * an MCP request must not be able to publish through a global the plugin config
86
99
  * never mentioned.
87
- */ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
88
- ...global,
89
- hooks: {
90
- ...global.hooks,
91
- beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
92
- ...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
93
- }
94
- }));
100
+ */ const installGlobalDraftGuards = (globals) => appendGuards(globals, {
101
+ beforeChange: refusePublishGlobal,
102
+ beforeOperation: forceDraftWriteGlobal
103
+ });
95
104
  //#endregion
96
105
  export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
@@ -1,6 +1,6 @@
1
- import { isIndexSegment, joinPath, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
2
- import { RESERVED_FIELD_NAMES, descriptorsUnder } from "../schema/walk.mjs";
3
1
  import { SchemaError } from "../schema/errors.mjs";
2
+ import { isIndexSegment, parentPointer, prototypeSegmentProblem, splitPath } from "../schema/path.mjs";
3
+ import { RESERVED_FIELD_NAMES, descriptorsUnder } from "../schema/walk.mjs";
4
4
  import { resolveDataPointer } from "../schema/pointer.mjs";
5
5
  import { EMPTY_ROOT, validateWriteValue } from "../schema/shape.mjs";
6
6
  import "../schema/index.mjs";
@@ -37,7 +37,7 @@ const resolvesReadOnly = (resolution) => {
37
37
  };
38
38
  const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
39
39
  doc: target.doc,
40
- pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
40
+ pointer: isElementPointer(target.pointer) ? parentPointer(target.pointer) : target.pointer,
41
41
  ref: target.ref
42
42
  }));
43
43
  const resolutionAt = (config, target) => {
@@ -60,7 +60,7 @@ const droppedFieldProblem = (config, target) => {
60
60
  return `"${target.pointer}" 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.`;
61
61
  };
62
62
  const emptiesTheRoot = (config, target) => {
63
- const list = joinPath(splitPath(target.pointer).slice(0, -1));
63
+ const list = parentPointer(target.pointer);
64
64
  const owner = resolutionAt(config, {
65
65
  ...target,
66
66
  pointer: list
@@ -1,17 +1,31 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  //#region src/write/publish-intent.ts
4
- const PUBLISH_INTENT = "__mcpxPublishIntent";
5
4
  const TOKEN = randomUUID();
6
- const withPublishIntent = (data) => ({
7
- ...data,
8
- [PUBLISH_INTENT]: TOKEN
5
+ const intent = (key) => ({
6
+ has: (data) => isPlainObject(data) && data[key] === TOKEN,
7
+ take: (data) => {
8
+ if (!isPlainObject(data) || data[key] !== TOKEN) return false;
9
+ delete data[key];
10
+ return true;
11
+ },
12
+ with: (data) => ({
13
+ ...data,
14
+ [key]: TOKEN
15
+ })
9
16
  });
10
- const hasPublishIntent = (data) => isPlainObject(data) && data[PUBLISH_INTENT] === TOKEN;
11
- /** Called by the last hook that needs the marker, which removes it. */ const takePublishIntent = (data) => {
12
- if (!hasPublishIntent(data)) return false;
13
- delete data[PUBLISH_INTENT];
14
- return true;
15
- };
17
+ const publish = intent("__mcpxPublishIntent");
18
+ const trash = intent("__mcpxTrashIntent");
19
+ const duplicate = intent("__mcpxDuplicateIntent");
20
+ const withPublishIntent = publish.with;
21
+ const hasPublishIntent = publish.has;
22
+ /**
23
+ * Called by the last hook that needs the marker, which removes it.
24
+ */ const takePublishIntent = publish.take;
25
+ const withTrashIntent = trash.with;
26
+ const hasTrashIntent = trash.has;
27
+ const takeTrashIntent = trash.take;
28
+ const withDuplicateIntent = duplicate.with;
29
+ const takeDuplicateIntent = duplicate.take;
16
30
  //#endregion
17
- export { hasPublishIntent, takePublishIntent, withPublishIntent };
31
+ export { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withDuplicateIntent, withPublishIntent, withTrashIntent };
@@ -1,6 +1,8 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
+ import { editorRoot } from "../schema/lexical.mjs";
3
+ import "../schema/index.mjs";
2
4
  //#region src/write/row-ids.ts
3
- const isRichTextState = (value) => isPlainObject(value["root"]) && Array.isArray(value["root"]["children"]);
5
+ const isRichTextState = (value) => Array.isArray(editorRoot(value)?.["children"]);
4
6
  const walkRows = (value, visit, isRow = false) => {
5
7
  if (Array.isArray(value)) {
6
8
  for (const entry of value) walkRows(entry, visit, true);
@@ -33,7 +35,9 @@ const walkRows = (value, visit, isRow = false) => {
33
35
  delete row["id"];
34
36
  });
35
37
  };
36
- /** For create, where no stored row exists and any incoming id is invented. */ const stripRowIds = (value) => {
38
+ /**
39
+ * For create, where no stored row exists and any incoming id is invented.
40
+ */ const stripRowIds = (value) => {
37
41
  const next = structuredClone(value);
38
42
  walkRows(next, (row) => {
39
43
  delete row["id"];
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
2
  import { splitPath } from "../schema/path.mjs";
3
- import { ROW_KEYS, blockOf, descriptorsUnder, findBlocksField } from "../schema/walk.mjs";
3
+ import { ROW_KEYS, blockForRow, classifyKey, descriptorsUnder, findFieldAt } from "../schema/walk.mjs";
4
4
  import "../schema/index.mjs";
5
5
  //#region src/write/write-data.ts
6
6
  const pickDescribed = (config, value, at) => {
@@ -10,43 +10,30 @@ const pickDescribed = (config, value, at) => {
10
10
  if (isRow) {
11
11
  for (const key of ROW_KEYS) if (Object.hasOwn(value, key)) result[key] = value[key];
12
12
  }
13
+ const pick = (row, at) => isPlainObject(row) ? pickDescribed(config, row, at) : row;
13
14
  for (const [key, entry] of Object.entries(value)) {
14
- const candidates = relative.filter(({ parts }) => parts[0] === key);
15
- if (candidates.length === 0) continue;
16
- const exact = candidates.find(({ parts }) => parts.length === 1);
17
- if (exact?.descriptor.type === "blocks" && Array.isArray(entry)) {
18
- const field = findBlocksField(fields, splitPath(exact.descriptor.path));
19
- result[key] = entry.map((row) => {
20
- const block = field && isPlainObject(row) && typeof row["blockType"] === "string" ? blockOf(config, field, row["blockType"]) : void 0;
21
- return block && isPlainObject(row) ? pickDescribed(config, row, {
15
+ const found = classifyKey(relative, prefix, key);
16
+ if (found.kind === "none") continue;
17
+ if (found.kind === "leaf") {
18
+ const field = found.descriptor.type === "blocks" ? findFieldAt(fields, splitPath(found.descriptor.path), "blocks") : void 0;
19
+ result[key] = field && Array.isArray(entry) ? entry.map((row) => {
20
+ const block = blockForRow(config, field, row);
21
+ return block ? pick(row, {
22
22
  fields: block.flattenedFields,
23
23
  prefix: [],
24
24
  isRow: true
25
25
  }) : row;
26
- });
27
- continue;
28
- }
29
- if (exact) {
30
- result[key] = entry;
31
- continue;
32
- }
33
- if (candidates.some(({ parts }) => parts[1] === "*")) {
34
- result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
35
- fields,
36
- prefix: [
37
- ...prefix,
38
- key,
39
- "*"
40
- ],
41
- isRow: true
42
- }) : row) : entry;
43
- continue;
44
- }
45
- result[key] = isPlainObject(entry) ? pickDescribed(config, entry, {
26
+ }) : entry;
27
+ } else if (found.kind === "rows") result[key] = Array.isArray(entry) ? entry.map((row) => pick(row, {
46
28
  fields,
47
- prefix: [...prefix, key],
29
+ prefix: found.prefix,
30
+ isRow: true
31
+ })) : entry;
32
+ else result[key] = pick(entry, {
33
+ fields,
34
+ prefix: found.prefix,
48
35
  isRow: false
49
- }) : entry;
36
+ });
50
37
  }
51
38
  return result;
52
39
  };
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": "2.0.0-beta.8",
4
+ "version": "2.0.0",
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",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "prettier": "@abinnovision/prettier-config",
59
59
  "dependencies": {
60
- "@modelcontextprotocol/sdk": "^1.30.0",
60
+ "@modelcontextprotocol/sdk": "^1.32.0",
61
61
  "rfc6902": "^5.3.0",
62
62
  "zod": "^4.6.5"
63
63
  },
@@ -65,23 +65,24 @@
65
65
  "@abinnovision/eslint-config-base": "^3.4.2",
66
66
  "@abinnovision/prettier-config": "^2.2.0",
67
67
  "@arethetypeswrong/core": "^0.18.5",
68
- "@payloadcms/db-sqlite": "3.89.0",
69
- "@payloadcms/richtext-lexical": "3.89.0",
70
- "@payloadcms/ui": "3.89.0",
71
- "@swc/core": "^1.16.2",
72
- "@types/node": "^26.5.1",
68
+ "@internal/test-utils": "0.0.0",
69
+ "@payloadcms/db-sqlite": "3.90.2",
70
+ "@payloadcms/richtext-lexical": "3.90.2",
71
+ "@payloadcms/ui": "3.90.2",
72
+ "@swc/core": "^1.16.13",
73
+ "@types/node": "^26.6.3",
73
74
  "@types/react": "^19.3.0",
74
- "@vitest/coverage-v8": "^5.0.0",
75
- "eslint": "^10.10.0",
76
- "payload": "3.89.0",
77
- "prettier": "^3.9.5",
78
- "publint": "^0.3.24",
75
+ "@vitest/coverage-v8": "^5.0.3",
76
+ "eslint": "^10.12.0",
77
+ "payload": "3.90.2",
78
+ "prettier": "^3.9.9",
79
+ "publint": "^0.3.25",
79
80
  "react": "^19.3.0",
80
81
  "react-dom": "^19.3.0",
81
82
  "tsdown": "^0.23.0",
82
83
  "typescript": "^6.0.3",
83
- "unplugin-swc": "^1.6.0",
84
- "vitest": "^5.0.0"
84
+ "unplugin-swc": "^2.0.0",
85
+ "vitest": "^5.0.3"
85
86
  },
86
87
  "peerDependencies": {
87
88
  "@payloadcms/ui": ">=3.88.0 <4",
@@ -1,16 +0,0 @@
1
- //#region src/builtin-tool-names.ts
2
- /**
3
- * Names of the builtin tools. Custom tools must not reuse them.
4
- */ const BUILTIN_TOOL_NAMES = [
5
- "listCapabilities",
6
- "describeSchema",
7
- "findDocuments",
8
- "getDocument",
9
- "findVersions",
10
- "patchDocument",
11
- "createDocument",
12
- "validateDocument",
13
- "publishDocument"
14
- ];
15
- //#endregion
16
- export { BUILTIN_TOOL_NAMES };