@abinnovision/payloadcms-mcpx 1.0.0-beta.10 → 1.0.0-beta.11

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.
package/README.md CHANGED
@@ -297,9 +297,62 @@ const publishQueue = defineMcpxTool({
297
297
 
298
298
  Each custom tool gets its own checkbox on every API key, default off.
299
299
 
300
- Custom tool shapes are registered as given, and the MCP SDK wraps them in a
301
- non-strict object: unknown arguments are stripped before your handler runs.
302
- Builtin tools reject them instead.
300
+ Custom tools take the same route as the builtins: one `McpxTool` shape, one
301
+ registration loop. Anything a builtin does, a custom tool can do.
302
+
303
+ `handler` receives `scope` alongside `args`, `req` and `extra`. The scope
304
+ carries what the key may touch (`readable`, `writable`, `readableGlobals`,
305
+ `writableGlobals`), the configured locales, the limits in force and the
306
+ exposed collections and globals. `req` is shorthand for `scope.req`.
307
+
308
+ `inputSchema` may be a function of that scope instead of a fixed shape, which
309
+ is how a tool narrows an enum to what the key may read:
310
+
311
+ ```ts
312
+ import { defineMcpxTool } from "@abinnovision/payloadcms-mcpx";
313
+ import { z } from "zod";
314
+
315
+ const whichCollection = defineMcpxTool({
316
+ name: "whichCollection",
317
+ description: "Echoes back one of the collections this key may read.",
318
+ isEnabled: (scope) =>
319
+ scope.capabilities.tools["whichCollection"] === true &&
320
+ scope.readable.length > 0,
321
+ inputSchema: (scope) => ({
322
+ collection: z.enum(scope.readable as [string, ...string[]]),
323
+ }),
324
+ handler: ({ args }) => ({
325
+ content: [{ type: "text", text: args.collection }],
326
+ }),
327
+ });
328
+ ```
329
+
330
+ `defineMcpxTool` defines every tool, builtin ones included, and infers the
331
+ handler's arguments from the input schema either way: from a fixed shape, or
332
+ from the object literal a per-request shape returns. Above, `args` is
333
+ `{ collection: string }` without being told.
334
+
335
+ Inference reaches as far as the shape's static type. A helper returning
336
+ `z.ZodRawShape` erases that type and leaves `args` as
337
+ `Record<string, unknown>`, so the builtins' shape helpers declare the superset
338
+ they produce instead: which keys a helper emits depends on the key's scope,
339
+ and the declared type states what a handler must cope with across every scope.
340
+ Their arguments stay derived from their schema that way, and cannot drift from
341
+ it. If your own helpers erase, state the arguments as a type argument:
342
+ `defineMcpxTool<Args>({ ... })`.
343
+
344
+ `isEnabled` decides whether the tool is registered for this key at all: a tool
345
+ that is not enabled never appears in `tools/list`. It defaults to the tool's
346
+ own checkbox, which is what the builtins replace to derive their availability
347
+ from the key's collection and global capabilities. Defining it **replaces**
348
+ the checkbox check, so restate `scope.capabilities.tools[name]` when you still
349
+ want it, as above.
350
+
351
+ Every input schema is registered strictly, custom tools included: an unknown
352
+ argument is rejected by name rather than stripped before the handler runs.
353
+
354
+ `jsonResult` and `errorResult` are exported so a custom tool can return
355
+ results shaped like a builtin's.
303
356
 
304
357
  ## Options
305
358
 
@@ -320,7 +373,7 @@ Builtin tools reject them instead.
320
373
  | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
321
374
  | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
322
375
  | `limits.maxDepth` | `1` | Upper bound for `depth` on reads. |
323
- | `tools` | `[]` | Custom tools. |
376
+ | `tools` | `[]` | Custom tools, defined the same way as the builtins. |
324
377
  | `auth.resolve` | none | Replace or wrap the default key resolution. |
325
378
  | `serverInfo` | package name and version | Reported to MCP clients. |
326
379
 
@@ -1,6 +1,6 @@
1
+ import { jsonRpcError } from "./result.mjs";
1
2
  import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.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,18 @@ 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),
15
14
  readableGlobals: readableGlobalSlugs(capabilities),
16
15
  writableGlobals: writableGlobalSlugs(capabilities),
17
16
  locales: localization ? localization.localeCodes : null,
18
- defaultLocale: localization ? localization.defaultLocale : null
17
+ defaultLocale: localization ? localization.defaultLocale : null,
18
+ limits: options.limits,
19
+ exposure: {
20
+ collections: options.collections,
21
+ globals: options.globals
22
+ }
19
23
  };
20
24
  };
21
25
  /**
@@ -68,7 +72,7 @@ const buildScope = (req, options, capabilities) => {
68
72
  code: -32600,
69
73
  message: "Invalid request: a JSON body is required."
70
74
  });
71
- const server = createMcpServer(buildScope(req, options, capabilities));
75
+ const server = createMcpServer(buildScope(req, options, capabilities), options);
72
76
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
73
77
  await server.connect(transport);
74
78
  const headers = new Headers(req.headers);
@@ -0,0 +1,14 @@
1
+ import "payload";
2
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ //#region src/endpoint/result.d.ts
4
+ /**
5
+ * A successful tool result carrying `value` as JSON text.
6
+ */
7
+ declare const jsonResult: (value: unknown) => CallToolResult;
8
+ /**
9
+ * A failed tool result. `extras` travel alongside the message so the client
10
+ * can act on them (problems, validation errors, the current `updatedAt`).
11
+ */
12
+ declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
13
+ //#endregion
14
+ export { errorResult, jsonResult };
@@ -4,16 +4,24 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { z } from "zod";
5
5
  //#region src/endpoint/server.ts
6
6
  /**
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));
7
+ * Builds a tool's input schema as a strict object, so an unknown argument is
8
+ * rejected with its name instead of being silently stripped and the tool
9
+ * answering as if it had not been passed. A tool may build its shape from the
10
+ * scope to narrow enums to what the key may touch.
11
+ */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
11
12
  /**
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;
13
+ * Whether the key may call the tool. A tool that does not decide for itself is
14
+ * gated by its own checkbox on the key, which is how the tools from
15
+ * `options.tools` work; the builtins derive it from the key's collection and
16
+ * global capabilities instead.
17
+ */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
18
+ /**
19
+ * Builds the MCP server for one request. Builtin and configured tools take the
20
+ * same route: each is registered against the key's capabilities, so
21
+ * `tools/list` shows exactly what the key may call and every `collection` enum
22
+ * is limited to what it may touch.
23
+ */ const createMcpServer = (scope, options) => {
24
+ const { req } = scope;
17
25
  const { logger } = req.payload;
18
26
  const server = new McpServer({
19
27
  name: options.serverInfo.name,
@@ -26,22 +34,15 @@ import { z } from "zod";
26
34
  return toToolError(error, logger);
27
35
  }
28
36
  };
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;
37
+ for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
38
+ if (!isToolEnabled(tool, scope)) continue;
39
39
  server.registerTool(tool.name, {
40
40
  description: tool.description,
41
- inputSchema: tool.inputSchema ?? {},
41
+ inputSchema: toolInputSchema(tool, scope),
42
42
  ...tool.annotations ? { annotations: tool.annotations } : {}
43
43
  }, (args, extra) => guarded(() => tool.handler({
44
44
  args,
45
+ scope,
45
46
  req,
46
47
  extra
47
48
  }))());
@@ -49,4 +50,4 @@ import { z } from "zod";
49
50
  return server;
50
51
  };
51
52
  //#endregion
52
- export { builtinInputSchema, createMcpServer };
53
+ export { createMcpServer, isToolEnabled, toolInputSchema };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
- import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
1
+ import { errorResult, jsonResult } from "./endpoint/result.mjs";
2
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool } from "./types.mjs";
2
3
  import { mcpxPlugin } from "./plugin.mjs";
3
4
  import { isMcpxRequest } from "./write/draft-guard.mjs";
4
5
  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 };
6
+ export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type McpxToolScope, type PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
+ import { errorResult, jsonResult } from "./endpoint/result.mjs";
2
+ import { defineMcpxTool } from "./types.mjs";
1
3
  import { isMcpxRequest } from "./write/draft-guard.mjs";
2
4
  import { mcpxPlugin } from "./plugin.mjs";
3
- import { defineMcpxTool } from "./types.mjs";
4
- export { defineMcpxTool, isMcpxRequest, mcpxPlugin };
5
+ export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
@@ -2,11 +2,12 @@ import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
2
  import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
3
3
  import { resolveTarget } from "./target.mjs";
4
4
  import { validateWriteValue } from "../schema/shape.mjs";
5
+ import { defineMcpxTool } from "../types.mjs";
5
6
  import { stripRowIds } from "../write/patch.mjs";
6
7
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
7
8
  import { z } from "zod";
8
9
  //#region src/tools/create-document.ts
9
- const createDocument = {
10
+ const createDocument = defineMcpxTool({
10
11
  name: "createDocument",
11
12
  description: `Creates a new document as a draft from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings. The draft may be incomplete: the response lists "publishBlockers", which patchDocument can then work through. Use this when no document exists yet; prefer patching an existing draft otherwise.`,
12
13
  annotations: {
@@ -24,7 +25,7 @@ const createDocument = {
24
25
  }),
25
26
  data: z.record(z.string(), z.unknown()).describe("Initial field values, as describeSchema lists them.")
26
27
  }),
27
- handler: async (args, scope) => {
28
+ handler: async ({ args, scope }) => {
28
29
  const target = resolveTarget(scope, { collection: args.collection }, "write");
29
30
  const { payload } = scope.req;
30
31
  const locale = localeOf(scope, args.locale);
@@ -63,6 +64,6 @@ const createDocument = {
63
64
  ...publishBlockers.length > 0 ? { publishBlockers } : {}
64
65
  });
65
66
  }
66
- };
67
+ });
67
68
  //#endregion
68
69
  export { createDocument };
@@ -2,10 +2,11 @@ import { translatorFor } from "../i18n.mjs";
2
2
  import { jsonResult } from "../endpoint/result.mjs";
3
3
  import { targetShape } from "./shared.mjs";
4
4
  import { refOf, resolveTarget } from "./target.mjs";
5
+ import { defineMcpxTool } from "../types.mjs";
5
6
  import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
6
7
  import { z } from "zod";
7
8
  //#region src/tools/describe-schema.ts
8
- const describeSchema = {
9
+ const describeSchema = defineMcpxTool({
9
10
  name: "describeSchema",
10
11
  description: `Describes the writable shape of a document, one node at a time.
11
12
 
@@ -31,7 +32,7 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
31
32
  paths: z.array(z.string()).optional().describe("Schema paths to describe, e.g. \"/layout/sections/sectionWrapper\". Omit for the collection root."),
32
33
  expand: z.boolean().optional().describe("Return every node reachable from the root in one response. Ignores paths.")
33
34
  }),
34
- handler: (args, scope) => {
35
+ handler: ({ args, scope }) => {
35
36
  const ref = refOf(resolveTarget(scope, args, "read"));
36
37
  const { config } = scope.req.payload;
37
38
  const describeNode = nodeDescriber(translatorFor(scope.req.i18n));
@@ -49,6 +50,6 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
49
50
  if (expanded?.truncated) nodes.push({ error: `Result truncated after ${String(400)} nodes. Request explicit paths instead.` });
50
51
  return Promise.resolve(jsonResult(nodes));
51
52
  }
52
- };
53
+ });
53
54
  //#endregion
54
55
  export { describeSchema };
@@ -1,9 +1,10 @@
1
1
  import { jsonResult } from "../endpoint/result.mjs";
2
2
  import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
3
3
  import { resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { z } from "zod";
5
6
  //#region src/tools/find-documents.ts
6
- const findDocuments = {
7
+ const findDocuments = defineMcpxTool({
7
8
  name: "findDocuments",
8
9
  description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
9
10
  annotations: {
@@ -15,7 +16,7 @@ const findDocuments = {
15
16
  collection: slugEnum(scope.readable).describe("Collection to search."),
16
17
  where: z.record(z.string(), z.unknown()).optional().describe("Payload where query."),
17
18
  sort: z.string().optional().describe("Sort field, prefix with \"-\" for descending."),
18
- limit: z.number().int().min(1).max(scope.options.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.options.limits.maxLimit)}.`),
19
+ limit: z.number().int().min(1).max(scope.limits.maxLimit).optional().describe(`Documents per page. Default 10, at most ${String(scope.limits.maxLimit)}.`),
19
20
  page: z.number().int().min(1).optional().describe("Page number, from 1."),
20
21
  ...depthShape(scope),
21
22
  select: z.record(z.string(), z.unknown()).optional().describe("Fields to return, e.g. {\"title\":true}."),
@@ -25,7 +26,7 @@ const findDocuments = {
25
26
  }),
26
27
  draft: z.boolean().optional().describe("Include the latest drafts. Default true.")
27
28
  }),
28
- handler: async (args, scope) => {
29
+ handler: async ({ args, scope }) => {
29
30
  resolveTarget(scope, { collection: args.collection }, "read");
30
31
  const locale = localeOf(scope, args.locale);
31
32
  const result = await scope.req.payload.find({
@@ -50,6 +51,6 @@ const findDocuments = {
50
51
  hasNextPage: result.hasNextPage
51
52
  });
52
53
  }
53
- };
54
+ });
54
55
  //#endregion
55
56
  export { findDocuments };
@@ -2,10 +2,11 @@ import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
2
2
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
3
3
  import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
4
4
  import { requireIdFor, resolveTarget } from "./target.mjs";
5
+ import { defineMcpxTool } from "../types.mjs";
5
6
  import { z } from "zod";
6
7
  import { Pointer } from "rfc6902";
7
8
  //#region src/tools/get-document.ts
8
- const getDocument = {
9
+ const getDocument = defineMcpxTool({
9
10
  name: "getDocument",
10
11
  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
 
@@ -29,7 +30,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
29
30
  }),
30
31
  draft: z.boolean().optional().describe("Return the latest draft. Default true.")
31
32
  }),
32
- handler: async (args, scope) => {
33
+ handler: async ({ args, scope }) => {
33
34
  const target = resolveTarget(scope, args, "read");
34
35
  const id = requireIdFor(target, args.id);
35
36
  const locale = localeOf(scope, args.locale);
@@ -63,6 +64,6 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
63
64
  value
64
65
  });
65
66
  }
66
- };
67
+ });
67
68
  //#endregion
68
69
  export { getDocument };
@@ -7,9 +7,12 @@ import { patchDocument } from "./patch-document.mjs";
7
7
  import { validateDocument } from "./validate-document.mjs";
8
8
  //#region src/tools/index.ts
9
9
  /**
10
- * The builtin tools in registration order. The surface is fixed: adding a
11
- * collection, block or field never changes it. Typed over `never` because
12
- * each tool validates its own arguments through its input schema.
10
+ * The builtin tools in registration order. They are ordinary {@link McpxTool}s
11
+ * that ship with the plugin and register through the same loop as the tools
12
+ * from `options.tools`; only their `isEnabled` differs, deriving from the
13
+ * key's collection and global capabilities rather than a checkbox of their
14
+ * own. The surface is fixed: adding a collection, block or field never
15
+ * changes it.
13
16
  */ const BUILTIN_TOOLS = [
14
17
  listCapabilities,
15
18
  describeSchema,
@@ -1,9 +1,10 @@
1
1
  import { translatorFor } from "../i18n.mjs";
2
2
  import { jsonResult } from "../endpoint/result.mjs";
3
3
  import { translateLabel } from "./shared.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { hasDraftValidationEnabled } from "payload/shared";
5
6
  //#region src/tools/list-capabilities.ts
6
- const listCapabilities = {
7
+ const listCapabilities = defineMcpxTool({
7
8
  name: "listCapabilities",
8
9
  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
 
@@ -14,10 +15,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
14
15
  },
15
16
  isEnabled: () => true,
16
17
  inputSchema: () => ({}),
17
- handler: (_args, scope) => {
18
+ handler: ({ scope }) => {
18
19
  const { payload } = scope.req;
19
20
  const translate = translatorFor(scope.req.i18n);
20
- const collections = scope.options.collections.flatMap((entry) => {
21
+ const collections = scope.exposure.collections.flatMap((entry) => {
21
22
  const capability = scope.capabilities.collections[entry.slug];
22
23
  const collection = payload.collections[entry.slug];
23
24
  if (!capability || !collection || !(capability.read || capability.write)) return [];
@@ -37,7 +38,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
37
38
  idType: collection.customIDType ?? payload.db.defaultIDType
38
39
  }];
39
40
  });
40
- const globals = scope.options.globals.flatMap((entry) => {
41
+ const globals = scope.exposure.globals.flatMap((entry) => {
41
42
  const capability = scope.capabilities.globals[entry.slug];
42
43
  const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
43
44
  if (!capability || !config || !(capability.read || capability.write)) return [];
@@ -59,10 +60,10 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
59
60
  codes: scope.locales,
60
61
  default: scope.defaultLocale
61
62
  } : null,
62
- limits: scope.options.limits,
63
+ limits: scope.limits,
63
64
  tools: Object.entries(scope.capabilities.tools).filter(([, enabled]) => enabled).map(([name]) => name)
64
65
  }));
65
66
  }
66
- };
67
+ });
67
68
  //#endregion
68
69
  export { listCapabilities };
@@ -1,6 +1,7 @@
1
1
  import { errorResult, jsonResult } from "../endpoint/result.mjs";
2
2
  import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
3
3
  import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
4
+ import { defineMcpxTool } from "../types.mjs";
4
5
  import { PATCH_OPERATION_SCHEMA, applyPatchToCopy, buildWriteData, findPatchProblems, isElementPointer } from "../write/patch.mjs";
5
6
  import { collectPublishBlockers } from "../write/publish-blockers.mjs";
6
7
  import { withTransaction } from "../write/transaction.mjs";
@@ -42,7 +43,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
42
43
  const actual = pointer.get(saved);
43
44
  return survives(expected, actual) ? [] : [operation.path];
44
45
  });
45
- const patchDocument = {
46
+ const patchDocument = defineMcpxTool({
46
47
  name: "patchDocument",
47
48
  description: DESCRIPTION,
48
49
  annotations: {
@@ -65,11 +66,12 @@ const patchDocument = {
65
66
  patches: z.array(PATCH_OPERATION_SCHEMA).min(1).describe("Operations, applied in order."),
66
67
  expectedUpdatedAt: z.string().optional().describe("The updatedAt read before patching. The write is refused if the document has changed since.")
67
68
  }),
68
- handler: async (args, scope) => {
69
+ handler: async ({ args, scope }) => {
69
70
  const target = resolveTarget(scope, args, "write");
70
71
  const id = requireIdFor(target, args.id);
71
72
  const { payload } = scope.req;
72
73
  const locale = localeOf(scope, args.locale);
74
+ const patches = args.patches;
73
75
  return await withTransaction(scope.req, async () => {
74
76
  const doc = await readTarget(scope, {
75
77
  target,
@@ -79,11 +81,11 @@ const patchDocument = {
79
81
  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"] });
80
82
  const problems = findPatchProblems(payload.config, {
81
83
  doc,
82
- patches: args.patches,
84
+ patches,
83
85
  ref: refOf(target)
84
86
  });
85
87
  if (problems.length > 0) return errorResult("No operation was applied.", { problems });
86
- const applied = applyPatchToCopy(doc, args.patches);
88
+ const applied = applyPatchToCopy(doc, patches);
87
89
  if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
88
90
  const write = {
89
91
  data: buildWriteData(payload.config, target.config, applied.next),
@@ -108,7 +110,7 @@ const patchDocument = {
108
110
  locale,
109
111
  privileged: true
110
112
  });
111
- const notApplied = notAppliedPointers(args.patches, applied.next, saved);
113
+ const notApplied = notAppliedPointers(patches, applied.next, saved);
112
114
  const publishBlockers = await collectPublishBlockers(scope.req, {
113
115
  doc: saved,
114
116
  entity: target
@@ -122,6 +124,6 @@ const patchDocument = {
122
124
  });
123
125
  });
124
126
  }
125
- };
127
+ });
126
128
  //#endregion
127
129
  export { patchDocument };
@@ -4,6 +4,11 @@ import { z } from "zod";
4
4
  //#region src/tools/shared.ts
5
5
  const slugEnum = (slugs) => z.enum(slugs);
6
6
  const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
7
+ /**
8
+ * Widens one branch to the superset a handler sees. The widening itself is
9
+ * unchecked — the runtime shape really does vary — so `Branch` checks what it
10
+ * can around it.
11
+ */ const widen = (branch) => branch;
7
12
  const slugsFor = (scope, operation) => ({
8
13
  collections: operation === "read" ? scope.readable : scope.writable,
9
14
  globals: operation === "read" ? scope.readableGlobals : scope.writableGlobals
@@ -18,12 +23,12 @@ const slugsFor = (scope, operation) => ({
18
23
  * argument optional, and the handler enforces the exclusivity there.
19
24
  */ const targetShape = (scope, operation, descriptions) => {
20
25
  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 {
26
+ if (globals.length === 0) return widen({ collection: slugEnum(collections).describe(descriptions.collection) });
27
+ if (collections.length === 0) return widen({ global: slugEnum(globals).describe(descriptions.global) });
28
+ return widen({
24
29
  collection: slugEnum(collections).optional().describe(descriptions.collection),
25
30
  global: slugEnum(globals).optional().describe(descriptions.global)
26
- };
31
+ });
27
32
  };
28
33
  /**
29
34
  * The `id` argument, which only a collection document has. Omitted when the key
@@ -31,18 +36,18 @@ const slugsFor = (scope, operation) => ({
31
36
  * in between, where `requireIdFor` enforces the dependency.
32
37
  */ const idShape = (scope, operation) => {
33
38
  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\".") };
39
+ if (collections.length === 0) return widen({});
40
+ if (globals.length === 0) return widen({ id: idSchema });
41
+ return widen({ id: idSchema.optional().describe("Document id. Required with \"collection\"; must be omitted with \"global\".") });
37
42
  };
38
43
  /**
39
44
  * The `locale` argument, present only when localization is configured.
40
45
  */ const localeShape = (scope, options) => {
41
- if (!scope.locales) return {};
46
+ if (!scope.locales) return widen({});
42
47
  const locale = z.enum(scope.locales);
43
- return { locale: (options.required ? locale : locale.optional()).describe(options.description) };
48
+ return widen({ locale: (options.required ? locale : locale.optional()).describe(options.description) });
44
49
  };
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)}.`) });
50
+ 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
51
  /**
47
52
  * The locale to operate on: the explicit argument, else the request's, else
48
53
  * the default. `undefined` when localization is off.
@@ -1,3 +1,3 @@
1
- import "./types.mjs";
1
+ import "../types.mjs";
2
2
  import "../schema/walk.mjs";
3
3
  import "payload";
@@ -1,9 +1,10 @@
1
1
  import { jsonResult } from "../endpoint/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
6
  //#region src/tools/validate-document.ts
6
- const validateDocument = {
7
+ const validateDocument = defineMcpxTool({
7
8
  name: "validateDocument",
8
9
  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
 
@@ -24,7 +25,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
24
25
  description: "Locale to validate."
25
26
  })
26
27
  }),
27
- handler: async (args, scope) => {
28
+ handler: async ({ args, scope }) => {
28
29
  const target = resolveTarget(scope, args, "write");
29
30
  const id = requireIdFor(target, args.id);
30
31
  const locale = localeOf(scope, args.locale);
@@ -50,6 +51,6 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
50
51
  publishBlockers
51
52
  });
52
53
  }
53
- };
54
+ });
54
55
  //#endregion
55
56
  export { validateDocument };
package/dist/types.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
2
2
  import { z } from "zod";
3
- import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
3
  import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
4
+ import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
5
5
  //#region src/types.d.ts
6
6
  declare module "payload" {
7
7
  interface RequestContext {
@@ -53,26 +53,110 @@ interface McpxGlobalOptions {
53
53
  }
54
54
  type McpxToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
55
55
  /**
56
- * A custom tool. It is gated by its own checkbox on every API key and runs
57
- * with `req.user` resolved from the key and `req.context.mcpx` set.
56
+ * A collection or global the plugin config exposes, before an API key's
57
+ * checkboxes narrow it further.
58
+ */
59
+ interface McpxExposedEntity {
60
+ slug: string;
61
+ read: boolean;
62
+ write: boolean;
63
+ allowLiveWrites: boolean;
64
+ hasDrafts: boolean;
65
+ /** Name of the capability group on the key document. */
66
+ fieldName: string;
67
+ }
68
+ /**
69
+ * Everything a tool knows about the current request: the authenticated
70
+ * request, what this key may touch and the limits in force.
71
+ */
72
+ interface McpxToolScope {
73
+ req: PayloadRequest;
74
+ capabilities: McpxResolvedCapabilities;
75
+ /** Collection slugs the key may read / write. */
76
+ readable: string[];
77
+ writable: string[];
78
+ /** Global slugs the key may read / write. */
79
+ readableGlobals: string[];
80
+ writableGlobals: string[];
81
+ /** Configured locale codes, or `null` when localization is off. */
82
+ locales: null | string[];
83
+ defaultLocale: null | string;
84
+ limits: {
85
+ maxLimit: number;
86
+ maxDepth: number;
87
+ };
88
+ /** What the plugin config exposes, before the key's checkboxes apply. */
89
+ exposure: {
90
+ collections: McpxExposedEntity[];
91
+ globals: McpxExposedEntity[];
92
+ };
93
+ }
94
+ /**
95
+ * A tool. The builtins and any tool passed through `options.tools` use this
96
+ * same shape and register through the same loop. Every tool runs with
97
+ * `req.user` resolved from the key and `req.context.mcpx` set.
98
+ *
99
+ * `Args` only needs stating when `inputSchema` is built per request, which
100
+ * leaves no static shape to infer from; a tool with a fixed shape gets its
101
+ * argument type from that shape.
58
102
  */
59
- interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape> {
103
+ interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
60
104
  /** camelCase, unique, not one of the builtin tool names. */
61
105
  name: string;
62
106
  description: string;
63
- inputSchema?: Shape;
64
107
  annotations?: ToolAnnotations;
108
+ /**
109
+ * Whether this key may call the tool; a tool that is not enabled never
110
+ * appears in `tools/list`. Defaults to the tool's own checkbox on the API
111
+ * key, which the builtins replace to derive availability from the key's
112
+ * collection and global capabilities. Defining it replaces that checkbox
113
+ * check rather than adding to it.
114
+ */
115
+ isEnabled?: (scope: McpxToolScope) => boolean;
116
+ /**
117
+ * A fixed shape, or one built per request so enums can be narrowed to what
118
+ * the key may touch. Registered strictly either way: an unknown argument is
119
+ * rejected by name instead of being stripped and the tool answering as if
120
+ * it had not been passed.
121
+ */
122
+ inputSchema?: Shape | ((scope: McpxToolScope) => z.ZodRawShape);
65
123
  handler(ctx: {
66
- args: z.infer<z.ZodObject<Shape>>;
124
+ args: Args;
125
+ scope: McpxToolScope;
126
+ /** Shorthand for `scope.req`. */
67
127
  req: PayloadRequest;
68
128
  extra: McpxToolExtra;
69
129
  }): CallToolResult | Promise<CallToolResult>;
70
130
  }
71
131
  /**
72
- * Identity helper that infers the argument type of a custom tool's handler
73
- * from its input schema.
132
+ * A tool with its argument type erased, which is how a registry holds tools of
133
+ * differing input shapes. Each tool validates its own arguments through its
134
+ * input schema.
135
+ */
136
+ type McpxAnyTool = McpxTool<z.ZodRawShape, never>;
137
+ /**
138
+ * Defines a tool with a fixed input shape. The handler's arguments are
139
+ * inferred from that shape.
140
+ */
141
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
142
+ inputSchema?: Shape;
143
+ }): McpxTool<Shape>;
144
+ /**
145
+ * Defines a tool whose input shape is built per request and returned as an
146
+ * object literal. The handler's arguments are inferred from that literal, so
147
+ * a scope-narrowed enum still types as the value it produces.
148
+ */
149
+ declare function defineMcpxTool<Shape extends z.ZodRawShape>(tool: McpxTool<Shape> & {
150
+ inputSchema: (scope: McpxToolScope) => Shape;
151
+ }): McpxAnyTool;
152
+ /**
153
+ * Defines a tool whose input shape is assembled from helpers that erase to
154
+ * `z.ZodRawShape`, as the builtins do. Nothing is left to infer from, so the
155
+ * handler's arguments are stated instead: `defineMcpxTool<Args>({ ... })`.
74
156
  */
75
- declare const defineMcpxTool: <Shape extends z.ZodRawShape>(tool: McpxTool<Shape>) => McpxTool<Shape>;
157
+ declare function defineMcpxTool<Args>(tool: McpxTool<z.ZodRawShape, Args> & {
158
+ inputSchema: (scope: McpxToolScope) => z.ZodRawShape;
159
+ }): McpxAnyTool;
76
160
  /**
77
161
  * Outcome of resolving an API key. `user` must carry `collection`.
78
162
  */
@@ -110,7 +194,7 @@ type McpxPluginOptions = {
110
194
  /** Upper bound for `depth` on reads. Default 1. */
111
195
  maxDepth?: number;
112
196
  };
113
- tools?: McpxTool[];
197
+ tools?: McpxAnyTool[];
114
198
  auth?: {
115
199
  /** Replace or wrap the default key resolution. Return `null` for 401. */
116
200
  resolve?: (args: {
@@ -140,4 +224,4 @@ interface McpxRequestContext {
140
224
  capabilities: McpxResolvedCapabilities;
141
225
  }
142
226
  //#endregion
143
- export { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool };
227
+ export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.10",
4
+ "version": "1.0.0-beta.11",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",
@@ -1,2 +0,0 @@
1
- import "./types.mjs";
2
- import "payload";
@@ -1,5 +0,0 @@
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";