@abinnovision/payloadcms-mcpx 2.0.0-beta.16 → 2.0.0-beta.18

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
@@ -116,19 +116,20 @@ every check.
116
116
 
117
117
  ## Tools
118
118
 
119
- | Tool | What it does | Needs |
120
- | ------------------ | ------------------------------------------------------------------ | --------- |
121
- | `listCapabilities` | Lists what this key may do. Clients call it first. | any key |
122
- | `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
123
- | `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
124
- | `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
125
- | `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
126
- | `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write` |
127
- | `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write` |
128
- | `validateDocument` | Lists what blocks publishing, without saving. | `write` |
129
- | `publishDocument` | Publishes the current draft. | `publish` |
130
- | `deleteDocument` | Asks to delete a document, which the key's user approves. | `delete` |
131
- | `runConfirmed` | Runs the approved calls. | `delete` |
119
+ | Tool | What it does | Needs |
120
+ | ------------------- | ------------------------------------------------------------------ | --------- |
121
+ | `listCapabilities` | Lists what this key may do. Clients call it first. | any key |
122
+ | `describeSchema` | Describes the fields at one schema path, with the paths below it. | `read` |
123
+ | `findDocuments` | Queries a collection with a Payload `where`, `sort` and `select`. | `read` |
124
+ | `getDocument` | Reads a document, a subtree of it, an old version or a diff. | `read` |
125
+ | `findVersions` | Lists the version history of a document or global, without bodies. | `read` |
126
+ | `patchDocument` | Applies JSON Patch operations to the current draft, or a new file. | `write` |
127
+ | `createDocument` | Creates a draft from a seed, with a file in an upload collection. | `write` |
128
+ | `duplicateDocument` | Copies a document into a new draft, in every locale. | `write` |
129
+ | `validateDocument` | Lists what blocks publishing, without saving. | `write` |
130
+ | `publishDocument` | Publishes the current draft. | `publish` |
131
+ | `deleteDocument` | Asks to delete a document, which the key's user approves. | `delete` |
132
+ | `runConfirmed` | Runs the approved calls. | `delete` |
132
133
 
133
134
  Collections and globals use the same tools. A tool that addresses one document takes either
134
135
  `collection` and `id`, or `global` alone. [`docs/concepts.md`](./docs/concepts.md#tools) lists
@@ -9,6 +9,7 @@
9
9
  "findVersions",
10
10
  "patchDocument",
11
11
  "createDocument",
12
+ "duplicateDocument",
12
13
  "validateDocument",
13
14
  "publishDocument",
14
15
  "deleteDocument",
@@ -33,7 +33,11 @@ const NO_CONFIRMATION = {
33
33
  }
34
34
  };
35
35
  const WRITE_STEP = {
36
- tools: ["patchDocument", "createDocument"],
36
+ tools: [
37
+ "patchDocument",
38
+ "createDocument",
39
+ "duplicateDocument"
40
+ ],
37
41
  does: "the write"
38
42
  };
39
43
  const WORKFLOW = [
@@ -1,6 +1,7 @@
1
1
  import { createDocument } from "./create-document.mjs";
2
2
  import { deleteDocument } from "./delete-document.mjs";
3
3
  import { describeSchema } from "./describe-schema.mjs";
4
+ import { duplicateDocument } from "./duplicate-document.mjs";
4
5
  import { findDocuments } from "./find-documents.mjs";
5
6
  import { findVersions } from "./find-versions.mjs";
6
7
  import { getDocument } from "./get-document.mjs";
@@ -23,6 +24,7 @@ import { validateDocument } from "./validate-document.mjs";
23
24
  findVersions,
24
25
  patchDocument,
25
26
  createDocument,
27
+ duplicateDocument,
26
28
  validateDocument,
27
29
  publishDocument,
28
30
  deleteDocument,
@@ -19,6 +19,36 @@ const DESCRIPTION = (scope) => `Creates a document in a collection. Use it only
19
19
 
20
20
  ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
21
21
  /**
22
+ * What createDocument and duplicateDocument return: the new document, re-read
23
+ * privileged in `locale`, with its links and publish blockers.
24
+ */ const createdResult = async (scope, args) => {
25
+ const { target, locale } = args;
26
+ const saved = await readDraft(scope, {
27
+ target: {
28
+ ...target,
29
+ id: args.id
30
+ },
31
+ locale,
32
+ privileged: true
33
+ });
34
+ const validation = await collectPublishBlockers(scope.req, {
35
+ doc: saved,
36
+ entity: target
37
+ });
38
+ return jsonResult({
39
+ id: saved["id"],
40
+ status: saved["_status"],
41
+ updatedAt: saved["updatedAt"],
42
+ ...await documentLinks(scope.req, {
43
+ target,
44
+ doc: saved,
45
+ locale
46
+ }),
47
+ ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
48
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
49
+ });
50
+ };
51
+ /**
22
52
  * Collection-only, because a global always exists. A create in an upload
23
53
  * collection needs `file`: the call then returns an upload grant, and the PUT
24
54
  * to it runs the call again with the bytes.
@@ -89,31 +119,12 @@ ${liveWriteSentence(scope, "create")}${uploadSentence(scope)}`;
89
119
  ...locale === void 0 ? {} : { locale },
90
120
  ...bytes === void 0 ? {} : { file: bytes }
91
121
  });
92
- const saved = await readDraft(scope, {
93
- target: {
94
- ...target,
95
- id: created["id"]
96
- },
97
- locale,
98
- privileged: true
99
- });
100
- const validation = await collectPublishBlockers(scope.req, {
101
- doc: saved,
102
- entity: target
103
- });
104
- return jsonResult({
105
- id: saved["id"],
106
- status: saved["_status"],
107
- updatedAt: saved["updatedAt"],
108
- ...await documentLinks(scope.req, {
109
- target,
110
- doc: saved,
111
- locale
112
- }),
113
- ...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
114
- ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
122
+ return await createdResult(scope, {
123
+ target,
124
+ id: created["id"],
125
+ locale
115
126
  });
116
127
  }
117
128
  });
118
129
  //#endregion
119
- export { createDocument };
130
+ export { createDocument, createdResult };
@@ -0,0 +1,49 @@
1
+ import { defineMcpxTool } from "../define-tool.mjs";
2
+ import { idSchema, liveWriteSentence, localeOf, slugEnum, slugsFor } from "./shared.mjs";
3
+ import { resolveEntity } from "./document.mjs";
4
+ import { createdResult } from "./create-document.mjs";
5
+ import { withDuplicateIntent } from "../write/publish-intent.mjs";
6
+ import { Forbidden } from "payload";
7
+ //#region src/tools/duplicate-document.ts
8
+ const duplicableSlugs = (scope) => slugsFor(scope, "create").collections.filter((slug) => scope.req.payload.config.collections.find((collection) => collection.slug === slug)?.disableDuplicate !== true && !scope.exposure.collections.some((entity) => entity.slug === slug && entity.isUpload));
9
+ const DESCRIPTION = (scope) => `Copies a document into a new one in the same collection. Use it instead of getDocument and createDocument to copy a document. It copies the latest version, the draft if there is one, in every locale. Unique text fields get " - Copy" appended. Returns the same fields as createDocument. To rename the copy, call patchDocument with the returned "updatedAt".
10
+
11
+ ${liveWriteSentence(scope, "create")}`;
12
+ /**
13
+ * Wraps Payload's duplicate, which checks read access on the source and create
14
+ * access on the copy, and runs the fields' `beforeDuplicate` hooks.
15
+ */ const duplicateDocument = defineMcpxTool({
16
+ name: "duplicateDocument",
17
+ description: DESCRIPTION,
18
+ annotations: {
19
+ readOnlyHint: false,
20
+ destructiveHint: false,
21
+ idempotentHint: false,
22
+ openWorldHint: false
23
+ },
24
+ isEnabled: (scope) => duplicableSlugs(scope).length > 0,
25
+ inputSchema: (scope) => ({
26
+ collection: slugEnum(duplicableSlugs(scope)),
27
+ id: idSchema
28
+ }),
29
+ handler: async ({ args, scope }) => {
30
+ const target = resolveEntity(scope, { collection: args.collection }, "create");
31
+ if (target.kind === "global" || !duplicableSlugs(scope).includes(target.slug)) throw new Forbidden(scope.req.t);
32
+ const created = await scope.req.payload.duplicate({
33
+ collection: target.slug,
34
+ id: args.id,
35
+ data: withDuplicateIntent({}),
36
+ depth: 0,
37
+ draft: true,
38
+ overrideAccess: false,
39
+ req: scope.req
40
+ });
41
+ return await createdResult(scope, {
42
+ target,
43
+ id: created["id"],
44
+ locale: localeOf(scope, void 0)
45
+ });
46
+ }
47
+ });
48
+ //#endregion
49
+ export { duplicateDocument };
package/dist/types.d.mts CHANGED
@@ -34,8 +34,9 @@ export interface McpxCollectionOptions {
34
34
  */
35
35
  read?: boolean;
36
36
  /**
37
- * Expose `patchDocument`, `validateDocument` and `createDocument`. An upload
38
- * collection is created in only where MCP accepts its files. Without drafts
37
+ * Expose `patchDocument`, `validateDocument`, `createDocument` and
38
+ * `duplicateDocument`. An upload collection is created in only where MCP
39
+ * accepts its files, and is never duplicated. Without drafts
39
40
  * a write changes live content. Needs `read`. Default `true` while `read` is.
40
41
  */
41
42
  write?: boolean;
@@ -1,4 +1,4 @@
1
- import { hasPublishIntent, hasTrashIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
1
+ import { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withTrashIntent } from "./publish-intent.mjs";
2
2
  import { isMcpxRequest } from "../request.mjs";
3
3
  import { APIError } from "payload";
4
4
  import { hasDraftsEnabled } from "payload/shared";
@@ -30,6 +30,11 @@ const scrubWriteArgs = (args, intent) => {
30
30
  const forceDraftWrite = (hookArgs) => {
31
31
  const { args, operation, req } = hookArgs;
32
32
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
33
+ if (operation === "create" && takeDuplicateIntent(args.data)) {
34
+ const next = scrubWriteArgs(args, void 0);
35
+ next["duplicateFromID"] = args.duplicateFromID;
36
+ return next;
37
+ }
33
38
  const intent = operation !== "update" ? void 0 : hasPublishIntent(args.data) ? "publish" : hasTrashIntent(args.data) ? "trash" : void 0;
34
39
  return scrubWriteArgs(args, intent);
35
40
  };
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  //#region src/write/publish-intent.ts
4
4
  const PUBLISH_INTENT = "__mcpxPublishIntent";
5
5
  const TRASH_INTENT = "__mcpxTrashIntent";
6
+ const DUPLICATE_INTENT = "__mcpxDuplicateIntent";
6
7
  const TOKEN = randomUUID();
7
8
  const take = (data, key) => {
8
9
  if (!isPlainObject(data) || data[key] !== TOKEN) return false;
@@ -25,5 +26,12 @@ const hasTrashIntent = (data) => isPlainObject(data) && data[TRASH_INTENT] === T
25
26
  /**
26
27
  * The counterpart of {@link takePublishIntent}.
27
28
  */ const takeTrashIntent = (data) => take(data, TRASH_INTENT);
29
+ const withDuplicateIntent = (data) => ({
30
+ ...data,
31
+ [DUPLICATE_INTENT]: TOKEN
32
+ });
33
+ /**
34
+ * Removes the marker, so it never reaches the operation.
35
+ */ const takeDuplicateIntent = (data) => take(data, DUPLICATE_INTENT);
28
36
  //#endregion
29
- export { hasPublishIntent, hasTrashIntent, takePublishIntent, takeTrashIntent, withPublishIntent, withTrashIntent };
37
+ export { hasPublishIntent, hasTrashIntent, takeDuplicateIntent, takePublishIntent, takeTrashIntent, withDuplicateIntent, withPublishIntent, withTrashIntent };
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.16",
4
+ "version": "2.0.0-beta.18",
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.1",
60
+ "@modelcontextprotocol/sdk": "^1.31.0",
61
61
  "rfc6902": "^5.3.0",
62
62
  "zod": "^4.6.5"
63
63
  },
@@ -69,20 +69,20 @@
69
69
  "@payloadcms/db-sqlite": "3.90.2",
70
70
  "@payloadcms/richtext-lexical": "3.90.2",
71
71
  "@payloadcms/ui": "3.90.2",
72
- "@swc/core": "^1.16.2",
73
- "@types/node": "^26.5.1",
72
+ "@swc/core": "^1.16.12",
73
+ "@types/node": "^26.6.3",
74
74
  "@types/react": "^19.3.0",
75
- "@vitest/coverage-v8": "^5.0.0",
76
- "eslint": "^10.10.0",
75
+ "@vitest/coverage-v8": "^5.0.2",
76
+ "eslint": "^10.11.0",
77
77
  "payload": "3.90.2",
78
- "prettier": "^3.9.5",
78
+ "prettier": "^3.9.9",
79
79
  "publint": "^0.3.24",
80
80
  "react": "^19.3.0",
81
81
  "react-dom": "^19.3.0",
82
82
  "tsdown": "^0.23.0",
83
83
  "typescript": "^6.0.3",
84
- "unplugin-swc": "^1.6.0",
85
- "vitest": "^5.0.0"
84
+ "unplugin-swc": "^2.0.0",
85
+ "vitest": "^5.0.2"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@payloadcms/ui": ">=3.88.0 <4",