@abinnovision/payloadcms-mcpx 1.0.0-beta.8 → 1.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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +331 -194
  3. package/dist/api-keys/collection.mjs +3 -3
  4. package/dist/api-keys/fields.mjs +56 -5
  5. package/dist/api-keys/setup-guide.mjs +56 -0
  6. package/dist/auth/resolve.mjs +5 -7
  7. package/dist/capabilities.mjs +23 -4
  8. package/dist/client/index.d.mts +2 -0
  9. package/dist/client/index.mjs +2 -0
  10. package/dist/client/setup-guide.d.mts +14 -0
  11. package/dist/client/setup-guide.mjs +87 -0
  12. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  13. package/dist/endpoint/handler.mjs +11 -5
  14. package/dist/endpoint/index.mjs +4 -0
  15. package/dist/endpoint/server.mjs +18 -26
  16. package/dist/i18n.mjs +4 -15
  17. package/dist/index.d.mts +4 -4
  18. package/dist/index.mjs +4 -3
  19. package/dist/options.mjs +31 -26
  20. package/dist/plugin.mjs +1 -0
  21. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  22. package/dist/request.mjs +8 -0
  23. package/dist/result.d.mts +11 -0
  24. package/dist/result.mjs +20 -0
  25. package/dist/schema/describe.mjs +3 -15
  26. package/dist/schema/index.mjs +8 -0
  27. package/dist/schema/lexical-pointer.mjs +125 -0
  28. package/dist/schema/lexical.mjs +195 -27
  29. package/dist/schema/outline.mjs +67 -0
  30. package/dist/schema/pointer.mjs +77 -30
  31. package/dist/schema/shape.mjs +133 -51
  32. package/dist/schema/walk.mjs +44 -64
  33. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  34. package/dist/tools/create-document.mjs +34 -15
  35. package/dist/tools/describe-schema.mjs +21 -7
  36. package/dist/tools/find-documents.mjs +13 -6
  37. package/dist/tools/get-document.mjs +45 -11
  38. package/dist/tools/list-capabilities.mjs +19 -9
  39. package/dist/tools/names.mjs +2 -1
  40. package/dist/tools/patch-document.mjs +32 -21
  41. package/dist/tools/publish-document.mjs +79 -0
  42. package/dist/tools/shared.mjs +84 -32
  43. package/dist/tools/target.mjs +7 -11
  44. package/dist/tools/validate-document.mjs +20 -12
  45. package/dist/types.d.mts +115 -42
  46. package/dist/types.mjs +3 -4
  47. package/dist/version.mjs +1 -1
  48. package/dist/write/draft-guard.mjs +47 -44
  49. package/dist/write/patch.mjs +174 -92
  50. package/dist/write/publish-blockers.mjs +13 -12
  51. package/dist/write/publish-intent.mjs +17 -0
  52. package/dist/write/transaction.mjs +8 -3
  53. package/package.json +24 -9
  54. package/dist/i18n.d.mts +0 -1
  55. package/dist/options.d.mts +0 -2
  56. package/dist/schema/lexical.d.mts +0 -1
  57. package/dist/schema/walk.d.mts +0 -3
  58. package/dist/tools/target.d.mts +0 -3
  59. package/dist/tools/types.d.mts +0 -5
  60. package/dist/write/publish-blockers.d.mts +0 -15
@@ -1,48 +1,102 @@
1
+ import { canCreate, canPublish, isLiveWrite } from "../capabilities.mjs";
1
2
  import { translateStatic } from "../i18n.mjs";
2
3
  import { NotFound } from "payload";
3
4
  import { z } from "zod";
4
5
  //#region src/tools/shared.ts
5
- const slugEnum = (slugs) => z.enum(slugs);
6
- const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
7
- const slugsFor = (scope, operation) => ({
8
- collections: operation === "read" ? scope.readable : scope.writable,
9
- globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
6
+ /**
7
+ * An out-of-scope slug fails schema validation before a handler runs, so a
8
+ * client only ever sees what its key may touch.
9
+ */ const slugEnum = (slugs) => z.enum(slugs);
10
+ /** Payload's id type follows the adapter, so both forms are handed on as read. */ const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
11
+ const slugsWhere = (scope, predicate, allowed) => {
12
+ const pick = (entities, slugs) => entities.filter((entity) => slugs.includes(entity.slug) && predicate(entity)).map((entity) => entity.slug);
13
+ return [...pick(scope.exposure.collections, allowed.collections), ...pick(scope.exposure.globals, allowed.globals)];
14
+ };
15
+ /**
16
+ * Slugs this key may write whose writes land live rather than as a draft. An
17
+ * entity without versions has no draft to land on, so `write: "live"` there
18
+ * makes every write a live one. Empty for every key that can only write drafts.
19
+ */ const liveWriteSlugs = (scope) => slugsWhere(scope, isLiveWrite, {
20
+ collections: scope.writable,
21
+ globals: scope.writableGlobals
22
+ });
23
+ /**
24
+ * Slugs this key may write but never create in, because their documents are
25
+ * files. Collection-only, since nothing creates a global either way.
26
+ */ const patchOnlySlugs = (scope) => slugsWhere(scope, (entity) => !canCreate(entity), {
27
+ collections: scope.writable,
28
+ globals: []
29
+ });
30
+ /** Slugs this key may write and, separately, publish. */ const publishableWriteSlugs = (scope) => slugsWhere(scope, canPublish, {
31
+ collections: scope.publishable,
32
+ globals: scope.publishableGlobals
10
33
  });
11
34
  /**
12
- * The `collection` and `global` arguments.
13
- *
14
- * When the key can reach no global, `global` is left out of the shape entirely
15
- * and `collection` stays required, mirroring how {@link localeShape} omits
16
- * `locale` when localization is off. A deployment without globals therefore
17
- * sees exactly the schema it saw before. Only the mixed case makes either
18
- * argument optional, and the handler enforces the exclusivity there.
35
+ * What a write actually does for this key, and what it takes to make it public.
36
+ * A live-write slug has no draft and no publish step; a publishable one has
37
+ * both. Stated per key so a client is never told its writes are drafts while
38
+ * they are not, nor that publishing is out of reach when it is not.
39
+ */ const draftSentence = (scope) => {
40
+ const live = liveWriteSlugs(scope);
41
+ const publishable = publishableWriteSlugs(scope);
42
+ return `${live.length === 0 ? "Every write lands as a draft." : `Writes land as drafts, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately.`} ${publishable.length === 0 ? "Nothing this key writes is ever published; publishing stays a human action in the admin panel." : `Publish a draft with publishDocument, which this key may do for ${publishable.join(", ")}. Publishing anything else stays a human action in the admin panel.`}`;
43
+ };
44
+ /** The value a client read back is a string; what it meets may be a Date. */ const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
45
+ /** Unchecked, because the runtime shape really does vary; `Branch` guards it. */ const widen = (branch) => branch;
46
+ /** The one list the shape helpers and {@link resolveTarget} both read. */ const slugsFor = (scope, operation) => {
47
+ switch (operation) {
48
+ case "create": return {
49
+ collections: slugsWhere(scope, canCreate, {
50
+ collections: scope.writable,
51
+ globals: []
52
+ }),
53
+ globals: []
54
+ };
55
+ case "publish": return {
56
+ collections: scope.publishable,
57
+ globals: scope.publishableGlobals
58
+ };
59
+ case "read": return {
60
+ collections: scope.readable,
61
+ globals: scope.readableGlobals
62
+ };
63
+ case "write": return {
64
+ collections: scope.writable,
65
+ globals: scope.writableGlobals
66
+ };
67
+ }
68
+ };
69
+ /**
70
+ * With no reachable global, `global` is left out and `collection` stays
71
+ * required, so a deployment without globals sees an unchanged schema. Only the
72
+ * mixed case makes either optional, and the handler enforces exclusivity there.
19
73
  */ const targetShape = (scope, operation, descriptions) => {
20
74
  const { collections, globals } = slugsFor(scope, operation);
21
- if (globals.length === 0) return { collection: slugEnum(collections).describe(descriptions.collection) };
22
- if (collections.length === 0) return { global: slugEnum(globals).describe(descriptions.global) };
23
- return {
75
+ if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
76
+ if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
77
+ return widen({
24
78
  collection: slugEnum(collections).optional().describe(descriptions.collection),
25
79
  global: slugEnum(globals).optional().describe(descriptions.global)
26
- };
80
+ });
27
81
  };
28
82
  /**
29
- * The `id` argument, which only a collection document has. Omitted when the key
30
- * can reach no collection, required when it can reach no global, and optional
31
- * in between, where `requireIdFor` enforces the dependency.
83
+ * Only a collection document has one. Optional in the mixed case, where
84
+ * `requireIdFor` enforces the dependency.
32
85
  */ const idShape = (scope, operation) => {
33
86
  const { collections, globals } = slugsFor(scope, operation);
34
- if (collections.length === 0) return {};
35
- if (globals.length === 0) return { id: idSchema };
36
- return { id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") };
87
+ if (collections.length === 0) return widen({});
88
+ if (globals.length === 0) return widen({ id: idSchema });
89
+ return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
37
90
  };
38
- /**
39
- * The `locale` argument, present only when localization is configured.
40
- */ const localeShape = (scope, options) => {
41
- if (!scope.locales) return {};
91
+ const localeShape = (scope, options) => {
92
+ if (!scope.locales) return widen({});
42
93
  const locale = z.enum(scope.locales);
43
- return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
94
+ return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
44
95
  };
45
- 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)}.`) });
96
+ /**
97
+ * Defaults to 0 rather than Payload's own default: a client usually wants ids
98
+ * it can write back, and populating a relation costs a query.
99
+ */ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits.maxDepth).optional().describe(`Relationship population depth. Default 0, at most ${String(scope.limits.maxDepth)}.`) });
46
100
  /**
47
101
  * The locale to operate on: the explicit argument, else the request's, else
48
102
  * the default. `undefined` when localization is off.
@@ -83,9 +137,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
83
137
  slug: args.target.slug
84
138
  });
85
139
  };
86
- /**
87
- * Resolves a collection label for the request's language.
88
- */ const translateLabel = (scope, label, fallback) => {
140
+ const translateLabel = (scope, label, fallback) => {
89
141
  const { i18n, t } = scope.req;
90
142
  const resolved = typeof label === "function" ? label({
91
143
  i18n,
@@ -94,4 +146,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
94
146
  return translateStatic(resolved, i18n) ?? fallback;
95
147
  };
96
148
  //#endregion
97
- export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
149
+ export { depthShape, draftSentence, idSchema, idShape, localeOf, localeShape, patchOnlySlugs, readTarget, sameInstant, slugEnum, slugsFor, targetShape, translateLabel };
@@ -1,3 +1,4 @@
1
+ import { slugsFor } from "./shared.mjs";
1
2
  import { APIError, Forbidden } from "payload";
2
3
  //#region src/tools/target.ts
3
4
  const refOf = (target) => ({
@@ -5,21 +6,17 @@ const refOf = (target) => ({
5
6
  slug: target.slug
6
7
  });
7
8
  /**
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.
9
+ * A raw input shape leaves no top-level `.refine` to express "exactly one of
10
+ * collection and global", so the rule is enforced here, with a message naming
11
+ * the offending arguments.
15
12
  */ const resolveTarget = (scope, args, operation) => {
16
13
  const { collection, global } = args;
14
+ const allowedSlugs = slugsFor(scope, operation);
17
15
  if (collection !== void 0 && global !== void 0) throw new APIError("Pass either \"collection\" or \"global\", not both.", 400);
18
16
  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
17
  if (collection !== void 0) {
20
- const allowed = operation === "read" ? scope.readable : scope.writable;
21
18
  const found = scope.req.payload.collections[collection];
22
- if (!allowed.includes(collection) || !found) throw new Forbidden(scope.req.t);
19
+ if (!allowedSlugs.collections.includes(collection) || !found) throw new Forbidden(scope.req.t);
23
20
  return {
24
21
  kind: "collection",
25
22
  slug: collection,
@@ -27,9 +24,8 @@ const refOf = (target) => ({
27
24
  };
28
25
  }
29
26
  const slug = global;
30
- const allowed = operation === "read" ? scope.readableGlobals : scope.writableGlobals;
31
27
  const found = scope.req.payload.globals.config.find((candidate) => candidate.slug === slug);
32
- if (!allowed.includes(slug) || !found) throw new Forbidden(scope.req.t);
28
+ if (!allowedSlugs.globals.includes(slug) || !found) throw new Forbidden(scope.req.t);
33
29
  return {
34
30
  kind: "global",
35
31
  slug,
@@ -1,17 +1,24 @@
1
- import { jsonResult } from "../endpoint/result.mjs";
1
+ import { jsonResult } from "../result.mjs";
2
2
  import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
3
  import { requireIdFor, resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
5
- //#region src/tools/validate-document.ts
6
- const validateDocument = {
6
+ /**
7
+ * Gated on write rather than read, because publish blockers only mean
8
+ * something to a caller who can act on them.
9
+ *
10
+ * It reads the document twice on purpose: once under the key's own access to
11
+ * refuse a caller who may not see it, then privileged, so the check runs over
12
+ * every field rather than the subset the user can read. It carries no
13
+ * `readOnlyHint`, because the traversal fires field hooks.
14
+ */ const validateDocument = defineMcpxTool({
7
15
  name: "validateDocument",
8
16
  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
17
 
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.`,
11
- annotations: {
12
- readOnlyHint: true,
13
- openWorldHint: false
14
- },
18
+ Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
19
+
20
+ Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
21
+ annotations: { openWorldHint: false },
15
22
  isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
16
23
  inputSchema: (scope) => ({
17
24
  ...targetShape(scope, "write", {
@@ -24,7 +31,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
24
31
  description: "Locale to validate."
25
32
  })
26
33
  }),
27
- handler: async (args, scope) => {
34
+ handler: async ({ args, scope }) => {
28
35
  const target = resolveTarget(scope, args, "write");
29
36
  const id = requireIdFor(target, args.id);
30
37
  const locale = localeOf(scope, args.locale);
@@ -39,7 +46,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
39
46
  locale,
40
47
  privileged: true
41
48
  });
42
- const publishBlockers = await collectPublishBlockers(scope.req, {
49
+ const validation = await collectPublishBlockers(scope.req, {
43
50
  doc,
44
51
  entity: target
45
52
  });
@@ -47,9 +54,10 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
47
54
  ...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
48
55
  status: doc["_status"],
49
56
  updatedAt: doc["updatedAt"],
50
- publishBlockers
57
+ publishBlockers: validation.blockers,
58
+ ...validation.unavailable ? { publishBlockersUnavailable: true } : {}
51
59
  });
52
60
  }
53
- };
61
+ });
54
62
  //#endregion
55
63
  export { validateDocument };
package/dist/types.d.mts CHANGED
@@ -12,74 +12,132 @@ declare module "payload" {
12
12
  }
13
13
  }
14
14
  /**
15
- * What an exposed collection offers to MCP clients. A key can only enable
16
- * what the config exposes here.
15
+ * How far an exposed entity lets MCP writes reach.
16
+ *
17
+ * - `false`: no write tool touches it.
18
+ * - `"draft"`: writes land as drafts and nothing MCP does changes what the
19
+ * 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.
17
23
  */
24
+ type McpxWriteMode = "draft" | "live" | false;
25
+ /** A key can only enable what the config exposes here. */
18
26
  interface McpxCollectionOptions {
19
- /** Expose `describeSchema`, `findDocuments` and `getDocument`. Default `true`. */
27
+ /** Expose `describeSchema`, `findDocuments`, `getDocument`. Default `true`. */
20
28
  read?: boolean;
21
29
  /**
22
- * Expose `patchDocument`, `createDocument` and `validateDocument`. Default
23
- * `false`. Requires `versions.drafts` unless `allowLiveWrites` is set.
30
+ * Expose `patchDocument`, `validateDocument` and, unless this is an upload
31
+ * collection, `createDocument`, and how far those writes reach. Default
32
+ * `false`.
24
33
  */
25
- write?: boolean;
26
- /**
27
- * Permit writes to a collection without drafts. Such writes land on the live
28
- * document because there is no draft to land on. Default `false`.
29
- */
30
- allowLiveWrites?: boolean;
34
+ write?: McpxWriteMode;
31
35
  }
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
- */
36
+ /** A singleton, so neither `findDocuments` nor `createDocument` reaches one. */
38
37
  interface McpxGlobalOptions {
39
38
  /** Expose `describeSchema` and `getDocument`. Default `true`. */
40
39
  read?: boolean;
41
40
  /**
42
- * Expose `patchDocument` and `validateDocument`. Default `false`. Requires
43
- * `versions.drafts` unless `allowLiveWrites` is set.
41
+ * Expose `patchDocument` and `validateDocument`, and how far those writes
42
+ * reach. Default `false`.
44
43
  */
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;
44
+ write?: McpxWriteMode;
51
45
  }
52
46
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
47
+ /** What the config exposes, before an API key's checkboxes narrow it. */
48
+ interface McpxExposedEntity {
49
+ slug: string;
50
+ read: boolean;
51
+ write: McpxWriteMode;
52
+ hasDrafts: boolean;
53
+ /** An upload document is a file, and no tool here can supply one. */
54
+ isUpload: boolean;
55
+ /** Name of the capability group on the key document. */
56
+ fieldName: string;
57
+ }
58
+ /** What a tool knows about the current request. */
59
+ interface McpxToolScope {
60
+ req: PayloadRequest;
61
+ capabilities: McpxResolvedCapabilities;
62
+ readable: string[];
63
+ writable: string[];
64
+ publishable: string[];
65
+ readableGlobals: string[];
66
+ writableGlobals: string[];
67
+ publishableGlobals: string[];
68
+ /** `null` when localization is off. */
69
+ locales: null | string[];
70
+ defaultLocale: null | string;
71
+ limits: {
72
+ maxLimit: number;
73
+ maxDepth: number;
74
+ };
75
+ exposure: {
76
+ collections: McpxExposedEntity[];
77
+ globals: McpxExposedEntity[];
78
+ };
79
+ }
53
80
  /**
54
- * A custom tool. It is gated by its own checkbox on every API key and runs
55
- * with `req.user` resolved from the key and `req.context.mcpx` set.
81
+ * A tool, builtin or custom. Runs with `req.user` resolved from the key and
82
+ * `req.context.mcpx` set. `Args` only needs stating when `inputSchema` is built
83
+ * per request, leaving no static shape to infer from.
56
84
  */
57
- interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
85
+ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
58
86
  /** camelCase, unique, not one of the builtin tool names. */
59
87
  name: string;
60
- description: string;
61
- inputSchema?: Shape;
88
+ /** Built per request so it can state what this key's writes actually do. */
89
+ description: string | ((scope: McpxToolScope) => string);
62
90
  annotations?: ToolAnnotations;
91
+ /**
92
+ * A tool that is not enabled never appears in `tools/list`. Defaults to the
93
+ * tool's own checkbox on the API key; defining it replaces that check rather
94
+ * than adding to it.
95
+ */
96
+ isEnabled?: (scope: McpxToolScope) => boolean;
97
+ /**
98
+ * Built per request so enums can be narrowed to what the key may touch.
99
+ * Registered strictly either way: an unknown argument is rejected by name
100
+ * rather than stripped.
101
+ */
102
+ inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
63
103
  handler(ctx: {
64
- args: z.infer<z.ZodObject<Shape>>;
104
+ args: Args;
105
+ scope: McpxToolScope;
106
+ /** Shorthand for `scope.req`. */
65
107
  req: PayloadRequest;
66
108
  extra: McpxToolExtra;
67
109
  }): CallToolResult | Promise<CallToolResult>;
68
110
  }
111
+ /** Argument type erased, so a registry can hold tools of differing shapes. */
112
+ type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
113
+ /** Fixed shape; arguments inferred from it. */
114
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
115
+ inputSchema?: Shape;
116
+ }): McpxTool<Shape>;
117
+ /** Per-request shape returned as an object literal; arguments inferred from it. */
118
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
119
+ inputSchema: (scope: McpxToolScope) => Shape;
120
+ }): McpxAnyTool;
69
121
  /**
70
- * Identity helper that infers the argument type of a custom tool's handler
71
- * from its input schema.
72
- */
73
- declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
74
- /**
75
- * Outcome of resolving an API key. `user` must carry `collection`.
122
+ * Per-request shape built from helpers that erase to `z.ZodRawShape`, as the
123
+ * builtins do. Nothing to infer from, so state the arguments instead.
76
124
  */
125
+ declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
126
+ inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
127
+ }): McpxAnyTool;
77
128
  interface McpxAuthResult {
129
+ /** Must carry `collection`. */
78
130
  user: TypedUser;
79
131
  apiKeyId: number | string;
80
132
  /** The `capabilities` group as stored on the key document. */
81
133
  capabilities: unknown;
82
134
  }
135
+ /**
136
+ * Everything the plugin accepts. `collections` is the only required option.
137
+ *
138
+ * A type alias rather than an interface: `definePlugin` constrains its options
139
+ * to `Record<string, unknown>`, which interfaces do not satisfy.
140
+ */
83
141
  type McpxPluginOptions = {
84
142
  /** Allow-list of collections. `true` is shorthand for `{ read: true }`. */
85
143
  collections: Partial<Record<CollectionSlug, McpxCollectionOptions | true>>;
@@ -90,6 +148,11 @@ type McpxPluginOptions = {
90
148
  apiKeys?: {
91
149
  /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
92
150
  slug?: string;
151
+ /**
152
+ * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
153
+ * client config. Default `true`. The snippets contain the key in full.
154
+ */
155
+ setupGuide?: boolean;
93
156
  /** Final override applied to the generated collection. */
94
157
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
95
158
  };
@@ -103,7 +166,7 @@ type McpxPluginOptions = {
103
166
  /** Upper bound for `depth` on reads. Default 1. */
104
167
  maxDepth?: number;
105
168
  };
106
- tools?: McpxTool[];
169
+ tools?: McpxAnyTool[];
107
170
  auth?: {
108
171
  /** Replace or wrap the default key resolution. Return `null` for 401. */
109
172
  resolve?: (args: {
@@ -116,21 +179,31 @@ type McpxPluginOptions = {
116
179
  version?: string;
117
180
  };
118
181
  };
182
+ /** What a key may do with one entity. Globals reuse this shape. */
119
183
  interface McpxCollectionCapabilities {
120
184
  read: boolean;
121
185
  write: boolean;
186
+ /** Only ever true where the config sets `write: "live"` and drafts exist. */
187
+ publish: boolean;
122
188
  }
123
- /**
124
- * Capabilities in force for one request: plugin config AND key checkboxes.
125
- */
189
+ /** In force for one request: plugin config AND key checkboxes. */
126
190
  interface McpxResolvedCapabilities {
127
191
  collections: Record<string, McpxCollectionCapabilities>;
128
192
  globals: Record<string, McpxCollectionCapabilities>;
129
193
  tools: Record<string, boolean>;
130
194
  }
195
+ /** Stamped on `req.context.mcpx`; see {@link isMcpxRequest}. */
131
196
  interface McpxRequestContext {
132
197
  apiKeyId: number | string;
133
198
  capabilities: McpxResolvedCapabilities;
134
199
  }
200
+ /** One reason a human could not publish the draft as it stands. */
201
+ interface PublishBlocker {
202
+ /** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
203
+ field?: string;
204
+ message: string;
205
+ /** JSON Pointer to the offending value, e.g. "/layout/2/title". */
206
+ path: string;
207
+ }
135
208
  //#endregion
136
- export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
209
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool };
package/dist/types.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  //#region src/types.ts
2
- /**
3
- * Identity helper that infers the argument type of a custom tool's handler
4
- * from its input schema.
5
- */ const defineMcpxTool = (tool) => tool;
2
+ function defineMcpxTool(tool) {
3
+ return tool;
4
+ }
6
5
  //#endregion
7
6
  export { 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 = "0.0.0";
4
+ */ const MCPX_VERSION = "1.0.0";
5
5
  //#endregion
6
6
  export { MCPX_VERSION };
@@ -1,10 +1,9 @@
1
+ import { hasPublishIntent, takePublishIntent } from "./publish-intent.mjs";
2
+ import { isMcpxRequest } from "../request.mjs";
1
3
  import { APIError } from "payload";
2
4
  import { hasDraftsEnabled } from "payload/shared";
3
5
  //#region src/write/draft-guard.ts
4
- /**
5
- * Operation arguments that widen or redirect a write. Cleared on every MCP
6
- * create and update so a tool cannot smuggle them in.
7
- */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
6
+ /** Cleared on every MCP write, publishes included, so none can be smuggled in. */ const STRIPPED_ARGS = /* @__PURE__ */ new Set([
8
7
  "where",
9
8
  "publishAllLocales",
10
9
  "publishSpecificLocale",
@@ -14,26 +13,28 @@ import { hasDraftsEnabled } from "payload/shared";
14
13
  "overwriteExistingFiles"
15
14
  ]);
16
15
  /**
17
- * Whether a request originated from the MCP endpoint. The endpoint stamps
18
- * `req.context.mcpx`, which travels into every local API call made with the
19
- * same `req`, including those made by custom tools.
20
- */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
21
- /**
22
- * Forces every MCP write into a draft save.
16
+ * Forces every MCP write into a draft save, unless it is the one write
17
+ * `publishDocument` asked for.
23
18
  *
24
19
  * `draft` alone is not enough: Payload's update path only saves a draft when
25
20
  * `data._status !== "published"`, so `_status` is dropped and left to Payload.
26
- * This runs as `beforeOperation`, before Payload reads any of these arguments,
27
- * so it holds for every create and update on an MCP request, not only the
28
- * builtin tools. Deletes are not guarded in v1; custom tools that delete are
29
- * the integrator's responsibility.
30
- */ const scrubWriteArgs = (args) => {
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) => {
31
29
  const next = Object.fromEntries(Object.entries(args).filter(([key]) => !STRIPPED_ARGS.has(key)));
32
30
  if (next["data"] && typeof next["data"] === "object") {
33
31
  const { _status: _ignoredStatus, deletedAt: _ignoredDeletedAt, ...data } = next["data"];
34
- next["data"] = data;
32
+ next["data"] = publishing ? {
33
+ ...data,
34
+ _status: "published"
35
+ } : data;
35
36
  }
36
- next["draft"] = true;
37
+ next["draft"] = !publishing;
37
38
  next["autosave"] = false;
38
39
  next["overrideLock"] = false;
39
40
  next["trash"] = false;
@@ -42,52 +43,54 @@ import { hasDraftsEnabled } from "payload/shared";
42
43
  const forceDraftWrite = (hookArgs) => {
43
44
  const { args, operation, req } = hookArgs;
44
45
  if (!isMcpxRequest(req) || operation !== "create" && operation !== "update") return args;
45
- return scrubWriteArgs(args);
46
+ const publishing = operation === "update" && hasPublishIntent(args.data);
47
+ return scrubWriteArgs(args, publishing);
46
48
  };
47
49
  /**
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.
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.
59
57
  */ const forceDraftWriteGlobal = (hookArgs) => {
60
58
  const { operation, req } = hookArgs;
61
59
  const args = hookArgs.args;
62
60
  if (!isMcpxRequest(req) || operation !== "update") return args;
63
- return scrubWriteArgs(args);
61
+ return scrubWriteArgs(args, hasPublishIntent(args["data"]));
64
62
  };
65
63
  /**
66
- * Refuses an MCP write that would still not land as a draft. An alarm rather
67
- * than the guarantee: `forceDraftWrite` should make it unreachable. It throws
68
- * instead of correcting `_status` because Payload has already chosen the write
69
- * branch by the time a `beforeChange` hook runs.
70
- */ const refuseUnlessDraft = (req, slug, data) => {
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) => {
69
+ const publishing = takePublishIntent(data);
71
70
  if (!isMcpxRequest(req)) return;
72
71
  const status = data._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)}).`);
75
- throw new APIError("MCP clients may only write drafts. This write was refused because it would not have been saved as one.", 403);
72
+ const expected = publishing ? "published" : "draft";
73
+ if (status === expected) return;
74
+ req.payload.logger.warn(`[payloadcms-mcpx] Refused a write to ${slug} that would not have been a ${expected} (_status: ${String(status)}).`);
75
+ 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
76
  };
77
- const refusePublish = ({ collection, data, req }) => {
78
- refuseUnlessDraft(req, collection.slug, data);
77
+ /**
78
+ * Installs {@link refuseUnlessExpected} on every collection write. Returns
79
+ * `data` unchanged when the write is allowed; the hook exists for its throw.
80
+ */ const refusePublish = ({ collection, data, req }) => {
81
+ refuseUnlessExpected(req, collection.slug, data);
79
82
  return data;
80
83
  };
81
- /** The global counterpart of {@link refusePublish}. */ const refusePublishGlobal = ({ data, global, req }) => {
84
+ const refusePublishGlobal = ({ data, global, req }) => {
82
85
  const next = data;
83
- refuseUnlessDraft(req, global.slug, next);
86
+ refuseUnlessExpected(req, global.slug, next);
84
87
  return next;
85
88
  };
86
89
  /**
87
90
  * Attaches the draft guard to every collection: `forceDraftWrite` everywhere
88
91
  * (it is a no-op outside MCP requests) and `refusePublish` wherever drafts
89
92
  * exist. Applied to the built collection list so nothing can join later
90
- * without being covered.
93
+ * without being covered. Both are appended last, so a user hook cannot win.
91
94
  */ const installDraftGuards = (collections) => collections.map((collection) => ({
92
95
  ...collection,
93
96
  hooks: {
@@ -110,4 +113,4 @@ const refusePublish = ({ collection, data, req }) => {
110
113
  }
111
114
  }));
112
115
  //#endregion
113
- export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, isMcpxRequest, refusePublish, refusePublishGlobal };
116
+ export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };