@abinnovision/payloadcms-mcpx 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -1,43 +1,50 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
- import { collectionEnum, ensureAllowed, idSchema, localeOf, localeShape, readDraft } from "./shared.mjs";
2
+ import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
+ import { requireIdFor, resolveTarget } from "./target.mjs";
3
4
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
4
5
  //#region src/tools/validate-document.ts
5
6
  const validateDocument = {
6
7
  name: "validateDocument",
7
- 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".`,
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".
9
+
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.`,
8
11
  annotations: {
9
12
  readOnlyHint: true,
10
13
  openWorldHint: false
11
14
  },
12
- isEnabled: (scope) => scope.writable.length > 0,
15
+ isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
13
16
  inputSchema: (scope) => ({
14
- collection: collectionEnum(scope.writable).describe("Collection holding the document."),
15
- id: idSchema,
17
+ ...targetShape(scope, "write", {
18
+ collection: "Collection holding the document.",
19
+ global: "Global to validate."
20
+ }),
21
+ ...idShape(scope, "write"),
16
22
  ...localeShape(scope, {
17
23
  required: true,
18
24
  description: "Locale to validate."
19
25
  })
20
26
  }),
21
27
  handler: async (args, scope) => {
22
- const collection = ensureAllowed(scope, args.collection, "write");
28
+ const target = resolveTarget(scope, args, "write");
29
+ const id = requireIdFor(target, args.id);
23
30
  const locale = localeOf(scope, args.locale);
24
- await readDraft(scope, {
25
- collection: args.collection,
26
- id: args.id,
31
+ await readTarget(scope, {
32
+ target,
33
+ id,
27
34
  locale
28
35
  });
29
- const doc = await readDraft(scope, {
30
- collection: args.collection,
31
- id: args.id,
36
+ const doc = await readTarget(scope, {
37
+ target,
38
+ id,
32
39
  locale,
33
40
  privileged: true
34
41
  });
35
42
  const publishBlockers = await collectPublishBlockers(scope.req, {
36
- collection,
37
- doc
43
+ doc,
44
+ entity: target
38
45
  });
39
46
  return jsonResult({
40
- id: doc["id"],
47
+ ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
41
48
  status: doc["_status"],
42
49
  updatedAt: doc["updatedAt"],
43
50
  publishBlockers
package/dist/types.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, CollectionSlug, PayloadRequest, TypedUser } from "payload";
1
+ import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
2
  import { z } from "zod";
3
3
  import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
4
  import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
@@ -29,6 +29,26 @@ interface McpxCollectionOptions {
29
29
  */
30
30
  allowLiveWrites?: boolean;
31
31
  }
32
+ /**
33
+ * What an exposed global offers to MCP clients. Structurally the same as
34
+ * {@link McpxCollectionOptions}, kept separate because the tools it names
35
+ * differ: a global is a singleton, so neither `findDocuments` nor
36
+ * `createDocument` reaches one.
37
+ */
38
+ interface McpxGlobalOptions {
39
+ /** Expose `describeSchema` and `getDocument`. Default `true`. */
40
+ read?: boolean;
41
+ /**
42
+ * Expose `patchDocument` and `validateDocument`. Default `false`. Requires
43
+ * `versions.drafts` unless `allowLiveWrites` is set.
44
+ */
45
+ write?: boolean;
46
+ /**
47
+ * Permit writes to a global without drafts. Such writes land on the live
48
+ * document because there is no draft to land on. Default `false`.
49
+ */
50
+ allowLiveWrites?: boolean;
51
+ }
32
52
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
33
53
  /**
34
54
  * A custom tool. It is gated by its own checkbox on every API key and runs
@@ -63,6 +83,8 @@ interface McpxAuthResult {
63
83
  type McpxPluginOptions = {
64
84
  /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
65
85
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
86
+ /** Allow-list of globals. `true` is shorthand for `{ read: true }`. */
87
+ globals?: Partial<Record<GlobalSlug, McpxGlobalOptions | true>>;
66
88
  /** Collection the keys act as. Default `config.admin.user`, then `users`. */
67
89
  userCollection?: CollectionSlug;
68
90
  apiKeys?: {
@@ -103,6 +125,7 @@ interface McpxCollectionCapabilities {
103
125
  */
104
126
  interface McpxResolvedCapabilities {
105
127
  collections: Record<string, McpxCollectionCapabilities>;
128
+ globals: Record<string, McpxCollectionCapabilities>;
106
129
  tools: Record<string, boolean>;
107
130
  }
108
131
  interface McpxRequestContext {
@@ -110,4 +133,4 @@ interface McpxRequestContext {
110
133
  capabilities: McpxResolvedCapabilities;
111
134
  }
112
135
  //#endregion
113
- export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
136
+ export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
package/dist/version.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  //#region src/version.ts
2
2
  /**
3
3
  * Package version injected at build time; sources under Vitest report "dev".
4
- */ const MCPX_VERSION = "1.0.0-beta.3";
4
+ */ const MCPX_VERSION = "0.0.0";
5
5
  //#endregion
6
6
  export { MCPX_VERSION };
@@ -27,9 +27,7 @@ import { hasDraftsEnabled } from "payload/shared";
27
27
  * so it holds for every create and update on an MCP request, not only the
28
28
  * builtin tools. Deletes are not guarded in v1; custom tools that delete are
29
29
  * the integrator's responsibility.
30
- */ const forceDraftWrite = (hookArgs) => {
31
- const { args, operation, req } = hookArgs;
32
- if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
30
+ */ const scrubWriteArgs = (args) => {
33
31
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
34
32
  if (next["data"] && typeof next["data"] === "object") {
35
33
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
@@ -41,18 +39,50 @@ import { hasDraftsEnabled } from "payload/shared";
41
39
  next["trash"] = false;
42
40
  return next;
43
41
  };
42
+ const forceDraftWrite = (hookArgs) => {
43
+ const { args, operation, req } = hookArgs;
44
+ if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
45
+ return scrubWriteArgs(args);
46
+ };
47
+ /**
48
+ * The global counterpart of {@link forceDraftWrite}. Payload invokes a global's
49
+ * `beforeOperation` with the whole argument bag and assigns the result back,
50
+ * exactly as the collection path does and before it reads `draft`,
51
+ * `publishAllLocales` or `data._status`, so the guard has the same reach here:
52
+ * every MCP write to a global, builtin tool or custom.
53
+ *
54
+ * The global operation union has no `create` member because a global always
55
+ * exists, so only `update` is intercepted. `STRIPPED_ARGS` covers the three
56
+ * publish vectors `updateGlobal` accepts; the rest of the set does not exist on
57
+ * that signature and filtering it is a harmless no-op. `slug` survives the
58
+ * filter, so the operation still knows what it is updating.
59
+ */ const forceDraftWriteGlobal = (hookArgs) => {
60
+ const { operation, req } = hookArgs;
61
+ const args = hookArgs.args;
62
+ if (!isMcpxRequest(req) || operation !== "update") return args;
63
+ return scrubWriteArgs(args);
64
+ };
44
65
  /**
45
66
  * Refuses an MCP write that would still not land as a draft. An alarm rather
46
67
  * than the guarantee: `forceDraftWrite` should make it unreachable. It throws
47
68
  * instead of correcting `_status` because Payload has already chosen the write
48
69
  * branch by the time a `beforeChange` hook runs.
49
- */ const refusePublish = ({ collection, data, req }) => {
50
- if (!isMcpxRequest(req)) return data;
70
+ */ const refuseUnlessDraft = (req, slug, data) => {
71
+ if (!isMcpxRequest(req)) return;
51
72
  const status = data._status;
52
- if (status === "draft") return data;
53
- req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${collection.slug} that would not have been a draft (_status: ${String(status)}).`);
73
+ if (status === "draft") return;
74
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a draft (_status: ${String(status)}).`);
54
75
  throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
55
76
  };
77
+ const refusePublish = ({ collection, data, req }) => {
78
+ refuseUnlessDraft(req, collection.slug, data);
79
+ return data;
80
+ };
81
+ /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
82
+ const next = data;
83
+ refuseUnlessDraft(req, global.slug, next);
84
+ return next;
85
+ };
56
86
  /**
57
87
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
58
88
  * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
@@ -66,5 +96,18 @@ import { hasDraftsEnabled } from "payload/shared";
66
96
  ...hasDraftsEnabled(collection) ? { beforeChange: [...collection.hooks?.beforeChange ?? [], refusePublish] } : {}
67
97
  }
68
98
  }));
99
+ /**
100
+ * Attaches the guard to every global, exposed or not, for the same reason
101
+ * `installDraftGuards` covers every collection: a custom tool running on an MCP
102
+ * request must not be able to publish through a global the plugin config never
103
+ * mentioned.
104
+ */ const installGlobalDraftGuards = (globals) => globals.map((global) => ({
105
+ ...global,
106
+ hooks: {
107
+ ...global.hooks,
108
+ beforeOperation: [...global.hooks?.beforeOperation ?? [], forceDraftWriteGlobal],
109
+ ...hasDraftsEnabled(global) ? { beforeChange: [...global.hooks?.beforeChange ?? [], refusePublishGlobal] } : {}
110
+ }
111
+ }));
69
112
  //#endregion
70
- export { forceDraftWrite, installDraftGuards, isMcpxRequest, refusePublish };
113
+ export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
@@ -1,4 +1,4 @@
1
- import { RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
1
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeFields, findBlocksField, splitPath } from "../schema/walk.mjs";
2
2
  import { validateWriteValue } from "../schema/shape.mjs";
3
3
  import { resolveDataPointer } from "../schema/pointer.mjs";
4
4
  import { z } from "zod";
@@ -6,8 +6,7 @@ import { Pointer, applyPatch } from "rfc6902";
6
6
  //#region src/write/patch.ts
7
7
  /**
8
8
  * One RFC 6902 operation as accepted by `patchDocument`.
9
- */ const JSON_POINTER_PATTERN = /^(\/([^~/]|~[01])*)*$/;
10
- const PATCH_OPERATION_SCHEMA = z.object({
9
+ */ const PATCH_OPERATION_SCHEMA = z.object({
11
10
  from: z.string().regex(JSON_POINTER_PATTERN).optional(),
12
11
  op: z.enum([
13
12
  "add",
@@ -127,9 +126,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
127
126
  for (const pointer of pointers) {
128
127
  const resolution = resolveDataPointer(config, {
129
128
  addedValue: value ?? moved,
130
- collection: target.collection,
131
129
  doc: target.doc,
132
- pointer
130
+ pointer,
131
+ ref: target.ref
133
132
  });
134
133
  if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
135
134
  pointer,
@@ -186,13 +185,13 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
186
185
  result[key] = entry;
187
186
  continue;
188
187
  }
189
- if (candidates.some(({ parts }) => parts[1] === "[]")) {
188
+ if (candidates.some(({ parts }) => parts[1] === "*")) {
190
189
  result[key] = Array.isArray(entry) ? entry.map((row) => isPlainObject(row) ? pickDescribed(config, row, {
191
190
  fields,
192
191
  prefix: [
193
192
  ...prefix,
194
193
  key,
195
- "[]"
194
+ "*"
196
195
  ],
197
196
  isRow: true
198
197
  }) : row) : entry;
@@ -209,9 +208,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
209
208
  /**
210
209
  * The data handed to `payload.update` after a patch: the patched document
211
210
  * reduced to the fields the client may write, plus row identity keys.
212
- */ const buildWriteData = (config, collection, doc) => {
211
+ */ const buildWriteData = (config, target, doc) => {
213
212
  return pickDescribed(config, doc, {
214
- fields: collection.flattenedFields,
213
+ fields: target.flattenedFields,
215
214
  prefix: [],
216
215
  isRow: false
217
216
  });
@@ -1,3 +1,4 @@
1
+ import "../tools/target.mjs";
1
2
  import { PayloadRequest } from "payload";
2
3
  //#region src/write/publish-blockers.d.ts
3
4
  /**
@@ -7,6 +8,7 @@ interface PublishBlocker {
7
8
  /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
8
9
  field?: string;
9
10
  message: string;
11
+ /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
10
12
  path: string;
11
13
  }
12
14
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { pointerFromPayloadPath } from "../schema/walk.mjs";
1
2
  import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
2
3
  //#region src/write/publish-blockers.ts
3
4
  /**
@@ -18,19 +19,20 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
18
19
  * Limits: only the locale the doc was read in is checked, and field-level
19
20
  * `beforeChange` hooks run again, which is safe only for pure ones.
20
21
  */ const collectPublishBlockers = async (req, target) => {
21
- const { collection, doc } = target;
22
+ const { doc, entity } = target;
22
23
  const id = doc["id"];
23
24
  const errors = [];
24
25
  const data = {
25
26
  ...structuredClone(doc),
26
27
  _status: "published"
27
28
  };
29
+ const context = { ...req.context };
28
30
  const shared = {
29
- collection,
30
- context: { ...req.context },
31
+ collection: entity.kind === "collection" ? entity.config : null,
32
+ context,
31
33
  data,
32
34
  doc,
33
- global: null,
35
+ global: entity.kind === "global" ? entity.config : null,
34
36
  operation: "update",
35
37
  overrideAccess: true,
36
38
  parentIndexPath: "",
@@ -44,7 +46,7 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
44
46
  try {
45
47
  await beforeValidateTraverseFields({
46
48
  ...shared,
47
- fields: collection.fields,
49
+ fields: entity.config.fields,
48
50
  siblingData: data
49
51
  });
50
52
  await beforeChangeTraverseFields({
@@ -52,19 +54,19 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
52
54
  docWithLocales: doc,
53
55
  errors,
54
56
  fieldLabelPath: "",
55
- fields: collection.fields,
57
+ fields: entity.config.fields,
56
58
  mergeLocaleActions: [],
57
59
  siblingData: data,
58
60
  siblingDocWithLocales: doc,
59
61
  skipValidation: false
60
62
  });
61
63
  } catch (error) {
62
- req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${collection.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
64
+ req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
63
65
  return [];
64
66
  }
65
67
  return errors.map((error) => ({
66
68
  message: error.message,
67
- path: error.path,
69
+ path: pointerFromPayloadPath(error.path),
68
70
  ...typeof error.label === "string" ? { field: error.label } : {}
69
71
  }));
70
72
  };
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.3",
4
+ "version": "1.0.0-beta.5",
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",