@abinnovision/payloadcms-mcpx 1.1.0-beta.6 → 2.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -470
- package/dist/admin/capability-matrix.d.mts +33 -0
- package/dist/admin/capability-matrix.mjs +354 -0
- package/dist/admin/capability-toggles.mjs +73 -0
- package/dist/{client → admin}/setup-guide.d.mts +4 -2
- package/dist/{client → admin}/setup-guide.mjs +3 -10
- package/dist/api-keys/capability-matrix.d.mts +12 -8
- package/dist/api-keys/capability-matrix.mjs +14 -86
- package/dist/api-keys/collection.mjs +2 -1
- package/dist/api-keys/fields.mjs +36 -24
- package/dist/auth/resolve.mjs +71 -23
- package/dist/{tools/names.mjs → builtin-tool-names.mjs} +1 -1
- package/dist/capabilities.mjs +44 -46
- package/dist/define-tool.d.mts +23 -0
- package/dist/{types.mjs → define-tool.mjs} +1 -1
- package/dist/endpoint/errors.mjs +5 -6
- package/dist/endpoint/handler.mjs +73 -29
- package/dist/endpoint/server.mjs +85 -22
- package/dist/endpoint/upload.mjs +135 -0
- package/dist/entity.d.mts +9 -0
- package/dist/guards.mjs +7 -0
- package/dist/i18n.mjs +10 -8
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -2
- package/dist/options.mjs +54 -47
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.mjs +13 -3
- package/dist/request.d.mts +2 -3
- package/dist/request.mjs +2 -3
- package/dist/result.d.mts +2 -3
- package/dist/result.mjs +3 -3
- package/dist/schema/admin-hidden.mjs +91 -0
- package/dist/schema/describe.mjs +17 -33
- package/dist/schema/errors.mjs +8 -0
- package/dist/schema/index.mjs +5 -2
- package/dist/schema/lexical-pointer.mjs +18 -20
- package/dist/schema/lexical.mjs +44 -93
- package/dist/schema/outline.mjs +5 -15
- package/dist/schema/path.mjs +28 -0
- package/dist/schema/pointer.mjs +18 -25
- package/dist/schema/shape.mjs +25 -71
- package/dist/schema/walk.mjs +43 -45
- package/dist/tools/builtin.mjs +4 -4
- package/dist/tools/create-document.mjs +50 -24
- package/dist/tools/describe-schema.mjs +38 -34
- package/dist/tools/document.mjs +124 -0
- package/dist/tools/find-documents.mjs +32 -19
- package/dist/tools/find-versions.mjs +22 -18
- package/dist/tools/get-document.mjs +60 -33
- package/dist/tools/list-capabilities.mjs +35 -19
- package/dist/tools/patch-document.mjs +122 -66
- package/dist/tools/publish-document.mjs +26 -32
- package/dist/tools/query-paths.mjs +57 -0
- package/dist/tools/read-request.d.mts +10 -0
- package/dist/tools/read-request.mjs +43 -0
- package/dist/tools/shared.mjs +74 -105
- package/dist/tools/validate-document.mjs +18 -30
- package/dist/tools/versions.mjs +37 -20
- package/dist/types.d.mts +135 -75
- package/dist/upload/file.mjs +166 -0
- package/dist/upload/grant.mjs +96 -0
- package/dist/version.mjs +3 -1
- package/dist/write/draft-guard.mjs +23 -41
- package/dist/write/patch.mjs +27 -183
- package/dist/write/publish-blockers.mjs +7 -14
- package/dist/write/publish-intent.mjs +6 -4
- package/dist/write/row-ids.mjs +46 -0
- package/dist/write/transaction.mjs +5 -8
- package/dist/write/write-data.mjs +64 -0
- package/package.json +11 -10
- package/dist/client/capability-matrix.d.mts +0 -26
- package/dist/client/capability-matrix.mjs +0 -349
- package/dist/endpoint/index.mjs +0 -4
- package/dist/tools/target.mjs +0 -45
- /package/dist/{client → admin}/index.d.mts +0 -0
- /package/dist/{client → admin}/index.mjs +0 -0
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,44 +1,107 @@
|
|
|
1
1
|
import { toToolError } from "./errors.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { fileSlugs } from "../upload/file.mjs";
|
|
3
|
+
import { liveWriteSlugs } from "../tools/shared.mjs";
|
|
4
|
+
import { MCPX_REPOSITORY_URL } from "../version.mjs";
|
|
3
5
|
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
4
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
7
|
import { z } from "zod";
|
|
6
8
|
//#region src/endpoint/server.ts
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Strict, so an unknown argument is rejected by name instead of stripped.
|
|
11
|
+
*/ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
12
|
+
const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
13
|
+
/**
|
|
14
|
+
* A tool that does not decide for itself is gated by its own checkbox.
|
|
15
|
+
*/ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
16
|
+
/**
|
|
17
|
+
* Runs one tool call. Payload's public errors become error results; anything
|
|
18
|
+
* else is logged and reported as an internal error.
|
|
19
|
+
*/ const runTool = async (tool, args, scope, req, extra) => {
|
|
20
|
+
try {
|
|
21
|
+
return await tool.handler({
|
|
22
|
+
args,
|
|
23
|
+
scope,
|
|
24
|
+
req,
|
|
25
|
+
extra
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return toToolError(error, req.payload.logger);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const WRITE_STEP = {
|
|
32
|
+
tools: ["patchDocument", "createDocument"],
|
|
33
|
+
does: "the write"
|
|
34
|
+
};
|
|
35
|
+
const WORKFLOW = [
|
|
36
|
+
{
|
|
37
|
+
tools: ["listCapabilities"],
|
|
38
|
+
does: "what this key may access"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
tools: ["describeSchema"],
|
|
42
|
+
does: "the fields"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
tools: ["findDocuments", "getDocument"],
|
|
46
|
+
does: "the content and its \"updatedAt\""
|
|
47
|
+
},
|
|
48
|
+
WRITE_STEP,
|
|
49
|
+
{
|
|
50
|
+
tools: ["validateDocument"],
|
|
51
|
+
does: "what blocks publishing"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
tools: ["publishDocument"],
|
|
55
|
+
does: "make the draft public"
|
|
56
|
+
}
|
|
57
|
+
];
|
|
58
|
+
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.";
|
|
59
|
+
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.`;
|
|
60
|
+
const DRAFTS = "Writes are saved as drafts, except in collections and globals without drafts, which patchDocument names: those go live immediately.";
|
|
61
|
+
const DRAFTS_ONLY = "Every write is saved as a draft.";
|
|
62
|
+
/**
|
|
63
|
+
* The workflow for the tools this key has, and what its writes reach.
|
|
64
|
+
*/ const serverInstructions = (scope) => {
|
|
65
|
+
const enabled = new Set(BUILTIN_TOOLS.filter((tool) => isToolEnabled(tool, scope)).map((tool) => tool.name));
|
|
66
|
+
const uploads = fileSlugs(scope).length > 0;
|
|
67
|
+
const steps = WORKFLOW.flatMap((step) => {
|
|
68
|
+
const present = step.tools.filter((name) => enabled.has(name));
|
|
69
|
+
if (present.length === 0) return [];
|
|
70
|
+
const line = `${present.join(" or ")}: ${step.does}.`;
|
|
71
|
+
return step === WRITE_STEP && uploads ? [line, UPLOAD_STEP] : [line];
|
|
72
|
+
}).map((step, index) => `${String(index + 1)}. ${step}`);
|
|
73
|
+
const publishing = enabled.has("publishDocument") ? "" : " This key cannot publish. A person publishes drafts in the admin panel.";
|
|
74
|
+
const drafts = liveWriteSlugs(scope, "write").length === 0 ? DRAFTS_ONLY : DRAFTS;
|
|
75
|
+
const writes = enabled.has("patchDocument") ? `\n\n${drafts}${publishing}` : "";
|
|
76
|
+
const paths = enabled.has("describeSchema") ? `\n\n${PATHS}` : "";
|
|
77
|
+
return `Call the tools in this order:\n${steps.join("\n")}${paths}${writes}`;
|
|
78
|
+
};
|
|
10
79
|
/**
|
|
11
80
|
* One server per request. Builtin and configured tools take the same route,
|
|
12
81
|
* each registered against the key's capabilities, so `tools/list` shows exactly
|
|
13
|
-
* what the key may call.
|
|
82
|
+
* what the key may call. The tool handlers of one JSON-RPC batch run one at a time,
|
|
83
|
+
* so a failed call cannot roll back a write another call reported as done.
|
|
14
84
|
*/ const createMcpServer = (scope, options) => {
|
|
15
85
|
const { req } = scope;
|
|
16
|
-
|
|
86
|
+
let queue = Promise.resolve();
|
|
17
87
|
const server = new McpServer({
|
|
18
88
|
name: options.serverInfo.name,
|
|
19
|
-
version: options.serverInfo.version
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
return await run();
|
|
24
|
-
} catch (error) {
|
|
25
|
-
return toToolError(error, logger);
|
|
26
|
-
}
|
|
27
|
-
};
|
|
89
|
+
version: options.serverInfo.version,
|
|
90
|
+
...options.diagnostics ? { websiteUrl: MCPX_REPOSITORY_URL } : {}
|
|
91
|
+
}, { instructions: serverInstructions(scope) });
|
|
28
92
|
for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
|
|
29
93
|
if (!isToolEnabled(tool, scope)) continue;
|
|
30
94
|
server.registerTool(tool.name, {
|
|
31
95
|
description: toolDescription(tool, scope),
|
|
32
96
|
inputSchema: toolInputSchema(tool, scope),
|
|
33
97
|
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
34
|
-
}, (args, extra) =>
|
|
35
|
-
args,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}))());
|
|
98
|
+
}, (args, extra) => {
|
|
99
|
+
const result = queue.then(async () => await runTool(tool, args, scope, req, extra));
|
|
100
|
+
queue = result.catch(() => void 0);
|
|
101
|
+
return result;
|
|
102
|
+
});
|
|
40
103
|
}
|
|
41
104
|
return server;
|
|
42
105
|
};
|
|
43
106
|
//#endregion
|
|
44
|
-
export { createMcpServer, isToolEnabled,
|
|
107
|
+
export { createMcpServer, isToolEnabled, runTool, toolInputSchema };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { checkApiKey } from "../auth/resolve.mjs";
|
|
2
|
+
import { claimGrant, uploadKvSlug } from "../upload/grant.mjs";
|
|
3
|
+
import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
|
|
4
|
+
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
5
|
+
import { isToolEnabled, runTool, toolInputSchema } from "./server.mjs";
|
|
6
|
+
import { authenticateAs } from "./handler.mjs";
|
|
7
|
+
//#region src/endpoint/upload.ts
|
|
8
|
+
const UPLOAD_TOOLS = /* @__PURE__ */ new Set(["createDocument", "patchDocument"]);
|
|
9
|
+
const respond = (status, body) => Response.json(body, { status });
|
|
10
|
+
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." });
|
|
11
|
+
const readExactly = async (req, size) => {
|
|
12
|
+
const declared = req.headers.get("content-length");
|
|
13
|
+
if (declared !== null && Number(declared) !== size) return null;
|
|
14
|
+
if (!req.body) return null;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let total = 0;
|
|
17
|
+
for await (const chunk of req.body) {
|
|
18
|
+
total += chunk.byteLength;
|
|
19
|
+
if (total > size) return null;
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
}
|
|
22
|
+
return total === size ? Buffer.concat(chunks) : null;
|
|
23
|
+
};
|
|
24
|
+
const statusOf = (result, data) => {
|
|
25
|
+
if (result.isError !== true) return 200;
|
|
26
|
+
if ("updatedAt" in data || data["status"] === 423) return 409;
|
|
27
|
+
if (data["status"] === 401 || data["status"] === 403) return 403;
|
|
28
|
+
return data["error"] === "Internal error" ? 500 : 422;
|
|
29
|
+
};
|
|
30
|
+
const parseResult = (result) => {
|
|
31
|
+
const [first] = result.content;
|
|
32
|
+
return first?.type === "text" ? JSON.parse(first.text) : {};
|
|
33
|
+
};
|
|
34
|
+
const NO_SESSION = {
|
|
35
|
+
signal: new AbortController().signal,
|
|
36
|
+
requestId: "upload",
|
|
37
|
+
sendNotification: () => Promise.resolve(),
|
|
38
|
+
sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
|
|
39
|
+
};
|
|
40
|
+
const reauthenticate = async (req, options, grant) => {
|
|
41
|
+
const keyDoc = await req.payload.findByID({
|
|
42
|
+
collection: options.apiKeysSlug,
|
|
43
|
+
id: grant.apiKeyId,
|
|
44
|
+
depth: 0,
|
|
45
|
+
overrideAccess: true,
|
|
46
|
+
disableErrors: true,
|
|
47
|
+
select: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
user: true,
|
|
50
|
+
capabilities: true,
|
|
51
|
+
expiresAt: true
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return await checkApiKey(req, options, keyDoc ?? void 0);
|
|
55
|
+
};
|
|
56
|
+
const claimAs = async (req, options, kind) => {
|
|
57
|
+
const slug = uploadKvSlug(req.payload.config);
|
|
58
|
+
const grantId = req.headers.get("x-mcpx-grant");
|
|
59
|
+
if (slug === void 0 || options.auth?.resolve || grantId === null) return;
|
|
60
|
+
const grant = await claimGrant(req.payload, slug, grantId);
|
|
61
|
+
if (grant?.kind !== kind) return;
|
|
62
|
+
const auth = await reauthenticate(req, options, grant);
|
|
63
|
+
if (!auth) return;
|
|
64
|
+
const scope = authenticateAs(req, options, auth);
|
|
65
|
+
req.query = {};
|
|
66
|
+
for (const name of [...req.searchParams.keys()]) req.searchParams.delete(name);
|
|
67
|
+
req.locale = grant.locale;
|
|
68
|
+
req.fallbackLocale = grant.fallbackLocale;
|
|
69
|
+
return {
|
|
70
|
+
grant,
|
|
71
|
+
scope
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Completes a call that returned an `upload`: claims the grant, acts as its
|
|
76
|
+
* key again, then runs the same tool with the same arguments and the body as
|
|
77
|
+
* the file. The response body is the tool's result. The grant header is never
|
|
78
|
+
* logged.
|
|
79
|
+
*/ const createUploadHandler = (options) => async (req) => {
|
|
80
|
+
const { payload } = req;
|
|
81
|
+
const claimed = await claimAs(req, options, "upload");
|
|
82
|
+
const file = claimed?.grant.args["file"];
|
|
83
|
+
if (!claimed || !UPLOAD_TOOLS.has(claimed.grant.tool) || !file) return refused();
|
|
84
|
+
const { grant, scope } = claimed;
|
|
85
|
+
const tool = BUILTIN_TOOLS.find((candidate) => candidate.name === grant.tool);
|
|
86
|
+
if (!tool || !isToolEnabled(tool, scope)) return refused();
|
|
87
|
+
const args = toolInputSchema(tool, scope).safeParse(grant.args);
|
|
88
|
+
if (!args.success) return refused();
|
|
89
|
+
if (file.size > uploadMaxBytes(payload.config)) return respond(413, { error: "The file is larger than allowed." });
|
|
90
|
+
const data = await readExactly(req, file.size);
|
|
91
|
+
if (data === null) return respond(400, { error: `The body must be exactly ${String(file.size)} bytes, the size the tool call declared.` });
|
|
92
|
+
setUploadedFile(req, {
|
|
93
|
+
data,
|
|
94
|
+
mimetype: file.mimeType,
|
|
95
|
+
name: file.filename,
|
|
96
|
+
size: data.length
|
|
97
|
+
});
|
|
98
|
+
const result = await runTool(tool, args.data, scope, req, NO_SESSION);
|
|
99
|
+
const body = parseResult(result);
|
|
100
|
+
return respond(statusOf(result, body), body);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Serves the file of a `getDocument` call that returned a `download`: claims
|
|
104
|
+
* the grant, acts as its key again, reloads the document as its user and
|
|
105
|
+
* hands the request to the collection's own `/file/:filename` endpoint, so
|
|
106
|
+
* Payload's file access, storage handlers and response headers apply.
|
|
107
|
+
*/ const createDownloadHandler = (options) => async (req) => {
|
|
108
|
+
const claimed = await claimAs(req, options, "download");
|
|
109
|
+
if (!claimed || !downloadSlugs(claimed.scope).includes(claimed.grant.collection)) return refused();
|
|
110
|
+
const { collection, id, draft } = claimed.grant;
|
|
111
|
+
const doc = await req.payload.findByID({
|
|
112
|
+
collection,
|
|
113
|
+
id,
|
|
114
|
+
depth: 0,
|
|
115
|
+
draft,
|
|
116
|
+
overrideAccess: false,
|
|
117
|
+
disableErrors: true,
|
|
118
|
+
select: {
|
|
119
|
+
filename: true,
|
|
120
|
+
prefix: true
|
|
121
|
+
},
|
|
122
|
+
req
|
|
123
|
+
});
|
|
124
|
+
const { endpoints } = req.payload.collections[collection]?.config ?? {};
|
|
125
|
+
const endpoint = Array.isArray(endpoints) ? endpoints.find((candidate) => candidate.method === "get" && candidate.path === "/file/:filename") : void 0;
|
|
126
|
+
if (typeof doc?.filename !== "string" || !endpoint) return refused();
|
|
127
|
+
if (typeof doc.prefix === "string") req.searchParams.set("prefix", doc.prefix);
|
|
128
|
+
req.routeParams = {
|
|
129
|
+
collection,
|
|
130
|
+
filename: doc.filename
|
|
131
|
+
};
|
|
132
|
+
return await endpoint.handler(req);
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
export { createDownloadHandler, createUploadHandler };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "payload";
|
|
2
|
+
//#region src/entity.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The id type follows the database adapter and a collection's own `id` field,
|
|
5
|
+
* so one project can have both. Payload's `DefaultDocumentIDType` narrows to
|
|
6
|
+
* the project default, so it cannot be used here.
|
|
7
|
+
*/
|
|
8
|
+
export type DocumentId = number | string;
|
|
9
|
+
//#endregion
|
package/dist/guards.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/guards.ts
|
|
2
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
/**
|
|
4
|
+
* An own property only, so a client-chosen key never reads the prototype.
|
|
5
|
+
*/ const ownValue = (record, key) => record !== void 0 && Object.hasOwn(record, key) ? record[key] : void 0;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { isPlainObject, ownValue };
|
package/dist/i18n.mjs
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
//#region src/i18n.ts
|
|
2
2
|
const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
3
|
-
|
|
3
|
+
const pick = (record, language) => {
|
|
4
4
|
for (const code of Array.isArray(language) ? language : [language]) {
|
|
5
5
|
const entry = record[code];
|
|
6
6
|
if (entry !== void 0 && entry.trim() !== "") return entry;
|
|
7
7
|
}
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
* Resolves a static label or `admin.description` to
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Resolves a static label or `admin.description` to one string: the request's
|
|
11
|
+
* language, then the configured fallback language, then the first entry the
|
|
12
|
+
* record declares.
|
|
13
13
|
*
|
|
14
14
|
* Anything that is not a string or a string-valued record is dropped. A
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* function or React component description may reach client-only i18n, so it is
|
|
16
|
+
* never invoked.
|
|
17
17
|
*/ const translateStatic = (value, language) => {
|
|
18
18
|
if (typeof value === "string") return value.trim() === "" ? void 0 : value;
|
|
19
19
|
const record = stringRecord(value);
|
|
20
20
|
if (!record) return;
|
|
21
21
|
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
/**
|
|
23
|
+
const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
24
|
+
/**
|
|
25
|
+
* For callers with no request. Both languages miss, so the first entry wins.
|
|
26
|
+
*/ const translateAny = translatorFor({
|
|
25
27
|
fallbackLanguage: "",
|
|
26
28
|
language: ""
|
|
27
29
|
});
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
3
|
+
import { defineMcpxTool } from "./define-tool.mjs";
|
|
2
4
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
3
5
|
import { isMcpxRequest } from "./request.mjs";
|
|
4
6
|
import { errorResult, jsonResult } from "./result.mjs";
|
|
5
|
-
|
|
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 };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { defineMcpxTool } from "./define-tool.mjs";
|
|
1
2
|
import { errorResult, jsonResult } from "./result.mjs";
|
|
2
|
-
import {
|
|
3
|
+
import { mcpxReadRequest } from "./tools/read-request.mjs";
|
|
3
4
|
import { isMcpxRequest } from "./request.mjs";
|
|
4
5
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
5
|
-
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
|
6
|
+
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin, mcpxReadRequest };
|
package/dist/options.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isPlainObject } from "./guards.mjs";
|
|
2
2
|
import "./version.mjs";
|
|
3
|
+
import { BUILTIN_TOOL_NAMES } from "./builtin-tool-names.mjs";
|
|
3
4
|
import { InvalidConfiguration } from "payload";
|
|
4
5
|
import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
|
|
5
6
|
//#region src/options.ts
|
|
@@ -11,45 +12,55 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
|
|
|
11
12
|
const fail = (message) => {
|
|
12
13
|
throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
|
|
13
14
|
};
|
|
14
|
-
/** The same transform the stock MCP plugin uses to derive field names. */ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
* The same transform the stock MCP plugin uses to derive field names.
|
|
17
|
+
*/ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
18
|
+
const assertExposable = (collection, apiKeysSlug) => {
|
|
19
19
|
const { slug } = collection;
|
|
20
20
|
if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
|
|
21
21
|
if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
|
|
22
22
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const OPTION_NAMES = [
|
|
24
|
+
"read",
|
|
25
|
+
"write",
|
|
26
|
+
"publish"
|
|
27
|
+
];
|
|
28
|
+
const REMOVED_WRITE_MODES = {
|
|
29
|
+
draft: "Use { publish: false } instead.",
|
|
30
|
+
live: "Writes are on by default, so remove it."
|
|
27
31
|
};
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* the tool responses model `_status` as a string. Refused until both are
|
|
33
|
-
* handled.
|
|
34
|
-
*/ const assertPublishable = (kind, config) => {
|
|
35
|
-
if (hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which write: "live" does not support yet.`);
|
|
32
|
+
const readFlag = (kind, slug, name, value) => {
|
|
33
|
+
if (value === void 0 || typeof value === "boolean") return value;
|
|
34
|
+
const moved = name === "write" && typeof value === "string" ? REMOVED_WRITE_MODES[value] : void 0;
|
|
35
|
+
return fail(`${kind} "${slug}" has ${name}: ${JSON.stringify(value)}. ${moved ?? "Use true or false."}`);
|
|
36
36
|
};
|
|
37
|
-
const assertWritable = (
|
|
38
|
-
|
|
39
|
-
if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
|
|
40
|
-
if (options.write === "draft" && !options.hasDrafts) fail(`Collection "${slug}" has no drafts. Enable versions.drafts or set write: "live".`);
|
|
41
|
-
if (options.write === "live") assertPublishable("Collection", collection);
|
|
37
|
+
const assertWritable = (kind, config, write) => {
|
|
38
|
+
if (write === "live" && hasLocalizeStatusEnabled(config)) fail(`${kind} "${config.slug}" has versions.drafts.localizeStatus enabled, which live writes do not support yet. Set publish: false or write: false.`);
|
|
42
39
|
};
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const normalizeCapabilities = (kind, config, raw) => {
|
|
41
|
+
const { slug } = config;
|
|
42
|
+
if (raw === false) fail(`${kind} "${slug}" is set to false. Remove the entry to hide the ${kind.toLowerCase()}.`);
|
|
43
|
+
if (raw !== true && !isPlainObject(raw)) fail(`${kind} "${slug}" has ${JSON.stringify(raw)}. Use true or an object of ${OPTION_NAMES.join(", ")}.`);
|
|
44
|
+
const settings = isPlainObject(raw) ? raw : {};
|
|
45
|
+
const unknownKey = Object.keys(settings).find((key) => !OPTION_NAMES.includes(key));
|
|
46
|
+
if (unknownKey !== void 0) fail(`${kind} "${slug}" has the unknown option "${unknownKey}". The options are ${OPTION_NAMES.join(", ")}.`);
|
|
47
|
+
const flag = (name) => readFlag(kind, slug, name, settings[name]);
|
|
48
|
+
const read = flag("read") ?? true;
|
|
49
|
+
const write = flag("write") ?? true;
|
|
50
|
+
const publish = flag("publish");
|
|
51
|
+
const hasDrafts = hasDraftsEnabled(config);
|
|
52
|
+
if (publish === false && write && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so every write goes live. Set write: false or remove publish: false.`);
|
|
53
|
+
if (publish === true && !hasDrafts) fail(`${kind} "${slug}" has no drafts, so there is nothing to publish. Enable versions.drafts or remove publish: true.`);
|
|
54
|
+
if (publish === true && !write) fail(`${kind} "${slug}" has publish: true but write: false. Publishing follows write.`);
|
|
55
|
+
return {
|
|
56
|
+
read,
|
|
57
|
+
write: write ? publish === false ? "draft" : "live" : false,
|
|
58
|
+
hasDrafts,
|
|
59
|
+
hasVersions: read && Boolean(config.versions)
|
|
60
|
+
};
|
|
45
61
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* `createdAt`/`updatedAt`, so the concurrency check the collection path guards
|
|
49
|
-
* for is always available here. Drafts are the only requirement left.
|
|
50
|
-
*/ const assertGlobalWritable = (global, options) => {
|
|
51
|
-
if (options.write === "draft" && !options.hasDrafts) fail(`Global "${global.slug}" has no drafts. Enable versions.drafts or set write: "live".`);
|
|
52
|
-
if (options.write === "live") assertPublishable("Global", global);
|
|
62
|
+
const assertGlobalExposable = (global) => {
|
|
63
|
+
if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
|
|
53
64
|
};
|
|
54
65
|
const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
55
66
|
const collections = config.collections ?? [];
|
|
@@ -59,18 +70,16 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
|
59
70
|
const collection = collections.find((candidate) => candidate.slug === slug);
|
|
60
71
|
if (!collection) return fail(`Exposed collection "${slug}" does not exist.`);
|
|
61
72
|
assertExposable(collection, apiKeysSlug);
|
|
62
|
-
const settings = raw === true ? {} : raw;
|
|
63
|
-
const hasDrafts = hasDraftsEnabled(collection);
|
|
64
73
|
const normalized = {
|
|
65
74
|
slug,
|
|
66
|
-
|
|
67
|
-
write: normalizeWriteMode("Collection", slug, settings.write),
|
|
68
|
-
hasDrafts,
|
|
69
|
-
hasVersions: Boolean(collection.versions),
|
|
75
|
+
...normalizeCapabilities("Collection", collection, raw),
|
|
70
76
|
isUpload: Boolean(collection.upload),
|
|
71
77
|
fieldName: toCamelCase(slug)
|
|
72
78
|
};
|
|
73
|
-
if (normalized.write !== false)
|
|
79
|
+
if (normalized.write !== false) {
|
|
80
|
+
if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
|
|
81
|
+
assertWritable("Collection", collection, normalized.write);
|
|
82
|
+
}
|
|
74
83
|
if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
|
|
75
84
|
fieldNames.add(normalized.fieldName);
|
|
76
85
|
return [normalized];
|
|
@@ -84,18 +93,13 @@ const normalizeGlobals = (config, options) => {
|
|
|
84
93
|
const global = globals.find((candidate) => candidate.slug === slug);
|
|
85
94
|
if (!global) return fail(`Exposed global "${slug}" does not exist.`);
|
|
86
95
|
assertGlobalExposable(global);
|
|
87
|
-
const settings = raw === true ? {} : raw;
|
|
88
|
-
const hasDrafts = hasDraftsEnabled(global);
|
|
89
96
|
const normalized = {
|
|
90
97
|
slug,
|
|
91
|
-
|
|
92
|
-
write: normalizeWriteMode("Global", slug, settings.write),
|
|
93
|
-
hasDrafts,
|
|
94
|
-
hasVersions: Boolean(global.versions),
|
|
98
|
+
...normalizeCapabilities("Global", global, raw),
|
|
95
99
|
isUpload: false,
|
|
96
100
|
fieldName: toCamelCase(slug)
|
|
97
101
|
};
|
|
98
|
-
if (normalized.write !== false)
|
|
102
|
+
if (normalized.write !== false) assertWritable("Global", global, normalized.write);
|
|
99
103
|
if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
|
|
100
104
|
fieldNames.add(normalized.fieldName);
|
|
101
105
|
return [normalized];
|
|
@@ -125,7 +129,9 @@ const normalizeLimits = (limits) => {
|
|
|
125
129
|
maxDepth
|
|
126
130
|
};
|
|
127
131
|
};
|
|
128
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Every problem is an `InvalidConfiguration`, so it fails at startup.
|
|
134
|
+
*/ const normalizeOptions = (config, options) => {
|
|
129
135
|
const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
|
|
130
136
|
const userCollection = options.userCollection ?? config.admin?.user ?? "users";
|
|
131
137
|
if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
|
|
@@ -145,7 +151,8 @@ const normalizeLimits = (limits) => {
|
|
|
145
151
|
serverInfo: {
|
|
146
152
|
name: options.serverInfo?.name ?? "payloadcms-mcpx",
|
|
147
153
|
version: options.serverInfo?.version ?? "1.0.0"
|
|
148
|
-
}
|
|
154
|
+
},
|
|
155
|
+
diagnostics: options.diagnostics ?? true
|
|
149
156
|
};
|
|
150
157
|
};
|
|
151
158
|
//#endregion
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { McpxPluginOptions } from "./types.mjs";
|
|
2
2
|
//#region src/plugin.d.ts
|
|
3
3
|
/**
|
|
4
|
-
* Mounts the MCP
|
|
5
|
-
* draft guard on every collection and global.
|
|
4
|
+
* Mounts the MCP, upload and download endpoints, adds the API key collection
|
|
5
|
+
* and installs the draft guard on every collection and global.
|
|
6
6
|
*/
|
|
7
7
|
export declare const mcpxPlugin: (options: McpxPluginOptions) => import("payload").Plugin;
|
|
8
8
|
//#endregion
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createApiKeysCollection } from "./api-keys/collection.mjs";
|
|
2
2
|
import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
|
|
3
|
-
import "./endpoint/
|
|
3
|
+
import { createDownloadHandler, createUploadHandler } from "./endpoint/upload.mjs";
|
|
4
4
|
import { normalizeOptions } from "./options.mjs";
|
|
5
5
|
import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
|
|
6
6
|
import { definePlugin } from "payload";
|
|
7
7
|
//#region src/plugin.ts
|
|
8
8
|
/**
|
|
9
|
-
* Mounts the MCP
|
|
10
|
-
* draft guard on every collection and global.
|
|
9
|
+
* Mounts the MCP, upload and download endpoints, adds the API key collection
|
|
10
|
+
* and installs the draft guard on every collection and global.
|
|
11
11
|
*/ const mcpxPlugin = definePlugin({
|
|
12
12
|
slug: "@abinnovision/payloadcms-mcpx",
|
|
13
13
|
order: 100,
|
|
@@ -35,6 +35,16 @@ import { definePlugin } from "payload";
|
|
|
35
35
|
path: normalized.endpointPath,
|
|
36
36
|
method: "delete",
|
|
37
37
|
handler: methodNotAllowed
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: `${normalized.endpointPath}/upload`,
|
|
41
|
+
method: "put",
|
|
42
|
+
handler: createUploadHandler(normalized)
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
path: `${normalized.endpointPath}/file`,
|
|
46
|
+
method: "get",
|
|
47
|
+
handler: createDownloadHandler(normalized)
|
|
38
48
|
}
|
|
39
49
|
]
|
|
40
50
|
};
|
package/dist/request.d.mts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { PayloadRequest } from "payload";
|
|
2
2
|
//#region src/request.d.ts
|
|
3
3
|
/**
|
|
4
|
-
* Whether
|
|
5
|
-
* `req
|
|
6
|
-
* same `req`, including those made by custom tools.
|
|
4
|
+
* Whether `req` came through the MCP endpoint, which stamps `req.context.mcpx`.
|
|
5
|
+
* The stamp follows `req` into every local API call, including custom tools.
|
|
7
6
|
*/
|
|
8
7
|
export declare const isMcpxRequest: (req: PayloadRequest) => boolean;
|
|
9
8
|
//#endregion
|
package/dist/request.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
//#region src/request.ts
|
|
2
2
|
/**
|
|
3
|
-
* Whether
|
|
4
|
-
* `req
|
|
5
|
-
* same `req`, including those made by custom tools.
|
|
3
|
+
* Whether `req` came through the MCP endpoint, which stamps `req.context.mcpx`.
|
|
4
|
+
* The stamp follows `req` into every local API call, including custom tools.
|
|
6
5
|
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
7
6
|
//#endregion
|
|
8
7
|
export { isMcpxRequest };
|
package/dist/result.d.mts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
//#region src/result.d.ts
|
|
3
|
-
/** `value` as JSON text. */
|
|
4
3
|
export declare const jsonResult: (value: unknown) => CallToolResult;
|
|
5
4
|
/**
|
|
6
|
-
* `extras`
|
|
7
|
-
*
|
|
5
|
+
* `extras` carries data the client can act on, such as problems, validation
|
|
6
|
+
* errors or the current `updatedAt`.
|
|
8
7
|
*/
|
|
9
8
|
export declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
|
|
10
9
|
//#endregion
|
package/dist/result.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
//#region src/result.ts
|
|
2
|
-
|
|
2
|
+
const jsonResult = (value) => ({ content: [{
|
|
3
3
|
type: "text",
|
|
4
4
|
text: JSON.stringify(value)
|
|
5
5
|
}] });
|
|
6
6
|
/**
|
|
7
|
-
* `extras`
|
|
8
|
-
*
|
|
7
|
+
* `extras` carries data the client can act on, such as problems, validation
|
|
8
|
+
* errors or the current `updatedAt`.
|
|
9
9
|
*/ const errorResult = (message, extras = {}) => ({
|
|
10
10
|
content: [{
|
|
11
11
|
type: "text",
|