@abinnovision/payloadcms-mcpx 1.0.0-beta.10
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/LICENSE +201 -0
- package/README.md +353 -0
- package/dist/api-keys/collection.mjs +58 -0
- package/dist/api-keys/fields.mjs +137 -0
- package/dist/api-keys/key.mjs +10 -0
- package/dist/api-keys/setup-guide.mjs +54 -0
- package/dist/auth/resolve.mjs +62 -0
- package/dist/capabilities.mjs +43 -0
- package/dist/client/index.d.mts +2 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/setup-guide.d.mts +14 -0
- package/dist/client/setup-guide.mjs +87 -0
- package/dist/endpoint/handler.mjs +86 -0
- package/dist/endpoint/result.mjs +65 -0
- package/dist/endpoint/server.mjs +52 -0
- package/dist/i18n.d.mts +1 -0
- package/dist/i18n.mjs +40 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +4 -0
- package/dist/options.d.mts +2 -0
- package/dist/options.mjs +146 -0
- package/dist/plugin.d.mts +9 -0
- package/dist/plugin.mjs +43 -0
- package/dist/schema/describe.mjs +160 -0
- package/dist/schema/lexical.d.mts +1 -0
- package/dist/schema/lexical.mjs +117 -0
- package/dist/schema/pointer.mjs +80 -0
- package/dist/schema/shape.mjs +208 -0
- package/dist/schema/walk.d.mts +3 -0
- package/dist/schema/walk.mjs +165 -0
- package/dist/tools/create-document.mjs +68 -0
- package/dist/tools/describe-schema.mjs +54 -0
- package/dist/tools/find-documents.mjs +55 -0
- package/dist/tools/get-document.mjs +68 -0
- package/dist/tools/index.mjs +23 -0
- package/dist/tools/list-capabilities.mjs +68 -0
- package/dist/tools/names.mjs +14 -0
- package/dist/tools/patch-document.mjs +127 -0
- package/dist/tools/shared.mjs +97 -0
- package/dist/tools/target.d.mts +3 -0
- package/dist/tools/target.mjs +49 -0
- package/dist/tools/types.d.mts +5 -0
- package/dist/tools/validate-document.mjs +55 -0
- package/dist/types.d.mts +143 -0
- package/dist/types.mjs +7 -0
- package/dist/version.mjs +6 -0
- package/dist/write/draft-guard.d.mts +10 -0
- package/dist/write/draft-guard.mjs +113 -0
- package/dist/write/patch.mjs +219 -0
- package/dist/write/publish-blockers.d.mts +15 -0
- package/dist/write/publish-blockers.mjs +74 -0
- package/dist/write/transaction.mjs +19 -0
- package/package.json +104 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
2
|
+
//#region src/api-keys/fields.ts
|
|
3
|
+
const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
|
|
4
|
+
const decryptKey = ({ req, value }) => {
|
|
5
|
+
if (typeof value !== "string") return value;
|
|
6
|
+
try {
|
|
7
|
+
return req.payload.decrypt(value);
|
|
8
|
+
} catch {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const checkbox = (name, description) => ({
|
|
13
|
+
name,
|
|
14
|
+
type: "checkbox",
|
|
15
|
+
defaultValue: false,
|
|
16
|
+
admin: { description }
|
|
17
|
+
});
|
|
18
|
+
const SETUP_GUIDE_FIELD = "setupGuide";
|
|
19
|
+
/**
|
|
20
|
+
* Fields every key carries. Key generation and the HMAC index live in the
|
|
21
|
+
* collection-level `beforeChange` hook (see `collection.ts`), because sibling
|
|
22
|
+
* field hooks run in parallel and cannot depend on each other's values.
|
|
23
|
+
*/ const createKeyFields = () => [
|
|
24
|
+
{
|
|
25
|
+
name: "label",
|
|
26
|
+
type: "text",
|
|
27
|
+
required: true,
|
|
28
|
+
admin: { description: "What this key is used for." }
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "enabled",
|
|
32
|
+
type: "checkbox",
|
|
33
|
+
defaultValue: true,
|
|
34
|
+
admin: { description: "Disabled keys are refused without revoking them." }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "apiKey",
|
|
38
|
+
type: "text",
|
|
39
|
+
access: {
|
|
40
|
+
create: () => false,
|
|
41
|
+
update: () => false
|
|
42
|
+
},
|
|
43
|
+
admin: {
|
|
44
|
+
readOnly: true,
|
|
45
|
+
description: "Generated when the key is created. Send it as `Authorization: Bearer <key>`."
|
|
46
|
+
},
|
|
47
|
+
hooks: {
|
|
48
|
+
beforeChange: [encryptKey],
|
|
49
|
+
afterRead: [decryptKey]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "apiKeyIndex",
|
|
54
|
+
type: "text",
|
|
55
|
+
hidden: true,
|
|
56
|
+
index: true
|
|
57
|
+
}
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* Wraps the key fields and the setup guide in unnamed tabs, so the wide
|
|
61
|
+
* snippets get the full form width without pushing the key itself out of view.
|
|
62
|
+
* Unnamed on purpose: named tabs would nest the data and move `capabilities`
|
|
63
|
+
* off the document root, which capability resolution reads.
|
|
64
|
+
*
|
|
65
|
+
* The guide tab is conditioned on the update operation. On create there is no
|
|
66
|
+
* key to hand out, and a tab leading to an empty panel is worse than no tab.
|
|
67
|
+
*/ const withSetupGuideTab = (keyFields, options) => {
|
|
68
|
+
if (!options.setupGuide) return keyFields;
|
|
69
|
+
return [{
|
|
70
|
+
type: "tabs",
|
|
71
|
+
tabs: [{
|
|
72
|
+
label: "Key",
|
|
73
|
+
fields: keyFields
|
|
74
|
+
}, {
|
|
75
|
+
label: "Connect a client",
|
|
76
|
+
admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
|
|
77
|
+
fields: [{
|
|
78
|
+
name: SETUP_GUIDE_FIELD,
|
|
79
|
+
type: "ui",
|
|
80
|
+
admin: {
|
|
81
|
+
disableListColumn: true,
|
|
82
|
+
components: { Field: {
|
|
83
|
+
path: "@abinnovision/payloadcms-mcpx/client",
|
|
84
|
+
exportName: "McpxSetupGuide",
|
|
85
|
+
clientProps: { endpointPath: options.endpointPath }
|
|
86
|
+
} }
|
|
87
|
+
}
|
|
88
|
+
}]
|
|
89
|
+
}]
|
|
90
|
+
}];
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* One checkbox per exposed operation, grouped per collection, per global and
|
|
94
|
+
* per custom tool. Only operations the plugin config exposes get a checkbox, so
|
|
95
|
+
* a key can never enable more than the config allows. Everything defaults to
|
|
96
|
+
* off, which is why a key issued before a capability existed stays closed to it.
|
|
97
|
+
*/ const createCapabilityFields = (options) => {
|
|
98
|
+
const collectionGroups = options.collections.map((collection) => ({
|
|
99
|
+
name: collection.fieldName,
|
|
100
|
+
type: "group",
|
|
101
|
+
label: collection.slug,
|
|
102
|
+
fields: [...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [], ...collection.write ? [checkbox("write", "Create, patch and validate drafts.")] : []]
|
|
103
|
+
}));
|
|
104
|
+
const globalGroups = options.globals.map((global) => ({
|
|
105
|
+
name: global.fieldName,
|
|
106
|
+
type: "group",
|
|
107
|
+
label: global.slug,
|
|
108
|
+
fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
|
|
109
|
+
}));
|
|
110
|
+
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
|
|
111
|
+
const groups = [
|
|
112
|
+
...collectionGroups.length > 0 ? [{
|
|
113
|
+
name: "collections",
|
|
114
|
+
type: "group",
|
|
115
|
+
fields: collectionGroups
|
|
116
|
+
}] : [],
|
|
117
|
+
...globalGroups.length > 0 ? [{
|
|
118
|
+
name: "globals",
|
|
119
|
+
type: "group",
|
|
120
|
+
fields: globalGroups
|
|
121
|
+
}] : [],
|
|
122
|
+
...toolCheckboxes.length > 0 ? [{
|
|
123
|
+
name: "tools",
|
|
124
|
+
type: "group",
|
|
125
|
+
fields: toolCheckboxes
|
|
126
|
+
}] : []
|
|
127
|
+
];
|
|
128
|
+
if (groups.length === 0) return [];
|
|
129
|
+
return [{
|
|
130
|
+
name: CAPABILITIES_FIELD,
|
|
131
|
+
type: "group",
|
|
132
|
+
admin: { description: "What this key may do. Unchecked means refused, whatever the plugin config allows." },
|
|
133
|
+
fields: groups
|
|
134
|
+
}];
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
export { SETUP_GUIDE_FIELD, createCapabilityFields, createKeyFields, withSetupGuideTab };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
//#region src/api-keys/key.ts
|
|
3
|
+
/**
|
|
4
|
+
* A fresh API key: 32 random bytes, base64url so it is safe in headers.
|
|
5
|
+
*/ const generateApiKey = () => crypto.randomBytes(32).toString("base64url");
|
|
6
|
+
/**
|
|
7
|
+
* Lookup index of a key, the same HMAC Payload core uses for `apiKeyIndex`.
|
|
8
|
+
*/ const hashApiKey = (secret, key) => crypto.createHmac("sha256", secret).update(key).digest("hex");
|
|
9
|
+
//#endregion
|
|
10
|
+
export { generateApiKey, hashApiKey };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/api-keys/setup-guide.ts
|
|
2
|
+
const KEY_PLACEHOLDER = "<your-key>";
|
|
3
|
+
/**
|
|
4
|
+
* Server name for the client config. MCP clients key their config by this, so
|
|
5
|
+
* it has to survive labels with spaces or punctuation.
|
|
6
|
+
*/ const toServerName = (label) => {
|
|
7
|
+
const slug = (label ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8
|
+
return slug === "" ? "payload" : slug;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The connection instructions for one key, split into independently copyable
|
|
12
|
+
* blocks. Kept a pure builder so the admin component holds only rendering and
|
|
13
|
+
* the snippets stay unit-testable.
|
|
14
|
+
*/ const buildSetupGuide = (input) => {
|
|
15
|
+
const key = typeof input.apiKey === "string" ? input.apiKey : KEY_PLACEHOLDER;
|
|
16
|
+
const name = toServerName(input.label);
|
|
17
|
+
const url = input.endpointUrl;
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: "endpoint",
|
|
21
|
+
title: "Endpoint",
|
|
22
|
+
description: "Streamable HTTP. Point any MCP client at this URL.",
|
|
23
|
+
snippet: url
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "header",
|
|
27
|
+
title: "Authorization header",
|
|
28
|
+
description: "The only accepted credential; cookies and JWTs are ignored.",
|
|
29
|
+
snippet: `Authorization: Bearer ${key}`
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "claude-code",
|
|
33
|
+
title: "Claude Code",
|
|
34
|
+
snippet: [`claude mcp add --transport http ${name} ${url} \\`, ` --header "Authorization: Bearer ${key}"`].join("\n")
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "claude-desktop",
|
|
38
|
+
title: "Claude Desktop",
|
|
39
|
+
description: "Has no direct header support, so it goes through mcp-remote.",
|
|
40
|
+
snippet: JSON.stringify({ mcpServers: { [name]: {
|
|
41
|
+
command: "npx",
|
|
42
|
+
args: [
|
|
43
|
+
"-y",
|
|
44
|
+
"mcp-remote",
|
|
45
|
+
url,
|
|
46
|
+
"--header",
|
|
47
|
+
`Authorization: Bearer ${key}`
|
|
48
|
+
]
|
|
49
|
+
} } }, null, 2)
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { KEY_PLACEHOLDER, buildSetupGuide, toServerName };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { hashApiKey } from "../api-keys/key.mjs";
|
|
2
|
+
//#region src/auth/resolve.ts
|
|
3
|
+
const BEARER = /^Bearer\s+(\S+)\s*$/i;
|
|
4
|
+
const relationId = (value) => {
|
|
5
|
+
if (typeof value === "string" || typeof value === "number") return value;
|
|
6
|
+
if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The bearer token of an `Authorization` header, or `null`.
|
|
10
|
+
*/ const parseBearer = (headers) => {
|
|
11
|
+
const header = headers.get("authorization");
|
|
12
|
+
if (!header) return null;
|
|
13
|
+
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the bearer key of a request to the user it acts as.
|
|
17
|
+
*
|
|
18
|
+
* The key is looked up by its HMAC index, the same way Payload resolves its own
|
|
19
|
+
* API keys. A missing, unknown, disabled or orphaned key yields `null`; nothing
|
|
20
|
+
* here throws, so the handler alone decides how a refusal looks.
|
|
21
|
+
*/ const resolveApiKeyAuth = async (req, options) => {
|
|
22
|
+
const key = parseBearer(req.headers);
|
|
23
|
+
if (key === null) return null;
|
|
24
|
+
const { payload } = req;
|
|
25
|
+
const { docs } = await payload.find({
|
|
26
|
+
collection: options.apiKeysSlug,
|
|
27
|
+
where: { apiKeyIndex: { equals: hashApiKey(payload.secret, key) } },
|
|
28
|
+
limit: 1,
|
|
29
|
+
pagination: false,
|
|
30
|
+
depth: 0,
|
|
31
|
+
overrideAccess: true,
|
|
32
|
+
select: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
user: true,
|
|
35
|
+
capabilities: true
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const keyDoc = docs[0];
|
|
39
|
+
const userId = relationId(keyDoc?.user);
|
|
40
|
+
if (!keyDoc || keyDoc.enabled !== true || userId === void 0) return null;
|
|
41
|
+
const userCollection = payload.collections[options.userCollection];
|
|
42
|
+
const user = await payload.findByID({
|
|
43
|
+
collection: options.userCollection,
|
|
44
|
+
id: userId,
|
|
45
|
+
depth: userCollection?.config.auth.depth ?? 0,
|
|
46
|
+
overrideAccess: true,
|
|
47
|
+
disableErrors: true
|
|
48
|
+
});
|
|
49
|
+
const lockUntil = typeof user?.["lockUntil"] === "string" ? Date.parse(user["lockUntil"]) : NaN;
|
|
50
|
+
if (!user || user["_verified"] === false || lockUntil > Date.now()) return null;
|
|
51
|
+
return {
|
|
52
|
+
user: {
|
|
53
|
+
...user,
|
|
54
|
+
collection: options.userCollection,
|
|
55
|
+
_strategy: "mcpx-api-key"
|
|
56
|
+
},
|
|
57
|
+
apiKeyId: keyDoc.id,
|
|
58
|
+
capabilities: keyDoc.capabilities
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { parseBearer, resolveApiKeyAuth };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/capabilities.ts
|
|
2
|
+
/** Name of the capability group on the key document. */ const CAPABILITIES_FIELD = "capabilities";
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const flag = (group, name) => isRecord(group) && group[name] === true;
|
|
5
|
+
/**
|
|
6
|
+
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
7
|
+
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
8
|
+
* issued before a capability existed stay closed.
|
|
9
|
+
*/ const resolveCapabilities = (options, keyCapabilities) => {
|
|
10
|
+
const collectionsGroup = isRecord(keyCapabilities) ? keyCapabilities["collections"] : void 0;
|
|
11
|
+
const globalsGroup = isRecord(keyCapabilities) ? keyCapabilities["globals"] : void 0;
|
|
12
|
+
const toolsGroup = isRecord(keyCapabilities) ? keyCapabilities["tools"] : void 0;
|
|
13
|
+
const collections = {};
|
|
14
|
+
for (const collection of options.collections) {
|
|
15
|
+
const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
|
|
16
|
+
collections[collection.slug] = {
|
|
17
|
+
read: collection.read && flag(group, "read"),
|
|
18
|
+
write: collection.write && flag(group, "write")
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const globals = {};
|
|
22
|
+
for (const global of options.globals) {
|
|
23
|
+
const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
|
|
24
|
+
globals[global.slug] = {
|
|
25
|
+
read: global.read && flag(group, "read"),
|
|
26
|
+
write: global.write && flag(group, "write")
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const tools = {};
|
|
30
|
+
for (const tool of options.tools) tools[tool.name] = flag(toolsGroup, tool.name);
|
|
31
|
+
return {
|
|
32
|
+
collections,
|
|
33
|
+
globals,
|
|
34
|
+
tools
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
|
|
38
|
+
const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
|
|
39
|
+
const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
|
|
40
|
+
const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
|
|
41
|
+
const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
|
|
42
|
+
//#endregion
|
|
43
|
+
export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
//#region src/client/setup-guide.d.ts
|
|
3
|
+
interface McpxSetupGuideProps {
|
|
4
|
+
/** Endpoint path below the API route, from the plugin options. */
|
|
5
|
+
endpointPath: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Per-key connection instructions on the API key edit view. Renders nothing
|
|
9
|
+
* until the document is saved, because before that there is no key to hand to
|
|
10
|
+
* a client.
|
|
11
|
+
*/
|
|
12
|
+
declare const McpxSetupGuide: React.FC<McpxSetupGuideProps>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { McpxSetupGuide };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { buildSetupGuide } from "../api-keys/setup-guide.mjs";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { CopyToClipboard, useConfig, useDocumentInfo, useFormFields } from "@payloadcms/ui";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
//#region src/client/setup-guide.tsx
|
|
7
|
+
const asString = (value) => typeof value === "string" ? value : void 0;
|
|
8
|
+
/**
|
|
9
|
+
* Reads `serverURL` when the config sets one and falls back to the browser's
|
|
10
|
+
* origin. The fallback has to wait for mount: this component is server-rendered
|
|
11
|
+
* first, where `window` does not exist.
|
|
12
|
+
*/ const useOrigin = (serverUrl) => {
|
|
13
|
+
const [origin, setOrigin] = useState(serverUrl);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (serverUrl === "") setOrigin(window.location.origin);
|
|
16
|
+
}, [serverUrl]);
|
|
17
|
+
return origin;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Payload's own theme variables, so the panel follows the admin's light and
|
|
21
|
+
* dark themes without shipping a stylesheet consumers would have to transpile.
|
|
22
|
+
*/ const styles = {
|
|
23
|
+
lead: { marginBottom: "calc(var(--base) * 0.75)" },
|
|
24
|
+
section: { marginBottom: "calc(var(--base) * 0.75)" },
|
|
25
|
+
sectionHeader: {
|
|
26
|
+
display: "flex",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
gap: "calc(var(--base) * 0.25)"
|
|
29
|
+
},
|
|
30
|
+
description: {
|
|
31
|
+
margin: "calc(var(--base) * 0.15) 0",
|
|
32
|
+
color: "var(--theme-elevation-500)"
|
|
33
|
+
},
|
|
34
|
+
snippet: {
|
|
35
|
+
margin: 0,
|
|
36
|
+
padding: "calc(var(--base) * 0.4)",
|
|
37
|
+
background: "var(--theme-elevation-50)",
|
|
38
|
+
border: "1px solid var(--theme-elevation-150)",
|
|
39
|
+
borderRadius: "3px",
|
|
40
|
+
fontFamily: "var(--font-mono)",
|
|
41
|
+
overflowX: "auto",
|
|
42
|
+
whiteSpace: "pre-wrap",
|
|
43
|
+
wordBreak: "break-all"
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Per-key connection instructions on the API key edit view. Renders nothing
|
|
48
|
+
* until the document is saved, because before that there is no key to hand to
|
|
49
|
+
* a client.
|
|
50
|
+
*/ const McpxSetupGuide = ({ endpointPath }) => {
|
|
51
|
+
const { id } = useDocumentInfo();
|
|
52
|
+
const { config } = useConfig();
|
|
53
|
+
const apiKey = useFormFields(([fields]) => fields["apiKey"]?.value);
|
|
54
|
+
const label = useFormFields(([fields]) => fields["label"]?.value);
|
|
55
|
+
const origin = useOrigin(config.serverURL);
|
|
56
|
+
if (id === void 0) return null;
|
|
57
|
+
const sections = buildSetupGuide({
|
|
58
|
+
endpointUrl: `${origin.replace(/\/+$/, "")}${config.routes.api}${endpointPath}`,
|
|
59
|
+
apiKey: asString(apiKey),
|
|
60
|
+
label: asString(label)
|
|
61
|
+
});
|
|
62
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
63
|
+
className: "field-type",
|
|
64
|
+
children: [/*#__PURE__*/ jsx("p", {
|
|
65
|
+
style: styles.lead,
|
|
66
|
+
children: "Every snippet below contains this key in full. Treat it like a password."
|
|
67
|
+
}), sections.map((section) => /*#__PURE__*/ jsxs("section", {
|
|
68
|
+
style: styles.section,
|
|
69
|
+
children: [
|
|
70
|
+
/*#__PURE__*/ jsxs("div", {
|
|
71
|
+
style: styles.sectionHeader,
|
|
72
|
+
children: [/*#__PURE__*/ jsx("strong", { children: section.title }), /*#__PURE__*/ jsx(CopyToClipboard, { value: section.snippet })]
|
|
73
|
+
}),
|
|
74
|
+
section.description ? /*#__PURE__*/ jsx("p", {
|
|
75
|
+
style: styles.description,
|
|
76
|
+
children: section.description
|
|
77
|
+
}) : null,
|
|
78
|
+
/*#__PURE__*/ jsx("pre", {
|
|
79
|
+
style: styles.snippet,
|
|
80
|
+
children: section.snippet
|
|
81
|
+
})
|
|
82
|
+
]
|
|
83
|
+
}, section.id))]
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
export { McpxSetupGuide };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
2
|
+
import { resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
3
|
+
import { jsonRpcError } from "./result.mjs";
|
|
4
|
+
import { createMcpServer } from "./server.mjs";
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
|
+
//#region src/endpoint/handler.ts
|
|
7
|
+
const buildScope = (req, options, capabilities) => {
|
|
8
|
+
const { localization } = req.payload.config;
|
|
9
|
+
return {
|
|
10
|
+
req,
|
|
11
|
+
options,
|
|
12
|
+
capabilities,
|
|
13
|
+
readable: readableSlugs(capabilities),
|
|
14
|
+
writable: writableSlugs(capabilities),
|
|
15
|
+
readableGlobals: readableGlobalSlugs(capabilities),
|
|
16
|
+
writableGlobals: writableGlobalSlugs(capabilities),
|
|
17
|
+
locales: localization ? localization.localeCodes : null,
|
|
18
|
+
defaultLocale: localization ? localization.defaultLocale : null
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Answers GET and DELETE on the endpoint path. The server is stateless and
|
|
23
|
+
* never streams, so only POST carries meaning.
|
|
24
|
+
*/ const methodNotAllowed = () => jsonRpcError({
|
|
25
|
+
status: 405,
|
|
26
|
+
code: -32e3,
|
|
27
|
+
message: "Method not allowed. MCP requests must use POST.",
|
|
28
|
+
headers: { allow: "POST" }
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The MCP endpoint. Authenticates the bearer key, sets `req.user` and the
|
|
32
|
+
* request marker, then serves the JSON-RPC body with a fresh server and
|
|
33
|
+
* transport. Any user Payload resolved from cookies or a JWT is ignored: only
|
|
34
|
+
* an API key authenticates here.
|
|
35
|
+
*/ const createMcpxHandler = (options) => async (req) => {
|
|
36
|
+
const resolveDefault = () => resolveApiKeyAuth(req, options);
|
|
37
|
+
const auth = options.auth?.resolve ? await options.auth.resolve({
|
|
38
|
+
req,
|
|
39
|
+
resolveDefault
|
|
40
|
+
}) : await resolveDefault();
|
|
41
|
+
if (!auth) return jsonRpcError({
|
|
42
|
+
status: 401,
|
|
43
|
+
code: -32001,
|
|
44
|
+
message: "Unauthorized: a valid API key is required.",
|
|
45
|
+
headers: { "www-authenticate": "Bearer" }
|
|
46
|
+
});
|
|
47
|
+
const capabilities = resolveCapabilities(options, auth.capabilities);
|
|
48
|
+
req.user = auth.user;
|
|
49
|
+
req.context = {
|
|
50
|
+
...req.context,
|
|
51
|
+
mcpx: {
|
|
52
|
+
apiKeyId: auth.apiKeyId,
|
|
53
|
+
capabilities
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let parsedBody;
|
|
57
|
+
try {
|
|
58
|
+
parsedBody = await req.json?.();
|
|
59
|
+
} catch {
|
|
60
|
+
return jsonRpcError({
|
|
61
|
+
status: 400,
|
|
62
|
+
code: -32700,
|
|
63
|
+
message: "Parse error: Invalid JSON"
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (parsedBody === void 0 || req.url === void 0) return jsonRpcError({
|
|
67
|
+
status: 400,
|
|
68
|
+
code: -32600,
|
|
69
|
+
message: "Invalid request: a JSON body is required."
|
|
70
|
+
});
|
|
71
|
+
const server = createMcpServer(buildScope(req, options, capabilities));
|
|
72
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
73
|
+
await server.connect(transport);
|
|
74
|
+
const headers = new Headers(req.headers);
|
|
75
|
+
headers.set("accept", "application/json, text/event-stream");
|
|
76
|
+
try {
|
|
77
|
+
return await transport.handleRequest(new Request(req.url, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers
|
|
80
|
+
}), { parsedBody });
|
|
81
|
+
} finally {
|
|
82
|
+
await server.close();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
export { createMcpxHandler, methodNotAllowed };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
2
|
+
import { APIError, ValidationError } from "payload";
|
|
3
|
+
//#region src/endpoint/result.ts
|
|
4
|
+
/**
|
|
5
|
+
* A JSON-RPC error response for failures that happen before the MCP server
|
|
6
|
+
* is involved (auth, method, body parsing).
|
|
7
|
+
*/ const jsonRpcError = (args) => {
|
|
8
|
+
const headers = new Headers(args.headers);
|
|
9
|
+
headers.set("content-type", "application/json");
|
|
10
|
+
return new Response(JSON.stringify({
|
|
11
|
+
jsonrpc: "2.0",
|
|
12
|
+
id: null,
|
|
13
|
+
error: {
|
|
14
|
+
code: args.code,
|
|
15
|
+
message: args.message
|
|
16
|
+
}
|
|
17
|
+
}), {
|
|
18
|
+
status: args.status,
|
|
19
|
+
headers
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A successful tool result carrying `value` as JSON text.
|
|
24
|
+
*/ const jsonResult = (value) => ({ content: [{
|
|
25
|
+
type: "text",
|
|
26
|
+
text: JSON.stringify(value)
|
|
27
|
+
}] });
|
|
28
|
+
/**
|
|
29
|
+
* A failed tool result. `extras` travel alongside the message so the client
|
|
30
|
+
* can act on them (problems, validation errors, the current `updatedAt`).
|
|
31
|
+
*/ const errorResult = (message, extras = {}) => ({
|
|
32
|
+
content: [{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: JSON.stringify({
|
|
35
|
+
error: message,
|
|
36
|
+
...extras
|
|
37
|
+
})
|
|
38
|
+
}],
|
|
39
|
+
isError: true
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Maps an exception thrown by a tool to a result the client can read.
|
|
43
|
+
*
|
|
44
|
+
* Payload's public errors keep their message and status; a `ValidationError`
|
|
45
|
+
* also surfaces its per-field detail, with each field's path restated as a
|
|
46
|
+
* JSON Pointer so it reads like every other path this plugin reports. Anything
|
|
47
|
+
* else is logged and reported as an internal error so no stack or driver
|
|
48
|
+
* message leaks to the client.
|
|
49
|
+
*/ const toToolError = (error, logger) => {
|
|
50
|
+
if (error instanceof ValidationError) return errorResult(error.message, {
|
|
51
|
+
status: error.status,
|
|
52
|
+
validationErrors: error.data.errors.map((entry) => ({
|
|
53
|
+
...entry,
|
|
54
|
+
path: pointerFromPayloadPath(entry.path)
|
|
55
|
+
}))
|
|
56
|
+
});
|
|
57
|
+
if (error instanceof APIError && error.isPublic) return errorResult(error.message, { status: error.status });
|
|
58
|
+
logger.error({
|
|
59
|
+
err: error,
|
|
60
|
+
msg: "[payloadcms-mcpx] Tool call failed."
|
|
61
|
+
});
|
|
62
|
+
return errorResult("Internal error");
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { errorResult, jsonResult, jsonRpcError, toToolError };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { toToolError } from "./result.mjs";
|
|
2
|
+
import { BUILTIN_TOOLS } from "../tools/index.mjs";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/endpoint/server.ts
|
|
6
|
+
/**
|
|
7
|
+
* Builds a builtin tool's input schema as a strict object, so an unknown
|
|
8
|
+
* argument is rejected with its name instead of being silently stripped and
|
|
9
|
+
* the tool answering as if it had not been passed.
|
|
10
|
+
*/ const builtinInputSchema = (tool, scope) => z.strictObject(tool.inputSchema(scope));
|
|
11
|
+
/**
|
|
12
|
+
* Builds the MCP server for one request. Tools are registered against the
|
|
13
|
+
* key's capabilities, so `tools/list` shows exactly what the key may call and
|
|
14
|
+
* every `collection` enum is limited to what it may touch.
|
|
15
|
+
*/ const createMcpServer = (scope) => {
|
|
16
|
+
const { req, options, capabilities } = scope;
|
|
17
|
+
const { logger } = req.payload;
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: options.serverInfo.name,
|
|
20
|
+
version: options.serverInfo.version
|
|
21
|
+
}, { instructions: "Start with listCapabilities, then describeSchema for the collection or global you work on. Writes always land as drafts; a human publishes." });
|
|
22
|
+
const guarded = (run) => async () => {
|
|
23
|
+
try {
|
|
24
|
+
return await run();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return toToolError(error, logger);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
for (const tool of BUILTIN_TOOLS) {
|
|
30
|
+
if (!tool.isEnabled(scope)) continue;
|
|
31
|
+
server.registerTool(tool.name, {
|
|
32
|
+
description: tool.description,
|
|
33
|
+
inputSchema: builtinInputSchema(tool, scope),
|
|
34
|
+
annotations: tool.annotations
|
|
35
|
+
}, (args) => guarded(() => tool.handler(args, scope))());
|
|
36
|
+
}
|
|
37
|
+
for (const tool of options.tools) {
|
|
38
|
+
if (capabilities.tools[tool.name] !== true) continue;
|
|
39
|
+
server.registerTool(tool.name, {
|
|
40
|
+
description: tool.description,
|
|
41
|
+
inputSchema: tool.inputSchema ?? {},
|
|
42
|
+
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
43
|
+
}, (args, extra) => guarded(() => tool.handler({
|
|
44
|
+
args,
|
|
45
|
+
req,
|
|
46
|
+
extra
|
|
47
|
+
}))());
|
|
48
|
+
}
|
|
49
|
+
return server;
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { builtinInputSchema, createMcpServer };
|
package/dist/i18n.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { PayloadRequest } from "payload";
|
package/dist/i18n.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/i18n.ts
|
|
2
|
+
/**
|
|
3
|
+
* A locale-keyed record, once it is known to hold nothing but strings.
|
|
4
|
+
*/ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
5
|
+
/**
|
|
6
|
+
* Picks the entry a language addresses, treating an empty value as absent so
|
|
7
|
+
* the chain continues rather than yielding a useless string.
|
|
8
|
+
*/ const pick = (record, language) => {
|
|
9
|
+
for (const code of Array.isArray(language) ? language : [language]) {
|
|
10
|
+
const entry = record[code];
|
|
11
|
+
if (entry !== void 0 && entry.trim() !== "") return entry;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a static label or `admin.description` to the one string a client
|
|
16
|
+
* can use: the request's language, then the fallback language configured for
|
|
17
|
+
* the deployment, then whichever entry the record declares first.
|
|
18
|
+
*
|
|
19
|
+
* Anything that is not a string or a string-valued record is dropped. A
|
|
20
|
+
* description written as a function or a React component is an admin-UI
|
|
21
|
+
* construct that may reach client-only i18n, so it is never invoked here.
|
|
22
|
+
*/ const translateStatic = (value, language) => {
|
|
23
|
+
if (typeof value === "string") return value.trim() === "" ? void 0 : value;
|
|
24
|
+
const record = stringRecord(value);
|
|
25
|
+
if (!record) return;
|
|
26
|
+
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Binds {@link translateStatic} to a request's language, so a walk that
|
|
30
|
+
* resolves many descriptions carries no request of its own.
|
|
31
|
+
*/ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
32
|
+
/**
|
|
33
|
+
* Translator for callers with no request in hand. Both language keys miss, so
|
|
34
|
+
* the chain degrades to the record's first entry.
|
|
35
|
+
*/ const translateAny = translatorFor({
|
|
36
|
+
fallbackLanguage: "",
|
|
37
|
+
language: ""
|
|
38
|
+
});
|
|
39
|
+
//#endregion
|
|
40
|
+
export { translateAny, translateStatic, translatorFor };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
|
|
2
|
+
import { mcpxPlugin } from "./plugin.mjs";
|
|
3
|
+
import { isMcpxRequest } from "./write/draft-guard.mjs";
|
|
4
|
+
import { PublishBlocker } from "./write/publish-blockers.mjs";
|
|
5
|
+
export { type McpxAuthResult, type McpxCollectionCapabilities, type McpxCollectionOptions, type McpxGlobalOptions, type McpxPluginOptions, type McpxRequestContext, type McpxResolvedCapabilities, type McpxTool, type McpxToolExtra, type PublishBlocker, defineMcpxTool, isMcpxRequest, mcpxPlugin };
|
package/dist/index.mjs
ADDED