@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.10

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 (71) hide show
  1. package/README.md +140 -470
  2. package/dist/admin/capability-matrix.d.mts +33 -0
  3. package/dist/admin/capability-matrix.mjs +354 -0
  4. package/dist/admin/capability-toggles.mjs +73 -0
  5. package/dist/{client → admin}/setup-guide.d.mts +4 -2
  6. package/dist/{client → admin}/setup-guide.mjs +3 -10
  7. package/dist/api-keys/capability-matrix.d.mts +12 -8
  8. package/dist/api-keys/capability-matrix.mjs +17 -85
  9. package/dist/api-keys/collection.mjs +2 -1
  10. package/dist/api-keys/fields.mjs +36 -24
  11. package/dist/auth/resolve.mjs +44 -6
  12. package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
  13. package/dist/capabilities.mjs +41 -43
  14. package/dist/define-tool.d.mts +23 -0
  15. package/dist/{types.mjs → define-tool.mjs} +1 -1
  16. package/dist/endpoint/errors.mjs +5 -6
  17. package/dist/endpoint/handler.mjs +49 -19
  18. package/dist/endpoint/server.mjs +72 -20
  19. package/dist/entity.d.mts +9 -0
  20. package/dist/guards.mjs +7 -0
  21. package/dist/i18n.mjs +10 -8
  22. package/dist/index.d.mts +5 -2
  23. package/dist/index.mjs +3 -2
  24. package/dist/options.mjs +52 -46
  25. package/dist/plugin.mjs +0 -1
  26. package/dist/request.d.mts +2 -3
  27. package/dist/request.mjs +2 -3
  28. package/dist/result.d.mts +2 -3
  29. package/dist/result.mjs +3 -3
  30. package/dist/schema/admin-hidden.mjs +91 -0
  31. package/dist/schema/describe.mjs +17 -33
  32. package/dist/schema/errors.mjs +8 -0
  33. package/dist/schema/index.mjs +5 -2
  34. package/dist/schema/lexical-pointer.mjs +18 -20
  35. package/dist/schema/lexical.mjs +44 -93
  36. package/dist/schema/outline.mjs +5 -15
  37. package/dist/schema/path.mjs +28 -0
  38. package/dist/schema/pointer.mjs +18 -25
  39. package/dist/schema/shape.mjs +25 -71
  40. package/dist/schema/walk.mjs +43 -45
  41. package/dist/tools/builtin.mjs +4 -4
  42. package/dist/tools/create-document.mjs +26 -23
  43. package/dist/tools/describe-schema.mjs +38 -34
  44. package/dist/tools/document.mjs +124 -0
  45. package/dist/tools/find-documents.mjs +32 -19
  46. package/dist/tools/find-versions.mjs +22 -18
  47. package/dist/tools/get-document.mjs +39 -32
  48. package/dist/tools/list-capabilities.mjs +23 -16
  49. package/dist/tools/patch-document.mjs +75 -49
  50. package/dist/tools/publish-document.mjs +26 -32
  51. package/dist/tools/query-paths.mjs +57 -0
  52. package/dist/tools/read-request.d.mts +10 -0
  53. package/dist/tools/read-request.mjs +43 -0
  54. package/dist/tools/shared.mjs +67 -99
  55. package/dist/tools/validate-document.mjs +18 -30
  56. package/dist/tools/versions.mjs +37 -20
  57. package/dist/types.d.mts +118 -74
  58. package/dist/write/draft-guard.mjs +23 -41
  59. package/dist/write/patch.mjs +27 -183
  60. package/dist/write/publish-blockers.mjs +7 -14
  61. package/dist/write/publish-intent.mjs +6 -4
  62. package/dist/write/row-ids.mjs +46 -0
  63. package/dist/write/transaction.mjs +5 -8
  64. package/dist/write/write-data.mjs +64 -0
  65. package/package.json +6 -5
  66. package/dist/client/capability-matrix.d.mts +0 -26
  67. package/dist/client/capability-matrix.mjs +0 -349
  68. package/dist/endpoint/index.mjs +0 -4
  69. package/dist/tools/target.mjs +0 -45
  70. /package/dist/{client → admin}/index.d.mts +0 -0
  71. /package/dist/{client → admin}/index.mjs +0 -0
package/dist/types.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DocumentId } from "./entity.mjs";
1
2
  import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
3
  import { z } from "zod";
3
4
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
@@ -17,59 +18,85 @@ declare module "payload" {
17
18
  * - `false`: no write tool touches it.
18
19
  * - `"draft"`: writes land as drafts and nothing MCP does changes what the
19
20
  * public sees. Requires `versions.drafts`.
20
- * - `"live"`: MCP may change live content. On an entity with drafts that means
21
- * `publishDocument` is exposed; on one without, where there is no draft to
22
- * land on, it means the write itself is permitted and lands live.
21
+ * - `"live"`: MCP may change live content. On an entity with drafts that
22
+ * exposes `publishDocument`. On one without, there is no draft, so the write
23
+ * itself is permitted and lands live.
23
24
  */
24
25
  export type McpxWriteMode = "draft" | "live" | false;
25
- /** A key can only enable what the config exposes here. */
26
+ /**
27
+ * What an entity exposes. The config names the entities that are reachable and
28
+ * only takes capabilities away; a key's checkboxes decide per key. `true` is
29
+ * shorthand for `{}`, which exposes everything the entity supports.
30
+ */
26
31
  export interface McpxCollectionOptions {
27
- /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
32
+ /**
33
+ * Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`.
34
+ */
28
35
  read?: boolean;
29
36
  /**
30
37
  * Expose `patchDocument`, `validateDocument` and, unless this is an upload
31
- * collection, `createDocument`, and how far those writes reach. Default
32
- * `false`.
38
+ * collection, `createDocument`. Without drafts a write changes live
39
+ * content. Default `true`.
33
40
  */
34
- write?: McpxWriteMode;
35
- }
36
- /** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
37
- export interface McpxGlobalOptions {
38
- /** Expose `describeSchema` and `getDocument`. Default `true`. */
39
- read?: boolean;
41
+ write?: boolean;
40
42
  /**
41
- * Expose `patchDocument` and `validateDocument`, and how far those writes
42
- * reach. Default `false`.
43
+ * Expose `publishDocument`, which promotes a draft to live content. Needs
44
+ * `versions.drafts` and `write`. Default `true` where the entity has drafts.
45
+ * `false` keeps writes as drafts and is refused where there are none.
43
46
  */
44
- write?: McpxWriteMode;
47
+ publish?: boolean;
45
48
  }
49
+ /**
50
+ * The same options. A singleton, so neither `findDocuments` nor
51
+ * `createDocument` reaches one.
52
+ */
53
+ export type McpxGlobalOptions = McpxCollectionOptions;
46
54
  export type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
47
- /** What the config exposes, before an API key's checkboxes narrow it. */
55
+ /**
56
+ * What the config exposes, before an API key's checkboxes narrow it.
57
+ */
48
58
  export interface McpxExposedEntity {
49
59
  slug: string;
50
60
  read: boolean;
51
61
  write: McpxWriteMode;
52
62
  hasDrafts: boolean;
53
- /** Payload keeps a version history, with or without drafts. */
63
+ /**
64
+ * The entity has Payload `versions`, with or without drafts, and is
65
+ * readable. Says nothing about drafts; see `hasDrafts`.
66
+ */
54
67
  hasVersions: boolean;
55
- /** An upload document is a file, and no tool here can supply one. */
68
+ /**
69
+ * An upload document is a file, and no tool here can supply one.
70
+ */
56
71
  isUpload: boolean;
57
- /** Name of the capability group on the key document. */
72
+ /**
73
+ * Name of the capability group on the key document.
74
+ */
58
75
  fieldName: string;
59
76
  }
60
- /** What a tool knows about the current request. */
61
- export interface McpxToolScope {
62
- req: PayloadRequest;
63
- capabilities: McpxResolvedCapabilities;
77
+ /**
78
+ * The slugs a key may read, write and publish.
79
+ */
80
+ export interface McpxScopeSlugs {
64
81
  readable: string[];
65
82
  writable: string[];
66
83
  publishable: string[];
67
- readableGlobals: string[];
68
- writableGlobals: string[];
69
- publishableGlobals: string[];
70
- /** `null` when localization is off. */
71
- locales: null | string[];
72
- defaultLocale: null | string;
84
+ }
85
+ /**
86
+ * What a tool knows about the current request.
87
+ */
88
+ export interface McpxToolScope {
89
+ req: PayloadRequest;
90
+ capabilities: McpxResolvedCapabilities;
91
+ collections: McpxScopeSlugs;
92
+ globals: McpxScopeSlugs;
93
+ /**
94
+ * `null` when localization is off.
95
+ */
96
+ localization: null | {
97
+ locales: string[];
98
+ defaultLocale: string;
99
+ };
73
100
  limits: {
74
101
  maxLimit: number;
75
102
  maxDepth: number;
@@ -85,9 +112,13 @@ export interface McpxToolScope {
85
112
  * per request, leaving no static shape to infer from.
86
113
  */
87
114
  export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
88
- /** camelCase, unique, not one of the builtin tool names. */
115
+ /**
116
+ * camelCase, unique, not one of the builtin tool names.
117
+ */
89
118
  name: string;
90
- /** Built per request so it can state what this key's writes actually do. */
119
+ /**
120
+ * Built per request so it can state what this key's writes do.
121
+ */
91
122
  description: string | ((scope: McpxToolScope) => string);
92
123
  annotations?: ToolAnnotations;
93
124
  /**
@@ -99,39 +130,32 @@ export interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.
99
130
  /**
100
131
  * Built per request so enums can be narrowed to what the key may touch.
101
132
  * Registered strictly either way: an unknown argument is rejected by name
102
- * rather than stripped.
133
+ * instead of stripped.
103
134
  */
104
135
  inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
105
136
  handler(ctx: {
106
137
  args: Args;
107
138
  scope: McpxToolScope;
108
- /** Shorthand for `scope.req`. */
139
+ /**
140
+ * Shorthand for `scope.req`.
141
+ */
109
142
  req: PayloadRequest;
110
143
  extra: McpxToolExtra;
111
144
  }): CallToolResult | Promise<CallToolResult>;
112
145
  }
113
- /** Argument type erased, so a registry can hold tools of differing shapes. */
114
- export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
115
- /** Fixed shape; arguments inferred from it. */
116
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
117
- inputSchema?: Shape;
118
- }): McpxTool<Shape>;
119
- /** Per-request shape returned as an object literal; arguments inferred from it. */
120
- export declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
121
- inputSchema: (scope: McpxToolScope) => Shape;
122
- }): McpxAnyTool;
123
146
  /**
124
- * Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
125
- * builtins do. Nothing to infer from, so state the arguments instead.
147
+ * Argument type erased, so a registry can hold tools of differing shapes.
126
148
  */
127
- export declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
128
- inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
129
- }): McpxAnyTool;
149
+ export type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
130
150
  export interface McpxAuthResult {
131
- /** Must carry `collection`. */
151
+ /**
152
+ * Must carry `collection`.
153
+ */
132
154
  user: TypedUser;
133
- apiKeyId: number | string;
134
- /** The `capabilities` group as stored on the key document. */
155
+ apiKeyId: DocumentId;
156
+ /**
157
+ * The `capabilities` group as stored on the key document.
158
+ */
135
159
  capabilities: unknown;
136
160
  }
137
161
  /**
@@ -141,36 +165,56 @@ export interface McpxAuthResult {
141
165
  * to `Record<string, unknown>`, which interfaces do not satisfy.
142
166
  */
143
167
  export type McpxPluginOptions = {
144
- /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
168
+ /**
169
+ * Allow-list of collections. `true` is shorthand for `{}`.
170
+ */
145
171
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
146
- /** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
172
+ /**
173
+ * Allow-list of globals. `true` is shorthand for `{}`.
174
+ */
147
175
  globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
148
- /** Collection the keys act as. Default `config.admin.user`, then `users`. */
176
+ /**
177
+ * Collection the keys act as. Default `config.admin.user`, then `users`.
178
+ */
149
179
  userCollection?: CollectionSlug;
150
180
  apiKeys?: {
151
- /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
181
+ /**
182
+ * Slug of the generated API key collection. Default `mcpx-api-keys`.
183
+ */
152
184
  slug?: string;
153
185
  /**
154
186
  * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
155
187
  * client config. Default `true`. The snippets contain the key in full.
156
188
  */
157
189
  setupGuide?: boolean;
158
- /** Final override applied to the generated collection. */
190
+ /**
191
+ * Final override applied to the generated collection.
192
+ */
159
193
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
160
194
  };
161
195
  endpoint?: {
162
- /** Endpoint path below the API route. Default `/mcpx`. */
196
+ /**
197
+ * Endpoint path below the API route. Default `/mcpx`.
198
+ */
163
199
  path?: string;
164
200
  };
165
201
  limits?: {
166
- /** Upper bound for `findDocuments.limit`. Default 25. */
202
+ /**
203
+ * Upper bound for `findDocuments.limit`. Default 25.
204
+ */
167
205
  maxLimit?: number;
168
- /** Upper bound for `depth` on reads. Default 1. */
206
+ /**
207
+ * Upper bound for `depth` on reads. Default 1.
208
+ */
169
209
  maxDepth?: number;
170
210
  };
171
211
  tools?: McpxAnyTool[];
172
212
  auth?: {
173
- /** Replace or wrap the default key resolution. Return `null` for 401. */
213
+ /**
214
+ * Replace or wrap the default key resolution. Return `null` for 401. A
215
+ * result whose user has no `id` or whose `collection` is not the user
216
+ * collection, or that has no `apiKeyId`, is also answered with 401.
217
+ */
174
218
  resolve?: (args: {
175
219
  req: PayloadRequest;
176
220
  resolveDefault: () => Promise<McpxAuthResult | null>;
@@ -181,30 +225,30 @@ export type McpxPluginOptions = {
181
225
  version?: string;
182
226
  };
183
227
  };
184
- /** What a key may do with one entity. Globals reuse this shape. */
228
+ /**
229
+ * What a key may do with one entity. Globals reuse this shape.
230
+ */
185
231
  export interface McpxCollectionCapabilities {
186
232
  read: boolean;
187
233
  write: boolean;
188
- /** Only ever true where the config sets `write: "live"` and drafts exist. */
234
+ /**
235
+ * Only ever true where the config lets writes publish and drafts exist.
236
+ */
189
237
  publish: boolean;
190
238
  }
191
- /** In force for one request: plugin config AND key checkboxes. */
239
+ /**
240
+ * In force for one request: the plugin config and the key checkboxes together.
241
+ */
192
242
  export interface McpxResolvedCapabilities {
193
243
  collections: Record<string, McpxCollectionCapabilities>;
194
244
  globals: Record<string, McpxCollectionCapabilities>;
195
245
  tools: Record<string, boolean>;
196
246
  }
197
- /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
247
+ /**
248
+ * Stamped on `req.context.mcpx`; see {@link isMcpxRequest}.
249
+ */
198
250
  export interface McpxRequestContext {
199
- apiKeyId: number | string;
251
+ apiKeyId: DocumentId;
200
252
  capabilities: McpxResolvedCapabilities;
201
253
  }
202
- /** One reason a human could not publish the draft as it stands. */
203
- export interface PublishBlocker {
204
- /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
205
- field?: string;
206
- message: string;
207
- /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
208
- path: string;
209
- }
210
254
  //#endregion
@@ -3,30 +3,16 @@ import { isMcpxRequest } from "../request.mjs";
3
3
  import { APIError } from "payload";
4
4
  import { hasDraftsEnabled } from "payload/shared";
5
5
  //#region src/write/draft-guard.ts
6
- /** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
6
+ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
7
7
  "where",
8
8
  "publishAllLocales",
9
- "publishSpecificLocale",
10
9
  "unpublishAllLocales",
11
10
  "duplicateFromID",
12
11
  "selectedLocales",
13
12
  "overwriteExistingFiles"
14
13
  ]);
15
- /**
16
- * Forces every MCP write into a draft save, unless it is the one write
17
- * `publishDocument` asked for.
18
- *
19
- * `draft` alone is not enough: Payload's update path only saves a draft when
20
- * `data._status !== "published"`, so `_status` is dropped and left to Payload.
21
- * Writing it here rather than in the tool keeps the tool honest, since this is
22
- * the only thing that can grant a publish.
23
- *
24
- * Not covered: deletes, `duplicate`, files (the local API lifts `file` and
25
- * `filePath` onto `req` before this runs), and anything going straight to
26
- * `payload.db`. `restoreVersion` is caught by {@link refusePublish} instead,
27
- * because it runs the collection's `beforeChange` hooks.
28
- */ const scrubWriteArgs = (args, publishing) => {
29
- const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
14
+ const scrubWriteArgs = (args, publishing) => {
15
+ const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key) && (publishing || key !== "publishSpecificLocale")));
30
16
  if (next["data"] && typeof next["data"] === "object") {
31
17
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
32
18
  next["data"] = publishing ? {
@@ -47,25 +33,22 @@ const forceDraftWrite = (hookArgs) => {
47
33
  return scrubWriteArgs(args, publishing);
48
34
  };
49
35
  /**
50
- * The global counterpart of {@link forceDraftWrite}, with one difference that
51
- * decides where the guarantee lives: `updateGlobal` destructures `draft` and
52
- * the publish arguments *before* it runs `beforeOperation` and re-reads only
53
- * `data` afterwards, so setting them here is a no-op. What lands is `data` with
54
- * `_status` stripped, which makes {@link refusePublishGlobal} the alarm that
55
- * actually holds the line. `publishDocument` therefore passes `draft: false` at
56
- * the call site, and this hook puts `_status` back rather than stripping it.
36
+ * The global counterpart of {@link forceDraftWrite}. `updateGlobal` reads
37
+ * `draft` and the publish arguments before this hook runs, so only changes to
38
+ * `data` take effect. A write to a global with drafts that did not ask for a
39
+ * draft save is therefore refused here, and `_status` is restored for a publish.
57
40
  */ const forceDraftWriteGlobal = (hookArgs) => {
58
- const { operation, req } = hookArgs;
41
+ const { global, operation, req } = hookArgs;
59
42
  const args = hookArgs.args;
60
43
  if (!isMcpxRequest(req) || operation !== "update") return args;
61
- return scrubWriteArgs(args, hasPublishIntent(args["data"]));
44
+ const publishing = hasPublishIntent(args["data"]);
45
+ if (!publishing && args["draft"] !== true && hasDraftsEnabled(global)) {
46
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${global.slug} that did not ask for a draft save.`);
47
+ 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);
48
+ }
49
+ return scrubWriteArgs(args, publishing);
62
50
  };
63
- /**
64
- * Throws instead of correcting `_status`, because Payload has already chosen
65
- * the write branch by the time a `beforeChange` hook runs. Unreachable for a
66
- * collection if {@link forceDraftWrite} did its job; the guarantee itself for a
67
- * global. Last hook that needs the marker, so it takes it off.
68
- */ const refuseUnlessExpected = (req, slug, data) => {
51
+ const refuseUnlessExpected = (req, slug, data) => {
69
52
  const publishing = takePublishIntent(data);
70
53
  if (!isMcpxRequest(req)) return;
71
54
  const status = data._status;
@@ -75,8 +58,8 @@ const forceDraftWrite = (hookArgs) => {
75
58
  throw new APIError(publishing ? "This publish was refused because it would not have saved a published document." : "MCP clients may only write drafts. This write was refused because it would not have been saved as one. Use publishDocument to publish.", 403);
76
59
  };
77
60
  /**
78
- * Installs {@link refuseUnlessExpected} on every collection write. Returns
79
- * `data` unchanged when the write is allowed; the hook exists for its throw.
61
+ * Runs {@link refuseUnlessExpected} on every collection write. Returns `data`
62
+ * unchanged; the hook exists for its throw.
80
63
  */ const refusePublish = ({ collection, data, req }) => {
81
64
  refuseUnlessExpected(req, collection.slug, data);
82
65
  return data;
@@ -88,9 +71,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
88
71
  };
89
72
  /**
90
73
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
91
- * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
92
- * exist. Applied to the built collection list so nothing can join later
93
- * without being covered. Both are appended last, so a user hook cannot win.
74
+ * (it does nothing outside MCP requests) and `refusePublish` wherever drafts
75
+ * exist. It runs on the final collection list, so no collection escapes it, and
76
+ * both hooks are appended last, so a user hook cannot override them.
94
77
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
95
78
  ...collection,
96
79
  hooks: {
@@ -100,10 +83,9 @@ const refusePublishGlobal = ({ data, global, req }) => {
100
83
  }
101
84
  }));
102
85
  /**
103
- * Attaches the guard to every global, exposed or not, for the same reason
104
- * `installDraftGuards` covers every collection: a custom tool running on an MCP
105
- * request must not be able to publish through a global the plugin config never
106
- * mentioned.
86
+ * Attaches the guard to every global, exposed or not: a custom tool running on
87
+ * an MCP request must not be able to publish through a global the plugin config
88
+ * never mentioned.
107
89
  */ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
108
90
  ...global,
109
91
  hooks: {