@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +328 -211
  3. package/dist/api-keys/fields.mjs +22 -5
  4. package/dist/api-keys/setup-guide.mjs +6 -4
  5. package/dist/auth/resolve.mjs +5 -7
  6. package/dist/capabilities.mjs +23 -4
  7. package/dist/client/index.d.mts +2 -2
  8. package/dist/client/setup-guide.d.mts +1 -1
  9. package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
  10. package/dist/endpoint/handler.mjs +11 -5
  11. package/dist/endpoint/index.mjs +4 -0
  12. package/dist/endpoint/server.mjs +18 -26
  13. package/dist/i18n.mjs +4 -15
  14. package/dist/index.d.mts +4 -4
  15. package/dist/index.mjs +4 -3
  16. package/dist/options.mjs +30 -26
  17. package/dist/plugin.mjs +1 -0
  18. package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
  19. package/dist/request.mjs +8 -0
  20. package/dist/result.d.mts +11 -0
  21. package/dist/result.mjs +20 -0
  22. package/dist/schema/describe.mjs +3 -15
  23. package/dist/schema/index.mjs +8 -0
  24. package/dist/schema/lexical-pointer.mjs +125 -0
  25. package/dist/schema/lexical.mjs +195 -27
  26. package/dist/schema/outline.mjs +67 -0
  27. package/dist/schema/pointer.mjs +77 -30
  28. package/dist/schema/shape.mjs +133 -51
  29. package/dist/schema/walk.mjs +44 -64
  30. package/dist/tools/{index.mjs → builtin.mjs} +8 -5
  31. package/dist/tools/create-document.mjs +34 -15
  32. package/dist/tools/describe-schema.mjs +21 -7
  33. package/dist/tools/find-documents.mjs +13 -6
  34. package/dist/tools/get-document.mjs +45 -11
  35. package/dist/tools/list-capabilities.mjs +19 -9
  36. package/dist/tools/names.mjs +2 -1
  37. package/dist/tools/patch-document.mjs +32 -21
  38. package/dist/tools/publish-document.mjs +79 -0
  39. package/dist/tools/shared.mjs +84 -32
  40. package/dist/tools/target.mjs +7 -11
  41. package/dist/tools/validate-document.mjs +20 -12
  42. package/dist/types.d.mts +110 -42
  43. package/dist/types.mjs +3 -4
  44. package/dist/version.mjs +1 -1
  45. package/dist/write/draft-guard.mjs +47 -44
  46. package/dist/write/patch.mjs +174 -92
  47. package/dist/write/publish-blockers.mjs +13 -12
  48. package/dist/write/publish-intent.mjs +17 -0
  49. package/dist/write/transaction.mjs +8 -3
  50. package/package.json +3 -3
  51. package/dist/i18n.d.mts +0 -1
  52. package/dist/options.d.mts +0 -2
  53. package/dist/schema/lexical.d.mts +0 -1
  54. package/dist/schema/walk.d.mts +0 -3
  55. package/dist/tools/target.d.mts +0 -3
  56. package/dist/tools/types.d.mts +0 -5
  57. package/dist/write/publish-blockers.d.mts +0 -15
@@ -1,4 +1,4 @@
1
- import { CAPABILITIES_FIELD } from "../capabilities.mjs";
1
+ import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite } from "../capabilities.mjs";
2
2
  //#region src/api-keys/fields.ts
3
3
  const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
4
4
  const decryptKey = ({ req, value }) => {
@@ -15,7 +15,7 @@ const checkbox = (name, description) => ({
15
15
  defaultValue: false,
16
16
  admin: { description }
17
17
  });
18
- const SETUP_GUIDE_FIELD = "setupGuide";
18
+ /** Name of the `ui` field the "Connect a client" tab renders. */ const SETUP_GUIDE_FIELD = "setupGuide";
19
19
  /**
20
20
  * Fields every key carries. Key generation and the HMAC index live in the
21
21
  * collection-level `beforeChange` hook (see `collection.ts`), because sibling
@@ -89,25 +89,42 @@ const SETUP_GUIDE_FIELD = "setupGuide";
89
89
  }]
90
90
  }];
91
91
  };
92
+ const PUBLISH_DESCRIPTION = "Publish the current draft. Changes what the public sees.";
92
93
  /**
93
94
  * One checkbox per exposed operation, grouped per collection, per global and
94
95
  * per custom tool. Only operations the plugin config exposes get a checkbox, so
95
96
  * a key can never enable more than the config allows. Everything defaults to
96
97
  * off, which is why a key issued before a capability existed stays closed to it.
98
+ *
99
+ * An entity without versions gets no `publish` checkbox even under
100
+ * `write: "live"`: there is no draft to promote there, the write itself is the
101
+ * live change, and a second checkbox would only make `write` a dead setting.
102
+ *
103
+ * An upload collection gets the same checkboxes as any other, only worded for
104
+ * what `write` reaches there: a document's own fields, never `createDocument`,
105
+ * because the file comes from the admin panel.
97
106
  */ const createCapabilityFields = (options) => {
98
107
  const collectionGroups = options.collections.map((collection) => ({
99
108
  name: collection.fieldName,
100
109
  type: "group",
101
110
  label: collection.slug,
102
- fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
111
+ fields: [
112
+ ...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
113
+ ...canWrite(collection) ? [checkbox("write", canCreate(collection) ? "Create, patch and validate drafts." : "Patch and validate drafts. The file itself is uploaded in the admin panel.")] : [],
114
+ ...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
115
+ ]
103
116
  }));
104
117
  const globalGroups = options.globals.map((global) => ({
105
118
  name: global.fieldName,
106
119
  type: "group",
107
120
  label: global.slug,
108
- fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
121
+ fields: [
122
+ ...global.read ? [checkbox("read", "Describe and read this global.")] : [],
123
+ ...canWrite(global) ? [checkbox("write", "Patch and validate this global's draft.")] : [],
124
+ ...canPublish(global) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
125
+ ]
109
126
  }));
110
- const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
127
+ const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
111
128
  const groups = [
112
129
  ...collectionGroups.length > 0 ? [{
113
130
  name: "collections",
@@ -1,5 +1,8 @@
1
1
  //#region src/api-keys/setup-guide.ts
2
- const KEY_PLACEHOLDER = "<your-key>";
2
+ /**
3
+ * Stands in for the key in the snippets whenever the real one is unavailable,
4
+ * so the instructions still render and say what is missing.
5
+ */ const KEY_PLACEHOLDER = "<your-key>";
3
6
  /**
4
7
  * Server name for the client config. MCP clients key their config by this, so
5
8
  * it has to survive labels with spaces or punctuation.
@@ -8,9 +11,8 @@ const KEY_PLACEHOLDER = "<your-key>";
8
11
  return slug === "" ? "payload" : slug;
9
12
  };
10
13
  /**
11
- * The connection instructions for one key, split into independently copyable
12
- * blocks. Kept a pure builder so the admin component holds only rendering and
13
- * the snippets stay unit-testable.
14
+ * A pure builder, so the admin component holds only rendering and the snippets
15
+ * stay unit-testable.
14
16
  */ const buildSetupGuide = (input) => {
15
17
  const key = typeof input.apiKey === "string" ? input.apiKey : KEY_PLACEHOLDER;
16
18
  const name = toServerName(input.label);
@@ -1,17 +1,15 @@
1
1
  import { hashApiKey } from "../api-keys/key.mjs";
2
2
  //#region src/auth/resolve.ts
3
3
  const BEARER = /^Bearer\s+(\S+)\s*$/i;
4
- /**
5
- * The bearer token of an `Authorization` header, or `null`.
6
- */ const parseBearer = (headers) => {
7
- const header = headers.get("authorization");
8
- if (!header) return null;
9
- return BEARER.exec(header.trim())?.[1] ?? null;
10
- };
11
4
  const relationId = (value) => {
12
5
  if (typeof value === "string" || typeof value === "number") return value;
13
6
  if (typeof value === "object" && value !== null && "id" in value) return value.id;
14
7
  };
8
+ const parseBearer = (headers) => {
9
+ const header = headers.get("authorization");
10
+ if (!header) return null;
11
+ return BEARER.exec(header.trim())?.[1] ?? null;
12
+ };
15
13
  /**
16
14
  * Resolves the bearer key of a request to the user it acts as.
17
15
  *
@@ -1,8 +1,23 @@
1
1
  //#region src/capabilities.ts
2
- /** Name of the capability group on the key document. */ const CAPABILITIES_FIELD = "capabilities";
2
+ /** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
3
+ /** Whatever the write lands on; {@link isLiveWrite} tells the two apart. */ const canWrite = (entity) => entity.write !== false;
4
+ /** The config lets MCP change live content and there is a draft to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
5
+ /**
6
+ * An upload document is a file, and no tool here carries one. Its own fields
7
+ * stay patchable; the first version is made in the admin panel.
8
+ */ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
9
+ /**
10
+ * With no versions there is no draft to land on, so `write: "live"` permits the
11
+ * write at all and every write is live.
12
+ */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
3
13
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
14
  const flag = (group, name) => isRecord(group) && group[name] === true;
5
15
  /**
16
+ * Publishing is an extension of writing, never a capability of its own: a key
17
+ * that may publish may also edit the draft it publishes. Both checkboxes are
18
+ * therefore required, on top of the config exposing publishing at all.
19
+ */ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
20
+ /**
6
21
  * Capabilities in force for a key: the plugin config decides what can exist,
7
22
  * the key's checkboxes decide what does. A missing checkbox is `false`, so keys
8
23
  * issued before a capability existed stay closed.
@@ -15,7 +30,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
15
30
  const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
16
31
  collections[collection.slug] = {
17
32
  read: collection.read && flag(group, "read"),
18
- write: collection.write && flag(group, "write")
33
+ write: canWrite(collection) && flag(group, "write"),
34
+ publish: publishFlag(collection, group)
19
35
  };
20
36
  }
21
37
  const globals = {};
@@ -23,7 +39,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
23
39
  const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
24
40
  globals[global.slug] = {
25
41
  read: global.read && flag(group, "read"),
26
- write: global.write && flag(group, "write")
42
+ write: canWrite(global) && flag(group, "write"),
43
+ publish: publishFlag(global, group)
27
44
  };
28
45
  }
29
46
  const tools = {};
@@ -37,7 +54,9 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
37
54
  const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
38
55
  const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
39
56
  const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
57
+ const publishableSlugs = (capabilities) => pick(capabilities.collections, "publish");
40
58
  const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
41
59
  const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
60
+ const publishableGlobalSlugs = (capabilities) => pick(capabilities.globals, "publish");
42
61
  //#endregion
43
- export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
62
+ export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
@@ -1,2 +1,2 @@
1
- import { McpxSetupGuide, McpxSetupGuideProps } from "./setup-guide.mjs";
2
- export { McpxSetupGuide, type McpxSetupGuideProps };
1
+ import { McpxSetupGuide } from "./setup-guide.mjs";
2
+ export { McpxSetupGuide };
@@ -11,4 +11,4 @@ interface McpxSetupGuideProps {
11
11
  */
12
12
  declare const McpxSetupGuide: React.FC<McpxSetupGuideProps>;
13
13
  //#endregion
14
- export { McpxSetupGuide, type McpxSetupGuideProps };
14
+ export { McpxSetupGuide };
@@ -1,6 +1,8 @@
1
+ import { errorResult } from "../result.mjs";
1
2
  import { pointerFromPayloadPath } from "../schema/walk.mjs";
3
+ import "../schema/index.mjs";
2
4
  import { APIError, ValidationError } from "payload";
3
- //#region src/endpoint/result.ts
5
+ //#region src/endpoint/errors.ts
4
6
  /**
5
7
  * A JSON-RPC error response for failures that happen before the MCP server
6
8
  * is involved (auth, method, body parsing).
@@ -20,27 +22,6 @@ import { APIError, ValidationError } from "payload";
20
22
  });
21
23
  };
22
24
  /**
23
- * A successful tool result carrying `value` as JSON text.
24
- */ const jsonResult = (value) => ({ content: [{
25
- type: "text",
26
- text: JSON.stringify(value)
27
- }] });
28
- /**
29
- * A failed tool result. `extras` travel alongside the message so the client
30
- * can act on them (problems, validation errors, the current `updatedAt`).
31
- */ const errorResult = (message, extras = {}) => ({
32
- content: [{
33
- type: "text",
34
- text: JSON.stringify({
35
- error: message,
36
- ...extras
37
- })
38
- }],
39
- isError: true
40
- });
41
- /**
42
- * Maps an exception thrown by a tool to a result the client can read.
43
- *
44
25
  * Payload's public errors keep their message and status; a `ValidationError`
45
26
  * also surfaces its per-field detail, with each field's path restated as a
46
27
  * JSON Pointer so it reads like every other path this plugin reports. Anything
@@ -62,4 +43,4 @@ import { APIError, ValidationError } from "payload";
62
43
  return errorResult("Internal error");
63
44
  };
64
45
  //#endregion
65
- export { errorResult, jsonResult, jsonRpcError, toToolError };
46
+ export { jsonRpcError, toToolError };
@@ -1,6 +1,6 @@
1
- import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
1
+ import { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
2
+ import { jsonRpcError } from "./errors.mjs";
2
3
  import { resolveApiKeyAuth } from "../auth/resolve.mjs";
3
- import { jsonRpcError } from "./result.mjs";
4
4
  import { createMcpServer } from "./server.mjs";
5
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6
6
  //#region src/endpoint/handler.ts
@@ -8,14 +8,20 @@ const buildScope = (req, options, capabilities) => {
8
8
  const { localization } = req.payload.config;
9
9
  return {
10
10
  req,
11
- options,
12
11
  capabilities,
13
12
  readable: readableSlugs(capabilities),
14
13
  writable: writableSlugs(capabilities),
14
+ publishable: publishableSlugs(capabilities),
15
15
  readableGlobals: readableGlobalSlugs(capabilities),
16
16
  writableGlobals: writableGlobalSlugs(capabilities),
17
+ publishableGlobals: publishableGlobalSlugs(capabilities),
17
18
  locales: localization ? localization.localeCodes : null,
18
- defaultLocale: localization ? localization.defaultLocale : null
19
+ defaultLocale: localization ? localization.defaultLocale : null,
20
+ limits: options.limits,
21
+ exposure: {
22
+ collections: options.collections,
23
+ globals: options.globals
24
+ }
19
25
  };
20
26
  };
21
27
  /**
@@ -68,7 +74,7 @@ const buildScope = (req, options, capabilities) => {
68
74
  code: -32600,
69
75
  message: "Invalid request: a JSON body is required."
70
76
  });
71
- const server = createMcpServer(buildScope(req, options, capabilities));
77
+ const server = createMcpServer(buildScope(req, options, capabilities), options);
72
78
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
73
79
  await server.connect(transport);
74
80
  const headers = new Headers(req.headers);
@@ -0,0 +1,4 @@
1
+ import { jsonRpcError, toToolError } from "./errors.mjs";
2
+ import { createMcpServer, isToolEnabled, toolDescription, toolInputSchema } from "./server.mjs";
3
+ import { createMcpxHandler, methodNotAllowed } from "./handler.mjs";
4
+ export { createMcpServer, createMcpxHandler, isToolEnabled, jsonRpcError, methodNotAllowed, toToolError, toolDescription, toolInputSchema };
@@ -1,24 +1,23 @@
1
- import { toToolError } from "./result.mjs";
2
- import { BUILTIN_TOOLS } from "../tools/index.mjs";
1
+ import { toToolError } from "./errors.mjs";
2
+ import { draftSentence } from "../tools/shared.mjs";
3
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { z } from "zod";
5
6
  //#region src/endpoint/server.ts
7
+ /** Strict, so an unknown argument is rejected by name rather than stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
8
+ /** May be built from the scope, to name the targets this key writes live. */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
9
+ /** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
6
10
  /**
7
- * Builds a builtin tool's input schema as a strict object, so an unknown
8
- * argument is rejected with its name instead of being silently stripped and
9
- * the tool answering as if it had not been passed.
10
- */ const builtinInputSchema = (tool, scope) => z.strictObject(tool.inputSchema(scope));
11
- /**
12
- * Builds the MCP server for one request. Tools are registered against the
13
- * key's capabilities, so `tools/list` shows exactly what the key may call and
14
- * every `collection` enum is limited to what it may touch.
15
- */ const createMcpServer = (scope) => {
16
- const { req, options, capabilities } = scope;
11
+ * One server per request. Builtin and configured tools take the same route,
12
+ * each registered against the key's capabilities, so `tools/list` shows exactly
13
+ * what the key may call.
14
+ */ const createMcpServer = (scope, options) => {
15
+ const { req } = scope;
17
16
  const { logger } = req.payload;
18
17
  const server = new McpServer({
19
18
  name: options.serverInfo.name,
20
19
  version: options.serverInfo.version
21
- }, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
20
+ }, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
22
21
  const guarded = (run) => async () => {
23
22
  try {
24
23
  return await run();
@@ -26,22 +25,15 @@ import { z } from "zod";
26
25
  return toToolError(error, logger);
27
26
  }
28
27
  };
29
- for (const tool of BUILTIN_TOOLS) {
30
- if (!tool.isEnabled(scope)) continue;
31
- server.registerTool(tool.name, {
32
- description: tool.description,
33
- inputSchema: builtinInputSchema(tool, scope),
34
- annotations: tool.annotations
35
- }, (args) => guarded(() => tool.handler(args, scope))());
36
- }
37
- for (const tool of options.tools) {
38
- if (capabilities.tools[tool.name] !== true) continue;
28
+ for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
29
+ if (!isToolEnabled(tool, scope)) continue;
39
30
  server.registerTool(tool.name, {
40
- description: tool.description,
41
- inputSchema: tool.inputSchema ?? {},
31
+ description: toolDescription(tool, scope),
32
+ inputSchema: toolInputSchema(tool, scope),
42
33
  ...tool.annotations ? { annotations: tool.annotations } : {}
43
34
  }, (args, extra) => guarded(() => tool.handler({
44
35
  args,
36
+ scope,
45
37
  req,
46
38
  extra
47
39
  }))());
@@ -49,4 +41,4 @@ import { z } from "zod";
49
41
  return server;
50
42
  };
51
43
  //#endregion
52
- export { builtinInputSchema, createMcpServer };
44
+ export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
package/dist/i18n.mjs CHANGED
@@ -1,11 +1,6 @@
1
1
  //#region src/i18n.ts
2
- /**
3
- * A locale-keyed record, once it is known to hold nothing but strings.
4
- */ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
5
- /**
6
- * Picks the entry a language addresses, treating an empty value as absent so
7
- * the chain continues rather than yielding a useless string.
8
- */ const pick = (record, language) => {
2
+ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
3
+ /** Treats an empty value as absent, so the fallback chain continues. */ const pick = (record, language) => {
9
4
  for (const code of Array.isArray(language) ? language : [language]) {
10
5
  const entry = record[code];
11
6
  if (entry !== void 0 && entry.trim() !== "") return entry;
@@ -25,14 +20,8 @@
25
20
  if (!record) return;
26
21
  return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
27
22
  };
28
- /**
29
- * Binds {@link translateStatic} to a request's language, so a walk that
30
- * resolves many descriptions carries no request of its own.
31
- */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
32
- /**
33
- * Translator for callers with no request in hand. Both language keys miss, so
34
- * the chain degrades to the record's first entry.
35
- */ const translateAny = translatorFor({
23
+ /** Bound to one request's language, so a walk carries no request of its own. */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
24
+ /** For callers with no request: both keys miss, so the first entry wins. */ const translateAny = translatorFor({
36
25
  fallbackLanguage: "",
37
26
  language: ""
38
27
  });
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
1
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
2
2
  import { mcpxPlugin } from "./plugin.mjs";
3
- import { isMcpxRequest } from "./write/draft-guard.mjs";
4
- import { PublishBlocker } from "./write/publish-blockers.mjs";
5
- export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
3
+ import { isMcpxRequest } from "./request.mjs";
4
+ import { errorResult, jsonResult } from "./result.mjs";
5
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { isMcpxRequest } from "./write/draft-guard.mjs";
2
- import { mcpxPlugin } from "./plugin.mjs";
1
+ import { errorResult, jsonResult } from "./result.mjs";
3
2
  import { defineMcpxTool } from "./types.mjs";
4
- export { defineMcpxTool, isMcpxRequest, mcpxPlugin };
3
+ import { isMcpxRequest } from "./request.mjs";
4
+ import { mcpxPlugin } from "./plugin.mjs";
5
+ export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/options.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
2
2
  import "./version.mjs";
3
3
  import { InvalidConfiguration } from "payload";
4
- import { hasDraftsEnabled } from "payload/shared";
4
+ import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
5
5
  //#region src/options.ts
6
6
  const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
7
7
  const DEFAULT_ENDPOINT_PATH = "/mcpx";
@@ -11,29 +11,36 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
11
11
  const fail = (message) => {
12
12
  throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
13
13
  };
14
+ /** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
14
15
  /**
15
- * Lower camel case of a slug, the same transform the stock MCP plugin applies
16
- * to derive field names from collection slugs.
17
- */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
18
- /**
19
- * Refuses collections that must never be reachable through MCP, read included.
20
16
  * Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
21
- * read, and email or lockout state is PII either way.
17
+ * read, and email or lockout state is PII either way. Refused for read too.
22
18
  */ const assertExposable = (collection, apiKeysSlug) => {
23
19
  const { slug } = collection;
24
20
  if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
25
21
  if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
26
22
  };
23
+ /** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
24
+ if (value === void 0 || value === false) return false;
25
+ if (value === "draft" || value === "live") return value;
26
+ return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
27
+ };
28
+ /**
29
+ * `localizeStatus` makes `_status` a localized field, which flips Payload's
30
+ * `publishAllLocales` default to false and turns `_status` into a locale-keyed
31
+ * object. Publishing would then cover one locale while reporting success, and
32
+ * the tool responses model `_status` as a string. Refused until both are
33
+ * handled.
34
+ */ const assertPublishable = (kind, config) => {
35
+ if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
36
+ };
27
37
  const assertWritable = (collection, options) => {
28
38
  const { slug } = collection;
29
- if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
30
39
  if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
31
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
40
+ if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
41
+ if (options.write === "live") assertPublishable("Collection", collection);
32
42
  };
33
- /**
34
- * Refuses globals that must never be reachable. Globals cannot be auth or
35
- * upload entities, so only Payload's own reserved namespace is left to guard.
36
- */ const assertGlobalExposable = (global) => {
43
+ /** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
37
44
  if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
38
45
  };
39
46
  /**
@@ -41,7 +48,8 @@ const assertWritable = (collection, options) => {
41
48
  * `createdAt`/`updatedAt`, so the concurrency check the collection path guards
42
49
  * for is always available here. Drafts are the only requirement left.
43
50
  */ const assertGlobalWritable = (global, options) => {
44
- if (!options.hasDrafts && !options.allowLiveWrites) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set allowLiveWrites.`);
51
+ if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
52
+ if (options.write === "live") assertPublishable("Global", global);
45
53
  };
46
54
  const normalizeCollections = (config, options, apiKeysSlug) => {
47
55
  const collections = config.collections ?? [];
@@ -56,12 +64,12 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
56
64
  const normalized = {
57
65
  slug,
58
66
  read: settings.read ?? true,
59
- write: settings.write ?? false,
60
- allowLiveWrites: settings.allowLiveWrites ?? false,
67
+ write: normalizeWriteMode("Collection", slug, settings.write),
61
68
  hasDrafts,
69
+ isUpload: Boolean(collection.upload),
62
70
  fieldName: toCamelCase(slug)
63
71
  };
64
- if (normalized.write) assertWritable(collection, normalized);
72
+ if (normalized.write !== false) assertWritable(collection, normalized);
65
73
  if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
66
74
  fieldNames.add(normalized.fieldName);
67
75
  return [normalized];
@@ -80,12 +88,12 @@ const normalizeGlobals = (config, options) => {
80
88
  const normalized = {
81
89
  slug,
82
90
  read: settings.read ?? true,
83
- write: settings.write ?? false,
84
- allowLiveWrites: settings.allowLiveWrites ?? false,
91
+ write: normalizeWriteMode("Global", slug, settings.write),
85
92
  hasDrafts,
93
+ isUpload: false,
86
94
  fieldName: toCamelCase(slug)
87
95
  };
88
- if (normalized.write) assertGlobalWritable(global, normalized);
96
+ if (normalized.write !== false) assertGlobalWritable(global, normalized);
89
97
  if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
90
98
  fieldNames.add(normalized.fieldName);
91
99
  return [normalized];
@@ -115,11 +123,7 @@ const normalizeLimits = (limits) => {
115
123
  maxDepth
116
124
  };
117
125
  };
118
- /**
119
- * Validates the plugin options against the incoming config and fills in
120
- * defaults. Every problem is an `InvalidConfiguration` so misconfiguration
121
- * fails at startup instead of at request time.
122
- */ const normalizeOptions = (config, options) => {
126
+ /** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
123
127
  const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
124
128
  const userCollection = options.userCollection ?? config.admin?.user ?? "users";
125
129
  if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
@@ -138,7 +142,7 @@ const normalizeLimits = (limits) => {
138
142
  auth: options.auth,
139
143
  serverInfo: {
140
144
  name: options.serverInfo?.name ?? "payloadcms-mcpx",
141
- version: options.serverInfo?.version ?? "0.0.0"
145
+ version: options.serverInfo?.version ?? "1.0.0"
142
146
  }
143
147
  };
144
148
  };
package/dist/plugin.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createApiKeysCollection } from "./api-keys/collection.mjs";
2
2
  import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
3
+ import "./endpoint/index.mjs";
3
4
  import { normalizeOptions } from "./options.mjs";
4
5
  import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
5
6
  import { definePlugin } from "payload";
@@ -1,5 +1,5 @@
1
- import { CollectionConfig, PayloadRequest } from "payload";
2
- //#region src/write/draft-guard.d.ts
1
+ import { PayloadRequest } from "payload";
2
+ //#region src/request.d.ts
3
3
  /**
4
4
  * Whether a request originated from the MCP endpoint. The endpoint stamps
5
5
  * `req.context.mcpx`, which travels into every local API call made with the
@@ -0,0 +1,8 @@
1
+ //#region src/request.ts
2
+ /**
3
+ * Whether a request originated from the MCP endpoint. The endpoint stamps
4
+ * `req.context.mcpx`, which travels into every local API call made with the
5
+ * same `req`, including those made by custom tools.
6
+ */ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
7
+ //#endregion
8
+ export { isMcpxRequest };
@@ -0,0 +1,11 @@
1
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ //#region src/result.d.ts
3
+ /** `value` as JSON text. */
4
+ declare const jsonResult: (value: unknown) => CallToolResult;
5
+ /**
6
+ * `extras` travel alongside the message so the client can act on them:
7
+ * problems, validation errors, the current `updatedAt`.
8
+ */
9
+ declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
10
+ //#endregion
11
+ export { errorResult, jsonResult };
@@ -0,0 +1,20 @@
1
+ //#region src/result.ts
2
+ /** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
3
+ type: "text",
4
+ text: JSON.stringify(value)
5
+ }] });
6
+ /**
7
+ * `extras` travel alongside the message so the client can act on them:
8
+ * problems, validation errors, the current `updatedAt`.
9
+ */ const errorResult = (message, extras = {}) => ({
10
+ content: [{
11
+ type: "text",
12
+ text: JSON.stringify({
13
+ error: message,
14
+ ...extras
15
+ })
16
+ }],
17
+ isError: true
18
+ });
19
+ //#endregion
20
+ export { errorResult, jsonResult };
@@ -2,13 +2,8 @@ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
2
  import { translateAny } from "../i18n.mjs";
3
3
  import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
4
4
  //#region src/schema/describe.ts
5
- /**
6
- * The longest descriptor path that is a prefix of `remaining`. Blocks and rich
7
- * text fields are both leaves of the walk, so at most one can match.
8
- */ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
9
- /**
10
- * Walks one step of a schema path through a blocks field.
11
- */ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
5
+ /** Blocks and rich text are both leaves of the walk, so at most one matches. */ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
6
+ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
12
7
  const field = findBlocksField(fields, match);
13
8
  if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
14
9
  const slug = remaining.at(match.length);
@@ -22,8 +17,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
22
17
  };
23
18
  };
24
19
  /**
25
- * Walks one step of a schema path into a Lexical node's own fields.
26
- *
27
20
  * A node that picks a block by slug takes one segment more, so `/content/block`
28
21
  * addresses the choice and `/content/block/callout` the definition. Everything
29
22
  * else, a link node being the usual case, resolves in a single segment.
@@ -52,8 +45,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
52
45
  };
53
46
  };
54
47
  /**
55
- * Walks a schema path to the field list it addresses.
56
- *
57
48
  * A schema path alternates a blocks field's own path with the slug of one of
58
49
  * the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
59
50
  * reaches `hero` as it exists under `pages` specifically. The slug sits where
@@ -114,11 +105,8 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
114
105
  }));
115
106
  };
116
107
  /**
117
- * Describes a collection or global root, one block reached through a schema
118
- * path, or the fields a Lexical node carries.
119
- *
120
108
  * Curried on the translator that resolves each `admin.description`, so a
121
- * request binds its language once and the walk itself stays request-free.
109
+ * request binds its language once and the walk stays request-free.
122
110
  */ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
123
111
  const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
124
112
  const descriptors = describeFields(fields, translate);
@@ -0,0 +1,8 @@
1
+ import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
3
+ import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
4
+ import { resolveLexicalPointer } from "./lexical-pointer.mjs";
5
+ import { lexicalOutline } from "./outline.mjs";
6
+ import { resolveDataPointer } from "./pointer.mjs";
7
+ import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
8
+ export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };