@abinnovision/payloadcms-mcpx 1.0.0-beta.11 → 1.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 +15 -3
- package/dist/api-keys/fields.mjs +1 -1
- package/dist/endpoint/{result.mjs → errors.mjs} +4 -21
- package/dist/endpoint/handler.mjs +1 -1
- package/dist/endpoint/index.mjs +4 -0
- package/dist/endpoint/server.mjs +10 -5
- package/dist/index.d.mts +4 -5
- package/dist/index.mjs +2 -2
- 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/{endpoint/result.d.mts → result.d.mts} +1 -2
- package/dist/result.mjs +22 -0
- package/dist/schema/index.mjs +6 -0
- package/dist/schema/pointer.mjs +1 -1
- package/dist/schema/walk.mjs +11 -15
- package/dist/tools/{index.mjs → builtin.mjs} +1 -1
- package/dist/tools/create-document.mjs +14 -9
- package/dist/tools/describe-schema.mjs +3 -3
- package/dist/tools/find-documents.mjs +1 -2
- package/dist/tools/get-document.mjs +2 -2
- package/dist/tools/list-capabilities.mjs +1 -2
- package/dist/tools/patch-document.mjs +11 -12
- package/dist/tools/shared.mjs +16 -1
- package/dist/tools/validate-document.mjs +8 -9
- package/dist/types.d.mts +17 -3
- package/dist/write/draft-guard.mjs +2 -6
- package/dist/write/patch.mjs +131 -57
- package/dist/write/publish-blockers.mjs +10 -3
- package/package.json +1 -1
- package/dist/i18n.d.mts +0 -1
- 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/write/publish-blockers.d.mts +0 -15
package/README.md
CHANGED
|
@@ -13,8 +13,9 @@ resolved server-side against the real config and the real document, so unknown
|
|
|
13
13
|
fields, misplaced blocks and unusable rich text nodes or node fields are refused with the
|
|
14
14
|
valid alternatives listed, never silently dropped.
|
|
15
15
|
|
|
16
|
-
Writes are RFC 6902 patches that
|
|
17
|
-
human action in the admin panel.
|
|
16
|
+
Writes are RFC 6902 patches that land as drafts, with `allowLiveWrites` as the
|
|
17
|
+
one documented exception; publishing stays a human action in the admin panel.
|
|
18
|
+
Every write returns the publish
|
|
18
19
|
blockers: the validation failures that still prevent a human from publishing
|
|
19
20
|
the draft. Capabilities are declared twice: the plugin config decides what
|
|
20
21
|
can exist, a checkbox on each API key decides what does, and a missing checkbox
|
|
@@ -153,7 +154,7 @@ only the slugs the key may touch.
|
|
|
153
154
|
| `getDocument` | Read one document or a subtree of it. | `collection` + `id` \| `global`, `path?` (JSON pointer), `depth?`, `locale?`, `draft?` |
|
|
154
155
|
| `patchDocument` | Apply RFC 6902 operations to the current draft. | `collection` + `id` \| `global`, `locale`, `patches`, `expectedUpdatedAt?` |
|
|
155
156
|
| `createDocument` | Create a draft from a minimal seed. | `collection`, `locale`, `data` |
|
|
156
|
-
| `validateDocument` | Publish blockers without
|
|
157
|
+
| `validateDocument` | Publish blockers without saving anything. | `collection` + `id` \| `global`, `locale` |
|
|
157
158
|
|
|
158
159
|
Rules the tools enforce and explain in their own descriptions:
|
|
159
160
|
|
|
@@ -266,6 +267,12 @@ Collections with `versions.drafts.validate: true` refuse invalid drafts
|
|
|
266
267
|
outright; those failures come back as `validationErrors`. Both carry pointers,
|
|
267
268
|
restated from the dotted paths Payload reports internally.
|
|
268
269
|
|
|
270
|
+
`publishBlockersUnavailable` marks a check that could not complete, which is
|
|
271
|
+
not the same answer as a document with nothing wrong with it. `validateDocument`
|
|
272
|
+
runs the same traversal without saving anything, so it is not free of side
|
|
273
|
+
effects: field `beforeValidate` and `beforeChange` hooks run, and it carries no
|
|
274
|
+
`readOnlyHint` for that reason.
|
|
275
|
+
|
|
269
276
|
Writes also report `notApplied`: pointers whose value Payload kept unchanged,
|
|
270
277
|
which happens when field-level access denies the update.
|
|
271
278
|
|
|
@@ -377,6 +384,11 @@ results shaped like a builtin's.
|
|
|
377
384
|
| `auth.resolve` | none | Replace or wrap the default key resolution. |
|
|
378
385
|
| `serverInfo` | package name and version | Reported to MCP clients. |
|
|
379
386
|
|
|
387
|
+
`allowLiveWrites` is the only way an MCP write reaches live content. Where it is
|
|
388
|
+
set, the server instructions and the `patchDocument` and `createDocument`
|
|
389
|
+
descriptions name those slugs for the key in question, so a client is never told
|
|
390
|
+
its writes are drafts while they are not.
|
|
391
|
+
|
|
380
392
|
Misconfiguration (unknown slugs, write on a collection without drafts, upload
|
|
381
393
|
collections exposed for write, tool name collisions) fails at startup with
|
|
382
394
|
`InvalidConfiguration`. Auth collections cannot be exposed at all, read
|
package/dist/api-keys/fields.mjs
CHANGED
|
@@ -107,7 +107,7 @@ const SETUP_GUIDE_FIELD = "setupGuide";
|
|
|
107
107
|
label: global.slug,
|
|
108
108
|
fields: [...global.read ? [checkbox("read", "Describe and read this global.")] : [], ...global.write ? [checkbox("write", "Patch and validate this global's draft.")] : []]
|
|
109
109
|
}));
|
|
110
|
-
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, tool.description));
|
|
110
|
+
const toolCheckboxes = options.tools.map((tool) => checkbox(tool.name, typeof tool.description === "string" ? tool.description : tool.name));
|
|
111
111
|
const groups = [
|
|
112
112
|
...collectionGroups.length > 0 ? [{
|
|
113
113
|
name: "collections",
|
|
@@ -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,25 +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
25
|
* Maps an exception thrown by a tool to a result the client can read.
|
|
43
26
|
*
|
|
44
27
|
* Payload's public errors keep their message and status; a `ValidationError`
|
|
@@ -62,4 +45,4 @@ import { APIError, ValidationError } from "payload";
|
|
|
62
45
|
return errorResult("Internal error");
|
|
63
46
|
};
|
|
64
47
|
//#endregion
|
|
65
|
-
export {
|
|
48
|
+
export { jsonRpcError, toToolError };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsonRpcError } from "./result.mjs";
|
|
2
1
|
import { readableGlobalSlugs, readableSlugs, resolveCapabilities, writableGlobalSlugs, writableSlugs } from "../capabilities.mjs";
|
|
2
|
+
import { jsonRpcError } from "./errors.mjs";
|
|
3
3
|
import { resolveApiKeyAuth } from "../auth/resolve.mjs";
|
|
4
4
|
import { createMcpServer } from "./server.mjs";
|
|
5
5
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
@@ -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,5 +1,6 @@
|
|
|
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
|
|
@@ -10,6 +11,10 @@ import { z } from "zod";
|
|
|
10
11
|
* scope to narrow enums to what the key may touch.
|
|
11
12
|
*/ const toolInputSchema = (tool, scope) => z.strictObject(typeof tool.inputSchema === "function" ? tool.inputSchema(scope) : tool.inputSchema ?? {});
|
|
12
13
|
/**
|
|
14
|
+
* A tool's description, which may be built from the scope so it can name the
|
|
15
|
+
* targets this key writes live.
|
|
16
|
+
*/ const toolDescription = (tool, scope) => typeof tool.description === "function" ? tool.description(scope) : tool.description;
|
|
17
|
+
/**
|
|
13
18
|
* Whether the key may call the tool. A tool that does not decide for itself is
|
|
14
19
|
* gated by its own checkbox on the key, which is how the tools from
|
|
15
20
|
* `options.tools` work; the builtins derive it from the key's collection and
|
|
@@ -26,7 +31,7 @@ import { z } from "zod";
|
|
|
26
31
|
const server = new McpServer({
|
|
27
32
|
name: options.serverInfo.name,
|
|
28
33
|
version: options.serverInfo.version
|
|
29
|
-
}, { instructions:
|
|
34
|
+
}, { instructions: `Start with listCapabilities, then describeSchema for the collection or global you work on. ${draftSentence(scope)}` });
|
|
30
35
|
const guarded = (run) => async () => {
|
|
31
36
|
try {
|
|
32
37
|
return await run();
|
|
@@ -37,7 +42,7 @@ import { z } from "zod";
|
|
|
37
42
|
for (const tool of [...BUILTIN_TOOLS, ...options.tools]) {
|
|
38
43
|
if (!isToolEnabled(tool, scope)) continue;
|
|
39
44
|
server.registerTool(tool.name, {
|
|
40
|
-
description: tool
|
|
45
|
+
description: toolDescription(tool, scope),
|
|
41
46
|
inputSchema: toolInputSchema(tool, scope),
|
|
42
47
|
...tool.annotations ? { annotations: tool.annotations } : {}
|
|
43
48
|
}, (args, extra) => guarded(() => tool.handler({
|
|
@@ -50,4 +55,4 @@ import { z } from "zod";
|
|
|
50
55
|
return server;
|
|
51
56
|
};
|
|
52
57
|
//#endregion
|
|
53
|
-
export { createMcpServer, isToolEnabled, toolInputSchema };
|
|
58
|
+
export { createMcpServer, isToolEnabled, toolDescription, toolInputSchema };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool } from "./types.mjs";
|
|
1
|
+
import { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool } from "./types.mjs";
|
|
3
2
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
4
|
-
import { isMcpxRequest } from "./
|
|
5
|
-
import {
|
|
6
|
-
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, PublishBlocker, defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "./
|
|
1
|
+
import { errorResult, jsonResult } from "./result.mjs";
|
|
2
2
|
import { defineMcpxTool } from "./types.mjs";
|
|
3
|
-
import { isMcpxRequest } from "./
|
|
3
|
+
import { isMcpxRequest } from "./request.mjs";
|
|
4
4
|
import { mcpxPlugin } from "./plugin.mjs";
|
|
5
5
|
export { defineMcpxTool, errorResult, isMcpxRequest, jsonResult, mcpxPlugin };
|
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 };
|
package/dist/result.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/result.ts
|
|
2
|
+
/**
|
|
3
|
+
* A successful tool result carrying `value` as JSON text.
|
|
4
|
+
*/ const jsonResult = (value) => ({ content: [{
|
|
5
|
+
type: "text",
|
|
6
|
+
text: JSON.stringify(value)
|
|
7
|
+
}] });
|
|
8
|
+
/**
|
|
9
|
+
* A failed tool result. `extras` travel alongside the message so the client
|
|
10
|
+
* can act on them (problems, validation errors, the current `updatedAt`).
|
|
11
|
+
*/ const errorResult = (message, extras = {}) => ({
|
|
12
|
+
content: [{
|
|
13
|
+
type: "text",
|
|
14
|
+
text: JSON.stringify({
|
|
15
|
+
error: message,
|
|
16
|
+
...extras
|
|
17
|
+
})
|
|
18
|
+
}],
|
|
19
|
+
isError: true
|
|
20
|
+
});
|
|
21
|
+
//#endregion
|
|
22
|
+
export { errorResult, jsonResult };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { allowedNodeTypes, lexicalSubSchema, nodeOptions, subSchemaNodeTypes } from "./lexical.mjs";
|
|
2
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf } from "./walk.mjs";
|
|
3
|
+
import { nodeDescriber, reachableSchemaPaths } from "./describe.mjs";
|
|
4
|
+
import { resolveDataPointer } from "./pointer.mjs";
|
|
5
|
+
import { validateWriteValue } from "./shape.mjs";
|
|
6
|
+
export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, allowedNodeTypes, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, lexicalSubSchema, nodeDescriber, nodeOptions, pointerFromPayloadPath, reachableSchemaPaths, resolveDataPointer, splitPath, subSchemaNodeTypes, targetOf, validateWriteValue };
|
package/dist/schema/pointer.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const partMatches = (part, segment) => segment !== void 0 && (part === "*" ? isI
|
|
|
43
43
|
if (isSubtreePrefix(descriptors, segments)) return {
|
|
44
44
|
...blockType === void 0 ? {} : { blockType },
|
|
45
45
|
fields,
|
|
46
|
-
prefix: segments
|
|
46
|
+
prefix: segments.map((segment) => isIndexSegment(segment) ? "*" : segment)
|
|
47
47
|
};
|
|
48
48
|
throw new Error(`"${joinPath(segments)}" is not a field here. Available: ${descriptors.map((descriptor) => descriptor.path).join(", ")}`);
|
|
49
49
|
}
|
package/dist/schema/walk.mjs
CHANGED
|
@@ -83,7 +83,7 @@ const withRows = (descriptor, field) => ({
|
|
|
83
83
|
* Whether a descriptor stands for a construct that only holds other fields.
|
|
84
84
|
*
|
|
85
85
|
* These describe a position rather than a value, so everything that resolves a
|
|
86
|
-
* path to something writable skips them; only {@link
|
|
86
|
+
* path to something writable skips them; only {@link nodeDescriber} reports
|
|
87
87
|
* them, to carry what the container itself declares.
|
|
88
88
|
*/ const isContainer = (descriptor) => descriptor.type === "array" || descriptor.type === "group" || descriptor.type === "tab";
|
|
89
89
|
/**
|
|
@@ -133,29 +133,25 @@ const withRows = (descriptor, field) => ({
|
|
|
133
133
|
/**
|
|
134
134
|
* The descriptors that address a value, which is what every walk resolving a
|
|
135
135
|
* path against a document needs. A container describes a position rather than
|
|
136
|
-
* a value, so only {@link
|
|
136
|
+
* a value, so only {@link nodeDescriber} reports one.
|
|
137
137
|
*/ const describeAddressableFields = (fields) => describeFields(fields).filter((descriptor) => !isContainer(descriptor));
|
|
138
138
|
/**
|
|
139
|
-
* Locates the
|
|
140
|
-
*/ const
|
|
139
|
+
* Locates the field of `type` that a resolved descriptor path refers to.
|
|
140
|
+
*/ const findFieldAt = (fields, path, type) => {
|
|
141
141
|
for (const field of fields) {
|
|
142
142
|
if (!("name" in field) || field.name !== path[0]) continue;
|
|
143
|
-
if (field.type ===
|
|
144
|
-
if (field.type === "tab" || field.type === "group") return
|
|
145
|
-
if (field.type === "array" && path[1] === "*") return
|
|
143
|
+
if (field.type === type && path.length === 1) return field;
|
|
144
|
+
if (field.type === "tab" || field.type === "group") return findFieldAt(field.flattenedFields, path.slice(1), type);
|
|
145
|
+
if (field.type === "array" && path[1] === "*") return findFieldAt(field.flattenedFields, path.slice(2), type);
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
148
|
/**
|
|
149
|
+
* Locates the blocks field that a resolved descriptor path refers to.
|
|
150
|
+
*/ const findBlocksField = (fields, path) => findFieldAt(fields, path, "blocks");
|
|
151
|
+
/**
|
|
149
152
|
* Locates the rich text field that a resolved descriptor path refers to, so
|
|
150
153
|
* its editor can be introspected for the fields its nodes carry.
|
|
151
|
-
*/ const findRichTextField = (fields, path) =>
|
|
152
|
-
for (const field of fields) {
|
|
153
|
-
if (!("name" in field) || field.name !== path[0]) continue;
|
|
154
|
-
if (field.type === "richText" && path.length === 1) return field;
|
|
155
|
-
if (field.type === "tab" || field.type === "group") return findRichTextField(field.flattenedFields, path.slice(1));
|
|
156
|
-
if (field.type === "array" && path[1] === "*") return findRichTextField(field.flattenedFields, path.slice(2));
|
|
157
|
-
}
|
|
158
|
-
};
|
|
154
|
+
*/ const findRichTextField = (fields, path) => findFieldAt(fields, path, "richText");
|
|
159
155
|
const targetOf = (config, ref) => {
|
|
160
156
|
const found = ref.kind === "collection" ? config.collections.find((candidate) => candidate.slug === ref.slug) : config.globals.find((candidate) => candidate.slug === ref.slug);
|
|
161
157
|
if (!found) throw new Error(`Unknown ${ref.kind} "${ref.slug}".`);
|
|
@@ -5,7 +5,7 @@ import { getDocument } from "./get-document.mjs";
|
|
|
5
5
|
import { listCapabilities } from "./list-capabilities.mjs";
|
|
6
6
|
import { patchDocument } from "./patch-document.mjs";
|
|
7
7
|
import { validateDocument } from "./validate-document.mjs";
|
|
8
|
-
//#region src/tools/
|
|
8
|
+
//#region src/tools/builtin.ts
|
|
9
9
|
/**
|
|
10
10
|
* The builtin tools in registration order. They are ordinary {@link McpxTool}s
|
|
11
11
|
* that ship with the plugin and register through the same loop as the tools
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
3
|
-
import { resolveTarget } from "./target.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
4
2
|
import { validateWriteValue } from "../schema/shape.mjs";
|
|
3
|
+
import "../schema/index.mjs";
|
|
4
|
+
import { draftSentence, localeOf, localeShape, readTarget, slugEnum } from "./shared.mjs";
|
|
5
|
+
import { resolveTarget } from "./target.mjs";
|
|
5
6
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
7
|
import { stripRowIds } from "../write/patch.mjs";
|
|
7
8
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
//#region src/tools/create-document.ts
|
|
11
|
+
const DESCRIPTION = (scope) => `Creates a new document from a minimal seed. Only the fields describeSchema lists may appear in "data"; unknown keys are refused with the valid siblings, and "id" is Payload's to assign. The document may be incomplete: the response lists "publishBlockers", which patchDocument can then work through, and "publishBlockersUnavailable" when that check itself failed. Use this when no document exists yet; prefer patching an existing draft otherwise.
|
|
12
|
+
|
|
13
|
+
${draftSentence(scope)}`;
|
|
10
14
|
const createDocument = defineMcpxTool({
|
|
11
15
|
name: "createDocument",
|
|
12
|
-
description:
|
|
16
|
+
description: DESCRIPTION,
|
|
13
17
|
annotations: {
|
|
14
18
|
readOnlyHint: false,
|
|
15
19
|
destructiveHint: false,
|
|
@@ -29,18 +33,18 @@ const createDocument = defineMcpxTool({
|
|
|
29
33
|
const target = resolveTarget(scope, { collection: args.collection }, "write");
|
|
30
34
|
const { payload } = scope.req;
|
|
31
35
|
const locale = localeOf(scope, args.locale);
|
|
32
|
-
|
|
36
|
+
if ("id" in args.data) return errorResult("Nothing was created.", { problems: ["/id: Payload assigns the id; it cannot be supplied."] });
|
|
33
37
|
const problems = validateWriteValue(payload.config, {
|
|
34
38
|
pointer: "",
|
|
35
39
|
resolution: {
|
|
36
40
|
fields: target.config.flattenedFields,
|
|
37
41
|
prefix: []
|
|
38
42
|
}
|
|
39
|
-
},
|
|
43
|
+
}, args.data);
|
|
40
44
|
if (problems.length > 0) return errorResult("Nothing was created.", { problems });
|
|
41
45
|
const created = await payload.create({
|
|
42
46
|
collection: args.collection,
|
|
43
|
-
data: stripRowIds(
|
|
47
|
+
data: stripRowIds(args.data),
|
|
44
48
|
depth: 0,
|
|
45
49
|
draft: true,
|
|
46
50
|
overrideAccess: false,
|
|
@@ -53,7 +57,7 @@ const createDocument = defineMcpxTool({
|
|
|
53
57
|
locale,
|
|
54
58
|
privileged: true
|
|
55
59
|
});
|
|
56
|
-
const
|
|
60
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
57
61
|
doc: saved,
|
|
58
62
|
entity: target
|
|
59
63
|
});
|
|
@@ -61,7 +65,8 @@ const createDocument = defineMcpxTool({
|
|
|
61
65
|
id: saved["id"],
|
|
62
66
|
status: saved["_status"],
|
|
63
67
|
updatedAt: saved["updatedAt"],
|
|
64
|
-
...
|
|
68
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
69
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
1
2
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import {
|
|
3
|
+
import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
4
|
+
import "../schema/index.mjs";
|
|
3
5
|
import { targetShape } from "./shared.mjs";
|
|
4
6
|
import { refOf, resolveTarget } from "./target.mjs";
|
|
5
7
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
|
-
import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
|
|
7
8
|
import { z } from "zod";
|
|
8
|
-
//#region src/tools/describe-schema.ts
|
|
9
9
|
const describeSchema = defineMcpxTool({
|
|
10
10
|
name: "describeSchema",
|
|
11
11
|
description: `Describes the writable shape of a document, one node at a time.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { depthShape, localeOf, localeShape, slugEnum } from "./shared.mjs";
|
|
3
3
|
import { resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
//#region src/tools/find-documents.ts
|
|
7
6
|
const findDocuments = defineMcpxTool({
|
|
8
7
|
name: "findDocuments",
|
|
9
8
|
description: `Finds documents in a collection. "where" is a Payload query object, e.g. {"title":{"contains":"home"}} or {"and":[...]}; "select" picks fields, e.g. {"title":true}. Drafts are included by default so unpublished work is visible. Keep depth at 0 unless populated relationships are needed; ids are enough for writes.`,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
1
2
|
import { JSON_POINTER_PATTERN } from "../schema/walk.mjs";
|
|
2
|
-
import
|
|
3
|
+
import "../schema/index.mjs";
|
|
3
4
|
import { depthShape, idShape, localeOf, localeShape, targetShape } from "./shared.mjs";
|
|
4
5
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
5
6
|
import { defineMcpxTool } from "../types.mjs";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { Pointer } from "rfc6902";
|
|
8
|
-
//#region src/tools/get-document.ts
|
|
9
9
|
const getDocument = defineMcpxTool({
|
|
10
10
|
name: "getDocument",
|
|
11
11
|
description: `Reads one document, or one subtree of it when "path" is given as a JSON pointer such as "/layout/sections/2". Returns the latest draft by default. Read before patching: the response carries "updatedAt" for expectedUpdatedAt and the indices pointers need.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
1
2
|
import { translatorFor } from "../i18n.mjs";
|
|
2
|
-
import { jsonResult } from "../endpoint/result.mjs";
|
|
3
3
|
import { translateLabel } from "./shared.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { hasDraftValidationEnabled } from "payload/shared";
|
|
6
|
-
//#region src/tools/list-capabilities.ts
|
|
7
6
|
const listCapabilities = defineMcpxTool({
|
|
8
7
|
name: "listCapabilities",
|
|
9
8
|
description: `Lists what this key may do: the collections and globals it can read or write, their draft behaviour and id type, the configured locales, the limits in force and the custom tools available. Call it first to orient; nothing here changes with the content model.
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { errorResult, jsonResult } from "../
|
|
2
|
-
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
1
|
+
import { errorResult, jsonResult } from "../result.mjs";
|
|
2
|
+
import { draftSentence, idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
3
|
import { refOf, requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
|
-
import { PATCH_OPERATION_SCHEMA,
|
|
5
|
+
import { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, isElementPointer } from "../write/patch.mjs";
|
|
6
6
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
7
7
|
import { withTransaction } from "../write/transaction.mjs";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { Pointer } from "rfc6902";
|
|
10
10
|
//#region src/tools/patch-document.ts
|
|
11
|
-
const DESCRIPTION = `Applies RFC 6902 JSON Patch operations to one document.
|
|
11
|
+
const DESCRIPTION = (scope) => `Applies RFC 6902 JSON Patch operations to one document.
|
|
12
12
|
|
|
13
13
|
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
${draftSentence(scope)}
|
|
16
16
|
|
|
17
17
|
Only the fields describeSchema lists can be addressed. A pointer that does not resolve is refused with the fields that are valid at that point, and nothing is applied unless every operation in the batch validates first. describeSchema reports field paths in this same pointer syntax; a path becomes a pointer into a document by replacing each "*" and each block slug with its 0-based index.
|
|
18
18
|
|
|
19
19
|
Adding a block requires "blockType" on the value. Append with "/-" as the last segment. To clear a field use "replace" with null; an array or blocks field refuses null and is emptied with [] instead. "remove" is only for list elements, because a field left out of a write is kept rather than cleared. Read the document first to learn the indices, and pass its "updatedAt" as expectedUpdatedAt so a concurrent edit is refused rather than overwritten.
|
|
20
20
|
|
|
21
|
-
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update.`;
|
|
21
|
+
A successful write may come back with "publishBlockers": everything still wrong with the draft, such as required fields left empty. Those do not fail the write, because a draft is allowed to be incomplete, but a human cannot publish the document until the list is empty. "notApplied" lists pointers whose value Payload kept unchanged, which happens when field-level access denies the update. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing about whether the document is publishable.`;
|
|
22
22
|
const sameInstant = (left, right) => typeof left === "string" && new Date(left).getTime() === new Date(right).getTime();
|
|
23
23
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
24
|
/**
|
|
@@ -48,7 +48,7 @@ const patchDocument = defineMcpxTool({
|
|
|
48
48
|
description: DESCRIPTION,
|
|
49
49
|
annotations: {
|
|
50
50
|
readOnlyHint: false,
|
|
51
|
-
destructiveHint:
|
|
51
|
+
destructiveHint: true,
|
|
52
52
|
idempotentHint: false,
|
|
53
53
|
openWorldHint: false
|
|
54
54
|
},
|
|
@@ -79,13 +79,11 @@ const patchDocument = defineMcpxTool({
|
|
|
79
79
|
locale
|
|
80
80
|
});
|
|
81
81
|
if (args.expectedUpdatedAt !== void 0 && !sameInstant(doc["updatedAt"], args.expectedUpdatedAt)) return errorResult("The document changed since you read it. Read it again and re-apply the patch.", { updatedAt: doc["updatedAt"] });
|
|
82
|
-
const
|
|
82
|
+
const applied = applyPatchOperations(payload.config, {
|
|
83
83
|
doc,
|
|
84
84
|
patches,
|
|
85
85
|
ref: refOf(target)
|
|
86
86
|
});
|
|
87
|
-
if (problems.length > 0) return errorResult("No operation was applied.", { problems });
|
|
88
|
-
const applied = applyPatchToCopy(doc, patches);
|
|
89
87
|
if ("problems" in applied) return errorResult("No operation was applied.", { problems: applied.problems });
|
|
90
88
|
const write = {
|
|
91
89
|
data: buildWriteData(payload.config, target.config, applied.next),
|
|
@@ -111,7 +109,7 @@ const patchDocument = defineMcpxTool({
|
|
|
111
109
|
privileged: true
|
|
112
110
|
});
|
|
113
111
|
const notApplied = notAppliedPointers(patches, applied.next, saved);
|
|
114
|
-
const
|
|
112
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
115
113
|
doc: saved,
|
|
116
114
|
entity: target
|
|
117
115
|
});
|
|
@@ -119,7 +117,8 @@ const patchDocument = defineMcpxTool({
|
|
|
119
117
|
...target.kind === "collection" ? { id: saved["id"] } : { global: target.slug },
|
|
120
118
|
status: saved["_status"],
|
|
121
119
|
updatedAt: saved["updatedAt"],
|
|
122
|
-
...
|
|
120
|
+
...validation.blockers.length > 0 ? { publishBlockers: validation.blockers } : {},
|
|
121
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {},
|
|
123
122
|
...notApplied.length > 0 ? { notApplied } : {}
|
|
124
123
|
});
|
|
125
124
|
});
|
package/dist/tools/shared.mjs
CHANGED
|
@@ -5,6 +5,21 @@ import { z } from "zod";
|
|
|
5
5
|
const slugEnum = (slugs) => z.enum(slugs);
|
|
6
6
|
const idSchema = z.union([z.string(), z.number()]).describe("Document id.");
|
|
7
7
|
/**
|
|
8
|
+
* Slugs this key may write whose writes land live rather than as a draft,
|
|
9
|
+
* which is what `allowLiveWrites` permits for an entity without versions.
|
|
10
|
+
* Empty for every key that can only write drafts.
|
|
11
|
+
*/ const liveWriteSlugs = (scope) => {
|
|
12
|
+
const live = (entities, writable) => entities.filter((entity) => writable.includes(entity.slug) && entity.allowLiveWrites && !entity.hasDrafts).map((entity) => entity.slug);
|
|
13
|
+
return [...live(scope.exposure.collections, scope.writable), ...live(scope.exposure.globals, scope.writableGlobals)];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The sentence the write tools and the server instructions end on: what a
|
|
17
|
+
* write actually does for this key.
|
|
18
|
+
*/ const draftSentence = (scope) => {
|
|
19
|
+
const live = liveWriteSlugs(scope);
|
|
20
|
+
return live.length === 0 ? "Every write lands as a draft and is never published; publishing stays a human action in the admin panel." : `Writes land as drafts and are never published, except for ${live.join(", ")}, which have no drafts: a write there changes the live document immediately. Publishing anything else stays a human action in the admin panel.`;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
8
23
|
* Widens one branch to the superset a handler sees. The widening itself is
|
|
9
24
|
* unchecked — the runtime shape really does vary — so `Branch` checks what it
|
|
10
25
|
* can around it.
|
|
@@ -99,4 +114,4 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.limits
|
|
|
99
114
|
return translateStatic(resolved, i18n) ?? fallback;
|
|
100
115
|
};
|
|
101
116
|
//#endregion
|
|
102
|
-
export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
117
|
+
export { depthShape, draftSentence, idSchema, idShape, liveWriteSlugs, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import { jsonResult } from "../
|
|
1
|
+
import { jsonResult } from "../result.mjs";
|
|
2
2
|
import { idShape, localeOf, localeShape, readTarget, targetShape } from "./shared.mjs";
|
|
3
3
|
import { requireIdFor, resolveTarget } from "./target.mjs";
|
|
4
4
|
import { defineMcpxTool } from "../types.mjs";
|
|
5
5
|
import { collectPublishBlockers } from "../write/publish-blockers.mjs";
|
|
6
|
-
//#region src/tools/validate-document.ts
|
|
7
6
|
const validateDocument = defineMcpxTool({
|
|
8
7
|
name: "validateDocument",
|
|
9
8
|
description: `Reports what still prevents a human from publishing the draft, without writing anything. The same list patchDocument returns after a write; use it to check work or to answer "is this ready".
|
|
10
9
|
|
|
11
|
-
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
10
|
+
Pass exactly one of "collection" and "global". "id" is required with "collection" and must be omitted with "global", because a global is a singleton.
|
|
11
|
+
|
|
12
|
+
Nothing is written, but the check runs the same field-level beforeValidate and beforeChange hooks a save would, so a hook with side effects fires. "publishBlockersUnavailable" means the check itself failed, so the empty list says nothing.`,
|
|
13
|
+
annotations: { openWorldHint: false },
|
|
16
14
|
isEnabled: (scope) => scope.writable.length + scope.writableGlobals.length > 0,
|
|
17
15
|
inputSchema: (scope) => ({
|
|
18
16
|
...targetShape(scope, "write", {
|
|
@@ -40,7 +38,7 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
40
38
|
locale,
|
|
41
39
|
privileged: true
|
|
42
40
|
});
|
|
43
|
-
const
|
|
41
|
+
const validation = await collectPublishBlockers(scope.req, {
|
|
44
42
|
doc,
|
|
45
43
|
entity: target
|
|
46
44
|
});
|
|
@@ -48,7 +46,8 @@ Pass exactly one of "collection" and "global". "id" is required with "collection
|
|
|
48
46
|
...target.kind === "collection" ? { id: doc["id"] } : { global: target.slug },
|
|
49
47
|
status: doc["_status"],
|
|
50
48
|
updatedAt: doc["updatedAt"],
|
|
51
|
-
publishBlockers
|
|
49
|
+
publishBlockers: validation.blockers,
|
|
50
|
+
...validation.unavailable ? { publishBlockersUnavailable: true } : {}
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
53
|
});
|
package/dist/types.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CollectionConfig, CollectionSlug, GlobalSlug, PayloadRequest, TypedUser } from "payload";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
4
3
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
4
|
+
import { CallToolResult, ServerNotification, ServerRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
//#region src/types.d.ts
|
|
6
6
|
declare module "payload" {
|
|
7
7
|
interface RequestContext {
|
|
@@ -103,7 +103,11 @@ interface McpxToolScope {
|
|
|
103
103
|
interface McpxTool<Shape extends z.ZodRawShape = z.ZodRawShape, Args = z.infer<z.ZodObject<Shape>>> {
|
|
104
104
|
/** camelCase, unique, not one of the builtin tool names. */
|
|
105
105
|
name: string;
|
|
106
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Fixed text, or text built per request so it can state what this key's
|
|
108
|
+
* writes actually do.
|
|
109
|
+
*/
|
|
110
|
+
description: string | ((scope: McpxToolScope) => string);
|
|
107
111
|
annotations?: ToolAnnotations;
|
|
108
112
|
/**
|
|
109
113
|
* Whether this key may call the tool; a tool that is not enabled never
|
|
@@ -223,5 +227,15 @@ interface McpxRequestContext {
|
|
|
223
227
|
apiKeyId: number | string;
|
|
224
228
|
capabilities: McpxResolvedCapabilities;
|
|
225
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* One reason a human could not publish the draft as it stands.
|
|
232
|
+
*/
|
|
233
|
+
interface PublishBlocker {
|
|
234
|
+
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
235
|
+
field?: string;
|
|
236
|
+
message: string;
|
|
237
|
+
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
238
|
+
path: string;
|
|
239
|
+
}
|
|
226
240
|
//#endregion
|
|
227
|
-
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, defineMcpxTool };
|
|
241
|
+
export { McpxAnyTool, McpxAuthResult, McpxCollectionCapabilities, McpxCollectionOptions, McpxExposedEntity, McpxGlobalOptions, McpxPluginOptions, McpxRequestContext, McpxResolvedCapabilities, McpxTool, McpxToolExtra, McpxToolScope, PublishBlocker, defineMcpxTool };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isMcpxRequest } from "../request.mjs";
|
|
1
2
|
import { APIError } from "payload";
|
|
2
3
|
import { hasDraftsEnabled } from "payload/shared";
|
|
3
4
|
//#region src/write/draft-guard.ts
|
|
@@ -14,11 +15,6 @@ import { hasDraftsEnabled } from "payload/shared";
|
|
|
14
15
|
"overwriteExistingFiles"
|
|
15
16
|
]);
|
|
16
17
|
/**
|
|
17
|
-
* Whether a request originated from the MCP endpoint. The endpoint stamps
|
|
18
|
-
* `req.context.mcpx`, which travels into every local API call made with the
|
|
19
|
-
* same `req`, including those made by custom tools.
|
|
20
|
-
*/ const isMcpxRequest = (req) => req.context.mcpx !== void 0;
|
|
21
|
-
/**
|
|
22
18
|
* Forces every MCP write into a draft save.
|
|
23
19
|
*
|
|
24
20
|
* `draft` alone is not enough: Payload's update path only saves a draft when
|
|
@@ -110,4 +106,4 @@ const refusePublish = ({ collection, data, req }) => {
|
|
|
110
106
|
}
|
|
111
107
|
}));
|
|
112
108
|
//#endregion
|
|
113
|
-
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards,
|
|
109
|
+
export { forceDraftWrite, forceDraftWriteGlobal, installDraftGuards, installGlobalDraftGuards, refusePublish, refusePublishGlobal };
|
package/dist/write/patch.mjs
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
|
-
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, splitPath } from "../schema/walk.mjs";
|
|
2
|
-
import { validateWriteValue } from "../schema/shape.mjs";
|
|
1
|
+
import { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, describeAddressableFields, findBlocksField, joinPath, splitPath } from "../schema/walk.mjs";
|
|
3
2
|
import { resolveDataPointer } from "../schema/pointer.mjs";
|
|
3
|
+
import { validateWriteValue } from "../schema/shape.mjs";
|
|
4
|
+
import "../schema/index.mjs";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { Pointer, applyPatch } from "rfc6902";
|
|
6
7
|
//#region src/write/patch.ts
|
|
8
|
+
const POINTER = z.string().regex(JSON_POINTER_PATTERN);
|
|
7
9
|
/**
|
|
8
|
-
* One RFC 6902 operation as accepted by `patchDocument`.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"add",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
* One RFC 6902 operation as accepted by `patchDocument`. Discriminated on `op`
|
|
11
|
+
* so an operation carries only the members RFC 6902 defines for it.
|
|
12
|
+
*/ const PATCH_OPERATION_SCHEMA = z.discriminatedUnion("op", [
|
|
13
|
+
z.strictObject({
|
|
14
|
+
op: z.literal("add"),
|
|
15
|
+
path: POINTER,
|
|
16
|
+
value: z.unknown()
|
|
17
|
+
}),
|
|
18
|
+
z.strictObject({
|
|
19
|
+
op: z.literal("remove"),
|
|
20
|
+
path: POINTER
|
|
21
|
+
}),
|
|
22
|
+
z.strictObject({
|
|
23
|
+
op: z.literal("replace"),
|
|
24
|
+
path: POINTER,
|
|
25
|
+
value: z.unknown()
|
|
26
|
+
}),
|
|
27
|
+
z.strictObject({
|
|
28
|
+
from: POINTER,
|
|
29
|
+
op: z.literal("move"),
|
|
30
|
+
path: POINTER
|
|
31
|
+
}),
|
|
32
|
+
z.strictObject({
|
|
33
|
+
from: POINTER,
|
|
34
|
+
op: z.literal("copy"),
|
|
35
|
+
path: POINTER
|
|
36
|
+
}),
|
|
37
|
+
z.strictObject({
|
|
38
|
+
op: z.literal("test"),
|
|
39
|
+
path: POINTER,
|
|
40
|
+
value: z.unknown()
|
|
41
|
+
})
|
|
42
|
+
]).describe("An RFC 6902 operation.");
|
|
22
43
|
/**
|
|
23
44
|
* Whether a pointer touches a field Payload maintains.
|
|
24
45
|
*/ const isReservedPointer = (pointer) => pointer.split("/").slice(1).some((segment) => RESERVED_FIELD_NAMES.has(segment));
|
|
@@ -87,59 +108,112 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
87
108
|
return next;
|
|
88
109
|
};
|
|
89
110
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return cloned;
|
|
104
|
-
});
|
|
105
|
-
const problems = applyPatch(next, prepared).flatMap((error, index) => error ? [`patches[${String(index)}]: ${error.message}`] : []);
|
|
106
|
-
if (problems.length > 0) return { problems };
|
|
107
|
-
reconcileRowIds(next, doc);
|
|
108
|
-
return { next };
|
|
111
|
+
* The operation as it is applied: values are cloned so the written document
|
|
112
|
+
* never shares references with the caller's operations, and a `replace` of a
|
|
113
|
+
* field the target locale has no value for becomes an `add`, which is what
|
|
114
|
+
* RFC 6902 requires when nothing is there to replace.
|
|
115
|
+
*/ const prepare = (operation, doc) => {
|
|
116
|
+
const cloned = "value" in operation ? {
|
|
117
|
+
...operation,
|
|
118
|
+
value: structuredClone(operation.value)
|
|
119
|
+
} : operation;
|
|
120
|
+
return cloned.op === "replace" && !isElementPointer(cloned.path) && Pointer.fromJSON(cloned.path).get(doc) === void 0 ? {
|
|
121
|
+
...cloned,
|
|
122
|
+
op: "add"
|
|
123
|
+
} : cloned;
|
|
109
124
|
};
|
|
110
125
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
* The value an operation writes at its path: the one it carries, or the one it
|
|
127
|
+
* takes from `from`. A `remove` writes nothing.
|
|
128
|
+
*/ const effectiveValue = (operation, doc) => {
|
|
129
|
+
if ("value" in operation) return operation.value;
|
|
130
|
+
return "from" in operation ? Pointer.fromJSON(operation.from).get(doc) : void 0;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Whether a resolved pointer lands in a read-only field. A pointer that stops
|
|
134
|
+
* short of one addresses a subtree, and the fields beneath it decide.
|
|
135
|
+
*/ const resolvesReadOnly = (resolution) => {
|
|
136
|
+
if (resolution.descriptor) return resolution.descriptor.readOnly === true;
|
|
137
|
+
const below = describeAddressableFields(resolution.fields).filter((descriptor) => resolution.prefix.every((part, offset) => part === splitPath(descriptor.path)[offset]));
|
|
138
|
+
return below.length > 0 && below.every((descriptor) => descriptor.readOnly);
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Whether the pointer addresses something read-only. An element carries no
|
|
142
|
+
* descriptor of its own, so the field it belongs to is read one segment up.
|
|
143
|
+
*/ const isReadOnlyPointer = (config, target) => resolvesReadOnly(resolveDataPointer(config, {
|
|
144
|
+
doc: target.doc,
|
|
145
|
+
pointer: isElementPointer(target.pointer) ? joinPath(splitPath(target.pointer).slice(0, -1)) : target.pointer,
|
|
146
|
+
ref: target.ref
|
|
147
|
+
}));
|
|
148
|
+
/**
|
|
149
|
+
* Checks one operation against the schema, in the state the document is in
|
|
150
|
+
* when that operation runs. Both pointers must resolve, whatever the operation
|
|
151
|
+
* writes at its path must pass write validation, and what it drops must not sit
|
|
152
|
+
* in a read-only field.
|
|
153
|
+
*/ const findOperationProblems = (config, target) => {
|
|
154
|
+
const { doc, operation, ref } = target;
|
|
155
|
+
const pointers = [operation.path, ..."from" in operation ? [operation.from] : []];
|
|
156
|
+
if (pointers.includes("")) return ["an empty pointer addresses the whole document. Address a field instead."];
|
|
119
157
|
const reserved = pointers.find(isReservedPointer);
|
|
120
|
-
if (reserved !== void 0) return [
|
|
158
|
+
if (reserved !== void 0) return [`"${reserved}" addresses a field Payload maintains. Drafts are the only thing this tool writes, and id, _status, createdAt and updatedAt are not writable.`];
|
|
121
159
|
const dropped = droppedPointer(operation);
|
|
122
|
-
if (dropped !== void 0 && !isElementPointer(dropped)) return [
|
|
123
|
-
const value = "value" in operation ? operation.value : void 0;
|
|
160
|
+
if (dropped !== void 0 && !isElementPointer(dropped)) return [`"${dropped}" is a field, not a list element, and removing it would do nothing. The patched document is written whole, and Payload keeps any field absent from a write rather than clearing it. Use "replace" with null to clear a field, or with [] to empty a list.`];
|
|
124
161
|
try {
|
|
125
|
-
const
|
|
162
|
+
const value = effectiveValue(operation, doc);
|
|
163
|
+
if (value !== void 0 && operation.op !== "test" && isReadOnlyPointer(config, {
|
|
164
|
+
doc,
|
|
165
|
+
pointer: operation.path,
|
|
166
|
+
ref
|
|
167
|
+
})) return [`"${operation.path}" is read-only and cannot be written.`];
|
|
168
|
+
if (dropped !== void 0 && isReadOnlyPointer(config, {
|
|
169
|
+
doc,
|
|
170
|
+
pointer: dropped,
|
|
171
|
+
ref
|
|
172
|
+
})) return [`"${dropped}" sits in a read-only field and cannot be removed.`];
|
|
126
173
|
for (const pointer of pointers) {
|
|
127
174
|
const resolution = resolveDataPointer(config, {
|
|
128
|
-
addedValue: value
|
|
129
|
-
doc
|
|
175
|
+
addedValue: value,
|
|
176
|
+
doc,
|
|
130
177
|
pointer,
|
|
131
|
-
ref
|
|
178
|
+
ref
|
|
132
179
|
});
|
|
133
|
-
if (pointer === operation.path && value !== void 0)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
if (pointer === operation.path && value !== void 0) {
|
|
181
|
+
const problems = validateWriteValue(config, {
|
|
182
|
+
pointer,
|
|
183
|
+
resolution
|
|
184
|
+
}, value);
|
|
185
|
+
if (problems.length > 0) return problems;
|
|
186
|
+
}
|
|
137
187
|
}
|
|
138
188
|
return [];
|
|
139
189
|
} catch (error) {
|
|
140
|
-
return [
|
|
190
|
+
return [error instanceof Error ? error.message : "invalid"];
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Validates and applies every operation against one evolving copy of the
|
|
195
|
+
* document, so an operation that depends on an earlier one resolves against
|
|
196
|
+
* the shape it actually modifies.
|
|
197
|
+
*
|
|
198
|
+
* The copy means a failing operation leaves the original untouched, and the
|
|
199
|
+
* caller writes nothing unless the whole batch came back applied, so a
|
|
200
|
+
* partially applied batch is never persisted.
|
|
201
|
+
*/ const applyPatchOperations = (config, target) => {
|
|
202
|
+
const next = structuredClone(target.doc);
|
|
203
|
+
for (const [index, operation] of target.patches.entries()) {
|
|
204
|
+
const at = `patches[${String(index)}]`;
|
|
205
|
+
const problems = findOperationProblems(config, {
|
|
206
|
+
doc: next,
|
|
207
|
+
operation,
|
|
208
|
+
ref: target.ref
|
|
209
|
+
});
|
|
210
|
+
if (problems.length > 0) return { problems: problems.map((problem) => `${at}: ${problem}`) };
|
|
211
|
+
const [error] = applyPatch(next, [prepare(operation, next)]);
|
|
212
|
+
if (error) return { problems: [`${at}: ${error.message}`] };
|
|
141
213
|
}
|
|
142
|
-
|
|
214
|
+
reconcileRowIds(next, target.doc);
|
|
215
|
+
return { next };
|
|
216
|
+
};
|
|
143
217
|
/**
|
|
144
218
|
* Keys Payload manages on a row that travel back into the write unchanged.
|
|
145
219
|
*/ const ROW_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -216,4 +290,4 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
|
|
|
216
290
|
});
|
|
217
291
|
};
|
|
218
292
|
//#endregion
|
|
219
|
-
export { PATCH_OPERATION_SCHEMA,
|
|
293
|
+
export { PATCH_OPERATION_SCHEMA, applyPatchOperations, buildWriteData, droppedPointer, isElementPointer, isReservedPointer, stripRowIds };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pointerFromPayloadPath } from "../schema/walk.mjs";
|
|
2
|
+
import "../schema/index.mjs";
|
|
2
3
|
import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payload";
|
|
3
4
|
//#region src/write/publish-blockers.ts
|
|
4
5
|
/**
|
|
@@ -18,6 +19,9 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
18
19
|
*
|
|
19
20
|
* Limits: only the locale the doc was read in is checked, and field-level
|
|
20
21
|
* `beforeChange` hooks run again, which is safe only for pure ones.
|
|
22
|
+
*
|
|
23
|
+
* `unavailable` marks a traversal that threw, which is not the same answer as
|
|
24
|
+
* a document with nothing wrong with it.
|
|
21
25
|
*/ const collectPublishBlockers = async (req, target) => {
|
|
22
26
|
const { doc, entity } = target;
|
|
23
27
|
const id = doc["id"];
|
|
@@ -62,13 +66,16 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields } from "payloa
|
|
|
62
66
|
});
|
|
63
67
|
} catch (error) {
|
|
64
68
|
req.payload.logger.warn(`[payloadcms-mcpx] Could not validate the ${entity.slug} draft: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
65
|
-
return
|
|
69
|
+
return {
|
|
70
|
+
blockers: [],
|
|
71
|
+
unavailable: true
|
|
72
|
+
};
|
|
66
73
|
}
|
|
67
|
-
return errors.map((error) => ({
|
|
74
|
+
return { blockers: errors.map((error) => ({
|
|
68
75
|
message: error.message,
|
|
69
76
|
path: pointerFromPayloadPath(error.path),
|
|
70
77
|
...typeof error.label === "string" ? { field: error.label } : {}
|
|
71
|
-
}));
|
|
78
|
+
})) };
|
|
72
79
|
};
|
|
73
80
|
//#endregion
|
|
74
81
|
export { collectPublishBlockers };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@abinnovision/payloadcms-mcpx",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.12",
|
|
5
5
|
"description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"payload",
|
package/dist/i18n.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import { PayloadRequest } from "payload";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import "payload";
|
package/dist/schema/walk.d.mts
DELETED
package/dist/tools/target.d.mts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import "../tools/target.mjs";
|
|
2
|
-
import { PayloadRequest } from "payload";
|
|
3
|
-
//#region src/write/publish-blockers.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* One reason a human could not publish the draft as it stands.
|
|
6
|
-
*/
|
|
7
|
-
interface PublishBlocker {
|
|
8
|
-
/** Resolved field label path, e.g. "Layout > Block 2 (Hero) > Title". */
|
|
9
|
-
field?: string;
|
|
10
|
-
message: string;
|
|
11
|
-
/** JSON Pointer to the offending value, e.g. "/layout/2/title". */
|
|
12
|
-
path: string;
|
|
13
|
-
}
|
|
14
|
-
//#endregion
|
|
15
|
-
export type { PublishBlocker };
|