@abinnovision/payloadcms-mcpx 1.0.0-beta.9 → 1.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 +33 -0
- package/README.md +328 -211
- package/dist/api-keys/fields.mjs +22 -5
- package/dist/api-keys/setup-guide.mjs +6 -4
- package/dist/auth/resolve.mjs +5 -7
- package/dist/capabilities.mjs +23 -4
- package/dist/client/index.d.mts +2 -2
- package/dist/client/setup-guide.d.mts +1 -1
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -23
- package/dist/endpoint/handler.mjs +11 -5
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +18 -26
- package/dist/i18n.mjs +4 -15
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -3
- package/dist/options.mjs +30 -26
- package/dist/plugin.mjs +1 -0
- package/dist/{write/draft-guard.d.mts → request.d.mts} +2 -2
- package/dist/request.mjs +8 -0
- package/dist/result.d.mts +11 -0
- package/dist/result.mjs +20 -0
- package/dist/schema/describe.mjs +3 -15
- package/dist/schema/index.mjs +8 -0
- package/dist/schema/lexical-pointer.mjs +125 -0
- package/dist/schema/lexical.mjs +195 -27
- package/dist/schema/outline.mjs +67 -0
- package/dist/schema/pointer.mjs +77 -30
- package/dist/schema/shape.mjs +133 -51
- package/dist/schema/walk.mjs +44 -64
- package/dist/tools/{index.mjs → builtin.mjs} +8 -5
- package/dist/tools/create-document.mjs +34 -15
- package/dist/tools/describe-schema.mjs +21 -7
- package/dist/tools/find-documents.mjs +13 -6
- package/dist/tools/get-document.mjs +45 -11
- package/dist/tools/list-capabilities.mjs +19 -9
- package/dist/tools/names.mjs +2 -1
- package/dist/tools/patch-document.mjs +32 -21
- package/dist/tools/publish-document.mjs +79 -0
- package/dist/tools/shared.mjs +84 -32
- package/dist/tools/target.mjs +7 -11
- package/dist/tools/validate-document.mjs +20 -12
- package/dist/types.d.mts +110 -42
- package/dist/types.mjs +3 -4
- package/dist/version.mjs +1 -1
- package/dist/write/draft-guard.mjs +47 -44
- package/dist/write/patch.mjs +174 -92
- package/dist/write/publish-blockers.mjs +13 -12
- package/dist/write/publish-intent.mjs +17 -0
- package/dist/write/transaction.mjs +8 -3
- package/package.json +3 -3
- package/dist/i18n.d.mts +0 -1
- package/dist/options.d.mts +0 -2
- package/dist/schema/lexical.d.mts +0 -1
- package/dist/schema/walk.d.mts +0 -3
- package/dist/tools/target.d.mts +0 -3
- package/dist/tools/types.d.mts +0 -5
- package/dist/write/publish-blockers.d.mts +0 -15
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAPABILITIES_FIELD } from "../capabilities.mjs";
|
|
1
|
+
import { CAPABILITIES_FIELD, canCreate, canPublish, canWrite } from "../capabilities.mjs";
|
|
2
2
|
//#region src/api-keys/fields.ts
|
|
3
3
|
const encryptKey = ({ req, value }) => typeof value === "string" ? req.payload.encrypt(value) : value;
|
|
4
4
|
const decryptKey = ({ req, value }) => {
|
|
@@ -15,7 +15,7 @@ const checkbox = (name, description) => ({
|
|
|
15
15
|
defaultValue: false,
|
|
16
16
|
admin: { description }
|
|
17
17
|
});
|
|
18
|
-
const SETUP_GUIDE_FIELD = "setupGuide";
|
|
18
|
+
/** Name of the `ui` field the "Connect a client" tab renders. */ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
19
19
|
/**
|
|
20
20
|
* Fields every key carries. Key generation and the HMAC index live in the
|
|
21
21
|
* collection-level `beforeChange` hook (see `collection.ts`), because sibling
|
|
@@ -89,25 +89,42 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
89
89
|
}]
|
|
90
90
|
}];
|
|
91
91
|
};
|
|
92
|
+
const PUBLISH_DESCRIPTION = "Publish the current draft. Changes what the public sees.";
|
|
92
93
|
/**
|
|
93
94
|
* One checkbox per exposed operation, grouped per collection, per global and
|
|
94
95
|
* per custom tool. Only operations the plugin config exposes get a checkbox, so
|
|
95
96
|
* a key can never enable more than the config allows. Everything defaults to
|
|
96
97
|
* off, which is why a key issued before a capability existed stays closed to it.
|
|
98
|
+
*
|
|
99
|
+
* An entity without versions gets no `publish` checkbox even under
|
|
100
|
+
* `write: "live"`: there is no draft to promote there, the write itself is the
|
|
101
|
+
* live change, and a second checkbox would only make `write` a dead setting.
|
|
102
|
+
*
|
|
103
|
+
* An upload collection gets the same checkboxes as any other, only worded for
|
|
104
|
+
* what `write` reaches there: a document's own fields, never `createDocument`,
|
|
105
|
+
* because the file comes from the admin panel.
|
|
97
106
|
*/ const createCapabilityFields = (options) => {
|
|
98
107
|
const collectionGroups = options.collections.map((collection) => ({
|
|
99
108
|
name: collection.fieldName,
|
|
100
109
|
type: "group",
|
|
101
110
|
label: collection.slug,
|
|
102
|
-
fields: [
|
|
111
|
+
fields: [
|
|
112
|
+
...collection.read ? [checkbox("read", "Describe, find and read documents.")] : [],
|
|
113
|
+
...canWrite(collection) ? [checkbox("write", canCreate(collection) ? "Create, patch and validate drafts." : "Patch and validate drafts. The file itself is uploaded in the admin panel.")] : [],
|
|
114
|
+
...canPublish(collection) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
|
|
115
|
+
]
|
|
103
116
|
}));
|
|
104
117
|
const globalGroups = options.globals.map((global) => ({
|
|
105
118
|
name: global.fieldName,
|
|
106
119
|
type: "group",
|
|
107
120
|
label: global.slug,
|
|
108
|
-
fields: [
|
|
121
|
+
fields: [
|
|
122
|
+
...global.read ? [checkbox("read", "Describe and read this global.")] : [],
|
|
123
|
+
...canWrite(global) ? [checkbox("write", "Patch and validate this global's draft.")] : [],
|
|
124
|
+
...canPublish(global) ? [checkbox("publish", PUBLISH_DESCRIPTION)] : []
|
|
125
|
+
]
|
|
109
126
|
}));
|
|
110
|
-
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
|
|
127
|
+
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
|
|
111
128
|
const groups = [
|
|
112
129
|
...collectionGroups.length > 0 ? [{
|
|
113
130
|
name: "collections",
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
//#region src/api-keys/setup-guide.ts
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Stands in for the key in the snippets whenever the real one is unavailable,
|
|
4
|
+
* so the instructions still render and say what is missing.
|
|
5
|
+
*/ const KEY_PLACEHOLDER = "<your-key>";
|
|
3
6
|
/**
|
|
4
7
|
* Server name for the client config. MCP clients key their config by this, so
|
|
5
8
|
* it has to survive labels with spaces or punctuation.
|
|
@@ -8,9 +11,8 @@ const KEY_PLACEHOLDER = "<your-key>";
|
|
|
8
11
|
return slug === "" ? "payload" : slug;
|
|
9
12
|
};
|
|
10
13
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the snippets stay unit-testable.
|
|
14
|
+
* A pure builder, so the admin component holds only rendering and the snippets
|
|
15
|
+
* stay unit-testable.
|
|
14
16
|
*/ const buildSetupGuide = (input) => {
|
|
15
17
|
const key = typeof input.apiKey === "string" ? input.apiKey : KEY_PLACEHOLDER;
|
|
16
18
|
const name = toServerName(input.label);
|
package/dist/auth/resolve.mjs
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { hashApiKey } from "../api-keys/key.mjs";
|
|
2
2
|
//#region src/auth/resolve.ts
|
|
3
3
|
const BEARER = /^Bearer\s+(\S+)\s*$/i;
|
|
4
|
-
/**
|
|
5
|
-
* The bearer token of an `Authorization` header, or `null`.
|
|
6
|
-
*/ const parseBearer = (headers) => {
|
|
7
|
-
const header = headers.get("authorization");
|
|
8
|
-
if (!header) return null;
|
|
9
|
-
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
10
|
-
};
|
|
11
4
|
const relationId = (value) => {
|
|
12
5
|
if (typeof value === "string" || typeof value === "number") return value;
|
|
13
6
|
if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
14
7
|
};
|
|
8
|
+
const parseBearer = (headers) => {
|
|
9
|
+
const header = headers.get("authorization");
|
|
10
|
+
if (!header) return null;
|
|
11
|
+
return BEARER.exec(header.trim())?.[1] ?? null;
|
|
12
|
+
};
|
|
15
13
|
/**
|
|
16
14
|
* Resolves the bearer key of a request to the user it acts as.
|
|
17
15
|
*
|
package/dist/capabilities.mjs
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
//#region src/capabilities.ts
|
|
2
|
-
/**
|
|
2
|
+
/** Group field holding the capability checkboxes on an API key document. */ const CAPABILITIES_FIELD = "capabilities";
|
|
3
|
+
/** Whatever the write lands on; {@link isLiveWrite} tells the two apart. */ const canWrite = (entity) => entity.write !== false;
|
|
4
|
+
/** The config lets MCP change live content and there is a draft to promote. */ const canPublish = (entity) => entity.write === "live" && entity.hasDrafts;
|
|
5
|
+
/**
|
|
6
|
+
* An upload document is a file, and no tool here carries one. Its own fields
|
|
7
|
+
* stay patchable; the first version is made in the admin panel.
|
|
8
|
+
*/ const canCreate = (entity) => canWrite(entity) && !entity.isUpload;
|
|
9
|
+
/**
|
|
10
|
+
* With no versions there is no draft to land on, so `write: "live"` permits the
|
|
11
|
+
* write at all and every write is live.
|
|
12
|
+
*/ const isLiveWrite = (entity) => entity.write === "live" && !entity.hasDrafts;
|
|
3
13
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
14
|
const flag = (group, name) => isRecord(group) && group[name] === true;
|
|
5
15
|
/**
|
|
16
|
+
* Publishing is an extension of writing, never a capability of its own: a key
|
|
17
|
+
* that may publish may also edit the draft it publishes. Both checkboxes are
|
|
18
|
+
* therefore required, on top of the config exposing publishing at all.
|
|
19
|
+
*/ const publishFlag = (entity, group) => canPublish(entity) && flag(group, "write") && flag(group, "publish");
|
|
20
|
+
/**
|
|
6
21
|
* Capabilities in force for a key: the plugin config decides what can exist,
|
|
7
22
|
* the key's checkboxes decide what does. A missing checkbox is `false`, so keys
|
|
8
23
|
* issued before a capability existed stay closed.
|
|
@@ -15,7 +30,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
|
|
|
15
30
|
const group = isRecord(collectionsGroup) ? collectionsGroup[collection.fieldName] : void 0;
|
|
16
31
|
collections[collection.slug] = {
|
|
17
32
|
read: collection.read && flag(group, "read"),
|
|
18
|
-
write: collection
|
|
33
|
+
write: canWrite(collection) && flag(group, "write"),
|
|
34
|
+
publish: publishFlag(collection, group)
|
|
19
35
|
};
|
|
20
36
|
}
|
|
21
37
|
const globals = {};
|
|
@@ -23,7 +39,8 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
|
|
|
23
39
|
const group = isRecord(globalsGroup) ? globalsGroup[global.fieldName] : void 0;
|
|
24
40
|
globals[global.slug] = {
|
|
25
41
|
read: global.read && flag(group, "read"),
|
|
26
|
-
write: global
|
|
42
|
+
write: canWrite(global) && flag(group, "write"),
|
|
43
|
+
publish: publishFlag(global, group)
|
|
27
44
|
};
|
|
28
45
|
}
|
|
29
46
|
const tools = {};
|
|
@@ -37,7 +54,9 @@ const flag = (group, name) => isRecord(group) && group[name] === true;
|
|
|
37
54
|
const pick = (entries, operation) => Object.entries(entries).filter(([, value]) => value[operation]).map(([slug]) => slug);
|
|
38
55
|
const readableSlugs = (capabilities) => pick(capabilities.collections, "read");
|
|
39
56
|
const writableSlugs = (capabilities) => pick(capabilities.collections, "write");
|
|
57
|
+
const publishableSlugs = (capabilities) => pick(capabilities.collections, "publish");
|
|
40
58
|
const readableGlobalSlugs = (capabilities) => pick(capabilities.globals, "read");
|
|
41
59
|
const writableGlobalSlugs = (capabilities) => pick(capabilities.globals, "write");
|
|
60
|
+
const publishableGlobalSlugs = (capabilities) => pick(capabilities.globals, "publish");
|
|
42
61
|
//#endregion
|
|
43
|
-
export { CAPABILITIES_FIELD, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
|
|
62
|
+
export { CAPABILITIES_FIELD, canCreate, canPublish, canWrite, isLiveWrite, publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs };
|
package/dist/client/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { McpxSetupGuide
|
|
2
|
-
export { McpxSetupGuide
|
|
1
|
+
import { McpxSetupGuide } from "./setup-guide.mjs";
|
|
2
|
+
export { McpxSetupGuide };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { errorResult } from "../result.mjs";
|
|
1
2
|
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
3
|
+
import "../schema/index.mjs";
|
|
2
4
|
import { APIError, ValidationError } from "payload";
|
|
3
|
-
//#region src/endpoint/
|
|
5
|
+
//#region src/endpoint/errors.ts
|
|
4
6
|
/**
|
|
5
7
|
* A JSON-RPC error response for failures that happen before the MCP server
|
|
6
8
|
* is involved (auth, method, body parsing).
|
|
@@ -20,27 +22,6 @@ import { APIError, ValidationError } from "payload";
|
|
|
20
22
|
});
|
|
21
23
|
};
|
|
22
24
|
/**
|
|
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
25
|
* Payload's public errors keep their message and status; a `ValidationError`
|
|
45
26
|
* also surfaces its per-field detail, with each field's path restated as a
|
|
46
27
|
* JSON Pointer so it reads like every other path this plugin reports. Anything
|
|
@@ -62,4 +43,4 @@ import { APIError, ValidationError } from "payload";
|
|
|
62
43
|
return errorResult("Internal error");
|
|
63
44
|
};
|
|
64
45
|
//#endregion
|
|
65
|
-
export {
|
|
46
|
+
export { jsonRpcError, toToolError };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
1
|
+
import { publishableGlobalSlugs, publishableSlugs, readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
2
|
+
import { jsonRpcError } from "./errors.mjs";
|
|
2
3
|
import { resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
3
|
-
import { jsonRpcError } from "./result.mjs";
|
|
4
4
|
import { createMcpServer } from "./server.mjs";
|
|
5
5
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
6
|
//#region src/endpoint/handler.ts
|
|
@@ -8,14 +8,20 @@ const buildScope = (req, options, capabilities) => {
|
|
|
8
8
|
const { localization } = req.payload.config;
|
|
9
9
|
return {
|
|
10
10
|
req,
|
|
11
|
-
options,
|
|
12
11
|
capabilities,
|
|
13
12
|
readable: readableSlugs(capabilities),
|
|
14
13
|
writable: writableSlugs(capabilities),
|
|
14
|
+
publishable: publishableSlugs(capabilities),
|
|
15
15
|
readableGlobals: readableGlobalSlugs(capabilities),
|
|
16
16
|
writableGlobals: writableGlobalSlugs(capabilities),
|
|
17
|
+
publishableGlobals: publishableGlobalSlugs(capabilities),
|
|
17
18
|
locales: localization ? localization.localeCodes : null,
|
|
18
|
-
defaultLocale: localization ? localization.defaultLocale : null
|
|
19
|
+
defaultLocale: localization ? localization.defaultLocale : null,
|
|
20
|
+
limits: options.limits,
|
|
21
|
+
exposure: {
|
|
22
|
+
collections: options.collections,
|
|
23
|
+
globals: options.globals
|
|
24
|
+
}
|
|
19
25
|
};
|
|
20
26
|
};
|
|
21
27
|
/**
|
|
@@ -68,7 +74,7 @@ const buildScope = (req, options, capabilities) => {
|
|
|
68
74
|
code: -32600,
|
|
69
75
|
message: "Invalid request: a JSON body is required."
|
|
70
76
|
});
|
|
71
|
-
const server = createMcpServer(buildScope(req, options, capabilities));
|
|
77
|
+
const server = createMcpServer(buildScope(req, options, capabilities), options);
|
|
72
78
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
73
79
|
await server.connect(transport);
|
|
74
80
|
const headers = new Headers(req.headers);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsonRpcError, toToolError } from "./errors.mjs";
|
|
2
|
+
import { createMcpServer, isToolEnabled, toolDescription, toolInputSchema } from "./server.mjs";
|
|
3
|
+
import { createMcpxHandler, methodNotAllowed } from "./handler.mjs";
|
|
4
|
+
export { createMcpServer, createMcpxHandler, isToolEnabled, jsonRpcError, methodNotAllowed, toToolError, toolDescription, toolInputSchema };
|
package/dist/endpoint/server.mjs
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
import { toToolError } from "./
|
|
2
|
-
import {
|
|
1
|
+
import { toToolError } from "./errors.mjs";
|
|
2
|
+
import { draftSentence } from "../tools/shared.mjs";
|
|
3
|
+
import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
//#region src/endpoint/server.ts
|
|
7
|
+
/** Strict, so an unknown argument is rejected by name rather than stripped. */ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
8
|
+
/** May be built from the scope, to name the targets this key writes live. */ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
9
|
+
/** A tool that does not decide for itself is gated by its own checkbox. */ const isToolEnabled = (tool, scope) => tool.isEnabled ? tool.isEnabled(scope) : scope.capabilities.tools[tool.name] === true;
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
10
|
-
*/ const
|
|
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;
|
|
11
|
+
* One server per request. Builtin and configured tools take the same route,
|
|
12
|
+
* each registered against the key's capabilities, so `tools/list` shows exactly
|
|
13
|
+
* what the key may call.
|
|
14
|
+
*/ const createMcpServer = (scope, options) => {
|
|
15
|
+
const { req } = scope;
|
|
17
16
|
const { logger } = req.payload;
|
|
18
17
|
const server = new McpServer({
|
|
19
18
|
name: options.serverInfo.name,
|
|
20
19
|
version: options.serverInfo.version
|
|
21
|
-
}, { instructions:
|
|
20
|
+
}, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
|
|
22
21
|
const guarded = (run) => async () => {
|
|
23
22
|
try {
|
|
24
23
|
return await run();
|
|
@@ -26,22 +25,15 @@ import { z } from "zod";
|
|
|
26
25
|
return toToolError(error, logger);
|
|
27
26
|
}
|
|
28
27
|
};
|
|
29
|
-
for (const tool of BUILTIN_TOOLS) {
|
|
30
|
-
if (!tool
|
|
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;
|
|
28
|
+
for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
|
|
29
|
+
if (!isToolEnabled(tool, scope)) continue;
|
|
39
30
|
server.registerTool(tool.name, {
|
|
40
|
-
description: tool
|
|
41
|
-
inputSchema: tool
|
|
31
|
+
description: toolDescription(tool, scope),
|
|
32
|
+
inputSchema: toolInputSchema(tool, scope),
|
|
42
33
|
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
43
34
|
}, (args, extra) => guarded(() => tool.handler({
|
|
44
35
|
args,
|
|
36
|
+
scope,
|
|
45
37
|
req,
|
|
46
38
|
extra
|
|
47
39
|
}))());
|
|
@@ -49,4 +41,4 @@ import { z } from "zod";
|
|
|
49
41
|
return server;
|
|
50
42
|
};
|
|
51
43
|
//#endregion
|
|
52
|
-
export {
|
|
44
|
+
export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
|
package/dist/i18n.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
//#region src/i18n.ts
|
|
2
|
-
|
|
3
|
-
|
|
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) => {
|
|
2
|
+
const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
|
|
3
|
+
/** Treats an empty value as absent, so the fallback chain continues. */ const pick = (record, language) => {
|
|
9
4
|
for (const code of Array.isArray(language) ? language : [language]) {
|
|
10
5
|
const entry = record[code];
|
|
11
6
|
if (entry !== void 0 && entry.trim() !== "") return entry;
|
|
@@ -25,14 +20,8 @@
|
|
|
25
20
|
if (!record) return;
|
|
26
21
|
return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
|
|
27
22
|
};
|
|
28
|
-
/**
|
|
29
|
-
|
|
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({
|
|
23
|
+
/** Bound to one request's language, so a walk carries no request of its own. */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
|
|
24
|
+
/** For callers with no request: both keys miss, so the first entry wins. */ const translateAny = translatorFor({
|
|
36
25
|
fallbackLanguage: "",
|
|
37
26
|
language: ""
|
|
38
27
|
});
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, defineMcpxTool } from "./types.mjs";
|
|
1
|
+
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool } from "./types.mjs";
|
|
2
2
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
3
|
-
import { isMcpxRequest } from "./
|
|
4
|
-
import {
|
|
5
|
-
export {
|
|
3
|
+
import { isMcpxRequest } from "./request.mjs";
|
|
4
|
+
import { errorResult, jsonResult } from "./result.mjs";
|
|
5
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, McpxWriteMode, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mcpxPlugin } from "./plugin.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "./result.mjs";
|
|
3
2
|
import { defineMcpxTool } from "./types.mjs";
|
|
4
|
-
|
|
3
|
+
import { isMcpxRequest } from "./request.mjs";
|
|
4
|
+
import { mcpxPlugin } from "./plugin.mjs";
|
|
5
|
+
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
package/dist/options.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BUILTIN_TOOL_NAMES } from "./tools/names.mjs";
|
|
2
2
|
import "./version.mjs";
|
|
3
3
|
import { InvalidConfiguration } from "payload";
|
|
4
|
-
import { hasDraftsEnabled } from "payload/shared";
|
|
4
|
+
import { hasDraftsEnabled, hasLocalizeStatusEnabled } from "payload/shared";
|
|
5
5
|
//#region src/options.ts
|
|
6
6
|
const DEFAULT_API_KEYS_SLUG = "mcpx-api-keys";
|
|
7
7
|
const DEFAULT_ENDPOINT_PATH = "/mcpx";
|
|
@@ -11,29 +11,36 @@ const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
|
|
|
11
11
|
const fail = (message) => {
|
|
12
12
|
throw new InvalidConfiguration(`[payloadcms-mcpx] ${message}`);
|
|
13
13
|
};
|
|
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());
|
|
14
15
|
/**
|
|
15
|
-
* Lower camel case of a slug, the same transform the stock MCP plugin applies
|
|
16
|
-
* to derive field names from collection slugs.
|
|
17
|
-
*/ const toCamelCase = (value) => value.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : "").replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
18
|
-
/**
|
|
19
|
-
* Refuses collections that must never be reachable through MCP, read included.
|
|
20
16
|
* Auth collections carry credentials: `useAPIKey` stores a key that decrypts on
|
|
21
|
-
* read, and email or lockout state is PII either way.
|
|
17
|
+
* read, and email or lockout state is PII either way. Refused for read too.
|
|
22
18
|
*/ const assertExposable = (collection, apiKeysSlug) => {
|
|
23
19
|
const { slug } = collection;
|
|
24
20
|
if (slug === apiKeysSlug || slug.startsWith("payload-")) fail(`Collection "${slug}" cannot be exposed.`);
|
|
25
21
|
if (collection.auth) fail(`Auth collection "${slug}" cannot be exposed. Its documents carry credentials.`);
|
|
26
22
|
};
|
|
23
|
+
/** Checked at runtime too: for a JS caller a typo would silently mean "no write". */ const normalizeWriteMode = (kind, slug, value) => {
|
|
24
|
+
if (value === void 0 || value === false) return false;
|
|
25
|
+
if (value === "draft" || value === "live") return value;
|
|
26
|
+
return fail(`${kind} "${slug}" has write: ${JSON.stringify(value)}. Use false, "draft" or "live".`);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `localizeStatus` makes `_status` a localized field, which flips Payload's
|
|
30
|
+
* `publishAllLocales` default to false and turns `_status` into a locale-keyed
|
|
31
|
+
* object. Publishing would then cover one locale while reporting success, and
|
|
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.`);
|
|
36
|
+
};
|
|
27
37
|
const assertWritable = (collection, options) => {
|
|
28
38
|
const { slug } = collection;
|
|
29
|
-
if (collection.upload) fail(`Upload collection "${slug}" cannot be exposed for write.`);
|
|
30
39
|
if (collection.timestamps === false) fail(`Collection "${slug}" has timestamps disabled, which write tools need for concurrency checks.`);
|
|
31
|
-
if (
|
|
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);
|
|
32
42
|
};
|
|
33
|
-
/**
|
|
34
|
-
* Refuses globals that must never be reachable. Globals cannot be auth or
|
|
35
|
-
* upload entities, so only Payload's own reserved namespace is left to guard.
|
|
36
|
-
*/ const assertGlobalExposable = (global) => {
|
|
43
|
+
/** Globals cannot be auth or upload, so only the reserved namespace is left. */ const assertGlobalExposable = (global) => {
|
|
37
44
|
if (global.slug.startsWith("payload-")) fail(`Global "${global.slug}" cannot be exposed.`);
|
|
38
45
|
};
|
|
39
46
|
/**
|
|
@@ -41,7 +48,8 @@ const assertWritable = (collection, options) => {
|
|
|
41
48
|
* `createdAt`/`updatedAt`, so the concurrency check the collection path guards
|
|
42
49
|
* for is always available here. Drafts are the only requirement left.
|
|
43
50
|
*/ const assertGlobalWritable = (global, options) => {
|
|
44
|
-
if (
|
|
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);
|
|
45
53
|
};
|
|
46
54
|
const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
47
55
|
const collections = config.collections ?? [];
|
|
@@ -56,12 +64,12 @@ const normalizeCollections = (config, options, apiKeysSlug) => {
|
|
|
56
64
|
const normalized = {
|
|
57
65
|
slug,
|
|
58
66
|
read: settings.read ?? true,
|
|
59
|
-
write: settings.write
|
|
60
|
-
allowLiveWrites: settings.allowLiveWrites ?? false,
|
|
67
|
+
write: normalizeWriteMode("Collection", slug, settings.write),
|
|
61
68
|
hasDrafts,
|
|
69
|
+
isUpload: Boolean(collection.upload),
|
|
62
70
|
fieldName: toCamelCase(slug)
|
|
63
71
|
};
|
|
64
|
-
if (normalized.write) assertWritable(collection, normalized);
|
|
72
|
+
if (normalized.write !== false) assertWritable(collection, normalized);
|
|
65
73
|
if (fieldNames.has(normalized.fieldName)) fail(`Collection "${slug}" maps to capability field "${normalized.fieldName}", which another exposed collection already uses.`);
|
|
66
74
|
fieldNames.add(normalized.fieldName);
|
|
67
75
|
return [normalized];
|
|
@@ -80,12 +88,12 @@ const normalizeGlobals = (config, options) => {
|
|
|
80
88
|
const normalized = {
|
|
81
89
|
slug,
|
|
82
90
|
read: settings.read ?? true,
|
|
83
|
-
write: settings.write
|
|
84
|
-
allowLiveWrites: settings.allowLiveWrites ?? false,
|
|
91
|
+
write: normalizeWriteMode("Global", slug, settings.write),
|
|
85
92
|
hasDrafts,
|
|
93
|
+
isUpload: false,
|
|
86
94
|
fieldName: toCamelCase(slug)
|
|
87
95
|
};
|
|
88
|
-
if (normalized.write) assertGlobalWritable(global, normalized);
|
|
96
|
+
if (normalized.write !== false) assertGlobalWritable(global, normalized);
|
|
89
97
|
if (fieldNames.has(normalized.fieldName)) fail(`Global "${slug}" maps to capability field "${normalized.fieldName}", which another exposed global already uses.`);
|
|
90
98
|
fieldNames.add(normalized.fieldName);
|
|
91
99
|
return [normalized];
|
|
@@ -115,11 +123,7 @@ const normalizeLimits = (limits) => {
|
|
|
115
123
|
maxDepth
|
|
116
124
|
};
|
|
117
125
|
};
|
|
118
|
-
/**
|
|
119
|
-
* Validates the plugin options against the incoming config and fills in
|
|
120
|
-
* defaults. Every problem is an `InvalidConfiguration` so misconfiguration
|
|
121
|
-
* fails at startup instead of at request time.
|
|
122
|
-
*/ const normalizeOptions = (config, options) => {
|
|
126
|
+
/** Every problem is an `InvalidConfiguration`, so it fails at startup. */ const normalizeOptions = (config, options) => {
|
|
123
127
|
const apiKeysSlug = options.apiKeys?.slug ?? DEFAULT_API_KEYS_SLUG;
|
|
124
128
|
const userCollection = options.userCollection ?? config.admin?.user ?? "users";
|
|
125
129
|
if ((config.collections ?? []).some((c) => c.slug === apiKeysSlug)) fail(`API key collection slug "${apiKeysSlug}" is already taken.`);
|
|
@@ -138,7 +142,7 @@ const normalizeLimits = (limits) => {
|
|
|
138
142
|
auth: options.auth,
|
|
139
143
|
serverInfo: {
|
|
140
144
|
name: options.serverInfo?.name ?? "payloadcms-mcpx",
|
|
141
|
-
version: options.serverInfo?.version ?? "
|
|
145
|
+
version: options.serverInfo?.version ?? "1.0.0"
|
|
142
146
|
}
|
|
143
147
|
};
|
|
144
148
|
};
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createApiKeysCollection } from "./api-keys/collection.mjs";
|
|
2
2
|
import { createMcpxHandler, methodNotAllowed } from "./endpoint/handler.mjs";
|
|
3
|
+
import "./endpoint/index.mjs";
|
|
3
4
|
import { normalizeOptions } from "./options.mjs";
|
|
4
5
|
import { installDraftGuards, installGlobalDraftGuards } from "./write/draft-guard.mjs";
|
|
5
6
|
import { definePlugin } from "payload";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
//#region src/
|
|
1
|
+
import { PayloadRequest } from "payload";
|
|
2
|
+
//#region src/request.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
5
5
|
* `req.context.mcpx`, which travels into every local API call made with the
|
package/dist/request.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/request.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
4
|
+
* `req.context.mcpx`, which travels into every local API call made with the
|
|
5
|
+
* same `req`, including those made by custom tools.
|
|
6
|
+
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { isMcpxRequest };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
//#region src/result.d.ts
|
|
3
|
+
/** `value` as JSON text. */
|
|
4
|
+
declare const jsonResult: (value: unknown) => CallToolResult;
|
|
5
|
+
/**
|
|
6
|
+
* `extras` travel alongside the message so the client can act on them:
|
|
7
|
+
* problems, validation errors, the current `updatedAt`.
|
|
8
|
+
*/
|
|
9
|
+
declare const errorResult: (message: string, extras?: Record<string, unknown>) => CallToolResult;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { errorResult, jsonResult };
|
package/dist/result.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/result.ts
|
|
2
|
+
/** `value` as JSON text. */ const jsonResult = (value) => ({ content: [{
|
|
3
|
+
type: "text",
|
|
4
|
+
text: JSON.stringify(value)
|
|
5
|
+
}] });
|
|
6
|
+
/**
|
|
7
|
+
* `extras` travel alongside the message so the client can act on them:
|
|
8
|
+
* problems, validation errors, the current `updatedAt`.
|
|
9
|
+
*/ const errorResult = (message, extras = {}) => ({
|
|
10
|
+
content: [{
|
|
11
|
+
type: "text",
|
|
12
|
+
text: JSON.stringify({
|
|
13
|
+
error: message,
|
|
14
|
+
...extras
|
|
15
|
+
})
|
|
16
|
+
}],
|
|
17
|
+
isError: true
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
export { errorResult, jsonResult };
|
package/dist/schema/describe.mjs
CHANGED
|
@@ -2,13 +2,8 @@ import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
|
|
|
2
2
|
import { translateAny } from "../i18n.mjs";
|
|
3
3
|
import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
|
|
4
4
|
//#region src/schema/describe.ts
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
* text fields are both leaves of the walk, so at most one can match.
|
|
8
|
-
*/ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
|
|
9
|
-
/**
|
|
10
|
-
* Walks one step of a schema path through a blocks field.
|
|
11
|
-
*/ const stepThroughBlocks = ({ config, fields, match, remaining }) => {
|
|
5
|
+
/** Blocks and rich text are both leaves of the walk, so at most one matches. */ const longestMatch = (descriptors, remaining) => descriptors.map((descriptor) => splitPath(descriptor.path)).filter((parts) => parts.every((part, offset) => part === remaining[offset])).sort((left, right) => right.length - left.length)[0];
|
|
6
|
+
const stepThroughBlocks = ({ config, fields, match, remaining }) => {
|
|
12
7
|
const field = findBlocksField(fields, match);
|
|
13
8
|
if (!field) throw new Error(`"${joinPath(match)}" could not be resolved.`);
|
|
14
9
|
const slug = remaining.at(match.length);
|
|
@@ -22,8 +17,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
22
17
|
};
|
|
23
18
|
};
|
|
24
19
|
/**
|
|
25
|
-
* Walks one step of a schema path into a Lexical node's own fields.
|
|
26
|
-
*
|
|
27
20
|
* A node that picks a block by slug takes one segment more, so `/content/block`
|
|
28
21
|
* addresses the choice and `/content/block/callout` the definition. Everything
|
|
29
22
|
* else, a link node being the usual case, resolves in a single segment.
|
|
@@ -52,8 +45,6 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
52
45
|
};
|
|
53
46
|
};
|
|
54
47
|
/**
|
|
55
|
-
* Walks a schema path to the field list it addresses.
|
|
56
|
-
*
|
|
57
48
|
* A schema path alternates a blocks field's own path with the slug of one of
|
|
58
49
|
* the blocks it accepts, so `/layout/sections/sectionWrapper/modules/hero`
|
|
59
50
|
* reaches `hero` as it exists under `pages` specifically. The slug sits where
|
|
@@ -114,11 +105,8 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
|
|
|
114
105
|
}));
|
|
115
106
|
};
|
|
116
107
|
/**
|
|
117
|
-
* Describes a collection or global root, one block reached through a schema
|
|
118
|
-
* path, or the fields a Lexical node carries.
|
|
119
|
-
*
|
|
120
108
|
* Curried on the translator that resolves each `admin.description`, so a
|
|
121
|
-
* request binds its language once and the walk
|
|
109
|
+
* request binds its language once and the walk stays request-free.
|
|
122
110
|
*/ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
|
|
123
111
|
const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
|
|
124
112
|
const descriptors = describeFields(fields, translate);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { REQUIRED_NODE_PROPERTIES, ROOT_PROPERTIES, allowedNodeTypes, constrainsFields, lexicalSubSchema, nodeOptions, nodeProblems, nodePropertiesFor, propertyProblem, rootProblems, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
|
|
3
|
+
import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
|
|
4
|
+
import { resolveLexicalPointer } from "./lexical-pointer.mjs";
|
|
5
|
+
import { lexicalOutline } from "./outline.mjs";
|
|
6
|
+
import { resolveDataPointer } from "./pointer.mjs";
|
|
7
|
+
import { EMPTY_ROOT, validateWriteValue } from "./shape.mjs";
|
|
8
|
+
export { EMPTY_ROOT, JSON_POINTER_PATTERN, REQUIRED_NODE_PROPERTIES, RESERVED_FIELD_NAMES, ROOT_PROPERTIES, allowedNodeTypes, blockOf, blockSlugsOf, constrainsFields, describeAddressableFields, describeFields, findBlocksField, findRichTextField, isIndexSegment, isPlainObject, joinPath, lexicalOutline, lexicalSubSchema, nodeDescriber, nodeOptions, nodeProblems, nodePropertiesFor, pointerFromPayloadPath, propertyProblem, reachableSchemaPaths, resolveDataPointer, resolveLexicalPointer, rootProblems, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|