@abinnovision/payloadcms-mcpx 2.0.0-beta.19 → 2.0.0-beta.22

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 (65) hide show
  1. package/README.md +2 -2
  2. package/dist/admin/capability-matrix.mjs +213 -316
  3. package/dist/admin/capability-toggles.mjs +24 -31
  4. package/dist/admin/confirmations-panel.mjs +61 -71
  5. package/dist/admin/confirmations.mjs +2 -1
  6. package/dist/admin/endpoint-url.mjs +7 -0
  7. package/dist/admin/setup-guide.d.mts +3 -3
  8. package/dist/admin/setup-guide.mjs +51 -33
  9. package/dist/api-keys/capability-matrix.d.mts +3 -6
  10. package/dist/api-keys/capability-matrix.mjs +9 -48
  11. package/dist/api-keys/collection.mjs +9 -7
  12. package/dist/api-keys/confirmation-view.mjs +2 -1
  13. package/dist/api-keys/fields.mjs +36 -66
  14. package/dist/auth/resolve.mjs +29 -10
  15. package/dist/capabilities.d.mts +2 -0
  16. package/dist/capabilities.mjs +62 -19
  17. package/dist/endpoint/confirmations.mjs +14 -14
  18. package/dist/endpoint/handler.mjs +6 -22
  19. package/dist/endpoint/http.mjs +27 -0
  20. package/dist/endpoint/upload.mjs +11 -43
  21. package/dist/grants/confirmation.mjs +11 -42
  22. package/dist/grants/grant.mjs +58 -40
  23. package/dist/guards.mjs +8 -1
  24. package/dist/index.d.mts +2 -2
  25. package/dist/index.mjs +1 -1
  26. package/dist/options.mjs +34 -54
  27. package/dist/plugin.mjs +2 -1
  28. package/dist/request.mjs +5 -1
  29. package/dist/result.mjs +11 -1
  30. package/dist/schema/admin-hidden.mjs +4 -4
  31. package/dist/schema/describe.mjs +12 -18
  32. package/dist/schema/index.mjs +5 -6
  33. package/dist/schema/lexical-pointer.mjs +8 -8
  34. package/dist/schema/lexical.mjs +15 -13
  35. package/dist/schema/outline.mjs +4 -5
  36. package/dist/schema/path.mjs +4 -1
  37. package/dist/schema/pointer.mjs +11 -17
  38. package/dist/schema/shape.mjs +38 -50
  39. package/dist/schema/walk.mjs +57 -18
  40. package/dist/tools/create-document.mjs +16 -44
  41. package/dist/tools/delete-document.mjs +14 -17
  42. package/dist/tools/describe-schema.mjs +7 -5
  43. package/dist/tools/document.mjs +130 -44
  44. package/dist/tools/duplicate-document.mjs +11 -10
  45. package/dist/tools/find-documents.mjs +15 -20
  46. package/dist/tools/find-versions.mjs +10 -14
  47. package/dist/tools/get-document.mjs +8 -12
  48. package/dist/tools/links.mjs +2 -1
  49. package/dist/tools/list-capabilities.mjs +2 -1
  50. package/dist/tools/patch-document.mjs +12 -44
  51. package/dist/tools/publish-document.mjs +9 -27
  52. package/dist/tools/query-paths.mjs +1 -1
  53. package/dist/tools/run-confirmed.mjs +38 -50
  54. package/dist/tools/shared.mjs +43 -41
  55. package/dist/tools/validate-document.mjs +4 -6
  56. package/dist/tools/versions.mjs +11 -6
  57. package/dist/types.d.mts +15 -28
  58. package/dist/upload/file.mjs +10 -18
  59. package/dist/write/draft-guard.mjs +20 -20
  60. package/dist/write/patch.mjs +4 -4
  61. package/dist/write/publish-intent.mjs +22 -28
  62. package/dist/write/row-ids.mjs +3 -1
  63. package/dist/write/write-data.mjs +18 -31
  64. package/package.json +7 -7
  65. package/dist/builtin-tool-names.mjs +0 -19
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITIES_FIELD } from "../capabilities.mjs";
2
- import { CAPABILITIES_DESCRIPTION, STORED_OPERATIONS, createCapabilityMatrix } from "./capability-matrix.mjs";
2
+ import { CAPABILITIES_DESCRIPTION, createCapabilityMatrix, rowOperations } from "./capability-matrix.mjs";
3
3
  //#region src/api-keys/fields.ts
4
4
  const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
5
5
  const decryptKey = ({ req, value }) => {
@@ -16,7 +16,24 @@ const checkbox = (name, description) => ({
16
16
  defaultValue: false,
17
17
  admin: { description }
18
18
  });
19
- const SETUP_GUIDE_FIELD = "setupGuide";
19
+ /**
20
+ * Access for fields only the server writes.
21
+ */ const SERVER_ONLY = {
22
+ create: () => false,
23
+ update: () => false
24
+ };
25
+ const adminField = (name, exportName, options) => ({
26
+ name,
27
+ type: "ui",
28
+ admin: {
29
+ disableListColumn: true,
30
+ components: { Field: {
31
+ path: "@abinnovision/payloadcms-mcpx/admin",
32
+ exportName,
33
+ clientProps: { endpointPath: options.endpointPath }
34
+ } }
35
+ }
36
+ });
20
37
  /**
21
38
  * Fields every key carries. Key generation and the HMAC index live in the
22
39
  * collection-level `beforeChange` hook (see `collection.ts`), because sibling
@@ -45,10 +62,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
45
62
  {
46
63
  name: "lastUsedAt",
47
64
  type: "date",
48
- access: {
49
- create: () => false,
50
- update: () => false
51
- },
65
+ access: SERVER_ONLY,
52
66
  admin: {
53
67
  readOnly: true,
54
68
  date: { pickerAppearance: "dayAndTime" },
@@ -58,10 +72,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
58
72
  {
59
73
  name: "apiKey",
60
74
  type: "text",
61
- access: {
62
- create: () => false,
63
- update: () => false
64
- },
75
+ access: SERVER_ONLY,
65
76
  admin: {
66
77
  readOnly: true,
67
78
  description: "Generated when the key is created. Send it as `Authorization: Bearer <key>`."
@@ -83,26 +94,12 @@ const SETUP_GUIDE_FIELD = "setupGuide";
83
94
  * a collection exposes `delete`. The component renders nothing while none is
84
95
  * pending and never touches the form, so deciding does not mark the key
85
96
  * modified.
86
- */ const createConfirmationFields = (options) => options.confirmations ? [{
87
- name: "confirmations",
88
- type: "ui",
89
- admin: {
90
- disableListColumn: true,
91
- components: { Field: {
92
- path: "@abinnovision/payloadcms-mcpx/admin",
93
- exportName: "McpxConfirmations",
94
- clientProps: { endpointPath: options.endpointPath }
95
- } }
96
- }
97
- }] : [];
97
+ */ const createConfirmationFields = (options) => options.confirmations ? [adminField("confirmations", "McpxConfirmations", options)] : [];
98
98
  /**
99
- * Wraps the key fields, the capabilities and the setup guide in unnamed tabs,
100
- * so wide tables and snippets get the full form width. Named tabs would nest
101
- * the data and move `capabilities` off the document root, which capability
102
- * resolution reads.
103
- *
104
- * The guide tab only shows on update: on create there is no key to hand out.
105
- */ const withKeyTabs = (keyFields, capabilityFields, options) => {
99
+ * Wraps the key fields and the capabilities in unnamed tabs, so wide tables
100
+ * get the full form width. Named tabs would nest the data and move
101
+ * `capabilities` off the document root, which capability resolution reads.
102
+ */ const withKeyTabs = (keyFields, capabilityFields) => {
106
103
  const tabs = [{
107
104
  label: "Key",
108
105
  fields: keyFields
@@ -111,22 +108,6 @@ const SETUP_GUIDE_FIELD = "setupGuide";
111
108
  label: "Capabilities",
112
109
  fields: capabilityFields
113
110
  });
114
- if (options.setupGuide) tabs.push({
115
- label: "Connect a client",
116
- admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
117
- fields: [{
118
- name: SETUP_GUIDE_FIELD,
119
- type: "ui",
120
- admin: {
121
- disableListColumn: true,
122
- components: { Field: {
123
- path: "@abinnovision/payloadcms-mcpx/admin",
124
- exportName: "McpxSetupGuide",
125
- clientProps: { endpointPath: options.endpointPath }
126
- } }
127
- }
128
- }]
129
- });
130
111
  return [{
131
112
  type: "tabs",
132
113
  tabs
@@ -147,28 +128,17 @@ const SETUP_GUIDE_FIELD = "setupGuide";
147
128
  name: row.fieldName,
148
129
  type: "group",
149
130
  label: row.slug,
150
- fields: STORED_OPERATIONS.filter((operation) => row[operation.id]).map((operation) => checkbox(operation.id, operation.description))
131
+ fields: rowOperations(row).map((operation) => checkbox(operation.id, operation.description))
151
132
  }));
152
- const collectionGroups = entityGroups(matrix.collections);
153
- const globalGroups = entityGroups(matrix.globals);
154
- const toolCheckboxes = matrix.tools.map((tool) => checkbox(tool.name, tool.description));
155
133
  const groups = [
156
- ...collectionGroups.length > 0 ? [{
157
- name: "collections",
158
- type: "group",
159
- fields: collectionGroups
160
- }] : [],
161
- ...globalGroups.length > 0 ? [{
162
- name: "globals",
163
- type: "group",
164
- fields: globalGroups
165
- }] : [],
166
- ...toolCheckboxes.length > 0 ? [{
167
- name: "tools",
168
- type: "group",
169
- fields: toolCheckboxes
170
- }] : []
171
- ];
134
+ ["collections", entityGroups(matrix.collections)],
135
+ ["globals", entityGroups(matrix.globals)],
136
+ ["tools", matrix.tools.map((tool) => checkbox(tool.name, tool.description))]
137
+ ].filter(([, fields]) => fields.length > 0).map(([name, fields]) => ({
138
+ name,
139
+ type: "group",
140
+ fields
141
+ }));
172
142
  if (groups.length === 0) return [];
173
143
  return [{
174
144
  name: CAPABILITIES_FIELD,
@@ -188,4 +158,4 @@ const SETUP_GUIDE_FIELD = "setupGuide";
188
158
  }];
189
159
  };
190
160
  //#endregion
191
- export { createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs };
161
+ export { SERVER_ONLY, createCapabilityFields, createConfirmationFields, createKeyFields, withKeyTabs };
@@ -4,16 +4,18 @@ import { hashApiKey } from "../api-keys/key.mjs";
4
4
  const BEARER = /^Bearer\s+(\S+)\s*$/i;
5
5
  const LAST_USED_INTERVAL_MS = 36e5;
6
6
  const toTime = (value) => typeof value === "string" || value instanceof Date ? new Date(value).getTime() : NaN;
7
- const relationId = (value) => {
8
- if (typeof value === "string" || typeof value === "number") return value;
9
- if (typeof value === "object" && value !== null && "id" in value) return value.id;
7
+ const isId = (value) => typeof value === "string" || typeof value === "number";
8
+ /**
9
+ * The id of a relationship value, populated or not.
10
+ */ const relationId = (value) => {
11
+ if (isId(value)) return value;
12
+ return isPlainObject(value) && isId(value["id"]) ? value["id"] : void 0;
10
13
  };
11
14
  const parseBearer = (headers) => {
12
15
  const header = headers.get("authorization");
13
16
  if (!header) return null;
14
17
  return BEARER.exec(header.trim())?.[1] ?? null;
15
18
  };
16
- const isId = (value) => typeof value === "string" || typeof value === "number";
17
19
  /**
18
20
  * Whether a resolved auth, from the default or a custom resolver, may become
19
21
  * `req.user`: a user of the configured user collection with an id, and a key id.
@@ -58,6 +60,26 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
58
60
  capabilities: keyDoc.capabilities
59
61
  };
60
62
  };
63
+ const KEY_SELECT = {
64
+ enabled: true,
65
+ user: true,
66
+ capabilities: true,
67
+ expiresAt: true
68
+ };
69
+ /**
70
+ * The user the key `id` acts as, or `null`, under the same checks as the
71
+ * bearer lookup.
72
+ */ const resolveApiKeyById = async (req, options, id) => {
73
+ const keyDoc = await req.payload.findByID({
74
+ collection: options.apiKeysSlug,
75
+ id,
76
+ depth: 0,
77
+ overrideAccess: true,
78
+ disableErrors: true,
79
+ select: KEY_SELECT
80
+ });
81
+ return await checkApiKey(req, options, keyDoc ?? void 0);
82
+ };
61
83
  /**
62
84
  * Resolves the bearer key of a request to the user it acts as.
63
85
  *
@@ -76,10 +98,7 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
76
98
  depth: 0,
77
99
  overrideAccess: true,
78
100
  select: {
79
- enabled: true,
80
- user: true,
81
- capabilities: true,
82
- expiresAt: true,
101
+ ...KEY_SELECT,
83
102
  lastUsedAt: true
84
103
  }
85
104
  });
@@ -99,10 +118,10 @@ const isId = (value) => typeof value === "string" || typeof value === "number";
99
118
  } catch (error) {
100
119
  payload.logger.warn({
101
120
  err: error,
102
- msg: "Could not record the last use of an MCP API key"
121
+ msg: "[payloadcms-mcpx] Could not record the last use of an MCP API key."
103
122
  });
104
123
  }
105
124
  return auth;
106
125
  };
107
126
  //#endregion
108
- export { checkApiKey, isValidAuthResult, parseBearer, resolveApiKeyAuth };
127
+ export { checkApiKey, isValidAuthResult, parseBearer, relationId, resolveApiKeyAuth, resolveApiKeyById };
@@ -0,0 +1,2 @@
1
+ import "./types.mjs";
2
+ import "./options.mjs";
@@ -4,21 +4,60 @@ import { isPlainObject } from "./guards.mjs";
4
4
  * Group field holding the capability checkboxes on an API key document.
5
5
  */ const CAPABILITIES_FIELD = "capabilities";
6
6
  /**
7
- * Covers draft and live writes; {@link isLiveWrite} separates them.
8
- */ const canWrite = (entity) => entity.write !== false;
7
+ * The operations a collection or global can expose. The generated checkboxes
8
+ * and the matrix segments share this wording. `requires` names the
9
+ * operation a grant depends on, which the server and the matrix both enforce.
10
+ */ const CAPABILITY_OPERATIONS = [
11
+ {
12
+ id: "read",
13
+ label: "Read",
14
+ description: "Describe, find and read."
15
+ },
16
+ {
17
+ id: "write",
18
+ label: "Write",
19
+ description: "Create, patch and validate drafts.",
20
+ requires: "read"
21
+ },
22
+ {
23
+ id: "publish",
24
+ label: "Publish",
25
+ description: "Promote the current draft to what the public sees.",
26
+ requires: "write"
27
+ },
28
+ {
29
+ id: "delete",
30
+ label: "Delete",
31
+ description: "Delete documents. Each one is approved by this key's user in the admin panel, unless the config allows trashing directly and this key is set to.",
32
+ requires: "read"
33
+ }
34
+ ];
9
35
  /**
10
- * The config lets MCP change live content and a draft exists to promote.
11
- */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
36
+ * Stored as a checkbox of its own but drawn as the approval shield of the Delete
37
+ * control.
38
+ */ const DELETE_UNATTENDED = {
39
+ id: "deleteUnattended",
40
+ label: "Trash directly",
41
+ description: "Move documents to trash without asking this key's user.",
42
+ requires: "delete"
43
+ };
44
+ /**
45
+ * Every operation with a stored checkbox: the capability operations plus
46
+ * the unattended delete. Each follows the one it requires, which the resolve
47
+ * loop depends on.
48
+ */ const STORED_OPERATIONS = [...CAPABILITY_OPERATIONS, DELETE_UNATTENDED];
12
49
  /**
13
- * Any writable collection. An upload collection also needs a file, which
14
- * depends on the request, so the tools check it per call.
15
- */ const canCreate = (entity) => canWrite(entity);
50
+ * The operations in one run of the access levels, each requiring the one
51
+ * before it. Delete is chosen separately.
52
+ */ const ACCESS_CHAIN = CAPABILITY_OPERATIONS.filter((operation) => operation.id !== "delete");
16
53
  /**
17
- * Without drafts there is no draft stage, so every write is live.
18
- */ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
54
+ * What an operation depends on. `resolveCapabilities` discards a grant whose
55
+ * prerequisite is off.
56
+ */ const requiresOf = (operation) => {
57
+ const found = STORED_OPERATIONS.find((candidate) => candidate.id === operation);
58
+ return found && "requires" in found ? found.requires : void 0;
59
+ };
19
60
  const flag = (group, name) => isPlainObject(group) && group[name] === true;
20
- const writeFlag = (entity, group) => canWrite(entity) && entity.read && flag(group, "read") && flag(group, "write");
21
- const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, group) && flag(group, "publish");
22
61
  /**
23
62
  * Capabilities in force for a key: the plugin config decides what can exist,
24
63
  * the key's checkboxes decide what does. A missing checkbox is `false`, so keys
@@ -29,14 +68,18 @@ const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, g
29
68
  const resolved = {};
30
69
  for (const entity of entities) {
31
70
  const group = isPlainObject(namespaceGroup) ? namespaceGroup[entity.fieldName] : void 0;
32
- const deleting = entity.delete && flag(group, "read") && flag(group, "delete");
33
- resolved[entity.slug] = {
34
- read: entity.read && flag(group, "read"),
35
- write: writeFlag(entity, group),
36
- publish: publishFlag(entity, group),
37
- delete: deleting,
38
- deleteUnattended: deleting && entity.deleteUnattended && flag(group, "deleteUnattended")
71
+ const granted = {
72
+ read: false,
73
+ write: false,
74
+ publish: false,
75
+ delete: false,
76
+ deleteUnattended: false
39
77
  };
78
+ for (const { id } of STORED_OPERATIONS) {
79
+ const needs = requiresOf(id);
80
+ granted[id] = entity[id] && flag(group, id) && (needs === void 0 || granted[needs]);
81
+ }
82
+ resolved[entity.slug] = granted;
40
83
  }
41
84
  return resolved;
42
85
  };
@@ -62,4 +105,4 @@ const publishFlag = (entity, group) => canPublish(entity) && writeFlag(entity, g
62
105
  };
63
106
  };
64
107
  //#endregion
65
- export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, resolveCapabilities, scopeSlugs };
108
+ export { ACCESS_CHAIN, CAPABILITIES_FIELD, CAPABILITY_OPERATIONS, STORED_OPERATIONS, requiresOf, resolveCapabilities, scopeSlugs };
@@ -1,19 +1,20 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
- import { CONFIRMATIONS_PATH } from "../api-keys/confirmation-view.mjs";
3
- import { grantKvSlug } from "../grants/grant.mjs";
4
- import { confirmationHandle, decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
2
+ import { grantContext, grantKvSlug, rowHandle } from "../grants/grant.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 "../grants/confirmation.mjs";
7
+ import { publicOrigin } from "../request.mjs";
5
8
  import { jsonResult } from "../result.mjs";
6
9
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
7
10
  import { formatAdminURL } from "payload/shared";
8
11
  //#region src/endpoint/confirmations.ts
9
- const respond = (status, body) => Response.json(body, { status });
10
12
  const confirmableTool = (name) => BUILTIN_TOOLS.find((tool) => tool.name === name && tool.confirm);
11
13
  const keyViewUrl = (req, options, apiKeyId) => {
12
- const { config } = req.payload;
13
14
  return formatAdminURL({
14
- adminRoute: config.routes.admin,
15
+ adminRoute: req.payload.config.routes.admin,
15
16
  path: `/collections/${options.apiKeysSlug}/${String(apiKeyId)}`,
16
- serverURL: config.serverURL || new URL(req.url ?? "").origin
17
+ serverURL: publicOrigin(req)
17
18
  });
18
19
  };
19
20
  /**
@@ -24,9 +25,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
24
25
  confirmed: false,
25
26
  request: async (args) => {
26
27
  const { req } = scope;
27
- const slug = grantKvSlug(req.payload.config);
28
- const apiKeyId = req.context.mcpx?.apiKeyId;
29
- if (slug === void 0 || apiKeyId === void 0) throw new Error("A confirmation was requested without a database KV.");
28
+ const { slug, apiKeyId } = grantContext(req);
30
29
  const view = keyViewUrl(req, options, apiKeyId);
31
30
  const { id, exp } = await issueConfirmation(req.payload, slug, {
32
31
  apiKeyId,
@@ -54,8 +53,8 @@ const authorize = async (req, options, keyId) => {
54
53
  disableErrors: true,
55
54
  select: { user: true }
56
55
  }).catch(() => null) : null;
57
- const user = isPlainObject(key?.user) ? key.user["id"] : key?.user;
58
- if (!key || typeof user !== "string" && typeof user !== "number" || String(user) !== String(req.user.id)) return respond(403, { error: "Only the user of this key may decide on its calls." });
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." });
59
58
  return {
60
59
  slug,
61
60
  apiKeyId: key.id
@@ -89,7 +88,7 @@ const viewOf = async (req, entry, highlight) => {
89
88
  if (allowed instanceof Response) return allowed;
90
89
  const { payload } = req;
91
90
  const highlighted = req.searchParams.get("confirmation");
92
- const highlight = highlighted === null ? void 0 : confirmationHandle(payload, highlighted);
91
+ const highlight = highlighted === null ? void 0 : rowHandle(payload, highlighted);
93
92
  const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
94
93
  const confirmations = [];
95
94
  for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
@@ -105,7 +104,8 @@ const viewOf = async (req, entry, highlight) => {
105
104
  if (allowed instanceof Response) return allowed;
106
105
  const handles = isPlainObject(body) ? body["handles"] : void 0;
107
106
  const decision = isPlainObject(body) ? body["decision"] : void 0;
108
- if (!Array.isArray(handles) || handles.length > 50 || !handles.every((handle) => typeof handle === "string") || decision !== "approved" && decision !== "rejected") return respond(400, { error: `Send "handles", at most ${String(50)}, and a "decision" of "approved" or "rejected".` });
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
109
  const decided = await decideConfirmations(req.payload, allowed.slug, allowed.apiKeyId, handles, decision);
110
110
  return respond(200, { decided });
111
111
  };
@@ -1,5 +1,5 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
- import { grantKvSlug } from "../grants/grant.mjs";
2
+ import { grantsAvailable, readCapped } from "./http.mjs";
3
3
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { jsonRpcError } from "./errors.mjs";
5
5
  import { createMcpServer } from "./server.mjs";
@@ -7,19 +7,6 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
7
7
  //#region src/endpoint/handler.ts
8
8
  const BODY_BYTES_LIMIT = 4194304;
9
9
  const BATCH_MESSAGES_LIMIT = 10;
10
- const readBody = async (req) => {
11
- if (Number(req.headers.get("content-length")) > BODY_BYTES_LIMIT) return null;
12
- if (!req.body) return;
13
- const decoder = new TextDecoder();
14
- let text = "";
15
- let size = 0;
16
- for await (const chunk of req.body) {
17
- size += chunk.byteLength;
18
- if (size > BODY_BYTES_LIMIT) return null;
19
- text += decoder.decode(chunk, { stream: true });
20
- }
21
- return text + decoder.decode();
22
- };
23
10
  /**
24
11
  * The scope the tools of one request see: the key's resolved capabilities, the
25
12
  * slugs they reach, and the locales and limits of the config.
@@ -35,11 +22,8 @@ const readBody = async (req) => {
35
22
  defaultLocale: localization.defaultLocale
36
23
  } : null,
37
24
  limits: options.limits,
38
- uploads: options.auth?.resolve === void 0 && grantKvSlug(req.payload.config) !== void 0,
39
- diagnostics: options.diagnostics ? {
40
- name: options.serverInfo.name,
41
- version: options.serverInfo.version
42
- } : null,
25
+ uploads: grantsAvailable(req, options) !== void 0,
26
+ diagnostics: options.diagnostics ? options.serverInfo : null,
43
27
  exposure: {
44
28
  collections: options.collections,
45
29
  globals: options.globals
@@ -94,13 +78,13 @@ const readBody = async (req) => {
94
78
  const scope = authenticateAs(req, options, auth);
95
79
  let parsedBody;
96
80
  try {
97
- const text = await readBody(req);
98
- if (text === null) return jsonRpcError({
81
+ const body = await readCapped(req, BODY_BYTES_LIMIT);
82
+ if (body === null) return jsonRpcError({
99
83
  status: 413,
100
84
  code: -32e3,
101
85
  message: `Request body too large: the limit is ${String(BODY_BYTES_LIMIT / 1024 / 1024)} MB.`
102
86
  });
103
- parsedBody = text === void 0 ? void 0 : JSON.parse(text);
87
+ parsedBody = body === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(body));
104
88
  } catch {
105
89
  return jsonRpcError({
106
90
  status: 400,
@@ -0,0 +1,27 @@
1
+ import { grantKvSlug } from "../grants/grant.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 grant 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
+ * grant endpoints cannot replay what such a resolver authenticated by.
25
+ */ const grantsAvailable = (req, options) => options.auth?.resolve === void 0 ? grantKvSlug(req.payload.config) : void 0;
26
+ //#endregion
27
+ export { grantsAvailable, readCapped, respond };
@@ -1,66 +1,34 @@
1
1
  import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { claimGrant, grantKvSlug } from "../grants/grant.mjs";
2
+ import { claimGrant } from "../grants/grant.mjs";
3
+ import { grantsAvailable, readCapped, respond } from "./http.mjs";
4
+ import { resolveApiKeyById } from "../auth/resolve.mjs";
5
+ import { parseResult } from "../result.mjs";
3
6
  import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
4
7
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
5
- import { checkApiKey } from "../auth/resolve.mjs";
6
8
  import { runTool } from "./server.mjs";
7
9
  import { authenticateAs } from "./handler.mjs";
8
10
  //#region src/endpoint/upload.ts
9
11
  const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
10
- const respond = (status, body) => Response.json(body, { status });
11
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." });
12
- const readExactly = async (req, size) => {
13
- const declared = req.headers.get("content-length");
14
- if (declared !== null && Number(declared) !== size) return null;
15
- if (!req.body) return null;
16
- const chunks = [];
17
- let total = 0;
18
- for await (const chunk of req.body) {
19
- total += chunk.byteLength;
20
- if (total > size) return null;
21
- chunks.push(chunk);
22
- }
23
- return total === size ? Buffer.concat(chunks) : null;
24
- };
25
13
  const statusOf = (result, data) => {
26
14
  if (result.isError !== true) return 200;
27
15
  if ("updatedAt" in data || data["status"] === 423) return 409;
28
16
  if (data["status"] === 401 || data["status"] === 403) return 403;
29
17
  return data["error"] === "Internal error" ? 500 : 422;
30
18
  };
31
- const parseResult = (result) => {
32
- const [first] = result.content;
33
- return first?.type === "text" ? JSON.parse(first.text) : {};
34
- };
35
19
  const NO_SESSION = {
36
20
  signal: new AbortController().signal,
37
21
  requestId: "upload",
38
22
  sendNotification: () => Promise.resolve(),
39
23
  sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
40
24
  };
41
- const reauthenticate = async (req, options, grant) => {
42
- const keyDoc = await req.payload.findByID({
43
- collection: options.apiKeysSlug,
44
- id: grant.apiKeyId,
45
- depth: 0,
46
- overrideAccess: true,
47
- disableErrors: true,
48
- select: {
49
- enabled: true,
50
- user: true,
51
- capabilities: true,
52
- expiresAt: true
53
- }
54
- });
55
- return await checkApiKey(req, options, keyDoc ?? void 0);
56
- };
57
25
  const claimAs = async (req, options, kind) => {
58
- const slug = grantKvSlug(req.payload.config);
26
+ const slug = grantsAvailable(req, options);
59
27
  const grantId = req.headers.get("x-mcpx-grant");
60
- if (slug === void 0 || options.auth?.resolve || grantId === null) return;
28
+ if (slug === void 0 || grantId === null) return;
61
29
  const grant = await claimGrant(req.payload, slug, grantId);
62
30
  if (grant?.kind !== kind) return;
63
- const auth = await reauthenticate(req, options, grant);
31
+ const auth = await resolveApiKeyById(req, options, grant.apiKeyId);
64
32
  if (!auth) return;
65
33
  const scope = authenticateAs(req, options, auth);
66
34
  req.query = {};
@@ -80,16 +48,16 @@ const claimAs = async (req, options, kind) => {
80
48
  */ const createUploadHandler = (options) => async (req) => {
81
49
  const { payload } = req;
82
50
  const claimed = await claimAs(req, options, "upload");
83
- const file = claimed?.grant.args["file"];
84
- if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !file) return refused();
51
+ if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !claimed.grant.file) return refused();
85
52
  const { grant, scope } = claimed;
53
+ const { file } = grant;
86
54
  const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
87
55
  if (!tool || !isToolEnabled(tool, scope)) return refused();
88
56
  const args = toolInputSchema(tool, scope).safeParse(grant.args);
89
57
  if (!args.success) return refused();
90
58
  if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
91
- const data = await readExactly(req, file.size);
92
- if (data === null) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
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.` });
93
61
  setUploadedFile(req, {
94
62
  data,
95
63
  mimetype: file.mimeType,