@abinnovision/payloadcms-mcpx 2.0.0-beta.9 → 2.1.0-beta.1

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -36
  3. package/dist/admin/capability-matrix.d.mts +5 -3
  4. package/dist/admin/capability-matrix.mjs +374 -272
  5. package/dist/admin/capability-toggles.mjs +79 -42
  6. package/dist/admin/confirmations-panel.mjs +232 -0
  7. package/dist/admin/confirmations.d.mts +15 -0
  8. package/dist/admin/confirmations.mjs +71 -0
  9. package/dist/admin/endpoint-url.mjs +7 -0
  10. package/dist/admin/index.d.mts +2 -1
  11. package/dist/admin/index.mjs +2 -1
  12. package/dist/admin/setup-guide.d.mts +3 -3
  13. package/dist/admin/setup-guide.mjs +51 -33
  14. package/dist/api-keys/capability-matrix.d.mts +7 -9
  15. package/dist/api-keys/capability-matrix.mjs +15 -37
  16. package/dist/api-keys/collection.mjs +16 -18
  17. package/dist/api-keys/confirmation-view.mjs +7 -0
  18. package/dist/api-keys/fields.mjs +50 -57
  19. package/dist/auth/resolve.mjs +69 -40
  20. package/dist/capabilities.d.mts +2 -0
  21. package/dist/capabilities.mjs +64 -16
  22. package/dist/define-tool.d.mts +4 -4
  23. package/dist/define-tool.mjs +8 -1
  24. package/dist/endpoint/confirmations.mjs +124 -0
  25. package/dist/endpoint/handler.mjs +25 -27
  26. package/dist/endpoint/http.mjs +27 -0
  27. package/dist/endpoint/server.mjs +48 -29
  28. package/dist/endpoint/upload.mjs +104 -0
  29. package/dist/guards.mjs +8 -1
  30. package/dist/index.d.mts +2 -2
  31. package/dist/index.mjs +1 -1
  32. package/dist/leases/confirmation.mjs +74 -0
  33. package/dist/leases/grant.mjs +29 -0
  34. package/dist/leases/lease.mjs +115 -0
  35. package/dist/options.mjs +68 -61
  36. package/dist/plugin.d.mts +3 -2
  37. package/dist/plugin.mjs +19 -4
  38. package/dist/request.mjs +5 -1
  39. package/dist/result.mjs +11 -1
  40. package/dist/schema/admin-hidden.mjs +4 -4
  41. package/dist/schema/describe.mjs +12 -18
  42. package/dist/schema/index.mjs +5 -6
  43. package/dist/schema/lexical-pointer.mjs +8 -8
  44. package/dist/schema/lexical.mjs +15 -13
  45. package/dist/schema/outline.mjs +4 -5
  46. package/dist/schema/path.mjs +4 -1
  47. package/dist/schema/pointer.mjs +11 -17
  48. package/dist/schema/shape.mjs +38 -50
  49. package/dist/schema/walk.mjs +57 -18
  50. package/dist/tools/builtin.mjs +7 -1
  51. package/dist/tools/create-document.mjs +38 -26
  52. package/dist/tools/delete-document.mjs +154 -0
  53. package/dist/tools/describe-schema.mjs +7 -5
  54. package/dist/tools/document.mjs +131 -45
  55. package/dist/tools/duplicate-document.mjs +50 -0
  56. package/dist/tools/find-documents.mjs +15 -20
  57. package/dist/tools/find-versions.mjs +11 -15
  58. package/dist/tools/get-document.mjs +31 -15
  59. package/dist/tools/links.mjs +53 -0
  60. package/dist/tools/list-capabilities.mjs +17 -7
  61. package/dist/tools/patch-document.mjs +57 -53
  62. package/dist/tools/publish-document.mjs +17 -29
  63. package/dist/tools/query-paths.mjs +1 -1
  64. package/dist/tools/run-confirmed.mjs +114 -0
  65. package/dist/tools/shared.mjs +53 -46
  66. package/dist/tools/validate-document.mjs +6 -8
  67. package/dist/tools/versions.mjs +11 -6
  68. package/dist/types.d.mts +158 -43
  69. package/dist/upload/file.mjs +178 -0
  70. package/dist/version.mjs +4 -2
  71. package/dist/write/draft-guard.mjs +35 -28
  72. package/dist/write/patch.mjs +4 -4
  73. package/dist/write/publish-intent.mjs +23 -11
  74. package/dist/write/row-ids.mjs +3 -1
  75. package/dist/write/write-data.mjs +18 -31
  76. package/package.json +14 -14
  77. package/dist/builtin-tool-names.mjs +0 -16
@@ -0,0 +1,124 @@
1
+ import { isPlainObject } from "../guards.mjs";
2
+ import { leaseContext, leaseHandle, leaseKvSlug } from "../leases/lease.mjs";
3
+ import { respond } from "./http.mjs";
4
+ import { CONFIRMATIONS_PATH, DECISIONS } from "../api-keys/confirmation-view.mjs";
5
+ import { relationId } from "../auth/resolve.mjs";
6
+ import { decideConfirmations, issueConfirmation, listConfirmations } from "../leases/confirmation.mjs";
7
+ import { publicOrigin } from "../request.mjs";
8
+ import { jsonResult } from "../result.mjs";
9
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
10
+ import { formatAdminURL } from "payload/shared";
11
+ //#region src/endpoint/confirmations.ts
12
+ const confirmableTool = (name) => BUILTIN_TOOLS.find((tool) => tool.name === name && tool.confirm);
13
+ const keyViewUrl = (req, options, apiKeyId) => {
14
+ return formatAdminURL({
15
+ adminRoute: req.payload.config.routes.admin,
16
+ path: `/collections/${options.apiKeysSlug}/${String(apiKeyId)}`,
17
+ serverURL: publicOrigin(req)
18
+ });
19
+ };
20
+ /**
21
+ * The `confirmation` a confirmable tool's handler gets on an MCP call. Its
22
+ * `request` stores the call for the key's user to approve and answers with
23
+ * the confirmation the client passes to `runConfirmed`.
24
+ */ const confirmationFor = (tool, scope, options) => ({
25
+ confirmed: false,
26
+ request: async (args) => {
27
+ const { req } = scope;
28
+ const { slug, apiKeyId } = leaseContext(req);
29
+ const view = keyViewUrl(req, options, apiKeyId);
30
+ const { id, exp } = await issueConfirmation(req.payload, slug, {
31
+ apiKeyId,
32
+ tool,
33
+ args,
34
+ locale: req.locale ?? null,
35
+ fallbackLocale: req.fallbackLocale ?? null
36
+ });
37
+ return jsonResult({ confirmation: {
38
+ id,
39
+ url: `${view}?confirmation=${encodeURIComponent(id)}`,
40
+ expiresAt: new Date(exp).toISOString()
41
+ } });
42
+ }
43
+ });
44
+ const authorize = async (req, options, keyId) => {
45
+ const slug = leaseKvSlug(req.payload.config);
46
+ if (slug === void 0) return respond(404, { error: "Confirmations are not available." });
47
+ if (req.user?.collection !== options.userCollection) return respond(401, { error: "Sign in as the user of this key." });
48
+ const key = typeof keyId === "string" || typeof keyId === "number" ? await req.payload.findByID({
49
+ collection: options.apiKeysSlug,
50
+ id: keyId,
51
+ depth: 0,
52
+ overrideAccess: true,
53
+ disableErrors: true,
54
+ select: { user: true }
55
+ }).catch(() => null) : null;
56
+ const user = relationId(key?.user);
57
+ if (!key || user === void 0 || String(user) !== String(req.user.id)) return respond(403, { error: "Only the user of this key may decide on its calls." });
58
+ return {
59
+ slug,
60
+ apiKeyId: key.id
61
+ };
62
+ };
63
+ const viewOf = async (req, entry, highlight) => {
64
+ const { tool: name, args } = entry.confirmation;
65
+ const tool = confirmableTool(name);
66
+ const summary = await tool?.confirm?.summarize({ args }, req).catch((error) => {
67
+ req.payload.logger.warn({
68
+ err: error,
69
+ msg: "[payloadcms-mcpx] Could not summarize a confirmation."
70
+ });
71
+ });
72
+ return {
73
+ handle: entry.handle,
74
+ tool: name,
75
+ group: tool?.confirm?.label ?? name,
76
+ highlighted: entry.handle === highlight,
77
+ summary: summary ?? {
78
+ label: name,
79
+ permanent: true
80
+ }
81
+ };
82
+ };
83
+ /**
84
+ * Lists the pending calls of `?key=`, for its user's admin session. A
85
+ * `?confirmation=` id marks its call as highlighted.
86
+ */ const listHandler = (options) => async (req) => {
87
+ const allowed = await authorize(req, options, req.searchParams.get("key"));
88
+ if (allowed instanceof Response) return allowed;
89
+ const { payload } = req;
90
+ const highlighted = req.searchParams.get("confirmation");
91
+ const highlight = highlighted === null ? void 0 : leaseHandle(payload, highlighted);
92
+ const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
93
+ const confirmations = [];
94
+ for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
95
+ return respond(200, { confirmations });
96
+ };
97
+ /**
98
+ * Approves or rejects pending calls of a key, for its user's admin session.
99
+ * The body is `{ key, handles, decision }`. Answers with the handles it
100
+ * changed; a call already decided, run or expired is left out.
101
+ */ const decideHandler = (options) => async (req) => {
102
+ const body = await req.json?.().catch(() => void 0);
103
+ const allowed = await authorize(req, options, isPlainObject(body) ? body["key"] : void 0);
104
+ if (allowed instanceof Response) return allowed;
105
+ const handles = isPlainObject(body) ? body["handles"] : void 0;
106
+ const decision = isPlainObject(body) ? body["decision"] : void 0;
107
+ const isDecision = (value) => DECISIONS.some((candidate) => candidate === value);
108
+ if (!Array.isArray(handles) || handles.length > 50 || !handles.every((handle) => typeof handle === "string") || !isDecision(decision)) return respond(400, { error: `Send "handles", at most ${String(50)}, and a "decision" of "approved" or "rejected".` });
109
+ const decided = await decideConfirmations(req.payload, allowed.slug, allowed.apiKeyId, handles, decision);
110
+ return respond(200, { decided });
111
+ };
112
+ /**
113
+ * The endpoints the API key's edit view reads and decides through.
114
+ */ const confirmationEndpoints = (options) => [{
115
+ path: `${options.endpointPath}${CONFIRMATIONS_PATH}`,
116
+ method: "get",
117
+ handler: listHandler(options)
118
+ }, {
119
+ path: `${options.endpointPath}${CONFIRMATIONS_PATH}`,
120
+ method: "post",
121
+ handler: decideHandler(options)
122
+ }];
123
+ //#endregion
124
+ export { confirmationEndpoints, confirmationFor };
@@ -1,4 +1,5 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
+ import { leasesAvailable, readCapped } from "./http.mjs";
2
3
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
3
4
  import { jsonRpcError } from "./errors.mjs";
4
5
  import { createMcpServer } from "./server.mjs";
@@ -6,19 +7,6 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
6
7
  //#region src/endpoint/handler.ts
7
8
  const BODY_BYTES_LIMIT = 4194304;
8
9
  const BATCH_MESSAGES_LIMIT = 10;
9
- const readBody = async (req) => {
10
- if (Number(req.headers.get("content-length")) > BODY_BYTES_LIMIT) return null;
11
- if (!req.body) return;
12
- const decoder = new TextDecoder();
13
- let text = "";
14
- let size = 0;
15
- for await (const chunk of req.body) {
16
- size += chunk.byteLength;
17
- if (size > BODY_BYTES_LIMIT) return null;
18
- text += decoder.decode(chunk, { stream: true });
19
- }
20
- return text + decoder.decode();
21
- };
22
10
  /**
23
11
  * The scope the tools of one request see: the key's resolved capabilities, the
24
12
  * slugs they reach, and the locales and limits of the config.
@@ -34,6 +22,8 @@ const readBody = async (req) => {
34
22
  defaultLocale: localization.defaultLocale
35
23
  } : null,
36
24
  limits: options.limits,
25
+ uploads: leasesAvailable(req, options) !== void 0,
26
+ diagnostics: options.diagnostics ? options.serverInfo : null,
37
27
  exposure: {
38
28
  collections: options.collections,
39
29
  globals: options.globals
@@ -41,6 +31,22 @@ const readBody = async (req) => {
41
31
  };
42
32
  };
43
33
  /**
34
+ * Makes `req` act as the key: sets `req.user`, replacing any user Payload
35
+ * resolved from cookies, and the `req.context.mcpx` stamp the draft guard
36
+ * reads. Returns the scope the tools see.
37
+ */ const authenticateAs = (req, options, auth) => {
38
+ const capabilities = resolveCapabilities(options, auth.capabilities);
39
+ req.user = auth.user;
40
+ req.context = {
41
+ ...req.context,
42
+ mcpx: {
43
+ apiKeyId: auth.apiKeyId,
44
+ capabilities
45
+ }
46
+ };
47
+ return buildScope(req, options, capabilities);
48
+ };
49
+ /**
44
50
  * Answers GET and DELETE on the endpoint path. The server is stateless and
45
51
  * never streams, so only POST carries meaning.
46
52
  */ const methodNotAllowed = () => jsonRpcError({
@@ -69,24 +75,16 @@ const readBody = async (req) => {
69
75
  headers: { "www-authenticate": "Bearer" }
70
76
  });
71
77
  }
72
- const capabilities = resolveCapabilities(options, auth.capabilities);
73
- req.user = auth.user;
74
- req.context = {
75
- ...req.context,
76
- mcpx: {
77
- apiKeyId: auth.apiKeyId,
78
- capabilities
79
- }
80
- };
78
+ const scope = authenticateAs(req, options, auth);
81
79
  let parsedBody;
82
80
  try {
83
- const text = await readBody(req);
84
- if (text === null) return jsonRpcError({
81
+ const body = await readCapped(req, BODY_BYTES_LIMIT);
82
+ if (body === null) return jsonRpcError({
85
83
  status: 413,
86
84
  code: -32e3,
87
85
  message: `Request body too large: the limit is ${String(BODY_BYTES_LIMIT / 1024 / 1024)} MB.`
88
86
  });
89
- parsedBody = text === void 0 ? void 0 : JSON.parse(text);
87
+ parsedBody = body === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(body));
90
88
  } catch {
91
89
  return jsonRpcError({
92
90
  status: 400,
@@ -104,7 +102,7 @@ const readBody = async (req) => {
104
102
  code: -32600,
105
103
  message: `Invalid request: a batch may hold at most ${String(BATCH_MESSAGES_LIMIT)} messages.`
106
104
  });
107
- const server = createMcpServer(buildScope(req, options, capabilities), options);
105
+ const server = createMcpServer(scope, options);
108
106
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
109
107
  await server.connect(transport);
110
108
  const headers = new Headers(req.headers);
@@ -119,4 +117,4 @@ const readBody = async (req) => {
119
117
  }
120
118
  };
121
119
  //#endregion
122
- export { buildScope, createMcpxHandler, methodNotAllowed };
120
+ export { authenticateAs, buildScope, createMcpxHandler, methodNotAllowed };
@@ -0,0 +1,27 @@
1
+ import { leaseKvSlug } from "../leases/lease.mjs";
2
+ //#region src/endpoint/http.ts
3
+ const respond = (status, body) => Response.json(body, { status });
4
+ /**
5
+ * The body, `null` once it passes `max` bytes, or `undefined` when there is
6
+ * none. A declared length over `max` is refused unread. A chunked body
7
+ * declares none, so bytes are counted as they arrive and reading stops at
8
+ * `max`.
9
+ */ const readCapped = async (req, max) => {
10
+ if (Number(req.headers.get("content-length")) > max) return null;
11
+ if (!req.body) return;
12
+ const chunks = [];
13
+ let size = 0;
14
+ for await (const chunk of req.body) {
15
+ size += chunk.byteLength;
16
+ if (size > max) return null;
17
+ chunks.push(chunk);
18
+ }
19
+ return Buffer.concat(chunks);
20
+ };
21
+ /**
22
+ * The lease KV slug where uploads, downloads and confirmations are possible:
23
+ * the KV is database-backed and no custom `auth.resolve` is set, since the
24
+ * lease endpoints cannot replay what such a resolver authenticated by.
25
+ */ const leasesAvailable = (req, options) => options.auth?.resolve === void 0 ? leaseKvSlug(req.payload.config) : void 0;
26
+ //#endregion
27
+ export { leasesAvailable, readCapped, respond };
@@ -1,16 +1,45 @@
1
- import { toToolError } from "./errors.mjs";
1
+ import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
+ import { fileSlugs } from "../upload/file.mjs";
2
3
  import { liveWriteSlugs } from "../tools/shared.mjs";
4
+ import { MCPX_REPOSITORY_URL } from "../version.mjs";
3
5
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
6
+ import { confirmationFor } from "./confirmations.mjs";
7
+ import { toToolError } from "./errors.mjs";
4
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { z } from "zod";
6
9
  //#region src/endpoint/server.ts
7
- /**
8
- * Strict, so an unknown argument is rejected by name instead of stripped.
9
- */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
10
10
  const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
11
+ const NO_CONFIRMATION = {
12
+ confirmed: false,
13
+ request: () => Promise.reject(/* @__PURE__ */ new Error("No confirmation can be stored here."))
14
+ };
11
15
  /**
12
- * A tool that does not decide for itself is gated by its own checkbox.
13
- */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
16
+ * Runs one tool call. Payload's public errors become error results; anything
17
+ * else is logged and reported as an internal error. A tool with `confirm`
18
+ * gets `confirmation`.
19
+ */ const runTool = async (tool, args, scope, req, extra, confirmation = NO_CONFIRMATION) => {
20
+ const ctx = {
21
+ args,
22
+ scope,
23
+ req,
24
+ extra
25
+ };
26
+ try {
27
+ return await (tool.confirm ? tool.handler({
28
+ ...ctx,
29
+ confirmation
30
+ }) : tool.handler(ctx));
31
+ } catch (error) {
32
+ return toToolError(error, req.payload.logger);
33
+ }
34
+ };
35
+ const WRITE_STEP = {
36
+ tools: [
37
+ "patchDocument",
38
+ "createDocument",
39
+ "duplicateDocument"
40
+ ],
41
+ does: "the write"
42
+ };
14
43
  const WORKFLOW = [
15
44
  {
16
45
  tools: ["listCapabilities"],
@@ -24,10 +53,7 @@ const WORKFLOW = [
24
53
  tools: ["findDocuments", "getDocument"],
25
54
  does: "the content and its \"updatedAt\""
26
55
  },
27
- {
28
- tools: ["patchDocument", "createDocument"],
29
- does: "the write"
30
- },
56
+ WRITE_STEP,
31
57
  {
32
58
  tools: ["validateDocument"],
33
59
  does: "what blocks publishing"
@@ -37,6 +63,7 @@ const WORKFLOW = [
37
63
  does: "make the draft public"
38
64
  }
39
65
  ];
66
+ const UPLOAD_STEP = "A write that returns \"upload\": PUT the file bytes to its \"url\" with its \"headers\". The response is the result of the write.";
40
67
  const PATHS = `describeSchema takes and returns schema paths. Every other "path", and "from" in a patch operation, is a pointer into a document, with a 0-based index where a schema path has "*" for an array element or a block slug: "/layout/sections/sectionWrapper/identifier" is "/layout/sections/0/identifier". In rich text a pointer enters the rich text state: "/content/block/callout/tone" is "/content/root/children/3/fields/tone". getDocument "outline" gives the index.`;
41
68
  const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
42
69
  const DRAFTS_ONLY = "Every write is saved as a draft.";
@@ -44,9 +71,12 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
44
71
  * The workflow for the tools this key has, and what its writes reach.
45
72
  */ const serverInstructions = (scope) => {
46
73
  const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
47
- const steps = WORKFLOW.flatMap(({ tools, does }) => {
48
- const present = tools.filter((name) => enabled.has(name));
49
- return present.length === 0 ? [] : [`${present.join(" or ")}: ${does}.`];
74
+ const uploads = fileSlugs(scope).length > 0;
75
+ const steps = WORKFLOW.flatMap((step) => {
76
+ const present = step.tools.filter((name) => enabled.has(name));
77
+ if (present.length === 0) return [];
78
+ const line = `${present.join(" or ")}: ${step.does}.`;
79
+ return step === WRITE_STEP && uploads ? [line, UPLOAD_STEP] : [line];
50
80
  }).map((step, index) => `${String(index + 1)}. ${step}`);
51
81
  const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
52
82
  const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
@@ -61,11 +91,11 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
61
91
  * so a failed call cannot roll back a write another call reported as done.
62
92
  */ const createMcpServer = (scope, options) => {
63
93
  const { req } = scope;
64
- const { logger } = req.payload;
65
94
  let queue = Promise.resolve();
66
95
  const server = new McpServer({
67
96
  name: options.serverInfo.name,
68
- version: options.serverInfo.version
97
+ version: options.serverInfo.version,
98
+ ...options.diagnostics ? { websiteUrl: MCPX_REPOSITORY_URL } : {}
69
99
  }, { instructions: serverInstructions(scope) });
70
100
  for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
71
101
  if (!isToolEnabled(tool, scope)) continue;
@@ -74,18 +104,7 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
74
104
  inputSchema: toolInputSchema(tool, scope),
75
105
  ...tool.annotations ? { annotations: tool.annotations } : {}
76
106
  }, (args, extra) => {
77
- const result = queue.then(async () => {
78
- try {
79
- return await tool.handler({
80
- args,
81
- scope,
82
- req,
83
- extra
84
- });
85
- } catch (error) {
86
- return toToolError(error, logger);
87
- }
88
- });
107
+ const result = queue.then(async () => await runTool(tool, args, scope, req, extra, confirmationFor(tool.name, scope, options)));
89
108
  queue = result.catch(() => void 0);
90
109
  return result;
91
110
  });
@@ -93,4 +112,4 @@ const DRAFTS_ONLY = "Every write is saved as a draft.";
93
112
  return server;
94
113
  };
95
114
  //#endregion
96
- export { createMcpServer, isToolEnabled, toolInputSchema };
115
+ export { createMcpServer, runTool };
@@ -0,0 +1,104 @@
1
+ import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
+ import { leasesAvailable, readCapped, respond } from "./http.mjs";
3
+ import { resolveApiKeyById } from "../auth/resolve.mjs";
4
+ import { parseResult } from "../result.mjs";
5
+ import { claimGrant } from "../leases/grant.mjs";
6
+ import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
7
+ import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
8
+ import { runTool } from "./server.mjs";
9
+ import { authenticateAs } from "./handler.mjs";
10
+ //#region src/endpoint/upload.ts
11
+ const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
12
+ const refused = () => respond(403, { error: "The request was refused: the grant is unknown, expired or used, or the key no longer allows it. Call the tool again for a new one." });
13
+ const statusOf = (result, data) => {
14
+ if (result.isError !== true) return 200;
15
+ if ("updatedAt" in data || data["status"] === 423) return 409;
16
+ if (data["status"] === 401 || data["status"] === 403) return 403;
17
+ return data["error"] === "Internal error" ? 500 : 422;
18
+ };
19
+ const NO_SESSION = {
20
+ signal: new AbortController().signal,
21
+ requestId: "upload",
22
+ sendNotification: () => Promise.resolve(),
23
+ sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
24
+ };
25
+ const claimAs = async (req, options, kind) => {
26
+ const slug = leasesAvailable(req, options);
27
+ const grantId = req.headers.get("x-mcpx-grant");
28
+ if (slug === void 0 || grantId === null) return;
29
+ const grant = await claimGrant(req.payload, slug, grantId);
30
+ if (grant?.kind !== kind) return;
31
+ const auth = await resolveApiKeyById(req, options, grant.apiKeyId);
32
+ if (!auth) return;
33
+ const scope = authenticateAs(req, options, auth);
34
+ req.query = {};
35
+ for (const name of [...req.searchParams.keys()]) req.searchParams.delete(name);
36
+ req.locale = grant.locale;
37
+ req.fallbackLocale = grant.fallbackLocale;
38
+ return {
39
+ grant,
40
+ scope
41
+ };
42
+ };
43
+ /**
44
+ * Completes a call that returned an `upload`: claims the grant, acts as its
45
+ * key again, then runs the same tool with the same arguments and the body as
46
+ * the file. The response body is the tool's result. The grant header is never
47
+ * logged.
48
+ */ const createUploadHandler = (options) => async (req) => {
49
+ const { payload } = req;
50
+ const claimed = await claimAs(req, options, "upload");
51
+ if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !claimed.grant.file) return refused();
52
+ const { grant, scope } = claimed;
53
+ const { file } = grant;
54
+ const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
55
+ if (!tool || !isToolEnabled(tool, scope)) return refused();
56
+ const args = toolInputSchema(tool, scope).safeParse(grant.args);
57
+ if (!args.success) return refused();
58
+ if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
59
+ const data = await readCapped(req, file.size);
60
+ if (data?.length !== file.size) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
61
+ setUploadedFile(req, {
62
+ data,
63
+ mimetype: file.mimeType,
64
+ name: file.filename,
65
+ size: data.length
66
+ });
67
+ const result = await runTool(tool, args.data, scope, req, NO_SESSION);
68
+ const body = parseResult(result);
69
+ return respond(statusOf(result, body), body);
70
+ };
71
+ /**
72
+ * Serves the file of a `getDocument` call that returned a `download`: claims
73
+ * the grant, acts as its key again, reloads the document as its user and
74
+ * hands the request to the collection's own `/file/:filename` endpoint, so
75
+ * Payload's file access, storage handlers and response headers apply.
76
+ */ const createDownloadHandler = (options) => async (req) => {
77
+ const claimed = await claimAs(req, options, "download");
78
+ if (!claimed || !downloadSlugs(claimed.scope).includes(claimed.grant.collection)) return refused();
79
+ const { collection, id, draft } = claimed.grant;
80
+ const doc = await req.payload.findByID({
81
+ collection,
82
+ id,
83
+ depth: 0,
84
+ draft,
85
+ overrideAccess: false,
86
+ disableErrors: true,
87
+ select: {
88
+ filename: true,
89
+ prefix: true
90
+ },
91
+ req
92
+ });
93
+ const { endpoints } = req.payload.collections[collection]?.config ?? {};
94
+ const endpoint = Array.isArray(endpoints) ? endpoints.find((candidate) => candidate.method === "get" && candidate.path === "/file/:filename") : void 0;
95
+ if (typeof doc?.filename !== "string" || !endpoint) return refused();
96
+ if (typeof doc.prefix === "string") req.searchParams.set("prefix", doc.prefix);
97
+ req.routeParams = {
98
+ collection,
99
+ filename: doc.filename
100
+ };
101
+ return await endpoint.handler(req);
102
+ };
103
+ //#endregion
104
+ export { createDownloadHandler, createUploadHandler };
package/dist/guards.mjs CHANGED
@@ -1,7 +1,14 @@
1
1
  //#region src/guards.ts
2
2
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
3
  /**
4
+ * A property of a plain object, `undefined` for any other value.
5
+ */ const propOf = (value, key) => isPlainObject(value) ? value[key] : void 0;
6
+ /**
4
7
  * An own property only, so a client-chosen key never reads the prototype.
5
8
  */ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
9
+ /**
10
+ * The entries whose value is not `undefined`, for spreading optional arguments
11
+ * into a call.
12
+ */ const definedProps = (props) => Object.fromEntries(Object.entries(props).filter(([, value]) => value !== void 0));
6
13
  //#endregion
7
- export { isPlainObject, ownValue };
14
+ export { definedProps, isPlainObject, ownValue, propOf };
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { DocumentId } from "./entity.mjs";
2
- import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode } from "./types.mjs";
2
+ import { McpxAnyTool, McpxAuthResult, McpxCollectionOptions, McpxEntityCapabilities, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxScopeSlugs, McpxTool, McpxToolExtra, McpxToolScope } from "./types.mjs";
3
3
  import { defineMcpxTool } from "./define-tool.mjs";
4
4
  import { mcpxPlugin } from "./plugin.mjs";
5
5
  import { isMcpxRequest } from "./request.mjs";
6
6
  import { errorResult, jsonResult } from "./result.mjs";
7
7
  import { mcpxReadRequest } from "./tools/read-request.mjs";
8
- export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, type McpxWriteMode, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
8
+ export { type McpxAnyTool, type McpxAuthResult, type McpxCollectionOptions, type DocumentId as McpxDocumentId, type McpxEntityCapabilities, type McpxExposedEntity, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxScopeSlugs, type McpxTool, type McpxToolExtra, type McpxToolScope, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineMcpxTool } from "./define-tool.mjs";
2
+ import { isMcpxRequest } from "./request.mjs";
2
3
  import { errorResult, jsonResult } from "./result.mjs";
3
4
  import { mcpxReadRequest } from "./tools/read-request.mjs";
4
- import { isMcpxRequest } from "./request.mjs";
5
5
  import { mcpxPlugin } from "./plugin.mjs";
6
6
  export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
@@ -0,0 +1,74 @@
1
+ import { LEASE_ID, NO_REQ, claimLease, issueLease, leaseHandle, leasePrefixes, leasesWith, unexpired } from "./lease.mjs";
2
+ //#region src/leases/confirmation.ts
3
+ const CONFIRMATION_TTL_MS = 9e5;
4
+ const HANDLE = /^[\da-f]{64}$/;
5
+ /**
6
+ * Stores a pending call and returns its id. A key may hold at most
7
+ * {@link CONFIRMATIONS_PER_KEY} calls.
8
+ */ const issueConfirmation = async (payload, slug, call) => {
9
+ const entry = {
10
+ ...call,
11
+ state: "pending"
12
+ };
13
+ return await issueLease(payload, slug, {
14
+ ...leasePrefixes("confirmation", call.apiKeyId),
15
+ cap: 50,
16
+ capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
17
+ ttlMs: CONFIRMATION_TTL_MS,
18
+ data: entry
19
+ });
20
+ };
21
+ /**
22
+ * The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
23
+ * another key is `undefined`, as is a malformed id, before any database read.
24
+ */ const readConfirmation = async (payload, slug, apiKeyId, id) => {
25
+ if (!LEASE_ID.test(id)) return;
26
+ const key = `${leasePrefixes("confirmation", apiKeyId).prefix}${leaseHandle(payload, id)}`;
27
+ const rows = await leasesWith(payload, slug, [key]);
28
+ return unexpired(rows.find((row) => row.key === key), Date.now());
29
+ };
30
+ /**
31
+ * Marks the call used and removes it. Of two concurrent claims exactly one
32
+ * returns `true`.
33
+ */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimLease(payload, slug, leasePrefixes("confirmation", apiKeyId), leaseHandle(payload, id), exp);
34
+ /**
35
+ * Every unexpired call of `apiKeyId` with its handle, oldest first.
36
+ */ const listConfirmations = async (payload, slug, apiKeyId) => {
37
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
38
+ const now = Date.now();
39
+ return (await leasesWith(payload, slug, [prefix])).flatMap((row) => {
40
+ const confirmation = unexpired(row, now);
41
+ return confirmation ? [{
42
+ handle: row.key.slice(prefix.length),
43
+ confirmation
44
+ }] : [];
45
+ }).sort((left, right) => left.confirmation.exp - right.confirmation.exp);
46
+ };
47
+ /**
48
+ * Approves or rejects the pending calls of `apiKeyId` among `handles`, and
49
+ * returns the handles it changed. A call that was decided, run or expired is
50
+ * left alone and not returned.
51
+ */ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
52
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
53
+ const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
54
+ if (keys.size === 0) return [];
55
+ const now = Date.now();
56
+ const decided = [];
57
+ for (const row of await leasesWith(payload, slug, [prefix])) {
58
+ const confirmation = unexpired(row, now);
59
+ if (!keys.has(row.key) || confirmation?.state !== "pending") continue;
60
+ const updated = await payload.db.updateMany({
61
+ collection: slug,
62
+ where: { and: [{ key: { equals: row.key } }, { "data.state": { equals: "pending" } }] },
63
+ data: { data: {
64
+ ...confirmation,
65
+ state
66
+ } },
67
+ req: NO_REQ
68
+ });
69
+ if (updated !== null && updated.length > 0) decided.push(row.key.slice(prefix.length));
70
+ }
71
+ return decided;
72
+ };
73
+ //#endregion
74
+ export { claimConfirmation, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };
@@ -0,0 +1,29 @@
1
+ import { LEASE_ID, claimLease, issueLease, leaseHandle, leasePrefixes, leasesMatching, unexpired } from "./lease.mjs";
2
+ //#region src/leases/grant.ts
3
+ const GRANT_TTL_MS = 3e5;
4
+ const GRANTS_PER_KEY = 10;
5
+ /**
6
+ * Stores a grant and returns its id. A key may hold at most
7
+ * {@link GRANTS_PER_KEY} grants.
8
+ */ const issueGrant = async (payload, slug, grant) => await issueLease(payload, slug, {
9
+ ...leasePrefixes("grant", grant.apiKeyId),
10
+ cap: GRANTS_PER_KEY,
11
+ capMessage: `This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`,
12
+ ttlMs: GRANT_TTL_MS,
13
+ data: grant
14
+ });
15
+ /**
16
+ * Loads the grant for `grantId` and marks it used, or returns `undefined` for
17
+ * a malformed, unknown, expired or used one. A malformed id is refused before
18
+ * any database read.
19
+ */ const claimGrant = async (payload, slug, grantId) => {
20
+ if (!LEASE_ID.test(grantId)) return;
21
+ const handle = leaseHandle(payload, grantId);
22
+ const row = (await leasesMatching(payload, slug, [handle])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${handle}`));
23
+ const grant = unexpired(row, Date.now());
24
+ if (!row || !grant) return;
25
+ const prefixes = leasePrefixes("grant", grant.apiKeyId);
26
+ return row.key === `${prefixes.prefix}${handle}` && await claimLease(payload, slug, prefixes, handle, grant.exp) ? grant : void 0;
27
+ };
28
+ //#endregion
29
+ export { claimGrant, issueGrant };