@abinnovision/payloadcms-mcpx 1.0.0-beta.4 → 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,20 +1,26 @@
1
1
  import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
2
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
3
- import { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape } from "./shared.mjs";
3
+ import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
+ import { requireIdFor, resolveTarget } from "./target.mjs";
4
5
  import { z } from "zod";
5
6
  import { Pointer } from "rfc6902";
6
7
  //#region src/tools/get-document.ts
7
8
  const getDocument = {
8
9
  name: "getDocument",
9
- 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.`,
10
+ 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.
11
+
12
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.`,
10
13
  annotations: {
11
14
  readOnlyHint: true,
12
15
  openWorldHint: false
13
16
  },
14
- isEnabled: (scope) => scope.readable.length > 0,
17
+ isEnabled: (scope) => scope.readable.length + scope.readableGlobals.length > 0,
15
18
  inputSchema: (scope) => ({
16
- collection: collectionEnum(scope.readable).describe("Collection holding the document."),
17
- id: idSchema,
19
+ ...targetShape(scope, "read", {
20
+ collection: "Collection holding the document.",
21
+ global: "Global to read."
22
+ }),
23
+ ...idShape(scope, "read"),
18
24
  path: z.string().regex(JSON_POINTER_PATTERN).optional().describe("JSON pointer to return only a subtree, e.g. \"/layout/sections/0\"."),
19
25
  ...depthShape(scope),
20
26
  ...localeShape(scope, {
@@ -24,17 +30,24 @@ const getDocument = {
24
30
  draft: z.boolean().optional().describe("Return the latest draft. Default true.")
25
31
  }),
26
32
  handler: async (args, scope) => {
27
- ensureAllowed(scope, args.collection, "read");
33
+ const target = resolveTarget(scope, args, "read");
34
+ const id = requireIdFor(target, args.id);
28
35
  const locale = localeOf(scope, args.locale);
29
- const doc = await scope.req.payload.findByID({
30
- collection: args.collection,
31
- id: args.id,
36
+ const shared = {
32
37
  depth: args.depth ?? 0,
33
38
  draft: args.draft ?? true,
34
39
  overrideAccess: false,
35
40
  req: scope.req,
36
41
  ...locale === void 0 ? {} : { locale }
37
- });
42
+ };
43
+ const doc = await (target.kind === "collection" ? scope.req.payload.findByID({
44
+ ...shared,
45
+ collection: target.slug,
46
+ id
47
+ }) : scope.req.payload.findGlobal({
48
+ ...shared,
49
+ slug: target.slug
50
+ }));
38
51
  if (args.path === void 0 || args.path === "") return jsonResult(doc);
39
52
  let value;
40
53
  try {
@@ -43,7 +56,7 @@ const getDocument = {
43
56
  return errorResult(`"${args.path}" is not a valid JSON pointer.`);
44
57
  }
45
58
  return jsonResult({
46
- id: doc["id"],
59
+ ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
47
60
  status: doc["_status"],
48
61
  updatedAt: doc["updatedAt"],
49
62
  path: args.path,
@@ -5,7 +5,9 @@ import { hasDraftValidationEnabled } from "payload/shared";
5
5
  //#region src/tools/list-capabilities.ts
6
6
  const listCapabilities = {
7
7
  name: "listCapabilities",
8
- description: `Lists what this key may do: the collections 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.`,
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.
9
+
10
+ A global is a singleton: it has no id, is not listed by findDocuments and cannot be created. Address one with the "global" argument where a collection document would take "collection" and "id".`,
9
11
  annotations: {
10
12
  readOnlyHint: true,
11
13
  openWorldHint: false
@@ -34,8 +36,24 @@ const listCapabilities = {
34
36
  idType: collection.customIDType ?? payload.db.defaultIDType
35
37
  }];
36
38
  });
39
+ const globals = scope.options.globals.flatMap((entry) => {
40
+ const capability = scope.capabilities.globals[entry.slug];
41
+ const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
42
+ if (!capability || !config || !(capability.read || capability.write)) return [];
43
+ const description = staticDescription(config.admin.description);
44
+ return [{
45
+ slug: entry.slug,
46
+ label: translateLabel(scope, config.label, entry.slug),
47
+ ...description === void 0 ? {} : { description },
48
+ read: capability.read,
49
+ write: capability.write,
50
+ drafts: entry.hasDrafts,
51
+ draftValidation: hasDraftValidationEnabled(config)
52
+ }];
53
+ });
37
54
  return Promise.resolve(jsonResult({
38
55
  collections,
56
+ ...globals.length > 0 ? { globals } : {},
39
57
  locales: scope.locales ? {
40
58
  codes: scope.locales,
41
59
  default: scope.defaultLocale
@@ -1,5 +1,6 @@
1
1
  import { errorResult, 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 { refOf, requireIdFor, resolveTarget } from "./target.mjs";
3
4
  import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
4
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
6
  import { withTransaction } from "../write/transaction.mjs";
@@ -8,6 +9,8 @@ import { Pointer } from "rfc6902";
8
9
  //#region src/tools/patch-document.ts
9
10
  const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
10
11
 
12
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
13
+
11
14
  The write always lands as a draft and is never published, whatever it contains; publishing stays a human action in the admin panel.
12
15
 
13
16
  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.
@@ -48,10 +51,13 @@ const patchDocument = {
48
51
  idempotentHint: false,
49
52
  openWorldHint: false
50
53
  },
51
- isEnabled: (scope) => scope.writable.length > 0,
54
+ isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
52
55
  inputSchema: (scope) => ({
53
- collection: collectionEnum(scope.writable).describe("Collection holding the document."),
54
- id: idSchema,
56
+ ...targetShape(scope, "write", {
57
+ collection: "Collection holding the document.",
58
+ global: "Global to patch."
59
+ }),
60
+ ...idShape(scope, "write"),
55
61
  ...localeShape(scope, {
56
62
  required: true,
57
63
  description: "Locale the patch applies to. Localized fields write here only."
@@ -60,47 +66,55 @@ const patchDocument = {
60
66
  expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
61
67
  }),
62
68
  handler: async (args, scope) => {
63
- const collection = ensureAllowed(scope, args.collection, "write");
69
+ const target = resolveTarget(scope, args, "write");
70
+ const id = requireIdFor(target, args.id);
64
71
  const { payload } = scope.req;
65
72
  const locale = localeOf(scope, args.locale);
66
73
  return await withTransaction(scope.req, async () => {
67
- const doc = await readDraft(scope, {
68
- collection: args.collection,
69
- id: args.id,
74
+ const doc = await readTarget(scope, {
75
+ target,
76
+ id,
70
77
  locale
71
78
  });
72
79
  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"] });
73
80
  const problems = findPatchProblems(payload.config, {
74
- collection: args.collection,
75
81
  doc,
76
- patches: args.patches
82
+ patches: args.patches,
83
+ ref: refOf(target)
77
84
  });
78
85
  if (problems.length > 0) return errorResult("No operation was applied.", { problems });
79
86
  const applied = applyPatchToCopy(doc, args.patches);
80
87
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
81
- await payload.update({
82
- collection: args.collection,
83
- id: args.id,
84
- data: buildWriteData(payload.config, collection, applied.next),
88
+ const write = {
89
+ data: buildWriteData(payload.config, target.config, applied.next),
85
90
  depth: 0,
86
91
  draft: true,
87
92
  overrideAccess: false,
88
93
  req: scope.req,
89
94
  ...locale === void 0 ? {} : { locale }
95
+ };
96
+ if (target.kind === "collection") await payload.update({
97
+ ...write,
98
+ collection: target.slug,
99
+ id
100
+ });
101
+ else await payload.updateGlobal({
102
+ ...write,
103
+ slug: target.slug
90
104
  });
91
- const saved = await readDraft(scope, {
92
- collection: args.collection,
93
- id: args.id,
105
+ const saved = await readTarget(scope, {
106
+ target,
107
+ id,
94
108
  locale,
95
109
  privileged: true
96
110
  });
97
111
  const notApplied = notAppliedPointers(args.patches, applied.next, saved);
98
112
  const publishBlockers = await collectPublishBlockers(scope.req, {
99
- collection,
100
- doc: saved
113
+ doc: saved,
114
+ entity: target
101
115
  });
102
116
  return jsonResult({
103
- id: saved["id"],
117
+ ...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
104
118
  status: saved["_status"],
105
119
  updatedAt: saved["updatedAt"],
106
120
  ...publishBlockers.length > 0 ? { publishBlockers } : {},
@@ -1,8 +1,39 @@
1
- import { Forbidden, NotFound } from "payload";
1
+ import { NotFound } from "payload";
2
2
  import { z } from "zod";
3
3
  //#region src/tools/shared.ts
4
- const collectionEnum = (slugs) => z.enum(slugs);
4
+ const slugEnum = (slugs) => z.enum(slugs);
5
5
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
6
+ const slugsFor = (scope, operation) => ({
7
+ collections: operation === "read" ? scope.readable : scope.writable,
8
+ globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
9
+ });
10
+ /**
11
+ * The `collection` and `global` arguments.
12
+ *
13
+ * When the key can reach no global, `global` is left out of the shape entirely
14
+ * and `collection` stays required, mirroring how {@link localeShape} omits
15
+ * `locale` when localization is off. A deployment without globals therefore
16
+ * sees exactly the schema it saw before. Only the mixed case makes either
17
+ * argument optional, and the handler enforces the exclusivity there.
18
+ */ const targetShape = (scope, operation, descriptions) => {
19
+ const { collections, globals } = slugsFor(scope, operation);
20
+ if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
21
+ if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
22
+ return {
23
+ collection: slugEnum(collections).optional().describe(descriptions.collection),
24
+ global: slugEnum(globals).optional().describe(descriptions.global)
25
+ };
26
+ };
27
+ /**
28
+ * The `id` argument, which only a collection document has. Omitted when the key
29
+ * can reach no collection, required when it can reach no global, and optional
30
+ * in between, where `requireIdFor` enforces the dependency.
31
+ */ const idShape = (scope, operation) => {
32
+ const { collections, globals } = slugsFor(scope, operation);
33
+ if (collections.length === 0) return {};
34
+ if (globals.length === 0) return { id: idSchema };
35
+ return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
36
+ };
6
37
  /**
7
38
  * The `locale` argument, present only when localization is configured.
8
39
  */ const localeShape = (scope, options) => {
@@ -12,15 +43,6 @@ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
12
43
  };
13
44
  const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.options.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.options.limits.maxDepth)}.`) });
14
45
  /**
15
- * Throws unless the key may perform `operation` on `slug`. The input schema
16
- * already limits the enum, so this only guards against a stale tool list.
17
- */ const ensureAllowed = (scope, slug, operation) => {
18
- const allowed = operation === "read" ? scope.readable : scope.writable;
19
- const collection = scope.req.payload.collections[slug];
20
- if (!allowed.includes(slug) || !collection) throw new Forbidden(scope.req.t);
21
- return collection.config;
22
- };
23
- /**
24
46
  * The locale to operate on: the explicit argument, else the request's, else
25
47
  * the default. `undefined` when localization is off.
26
48
  */ const localeOf = (scope, locale) => {
@@ -31,23 +53,34 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
31
53
  /**
32
54
  * Reads the current draft in a fixed locale with no fallback, which is the
33
55
  * shape that may be written back or validated without mixing locales.
34
- */ const readDraft = async (scope, args) => {
35
- const doc = await scope.req.payload.findByID({
36
- collection: args.collection,
37
- id: args.id,
56
+ */ const readTarget = async (scope, args) => {
57
+ const { payload } = scope.req;
58
+ const privileged = args.privileged === true;
59
+ const shared = {
38
60
  depth: 0,
39
61
  draft: true,
40
62
  ...args.locale === void 0 ? {} : {
41
63
  locale: args.locale,
42
64
  fallbackLocale: false
43
65
  },
44
- overrideAccess: args.privileged === true,
45
- showHiddenFields: args.privileged === true,
46
- disableErrors: true,
66
+ overrideAccess: privileged,
67
+ showHiddenFields: privileged,
47
68
  req: scope.req
69
+ };
70
+ if (args.target.kind === "collection") {
71
+ const doc = await payload.findByID({
72
+ ...shared,
73
+ collection: args.target.slug,
74
+ id: args.id,
75
+ disableErrors: true
76
+ });
77
+ if (!doc) throw new NotFound(scope.req.t);
78
+ return doc;
79
+ }
80
+ return await payload.findGlobal({
81
+ ...shared,
82
+ slug: args.target.slug
48
83
  });
49
- if (!doc) throw new NotFound(scope.req.t);
50
- return doc;
51
84
  };
52
85
  /**
53
86
  * Resolves a collection label for the request's language.
@@ -62,4 +95,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
62
95
  return fallback;
63
96
  };
64
97
  //#endregion
65
- export { collectionEnum, depthShape, ensureAllowed, idSchema, localeOf, localeShape, readDraft, translateLabel };
98
+ export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
@@ -0,0 +1,3 @@
1
+ import "./types.mjs";
2
+ import "../schema/walk.mjs";
3
+ import "payload";
@@ -0,0 +1,49 @@
1
+ import { APIError, Forbidden } from "payload";
2
+ //#region src/tools/target.ts
3
+ const refOf = (target) => ({
4
+ kind: target.kind,
5
+ slug: target.slug
6
+ });
7
+ /**
8
+ * Resolves the `collection`/`global` arguments to one entity and checks the key
9
+ * may perform `operation` on it.
10
+ *
11
+ * A tool's `inputSchema` returns a raw shape, which leaves no top-level
12
+ * `.refine` to express "exactly one of collection and global". The rule is
13
+ * enforced here instead, with a message naming the offending arguments so one
14
+ * failed call teaches it.
15
+ */ const resolveTarget = (scope, args, operation) => {
16
+ const { collection, global } = args;
17
+ if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
18
+ if (collection === void 0 && global === void 0) throw new APIError("One of \"collection\" or \"global\" is required. Call listCapabilities to see which slugs are available.", 400);
19
+ if (collection !== void 0) {
20
+ const allowed = operation === "read" ? scope.readable : scope.writable;
21
+ const found = scope.req.payload.collections[collection];
22
+ if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
+ return {
24
+ kind: "collection",
25
+ slug: collection,
26
+ config: found.config
27
+ };
28
+ }
29
+ const slug = global;
30
+ const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
31
+ const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
32
+ if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
33
+ return {
34
+ kind: "global",
35
+ slug,
36
+ config: found
37
+ };
38
+ };
39
+ /**
40
+ * Checks `id` against the resolved target. A collection document needs one; a
41
+ * global is a singleton and must not carry one. The schema cannot express the
42
+ * dependency, so it is stated here and in every affected tool description.
43
+ */ const requireIdFor = (target, id) => {
44
+ if (target.kind === "collection" && id === void 0) throw new APIError(`"id" is required when "collection" is "${target.slug}".`, 400);
45
+ if (target.kind === "global" && id !== void 0) throw new APIError(`"id" must be omitted when "global" is "${target.slug}"; a global is a singleton.`, 400);
46
+ return target.kind === "collection" ? id : void 0;
47
+ };
48
+ //#endregion
49
+ export { refOf, requireIdFor, resolveTarget };
@@ -0,0 +1,5 @@
1
+ import "../types.mjs";
2
+ import "../options.mjs";
3
+ import { PayloadRequest } from "payload";
4
+ import { z } from "zod";
5
+ import { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
@@ -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 };
@@ -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 };
@@ -126,9 +126,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
126
126
  for (const pointer of pointers) {
127
127
  const resolution = resolveDataPointer(config, {
128
128
  addedValue: value ?? moved,
129
- collection: target.collection,
130
129
  doc: target.doc,
131
- pointer
130
+ pointer,
131
+ ref: target.ref
132
132
  });
133
133
  if (pointer === operation.path && value !== void 0) return validateWriteValue(config, {
134
134
  pointer,
@@ -208,9 +208,9 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
208
208
  /**
209
209
  * The data handed to `payload.update` after a patch: the patched document
210
210
  * reduced to the fields the client may write, plus row identity keys.
211
- */ const buildWriteData = (config, collection, doc) => {
211
+ */ const buildWriteData = (config, target, doc) => {
212
212
  return pickDescribed(config, doc, {
213
- fields: collection.flattenedFields,
213
+ fields: target.flattenedFields,
214
214
  prefix: [],
215
215
  isRow: false
216
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
  /**