@next-buildr/payload 0.0.0-stage → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/adapter/adapter.test.d.ts +2 -0
- package/dist/adapter/adapter.test.d.ts.map +1 -0
- package/dist/adapter/adapter.test.js +282 -0
- package/dist/adapter/canvas-data-source.d.ts +9 -0
- package/dist/adapter/canvas-data-source.d.ts.map +1 -0
- package/dist/adapter/canvas-data-source.js +24 -0
- package/dist/adapter/document-adapter.d.ts +20 -0
- package/dist/adapter/document-adapter.d.ts.map +1 -0
- package/dist/adapter/document-adapter.js +183 -0
- package/dist/adapter/http.d.ts +34 -0
- package/dist/adapter/http.d.ts.map +1 -0
- package/dist/adapter/http.js +72 -0
- package/dist/adapter/index.d.ts +4 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +5 -0
- package/dist/admin/index.d.ts +4 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/layout-field-connected.d.ts +6 -0
- package/dist/admin/layout-field-connected.d.ts.map +1 -0
- package/dist/admin/layout-field-connected.js +16 -0
- package/dist/admin/layout-field-view.d.ts +16 -0
- package/dist/admin/layout-field-view.d.ts.map +1 -0
- package/dist/admin/layout-field-view.js +14 -0
- package/dist/admin/layout-field.d.ts +6 -0
- package/dist/admin/layout-field.d.ts.map +1 -0
- package/dist/admin/layout-field.js +13 -0
- package/dist/admin/layout-field.test.d.ts +2 -0
- package/dist/admin/layout-field.test.d.ts.map +1 -0
- package/dist/admin/layout-field.test.js +41 -0
- package/dist/contract.d.ts +289 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +176 -0
- package/dist/data/build-context.d.ts +20 -0
- package/dist/data/build-context.d.ts.map +1 -0
- package/dist/data/build-context.js +25 -0
- package/dist/data/data.test.d.ts +2 -0
- package/dist/data/data.test.d.ts.map +1 -0
- package/dist/data/data.test.js +261 -0
- package/dist/data/index.d.ts +10 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +5 -0
- package/dist/data/normalize.d.ts +14 -0
- package/dist/data/normalize.d.ts.map +1 -0
- package/dist/data/normalize.js +128 -0
- package/dist/data/payload-data-source.d.ts +42 -0
- package/dist/data/payload-data-source.d.ts.map +1 -0
- package/dist/data/payload-data-source.js +179 -0
- package/dist/data/payload-data-source.test.d.ts +2 -0
- package/dist/data/payload-data-source.test.d.ts.map +1 -0
- package/dist/data/payload-data-source.test.js +160 -0
- package/dist/data/resolve-layout.d.ts +45 -0
- package/dist/data/resolve-layout.d.ts.map +1 -0
- package/dist/data/resolve-layout.js +111 -0
- package/dist/data/schema-from-fields.d.ts +63 -0
- package/dist/data/schema-from-fields.d.ts.map +1 -0
- package/dist/data/schema-from-fields.js +178 -0
- package/dist/data/where.d.ts +53 -0
- package/dist/data/where.d.ts.map +1 -0
- package/dist/data/where.js +210 -0
- package/dist/mcp/http-backend.d.ts +35 -0
- package/dist/mcp/http-backend.d.ts.map +1 -0
- package/dist/mcp/http-backend.js +337 -0
- package/dist/mcp/http-backend.test.d.ts +2 -0
- package/dist/mcp/http-backend.test.d.ts.map +1 -0
- package/dist/mcp/http-backend.test.js +178 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/route.d.ts +63 -0
- package/dist/mcp/route.d.ts.map +1 -0
- package/dist/mcp/route.js +230 -0
- package/dist/next/get-document.d.ts +64 -0
- package/dist/next/get-document.d.ts.map +1 -0
- package/dist/next/get-document.js +208 -0
- package/dist/next/get-document.test.d.ts +2 -0
- package/dist/next/get-document.test.d.ts.map +1 -0
- package/dist/next/get-document.test.js +155 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +4 -0
- package/dist/next/next-helpers.test.d.ts +2 -0
- package/dist/next/next-helpers.test.d.ts.map +1 -0
- package/dist/next/next-helpers.test.js +122 -0
- package/dist/next/revalidate.d.ts +44 -0
- package/dist/next/revalidate.d.ts.map +1 -0
- package/dist/next/revalidate.js +45 -0
- package/dist/next/seo.d.ts +43 -0
- package/dist/next/seo.d.ts.map +1 -0
- package/dist/next/seo.js +53 -0
- package/dist/next/tags.d.ts +23 -0
- package/dist/next/tags.d.ts.map +1 -0
- package/dist/next/tags.js +31 -0
- package/dist/plugin/access.d.ts +19 -0
- package/dist/plugin/access.d.ts.map +1 -0
- package/dist/plugin/access.js +32 -0
- package/dist/plugin/collections/form-submissions.d.ts +10 -0
- package/dist/plugin/collections/form-submissions.d.ts.map +1 -0
- package/dist/plugin/collections/form-submissions.js +37 -0
- package/dist/plugin/collections/templates.d.ts +13 -0
- package/dist/plugin/collections/templates.d.ts.map +1 -0
- package/dist/plugin/collections/templates.js +118 -0
- package/dist/plugin/endpoints/access.test.d.ts +2 -0
- package/dist/plugin/endpoints/access.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/access.test.js +91 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts +2 -0
- package/dist/plugin/endpoints/adapter-contract.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/adapter-contract.test.js +45 -0
- package/dist/plugin/endpoints/context.d.ts +65 -0
- package/dist/plugin/endpoints/context.d.ts.map +1 -0
- package/dist/plugin/endpoints/context.js +113 -0
- package/dist/plugin/endpoints/data-context.d.ts +8 -0
- package/dist/plugin/endpoints/data-context.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-context.js +63 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-endpoints.test.js +125 -0
- package/dist/plugin/endpoints/data-query.test.d.ts +2 -0
- package/dist/plugin/endpoints/data-query.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-query.test.js +96 -0
- package/dist/plugin/endpoints/data-schema.d.ts +5 -0
- package/dist/plugin/endpoints/data-schema.d.ts.map +1 -0
- package/dist/plugin/endpoints/data-schema.js +21 -0
- package/dist/plugin/endpoints/data.d.ts +10 -0
- package/dist/plugin/endpoints/data.d.ts.map +1 -0
- package/dist/plugin/endpoints/data.js +96 -0
- package/dist/plugin/endpoints/document.d.ts +11 -0
- package/dist/plugin/endpoints/document.d.ts.map +1 -0
- package/dist/plugin/endpoints/document.js +119 -0
- package/dist/plugin/endpoints/documents.d.ts +10 -0
- package/dist/plugin/endpoints/documents.d.ts.map +1 -0
- package/dist/plugin/endpoints/documents.js +145 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts +42 -0
- package/dist/plugin/endpoints/endpoints.test-kit.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test-kit.js +141 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts +2 -0
- package/dist/plugin/endpoints/endpoints.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/endpoints.test.js +209 -0
- package/dist/plugin/endpoints/forms.d.ts +16 -0
- package/dist/plugin/endpoints/forms.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.js +199 -0
- package/dist/plugin/endpoints/forms.test.d.ts +2 -0
- package/dist/plugin/endpoints/forms.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms.test.js +346 -0
- package/dist/plugin/endpoints/guards.d.ts +9 -0
- package/dist/plugin/endpoints/guards.d.ts.map +1 -0
- package/dist/plugin/endpoints/guards.js +35 -0
- package/dist/plugin/endpoints/index.d.ts +6 -0
- package/dist/plugin/endpoints/index.d.ts.map +1 -0
- package/dist/plugin/endpoints/index.js +32 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales-no-fallback.test.js +30 -0
- package/dist/plugin/endpoints/locales.test.d.ts +2 -0
- package/dist/plugin/endpoints/locales.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/locales.test.js +128 -0
- package/dist/plugin/endpoints/manifest.d.ts +5 -0
- package/dist/plugin/endpoints/manifest.d.ts.map +1 -0
- package/dist/plugin/endpoints/manifest.js +18 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-http-backend.test.js +84 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-off.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-off.test.js +33 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-publish.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-publish.test.js +42 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp-route.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp-route.test.js +132 -0
- package/dist/plugin/endpoints/mcp.test.d.ts +2 -0
- package/dist/plugin/endpoints/mcp.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/mcp.test.js +231 -0
- package/dist/plugin/endpoints/media.d.ts +7 -0
- package/dist/plugin/endpoints/media.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.js +115 -0
- package/dist/plugin/endpoints/media.test.d.ts +2 -0
- package/dist/plugin/endpoints/media.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/media.test.js +95 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts +2 -0
- package/dist/plugin/endpoints/publish-a11y.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish-a11y.test.js +27 -0
- package/dist/plugin/endpoints/publish.d.ts +9 -0
- package/dist/plugin/endpoints/publish.d.ts.map +1 -0
- package/dist/plugin/endpoints/publish.js +81 -0
- package/dist/plugin/endpoints/rename.d.ts +9 -0
- package/dist/plugin/endpoints/rename.d.ts.map +1 -0
- package/dist/plugin/endpoints/rename.js +47 -0
- package/dist/plugin/endpoints/respond.d.ts +9 -0
- package/dist/plugin/endpoints/respond.d.ts.map +1 -0
- package/dist/plugin/endpoints/respond.js +10 -0
- package/dist/plugin/endpoints/samples.d.ts +5 -0
- package/dist/plugin/endpoints/samples.d.ts.map +1 -0
- package/dist/plugin/endpoints/samples.js +42 -0
- package/dist/plugin/endpoints/save.d.ts +9 -0
- package/dist/plugin/endpoints/save.d.ts.map +1 -0
- package/dist/plugin/endpoints/save.js +58 -0
- package/dist/plugin/endpoints/session.d.ts +5 -0
- package/dist/plugin/endpoints/session.d.ts.map +1 -0
- package/dist/plugin/endpoints/session.js +30 -0
- package/dist/plugin/endpoints/templates.test.d.ts +2 -0
- package/dist/plugin/endpoints/templates.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/templates.test.js +191 -0
- package/dist/plugin/fields.d.ts +28 -0
- package/dist/plugin/fields.d.ts.map +1 -0
- package/dist/plugin/fields.js +62 -0
- package/dist/plugin/forms/forms.test.d.ts +2 -0
- package/dist/plugin/forms/forms.test.d.ts.map +1 -0
- package/dist/plugin/forms/forms.test.js +88 -0
- package/dist/plugin/forms/notify.d.ts +6 -0
- package/dist/plugin/forms/notify.d.ts.map +1 -0
- package/dist/plugin/forms/notify.js +13 -0
- package/dist/plugin/forms/rate-limit.d.ts +24 -0
- package/dist/plugin/forms/rate-limit.d.ts.map +1 -0
- package/dist/plugin/forms/rate-limit.js +36 -0
- package/dist/plugin/forms/validate.d.ts +22 -0
- package/dist/plugin/forms/validate.d.ts.map +1 -0
- package/dist/plugin/forms/validate.js +79 -0
- package/dist/plugin/hooks/layout-hook.d.ts +9 -0
- package/dist/plugin/hooks/layout-hook.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.js +33 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts +2 -0
- package/dist/plugin/hooks/layout-hook.test.d.ts.map +1 -0
- package/dist/plugin/hooks/layout-hook.test.js +191 -0
- package/dist/plugin/hooks/process-layout.d.ts +32 -0
- package/dist/plugin/hooks/process-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/process-layout.js +68 -0
- package/dist/plugin/hooks/read-layout.d.ts +17 -0
- package/dist/plugin/hooks/read-layout.d.ts.map +1 -0
- package/dist/plugin/hooks/read-layout.js +39 -0
- package/dist/plugin/index.d.ts +18 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +105 -0
- package/dist/plugin/locales.d.ts +32 -0
- package/dist/plugin/locales.d.ts.map +1 -0
- package/dist/plugin/locales.js +56 -0
- package/dist/plugin/options.d.ts +77 -0
- package/dist/plugin/options.d.ts.map +1 -0
- package/dist/plugin/options.js +123 -0
- package/dist/plugin/plugin.test.d.ts +2 -0
- package/dist/plugin/plugin.test.d.ts.map +1 -0
- package/dist/plugin/plugin.test.js +186 -0
- package/dist/plugin/write-guard.d.ts +14 -0
- package/dist/plugin/write-guard.d.ts.map +1 -0
- package/dist/plugin/write-guard.js +18 -0
- package/package.json +108 -3
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { defaultTheme, fromManifest, ok, parseDocument, } from '@next-buildr/core';
|
|
2
|
+
import { createDocumentInputSchema, listDocumentsQuerySchema, listMediaQuerySchema, mcpFail, sessionSchema, } from '@next-buildr/mcp';
|
|
3
|
+
import { conflictResponseSchema, createDocumentResponseSchema, DOCUMENT_LIST_PAGE_SIZE, dataSchemaResponseSchema, documentListResponseSchema, documentResponseSchema, errorResponseSchema, invalidResponseSchema, mediaListResponseSchema, publishResponseSchema, saveResponseSchema, sessionResponseSchema, } from '../contract.js';
|
|
4
|
+
const MAX_GATHER_PAGES = 25;
|
|
5
|
+
const RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
6
|
+
const seg = (value) => encodeURIComponent(String(value));
|
|
7
|
+
const documentPath = (ref) => `/buildr/documents/${seg(ref.collection)}/${seg(ref.id)}`;
|
|
8
|
+
const diagnostic = (message) => ({
|
|
9
|
+
code: 'mcp.invalid-input',
|
|
10
|
+
message,
|
|
11
|
+
severity: 'error',
|
|
12
|
+
});
|
|
13
|
+
/** Diagnostics for input a Zod schema refused. */
|
|
14
|
+
const zodDiagnostics = (error) => error.issues.map((issue) => ({
|
|
15
|
+
code: 'mcp.invalid-input',
|
|
16
|
+
message: issue.message,
|
|
17
|
+
severity: 'error',
|
|
18
|
+
path: issue.path.filter((part) => typeof part !== 'symbol'),
|
|
19
|
+
}));
|
|
20
|
+
const invalid = (message, diagnostics = [diagnostic(message)]) => mcpFail({ code: 'invalid', message, diagnostics });
|
|
21
|
+
/**
|
|
22
|
+
* The `McpBackend` of `@next-buildr/mcp` over the builder API of a site running the Payload plugin
|
|
23
|
+
* (docs/mcp.md#http-backend). Authenticates with a Payload API key; every response is checked
|
|
24
|
+
* against the contract and documents with `parseDocument`. Only GETs are retried. The API key is
|
|
25
|
+
* sent as a header only and is scrubbed from every message this backend produces.
|
|
26
|
+
*/
|
|
27
|
+
export function createPayloadMcpBackend(options) {
|
|
28
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
29
|
+
const authHeader = `${options.collection ?? 'users'} API-Key ${options.apiKey}`;
|
|
30
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
31
|
+
const retries = options.retries ?? 2;
|
|
32
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
33
|
+
const theme = options.theme ?? defaultTheme;
|
|
34
|
+
const siteOrigin = (() => {
|
|
35
|
+
try {
|
|
36
|
+
return (options.siteUrl ?? new URL(base).origin).replace(/\/+$/, '');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
/** Every message that leaves this module passes here: the key must never show up in one. */
|
|
43
|
+
const redact = (text) => options.apiKey === '' ? text : text.split(options.apiKey).join('***');
|
|
44
|
+
const network = (message, retryable) => mcpFail({ code: 'network', message: redact(message), retryable });
|
|
45
|
+
async function attempt(method, path, init) {
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
for (const [key, value] of Object.entries(init.query ?? {})) {
|
|
48
|
+
if (value !== undefined)
|
|
49
|
+
params.set(key, value);
|
|
50
|
+
}
|
|
51
|
+
const search = params.toString();
|
|
52
|
+
const url = `${base}${path}${search === '' ? '' : `?${search}`}`;
|
|
53
|
+
try {
|
|
54
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
55
|
+
const response = await doFetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
authorization: authHeader,
|
|
59
|
+
accept: 'application/json',
|
|
60
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
61
|
+
},
|
|
62
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
63
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
64
|
+
});
|
|
65
|
+
const text = await response.text().catch(() => '');
|
|
66
|
+
let body;
|
|
67
|
+
if (text !== '') {
|
|
68
|
+
try {
|
|
69
|
+
body = JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
body = undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const retryAfter = Number(response.headers.get('retry-after'));
|
|
76
|
+
return {
|
|
77
|
+
status: response.status,
|
|
78
|
+
body,
|
|
79
|
+
retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// The cause is dropped on purpose: it is not needed to act on and could echo request details.
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Sends a request; only GETs are retried. `undefined` means the server never answered. */
|
|
88
|
+
async function send(method, path, init = {}) {
|
|
89
|
+
const maxRetries = method === 'GET' ? retries : 0;
|
|
90
|
+
for (let n = 0;; n += 1) {
|
|
91
|
+
const reply = await attempt(method, path, init);
|
|
92
|
+
const retryable = reply === undefined || RETRY_STATUSES.has(reply.status);
|
|
93
|
+
if (!retryable || n >= maxRetries)
|
|
94
|
+
return reply;
|
|
95
|
+
const hinted = reply?.retryAfter;
|
|
96
|
+
await sleep(Math.min(hinted === undefined ? 200 * 2 ** n : hinted * 1000, 5_000));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const messageOf = (reply, fallback) => {
|
|
100
|
+
const parsed = errorResponseSchema.safeParse(reply.body);
|
|
101
|
+
return redact(parsed.success ? parsed.data.error : fallback);
|
|
102
|
+
};
|
|
103
|
+
/** The failure of a reply that is not the success the caller expects. */
|
|
104
|
+
function refusal(reply, what) {
|
|
105
|
+
if (reply === undefined)
|
|
106
|
+
return network(`The site could not be reached (${what}).`, true);
|
|
107
|
+
switch (reply.status) {
|
|
108
|
+
case 401:
|
|
109
|
+
return mcpFail({
|
|
110
|
+
code: 'forbidden',
|
|
111
|
+
message: 'The API key was not accepted. Check the key and that its user has API access enabled.',
|
|
112
|
+
});
|
|
113
|
+
case 403:
|
|
114
|
+
return mcpFail({
|
|
115
|
+
code: 'forbidden',
|
|
116
|
+
message: messageOf(reply, 'This user may not do that.'),
|
|
117
|
+
});
|
|
118
|
+
case 404:
|
|
119
|
+
return mcpFail({
|
|
120
|
+
code: 'not-found',
|
|
121
|
+
message: messageOf(reply, `Not found (${what}).`),
|
|
122
|
+
});
|
|
123
|
+
case 409: {
|
|
124
|
+
const parsed = conflictResponseSchema.safeParse(reply.body);
|
|
125
|
+
if (parsed.success) {
|
|
126
|
+
return mcpFail({
|
|
127
|
+
code: 'conflict',
|
|
128
|
+
message: `The document was changed by somebody else; it is now at revision ${parsed.data.currentRevision}. Reload it and re-apply your changes.`,
|
|
129
|
+
currentRevision: parsed.data.currentRevision,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return network('The site reported a conflict without a revision.', false);
|
|
133
|
+
}
|
|
134
|
+
case 400:
|
|
135
|
+
return invalid(messageOf(reply, 'The request was not valid.'));
|
|
136
|
+
case 422: {
|
|
137
|
+
const parsed = invalidResponseSchema.safeParse(reply.body);
|
|
138
|
+
if (parsed.success && parsed.data.diagnostics.length > 0) {
|
|
139
|
+
return invalid(redact(parsed.data.diagnostics[0]?.message ?? 'The document is not valid.'), parsed.data.diagnostics.map((d) => ({
|
|
140
|
+
...d,
|
|
141
|
+
message: redact(d.message),
|
|
142
|
+
})));
|
|
143
|
+
}
|
|
144
|
+
return invalid(messageOf(reply, 'The document is not valid.'));
|
|
145
|
+
}
|
|
146
|
+
case 429:
|
|
147
|
+
return network(`Too many requests${reply.retryAfter === undefined ? '' : `; retry in ${reply.retryAfter} s`}.`, true);
|
|
148
|
+
default:
|
|
149
|
+
return network(`The site answered ${reply.status} (${what}).`, reply.status >= 500);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** The body of `reply` when it has `status` and matches `schema`, else a failure. */
|
|
153
|
+
function expectBody(reply, schema, what, status = 200) {
|
|
154
|
+
if (reply === undefined || reply.status !== status)
|
|
155
|
+
return refusal(reply, what);
|
|
156
|
+
const parsed = schema.safeParse(reply.body);
|
|
157
|
+
if (!parsed.success) {
|
|
158
|
+
return network(`The site's answer does not match the builder contract (${what}).`, false);
|
|
159
|
+
}
|
|
160
|
+
return ok(parsed.data);
|
|
161
|
+
}
|
|
162
|
+
const summaryOf = (doc) => ({
|
|
163
|
+
ref: doc.ref,
|
|
164
|
+
title: doc.title,
|
|
165
|
+
slug: doc.slug,
|
|
166
|
+
status: doc.status,
|
|
167
|
+
updatedAt: doc.updatedAt,
|
|
168
|
+
revision: doc.revision,
|
|
169
|
+
});
|
|
170
|
+
/** One server page (20 documents) of one collection. */
|
|
171
|
+
async function fetchPage(collection, search, page) {
|
|
172
|
+
const reply = await send('GET', '/buildr/documents', {
|
|
173
|
+
query: { collection, search, page: String(page) },
|
|
174
|
+
});
|
|
175
|
+
return expectBody(reply, documentListResponseSchema, 'listing documents');
|
|
176
|
+
}
|
|
177
|
+
async function listDocuments(input) {
|
|
178
|
+
const query = listDocumentsQuerySchema.safeParse(input ?? {});
|
|
179
|
+
if (!query.success)
|
|
180
|
+
return invalid('The query is not valid.', zodDiagnostics(query.error));
|
|
181
|
+
const { collection, search, status, page, limit } = query.data;
|
|
182
|
+
const collections = collection === undefined ? [...(options.collections ?? [])] : [collection];
|
|
183
|
+
if (collections.length === 0)
|
|
184
|
+
return invalid('Pass a collection to list documents from.');
|
|
185
|
+
const start = (page - 1) * limit;
|
|
186
|
+
const finish = (items, total) => ok({ items, page, total, totalPages: Math.ceil(total / limit) });
|
|
187
|
+
// A status filter or several collections need everything (capped); one collection is windowed.
|
|
188
|
+
if (collections.length > 1 || status !== undefined) {
|
|
189
|
+
const all = [];
|
|
190
|
+
for (const name of collections) {
|
|
191
|
+
for (let n = 1; n <= MAX_GATHER_PAGES; n += 1) {
|
|
192
|
+
const result = await fetchPage(name, search, n);
|
|
193
|
+
if (!result.ok) {
|
|
194
|
+
if (result.error.code === 'not-found')
|
|
195
|
+
break;
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
all.push(...result.value.items.map(summaryOf));
|
|
199
|
+
if (n >= result.value.totalPages)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const filtered = (status === undefined ? all : all.filter((d) => d.status === status)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
204
|
+
return finish(filtered.slice(start, start + limit), filtered.length);
|
|
205
|
+
}
|
|
206
|
+
const name = collections[0];
|
|
207
|
+
const first = Math.floor(start / DOCUMENT_LIST_PAGE_SIZE) + 1;
|
|
208
|
+
const last = Math.floor((start + limit - 1) / DOCUMENT_LIST_PAGE_SIZE) + 1;
|
|
209
|
+
const items = [];
|
|
210
|
+
let totalPages = 0;
|
|
211
|
+
let lastCount = 0;
|
|
212
|
+
let lastSeen = 0;
|
|
213
|
+
for (let n = first; n <= last; n += 1) {
|
|
214
|
+
const result = await fetchPage(name, search, n);
|
|
215
|
+
if (!result.ok) {
|
|
216
|
+
// A collection the builder does not know has no documents.
|
|
217
|
+
if (result.error.code === 'not-found')
|
|
218
|
+
return finish([], 0);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
totalPages = result.value.totalPages;
|
|
222
|
+
if (n > totalPages)
|
|
223
|
+
break;
|
|
224
|
+
items.push(...result.value.items.map(summaryOf));
|
|
225
|
+
lastSeen = n;
|
|
226
|
+
lastCount = result.value.items.length;
|
|
227
|
+
if (n >= totalPages)
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (totalPages === 0)
|
|
231
|
+
return finish([], 0);
|
|
232
|
+
if (lastSeen !== totalPages) {
|
|
233
|
+
// The exact total needs the size of the last server page.
|
|
234
|
+
const tail = await fetchPage(name, search, totalPages);
|
|
235
|
+
if (!tail.ok)
|
|
236
|
+
return tail;
|
|
237
|
+
lastCount = tail.value.items.length;
|
|
238
|
+
}
|
|
239
|
+
const total = (totalPages - 1) * DOCUMENT_LIST_PAGE_SIZE + lastCount;
|
|
240
|
+
const offset = start - (first - 1) * DOCUMENT_LIST_PAGE_SIZE;
|
|
241
|
+
return finish(items.slice(offset, offset + limit), total);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
async getSession() {
|
|
245
|
+
const reply = await send('GET', '/buildr/session');
|
|
246
|
+
const body = expectBody(reply, sessionResponseSchema, 'reading the session');
|
|
247
|
+
if (!body.ok)
|
|
248
|
+
return body;
|
|
249
|
+
const parsed = sessionSchema.safeParse(body.value);
|
|
250
|
+
if (!parsed.success) {
|
|
251
|
+
return network('The session does not match the builder contract.', false);
|
|
252
|
+
}
|
|
253
|
+
return ok(parsed.data);
|
|
254
|
+
},
|
|
255
|
+
async getManifest() {
|
|
256
|
+
const reply = await send('GET', '/buildr/manifest');
|
|
257
|
+
if (reply === undefined || reply.status !== 200) {
|
|
258
|
+
return refusal(reply, 'reading the manifest');
|
|
259
|
+
}
|
|
260
|
+
const manifest = fromManifest(reply.body);
|
|
261
|
+
if (!manifest.ok)
|
|
262
|
+
return network('The manifest does not match the builder contract.', false);
|
|
263
|
+
return ok(manifest.value);
|
|
264
|
+
},
|
|
265
|
+
async getTheme() {
|
|
266
|
+
return ok(theme);
|
|
267
|
+
},
|
|
268
|
+
listDocuments,
|
|
269
|
+
async createDocument(input) {
|
|
270
|
+
const parsed = createDocumentInputSchema.safeParse(input);
|
|
271
|
+
if (!parsed.success)
|
|
272
|
+
return invalid('The input is not valid.', zodDiagnostics(parsed.error));
|
|
273
|
+
const reply = await send('POST', '/buildr/documents', { body: parsed.data });
|
|
274
|
+
const body = expectBody(reply, createDocumentResponseSchema, 'creating a document', 201);
|
|
275
|
+
return body.ok ? ok(summaryOf(body.value)) : body;
|
|
276
|
+
},
|
|
277
|
+
async load(ref, loadOptions) {
|
|
278
|
+
const reply = await send('GET', documentPath(ref), {
|
|
279
|
+
query: { draft: '1', locale: loadOptions?.locale },
|
|
280
|
+
});
|
|
281
|
+
const body = expectBody(reply, documentResponseSchema, 'loading the document');
|
|
282
|
+
if (!body.ok)
|
|
283
|
+
return body;
|
|
284
|
+
const doc = parseDocument(body.value.document);
|
|
285
|
+
if (!doc.ok)
|
|
286
|
+
return invalid('The stored document is not valid.', doc.error);
|
|
287
|
+
const { document: _raw, ...rest } = body.value;
|
|
288
|
+
const { readOnly, ...fields } = rest;
|
|
289
|
+
return ok({
|
|
290
|
+
...fields,
|
|
291
|
+
document: doc.value,
|
|
292
|
+
...(readOnly === undefined ? {} : { readOnly }),
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
async save(ref, doc, baseRevision) {
|
|
296
|
+
const parsed = parseDocument(doc);
|
|
297
|
+
if (!parsed.ok)
|
|
298
|
+
return invalid('The document is not valid.', parsed.error);
|
|
299
|
+
const reply = await send('PUT', documentPath(ref), {
|
|
300
|
+
body: { document: parsed.value, baseRevision, autosave: false },
|
|
301
|
+
});
|
|
302
|
+
return expectBody(reply, saveResponseSchema, 'saving the document');
|
|
303
|
+
},
|
|
304
|
+
async publish(ref, baseRevision) {
|
|
305
|
+
const reply = await send('POST', `${documentPath(ref)}/publish`, { body: { baseRevision } });
|
|
306
|
+
return expectBody(reply, publishResponseSchema, 'publishing the document');
|
|
307
|
+
},
|
|
308
|
+
async getDataSchema(collection) {
|
|
309
|
+
const reply = await send('GET', `/buildr/data-schema/${seg(collection)}`);
|
|
310
|
+
return expectBody(reply, dataSchemaResponseSchema, 'reading the data schema');
|
|
311
|
+
},
|
|
312
|
+
async listMedia(input) {
|
|
313
|
+
const query = listMediaQuerySchema.safeParse(input ?? {});
|
|
314
|
+
if (!query.success)
|
|
315
|
+
return invalid('The query is not valid.', zodDiagnostics(query.error));
|
|
316
|
+
const reply = await send('GET', '/buildr/media', {
|
|
317
|
+
query: {
|
|
318
|
+
search: query.data.search === '' ? undefined : query.data.search,
|
|
319
|
+
type: query.data.type,
|
|
320
|
+
page: String(query.data.page),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// A site without a media collection has no media.
|
|
324
|
+
if (reply?.status === 404)
|
|
325
|
+
return ok({ items: [], page: query.data.page, totalPages: 0 });
|
|
326
|
+
return expectBody(reply, mediaListResponseSchema, 'listing media');
|
|
327
|
+
},
|
|
328
|
+
async previewUrl(ref) {
|
|
329
|
+
const reply = await send('GET', documentPath(ref), { query: { draft: '1' } });
|
|
330
|
+
const body = expectBody(reply, documentResponseSchema, 'resolving the preview');
|
|
331
|
+
if (!body.ok)
|
|
332
|
+
return body;
|
|
333
|
+
const path = body.value.previewPath;
|
|
334
|
+
return ok(path === null ? null : `${siteOrigin}${path}`);
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-backend.test.d.ts","sourceRoot":"","sources":["../../src/mcp/http-backend.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createEmptyDocument } from '@next-buildr/core';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createPayloadMcpBackend } from './http-backend.js';
|
|
4
|
+
const KEY = 'super-secret-api-key-123';
|
|
5
|
+
const json = (status, body, headers = {}) => new Response(JSON.stringify(body), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
8
|
+
});
|
|
9
|
+
const summary = (id) => ({
|
|
10
|
+
ref: { collection: 'pages', id: String(id) },
|
|
11
|
+
title: `Page ${id}`,
|
|
12
|
+
slug: `page-${id}`,
|
|
13
|
+
status: 'draft',
|
|
14
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
15
|
+
revision: 0,
|
|
16
|
+
layoutSource: 'builtin',
|
|
17
|
+
previewPath: null,
|
|
18
|
+
});
|
|
19
|
+
const ref = { collection: 'pages', id: '1' };
|
|
20
|
+
function setup(handler, o = {}) {
|
|
21
|
+
const requests = [];
|
|
22
|
+
const fetch = vi.fn(async (input, init) => {
|
|
23
|
+
const request = new Request(input, init);
|
|
24
|
+
requests.push(request);
|
|
25
|
+
return handler(request);
|
|
26
|
+
});
|
|
27
|
+
const backend = createPayloadMcpBackend({
|
|
28
|
+
baseUrl: 'https://site.test/api/',
|
|
29
|
+
apiKey: KEY,
|
|
30
|
+
fetch: fetch,
|
|
31
|
+
sleep: async () => { },
|
|
32
|
+
...o,
|
|
33
|
+
});
|
|
34
|
+
return { backend, requests, fetch };
|
|
35
|
+
}
|
|
36
|
+
describe('createPayloadMcpBackend', () => {
|
|
37
|
+
it('sends the key as an API-Key header and never in the URL', async () => {
|
|
38
|
+
const { backend, requests } = setup(() => json(200, {
|
|
39
|
+
user: { id: 1 },
|
|
40
|
+
permissions: { canEdit: true, canPublish: false, canUnlockTemplates: false },
|
|
41
|
+
limits: { maxNodes: 10, maxBytes: 10 },
|
|
42
|
+
}));
|
|
43
|
+
expect((await backend.getSession()).ok).toBe(true);
|
|
44
|
+
expect(requests[0]?.headers.get('authorization')).toBe(`users API-Key ${KEY}`);
|
|
45
|
+
expect(requests[0]?.url).toBe('https://site.test/api/buildr/session');
|
|
46
|
+
expect(requests[0]?.url).not.toContain(KEY);
|
|
47
|
+
});
|
|
48
|
+
it('uses the configured auth collection', async () => {
|
|
49
|
+
const { backend, requests } = setup(() => json(404, {}), { collection: 'agents' });
|
|
50
|
+
await backend.getDataSchema('pages');
|
|
51
|
+
expect(requests[0]?.headers.get('authorization')).toBe(`agents API-Key ${KEY}`);
|
|
52
|
+
});
|
|
53
|
+
it('never leaks the key: not from thrown fetch errors, server messages or diagnostics', async () => {
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const handler of [
|
|
56
|
+
() => {
|
|
57
|
+
throw new Error(`connect failed with Authorization: users API-Key ${KEY}`);
|
|
58
|
+
},
|
|
59
|
+
() => json(500, { error: `boom ${KEY}` }),
|
|
60
|
+
() => json(403, { error: `denied for ${KEY}` }),
|
|
61
|
+
() => json(400, { error: `bad ${KEY}` }),
|
|
62
|
+
() => json(404, { error: `missing ${KEY}` }),
|
|
63
|
+
() => json(422, { diagnostics: [{ code: 'x', message: `no ${KEY}`, severity: 'error' }] }),
|
|
64
|
+
() => json(401, { error: KEY }),
|
|
65
|
+
() => new Response(`<html>${KEY}</html>`, { status: 200 }),
|
|
66
|
+
]) {
|
|
67
|
+
const { backend } = setup(handler, { retries: 0 });
|
|
68
|
+
results.push(await backend.load(ref), await backend.save(ref, createEmptyDocument(), 0));
|
|
69
|
+
}
|
|
70
|
+
expect(results.every((r) => !r.ok)).toBe(true);
|
|
71
|
+
expect(JSON.stringify(results)).not.toContain(KEY);
|
|
72
|
+
});
|
|
73
|
+
it('retries GETs with backoff on network errors and 503, then succeeds', async () => {
|
|
74
|
+
const sleep = vi.fn(async (_ms) => { });
|
|
75
|
+
let calls = 0;
|
|
76
|
+
const { backend } = setup(() => {
|
|
77
|
+
calls += 1;
|
|
78
|
+
if (calls === 1)
|
|
79
|
+
throw new Error('reset');
|
|
80
|
+
if (calls === 2)
|
|
81
|
+
return json(503, {});
|
|
82
|
+
return json(200, { items: [], page: 1, totalPages: 0 });
|
|
83
|
+
}, { sleep, retries: 2 });
|
|
84
|
+
expect((await backend.listDocuments({ collection: 'pages' })).ok).toBe(true);
|
|
85
|
+
expect(calls).toBe(3);
|
|
86
|
+
expect(sleep.mock.calls.map((c) => c[0])).toEqual([200, 400]);
|
|
87
|
+
});
|
|
88
|
+
it('reports a retryable network error once retries are exhausted', async () => {
|
|
89
|
+
const { backend, fetch } = setup(() => json(503, {}), { retries: 1 });
|
|
90
|
+
const result = await backend.load(ref);
|
|
91
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
92
|
+
expect(!result.ok && result.error).toMatchObject({ code: 'network', retryable: true });
|
|
93
|
+
});
|
|
94
|
+
it('never retries writes', async () => {
|
|
95
|
+
const { backend, fetch } = setup(() => json(503, {}), { retries: 3 });
|
|
96
|
+
await backend.save(ref, createEmptyDocument(), 0);
|
|
97
|
+
await backend.publish(ref, 0);
|
|
98
|
+
await backend.createDocument({ collection: 'pages', title: 'x' });
|
|
99
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
100
|
+
});
|
|
101
|
+
it('maps 409 to conflict with the current revision and 429 to a retryable network error', async () => {
|
|
102
|
+
const conflict = setup(() => json(409, { currentRevision: 7 })).backend;
|
|
103
|
+
const saved = await conflict.save(ref, createEmptyDocument(), 3);
|
|
104
|
+
expect(!saved.ok && saved.error).toMatchObject({ code: 'conflict', currentRevision: 7 });
|
|
105
|
+
const limited = setup(() => json(429, { error: 'slow down' }, { 'retry-after': '7' })).backend;
|
|
106
|
+
const created = await limited.createDocument({ collection: 'pages', title: 'x' });
|
|
107
|
+
expect(!created.ok && created.error).toMatchObject({ code: 'network', retryable: true });
|
|
108
|
+
});
|
|
109
|
+
it('maps 422 to invalid with the server diagnostics', async () => {
|
|
110
|
+
const { backend } = setup(() => json(422, {
|
|
111
|
+
diagnostics: [
|
|
112
|
+
{ code: 'doc.bad', message: 'Bad node.', severity: 'error', path: ['nodes', 'a'] },
|
|
113
|
+
],
|
|
114
|
+
}));
|
|
115
|
+
const result = await backend.save(ref, createEmptyDocument(), 0);
|
|
116
|
+
expect(!result.ok && result.error).toMatchObject({
|
|
117
|
+
code: 'invalid',
|
|
118
|
+
diagnostics: [{ code: 'doc.bad', path: ['nodes', 'a'] }],
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it('rejects an answer that breaks the contract without retrying', async () => {
|
|
122
|
+
const { backend, fetch } = setup(() => json(200, { unexpected: true }));
|
|
123
|
+
const result = await backend.getSession();
|
|
124
|
+
expect(!result.ok && result.error).toMatchObject({ code: 'network', retryable: false });
|
|
125
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
it('validates input before sending anything', async () => {
|
|
128
|
+
const { backend, fetch } = setup(() => json(200, {}));
|
|
129
|
+
expect((await backend.listDocuments({ limit: 0 })).ok).toBe(false);
|
|
130
|
+
expect((await backend.createDocument({ collection: 'pages', title: '' })).ok).toBe(false);
|
|
131
|
+
expect((await backend.save(ref, { nonsense: true }, 0)).ok).toBe(false);
|
|
132
|
+
expect((await backend.listDocuments()).ok).toBe(false); // no collection, none configured
|
|
133
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
it('windows a listing over the 20-per-page server pages and reports the exact total', async () => {
|
|
136
|
+
const { backend, requests } = setup((request) => {
|
|
137
|
+
const page = Number(new URL(request.url).searchParams.get('page'));
|
|
138
|
+
const count = page === 3 ? 5 : 20;
|
|
139
|
+
const items = Array.from({ length: count }, (_, i) => summary((page - 1) * 20 + i + 1));
|
|
140
|
+
return json(200, { items, page, totalPages: 3 });
|
|
141
|
+
});
|
|
142
|
+
const result = await backend.listDocuments({ collection: 'pages', page: 2, limit: 30 });
|
|
143
|
+
expect(result.ok && result.value.total).toBe(45);
|
|
144
|
+
expect(result.ok && result.value.totalPages).toBe(2);
|
|
145
|
+
expect(result.ok && result.value.items.map((i) => i.ref.id)).toEqual(Array.from({ length: 15 }, (_, i) => String(31 + i)));
|
|
146
|
+
expect(requests.map((r) => new URL(r.url).searchParams.get('page'))).toEqual(['2', '3']);
|
|
147
|
+
});
|
|
148
|
+
it('lists the configured collections when none is given, filtered by status', async () => {
|
|
149
|
+
const { backend } = setup((request) => {
|
|
150
|
+
const collection = new URL(request.url).searchParams.get('collection');
|
|
151
|
+
const draft = { ...summary(collection === 'pages' ? 1 : 2), ref: { collection, id: '1' } };
|
|
152
|
+
const live = { ...summary(3), status: 'published', ref: { collection, id: '3' } };
|
|
153
|
+
return json(200, { items: [draft, live], page: 1, totalPages: 1 });
|
|
154
|
+
}, { collections: ['pages', 'posts'] });
|
|
155
|
+
const all = await backend.listDocuments();
|
|
156
|
+
expect(all.ok && all.value.total).toBe(4);
|
|
157
|
+
const drafts = await backend.listDocuments({ status: 'draft' });
|
|
158
|
+
expect(drafts.ok && drafts.value.items.every((i) => i.status === 'draft')).toBe(true);
|
|
159
|
+
expect(drafts.ok && drafts.value.total).toBe(2);
|
|
160
|
+
});
|
|
161
|
+
it('builds the preview URL from the site origin', async () => {
|
|
162
|
+
const { backend } = setup(() => json(200, {
|
|
163
|
+
ref,
|
|
164
|
+
title: 'Home',
|
|
165
|
+
slug: 'home',
|
|
166
|
+
status: 'draft',
|
|
167
|
+
updatedAt: 'x',
|
|
168
|
+
revision: 0,
|
|
169
|
+
document: createEmptyDocument(),
|
|
170
|
+
contextRef: 'pages:1',
|
|
171
|
+
layoutSource: 'document',
|
|
172
|
+
layoutRef: 'pages:1',
|
|
173
|
+
previewPath: '/home',
|
|
174
|
+
}));
|
|
175
|
+
const url = await backend.previewUrl(ref);
|
|
176
|
+
expect(url.ok && url.value).toBe('https://site.test/home');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mcp/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,KAAK,wBAAwB,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type RegistryMeta, type Theme } from '@next-buildr/core';
|
|
2
|
+
import { type Payload } from 'payload';
|
|
3
|
+
import { type RateLimiter } from '../plugin/forms/rate-limit.ts';
|
|
4
|
+
/**
|
|
5
|
+
* `@next-buildr/payload/mcp/route`: the remote MCP server of a site (ADR-024, docs/mcp.md). A Next.js
|
|
6
|
+
* route handler over the SDK's Streamable HTTP transport in its stateless request/response mode.
|
|
7
|
+
* It is the only file of `payload/mcp` that may import `payload`.
|
|
8
|
+
*/
|
|
9
|
+
export interface BuildrMcpRouteOptions {
|
|
10
|
+
/** The Payload instance, or a function that returns it (`() => getPayload({ config })`). */
|
|
11
|
+
readonly payload: Payload | (() => Promise<Payload>);
|
|
12
|
+
/** The site's registry; its manifest is served when the plugin was configured without one. */
|
|
13
|
+
readonly registry: {
|
|
14
|
+
readonly meta: RegistryMeta;
|
|
15
|
+
};
|
|
16
|
+
/** The site's theme (default: the default theme). */
|
|
17
|
+
readonly theme?: Theme;
|
|
18
|
+
/** Lets agents call `publish` (they still need `mcp.allowPublish` and `access.publish`). Default false. */
|
|
19
|
+
readonly allowPublish?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Origins (besides the site's own and Payload's `csrf` list) that may send a browser `Origin`
|
|
22
|
+
* header. Clients without an `Origin` header (every MCP client that is not a web page) are unaffected.
|
|
23
|
+
*/
|
|
24
|
+
readonly allowedOrigins?: readonly string[];
|
|
25
|
+
/** The builder collections agents may list (default: every collection the plugin extended). */
|
|
26
|
+
readonly collections?: readonly string[];
|
|
27
|
+
/** Origin used to build preview URLs (default: the origin of the request). */
|
|
28
|
+
readonly siteUrl?: string;
|
|
29
|
+
/** Requests per authenticated user and window (default 120 per minute). */
|
|
30
|
+
readonly rateLimit?: {
|
|
31
|
+
readonly limit: number;
|
|
32
|
+
readonly windowMs: number;
|
|
33
|
+
};
|
|
34
|
+
/** Replaces the in-memory limiter (which does not span serverless instances). */
|
|
35
|
+
readonly rateLimiter?: RateLimiter;
|
|
36
|
+
/** Idle time after which a user's open documents are dropped (default 30 minutes). */
|
|
37
|
+
readonly sessionTtlMs?: number;
|
|
38
|
+
/** Largest accepted request body in bytes (default 1 MB). */
|
|
39
|
+
readonly maxBodyBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface BuildrMcpRoute {
|
|
42
|
+
readonly POST: (request: Request) => Promise<Response>;
|
|
43
|
+
readonly GET: (request: Request) => Promise<Response>;
|
|
44
|
+
readonly DELETE: (request: Request) => Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `createBuildrMcpRoute({ payload, registry })` returns the `POST`/`GET`/`DELETE` handlers of the
|
|
48
|
+
* MCP endpoint. Mount them at `app/(builder)/api/buildr/mcp/route.ts`.
|
|
49
|
+
*
|
|
50
|
+
* - Disabled (`404`) unless the plugin has `mcp.enabled`.
|
|
51
|
+
* - Only `Authorization: <collection> API-Key <key>` authenticates; cookies are never read (`401`),
|
|
52
|
+
* so a browser cannot drive the endpoint cross-site. A browser `Origin` must be the site's own,
|
|
53
|
+
* in Payload's `csrf` list or in `allowedOrigins`.
|
|
54
|
+
* - The tools run against a local backend: the builder API of the same Payload, called in-process
|
|
55
|
+
* (no network hop) as the agent user, so Payload's access control, the plugin's `access`, the
|
|
56
|
+
* write limits and the revision checks apply exactly as over the network.
|
|
57
|
+
* - Stateless: each request is one JSON-RPC exchange on a fresh server. Open documents live in a
|
|
58
|
+
* per-user session store held by this route (one per `createBuildrMcpRoute` call, never global);
|
|
59
|
+
* on serverless platforms it lives as long as the instance does, and an unknown session after a
|
|
60
|
+
* cold start is reported to the agent, which reopens the document.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createBuildrMcpRoute(options: BuildrMcpRouteOptions): BuildrMcpRoute;
|
|
63
|
+
//# sourceMappingURL=route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../src/mcp/route.ts"],"names":[],"mappings":"AACA,OAAO,EAAM,KAAK,YAAY,EAAE,KAAK,KAAK,EAAc,MAAM,mBAAmB,CAAC;AAWlF,OAAO,EAAmB,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AACxD,OAAO,EAA2B,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAG1F;;;;GAIG;AAEH,MAAM,WAAW,qBAAqB;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IACrD,8FAA8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;KAAE,CAAC;IACnD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IACvB,2GAA2G;IAC3G,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,+FAA+F;IAC/F,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,sFAAsF;IACtF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvD,QAAQ,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACtD,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC1D;AAiCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CAqNnF"}
|