@abinnovision/payloadcms-mcpx 2.0.0-beta.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +56 -36
- package/dist/admin/capability-matrix.d.mts +5 -3
- package/dist/admin/capability-matrix.mjs +374 -272
- package/dist/admin/capability-toggles.mjs +79 -42
- package/dist/admin/confirmations-panel.mjs +232 -0
- package/dist/admin/confirmations.d.mts +15 -0
- package/dist/admin/confirmations.mjs +71 -0
- package/dist/admin/endpoint-url.mjs +7 -0
- package/dist/admin/index.d.mts +2 -1
- package/dist/admin/index.mjs +2 -1
- package/dist/admin/setup-guide.d.mts +3 -3
- package/dist/admin/setup-guide.mjs +51 -33
- package/dist/api-keys/capability-matrix.d.mts +7 -9
- package/dist/api-keys/capability-matrix.mjs +15 -37
- package/dist/api-keys/collection.mjs +16 -18
- package/dist/api-keys/confirmation-view.mjs +7 -0
- package/dist/api-keys/fields.mjs +50 -57
- package/dist/auth/resolve.mjs +69 -40
- package/dist/capabilities.d.mts +2 -0
- package/dist/capabilities.mjs +64 -16
- package/dist/define-tool.d.mts +4 -4
- package/dist/define-tool.mjs +8 -1
- package/dist/endpoint/confirmations.mjs +124 -0
- package/dist/endpoint/handler.mjs +25 -27
- package/dist/endpoint/http.mjs +27 -0
- package/dist/endpoint/server.mjs +48 -29
- package/dist/endpoint/upload.mjs +104 -0
- package/dist/grants/confirmation.mjs +75 -0
- package/dist/grants/grant.mjs +137 -0
- package/dist/guards.mjs +8 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/options.mjs +68 -61
- package/dist/plugin.d.mts +3 -2
- package/dist/plugin.mjs +19 -4
- package/dist/request.mjs +5 -1
- package/dist/result.mjs +11 -1
- package/dist/schema/admin-hidden.mjs +4 -4
- package/dist/schema/describe.mjs +12 -18
- package/dist/schema/index.mjs +5 -6
- package/dist/schema/lexical-pointer.mjs +8 -8
- package/dist/schema/lexical.mjs +15 -13
- package/dist/schema/outline.mjs +4 -5
- package/dist/schema/path.mjs +4 -1
- package/dist/schema/pointer.mjs +11 -17
- package/dist/schema/shape.mjs +38 -50
- package/dist/schema/walk.mjs +57 -18
- package/dist/tools/builtin.mjs +7 -1
- package/dist/tools/create-document.mjs +38 -26
- package/dist/tools/delete-document.mjs +154 -0
- package/dist/tools/describe-schema.mjs +7 -5
- package/dist/tools/document.mjs +131 -45
- package/dist/tools/duplicate-document.mjs +50 -0
- package/dist/tools/find-documents.mjs +15 -20
- package/dist/tools/find-versions.mjs +11 -15
- package/dist/tools/get-document.mjs +31 -15
- package/dist/tools/links.mjs +53 -0
- package/dist/tools/list-capabilities.mjs +17 -7
- package/dist/tools/patch-document.mjs +57 -53
- package/dist/tools/publish-document.mjs +17 -29
- package/dist/tools/query-paths.mjs +1 -1
- package/dist/tools/run-confirmed.mjs +114 -0
- package/dist/tools/shared.mjs +53 -46
- package/dist/tools/validate-document.mjs +6 -8
- package/dist/tools/versions.mjs +11 -6
- package/dist/types.d.mts +158 -43
- package/dist/upload/file.mjs +177 -0
- package/dist/version.mjs +4 -2
- package/dist/write/draft-guard.mjs +35 -28
- package/dist/write/patch.mjs +4 -4
- package/dist/write/publish-intent.mjs +23 -11
- package/dist/write/row-ids.mjs +3 -1
- package/dist/write/write-data.mjs +18 -31
- package/package.json +14 -14
- package/dist/builtin-tool-names.mjs +0 -16
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isPlainObject } from "../guards.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";
|
|
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 } = grantContext(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 = grantKvSlug(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 : rowHandle(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 { grantsAvailable, 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: grantsAvailable(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
|
|
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
|
|
84
|
-
if (
|
|
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 =
|
|
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(
|
|
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 { 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 };
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
13
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
115
|
+
export { createMcpServer, runTool };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { isToolEnabled, toolInputSchema } from "../define-tool.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";
|
|
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 = grantsAvailable(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 };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NO_REQ, ROW_ID, claimRow, expOf, issueRow, rowHandle, rowPrefixes, rowsWith } from "./grant.mjs";
|
|
2
|
+
//#region src/grants/confirmation.ts
|
|
3
|
+
const CONFIRMATION_TTL_MS = 9e5;
|
|
4
|
+
const HANDLE = /^[\da-f]{64}$/;
|
|
5
|
+
const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
|
|
6
|
+
/**
|
|
7
|
+
* Stores a pending call and returns its id. A key may hold at most
|
|
8
|
+
* {@link CONFIRMATIONS_PER_KEY} calls.
|
|
9
|
+
*/ const issueConfirmation = async (payload, slug, call) => {
|
|
10
|
+
const entry = {
|
|
11
|
+
...call,
|
|
12
|
+
state: "pending"
|
|
13
|
+
};
|
|
14
|
+
return await issueRow(payload, slug, {
|
|
15
|
+
...rowPrefixes("confirmation", call.apiKeyId),
|
|
16
|
+
cap: 50,
|
|
17
|
+
capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
|
|
18
|
+
ttlMs: CONFIRMATION_TTL_MS,
|
|
19
|
+
data: entry
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
|
|
24
|
+
* another key is `undefined`, as is a malformed id, before any database read.
|
|
25
|
+
*/ const readConfirmation = async (payload, slug, apiKeyId, id) => {
|
|
26
|
+
if (!ROW_ID.test(id)) return;
|
|
27
|
+
const key = `${rowPrefixes("confirmation", apiKeyId).prefix}${rowHandle(payload, id)}`;
|
|
28
|
+
const rows = await rowsWith(payload, slug, [key]);
|
|
29
|
+
return asConfirmation(rows.find((row) => row.key === key), Date.now());
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Marks the call used and removes it. Of two concurrent claims exactly one
|
|
33
|
+
* returns `true`.
|
|
34
|
+
*/ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimRow(payload, slug, rowPrefixes("confirmation", apiKeyId), rowHandle(payload, id), exp);
|
|
35
|
+
/**
|
|
36
|
+
* Every unexpired call of `apiKeyId` with its handle, oldest first.
|
|
37
|
+
*/ const listConfirmations = async (payload, slug, apiKeyId) => {
|
|
38
|
+
const { prefix } = rowPrefixes("confirmation", apiKeyId);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
return (await rowsWith(payload, slug, [prefix])).flatMap((row) => {
|
|
41
|
+
const confirmation = asConfirmation(row, now);
|
|
42
|
+
return confirmation ? [{
|
|
43
|
+
handle: row.key.slice(prefix.length),
|
|
44
|
+
confirmation
|
|
45
|
+
}] : [];
|
|
46
|
+
}).sort((left, right) => left.confirmation.exp - right.confirmation.exp);
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Approves or rejects the pending calls of `apiKeyId` among `handles`, and
|
|
50
|
+
* returns the handles it changed. A call that was decided, run or expired is
|
|
51
|
+
* left alone and not returned.
|
|
52
|
+
*/ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
|
|
53
|
+
const { prefix } = rowPrefixes("confirmation", apiKeyId);
|
|
54
|
+
const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
|
|
55
|
+
if (keys.size === 0) return [];
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const decided = [];
|
|
58
|
+
for (const row of await rowsWith(payload, slug, [prefix])) {
|
|
59
|
+
const confirmation = asConfirmation(row, now);
|
|
60
|
+
if (!keys.has(row.key) || confirmation?.state !== "pending") continue;
|
|
61
|
+
const updated = await payload.db.updateMany({
|
|
62
|
+
collection: slug,
|
|
63
|
+
where: { and: [{ key: { equals: row.key } }, { "data.state": { equals: "pending" } }] },
|
|
64
|
+
data: { data: {
|
|
65
|
+
...confirmation,
|
|
66
|
+
state
|
|
67
|
+
} },
|
|
68
|
+
req: NO_REQ
|
|
69
|
+
});
|
|
70
|
+
if (updated !== null && updated.length > 0) decided.push(row.key.slice(prefix.length));
|
|
71
|
+
}
|
|
72
|
+
return decided;
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
export { claimConfirmation, decideConfirmations, issueConfirmation, listConfirmations, readConfirmation };
|