drupal-mcp-connector 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +5 -0
- package/package.json +3 -1
- package/src/index.js +16 -8
- package/src/lib/backends/backend-interface.js +3 -0
- package/src/lib/backends/graphql.js +1 -0
- package/src/lib/backends/jsonapi.js +1 -0
- package/src/lib/dispatch.js +10 -0
- package/src/lib/draft-write.js +18 -429
- package/src/lib/module-tools.js +241 -0
- package/src/lib/node-draft-inventory.js +4 -47
- package/src/lib/operations.js +5 -0
- package/src/lib/patch-preflight.js +7 -15
- package/src/lib/sentinel-draft.js +530 -0
- package/src/lib/server-tools.js +50 -16
- package/src/lib/verify.js +7 -1
- package/src/tools/config.js +14 -4
- package/src/tools/media.js +14 -18
- package/src/tools/moderation.js +21 -21
- package/src/tools/nodes.js +1 -1
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +11 -1
- package/src/tools/reports-content.js +7 -6
- package/src/tools/revisions.js +24 -23
- package/src/tools/structure.js +1 -1
- package/src/tools/taxonomy.js +1 -1
- package/src/tools/translations.js +5 -3
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Sentinel HTTP client and one fail-closed absence policy
|
|
3
|
+
* (d.o #3621022 / GitHub #176).
|
|
4
|
+
*
|
|
5
|
+
* Path builders, If-Match, lang/state headers, missing-endpoint classifiers,
|
|
6
|
+
* validated inventory, and working-revision rewrites live here. Tools call
|
|
7
|
+
* these functions; they must not sniff GraphQL `rawQuery` or copy the
|
|
8
|
+
* missing-endpoint English sentence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
12
|
+
|
|
13
|
+
const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
|
|
14
|
+
const MISSING_DRAFT_ENDPOINT =
|
|
15
|
+
"The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
|
|
16
|
+
"Update the server-side module; the draft was not discarded and no canonical fallback was attempted.";
|
|
17
|
+
const MISSING_TRANSLATION_ENDPOINT =
|
|
18
|
+
"The site does not provide Sentinel's governed draft-translation endpoint. " +
|
|
19
|
+
"Update MCP Sentinel; no canonical langcode PATCH was attempted.";
|
|
20
|
+
const MISSING_DRAFT_RE = /does not provide Sentinel's governed draft endpoint/;
|
|
21
|
+
const MISSING_TRANSLATION_RE = /does not provide Sentinel's governed draft-translation endpoint/;
|
|
22
|
+
const DRUPAL_ABSENCE_RE = /Drupal (404|405)\b/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a backend can issue Sentinel's JSON:API draft/translation routes.
|
|
26
|
+
* Requires `capabilities().sentinelDraft` when that flag is present.
|
|
27
|
+
* Otherwise requires JSON:API `resourcePath` plus `rawQuery`. GraphQL
|
|
28
|
+
* `rawQuery({ query })` does not count.
|
|
29
|
+
* @param {object} [backend]
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function supportsSentinelDraft(backend) {
|
|
33
|
+
if (!backend || typeof backend !== "object") return false;
|
|
34
|
+
if (typeof backend.capabilities === "function") {
|
|
35
|
+
let caps;
|
|
36
|
+
try {
|
|
37
|
+
caps = backend.capabilities();
|
|
38
|
+
} catch {
|
|
39
|
+
caps = undefined;
|
|
40
|
+
}
|
|
41
|
+
if (caps && typeof caps === "object" && Object.hasOwn(caps, "sentinelDraft")) {
|
|
42
|
+
return caps.sentinelDraft === true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return typeof backend.rawQuery === "function" && typeof backend.resourcePath === "function";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether an error is Sentinel's missing governed draft endpoint (404/405).
|
|
50
|
+
* @param {unknown} error
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function isMissingDraftEndpoint(error) {
|
|
54
|
+
return MISSING_DRAFT_RE.test(String(error?.message ?? ""));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether an error is Sentinel's missing governed draft-translation endpoint
|
|
59
|
+
* (404/405). Permission, transport, and malformed inventory are not absence.
|
|
60
|
+
* @param {unknown} error
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isMissingTranslationEndpoint(error) {
|
|
64
|
+
return MISSING_TRANSLATION_RE.test(String(error?.message ?? ""));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {unknown} error
|
|
69
|
+
* @param {string} message
|
|
70
|
+
* @returns {Error}
|
|
71
|
+
*/
|
|
72
|
+
function missingEndpointError(error, message) {
|
|
73
|
+
if (DRUPAL_ABSENCE_RE.test(String(error?.message))) {
|
|
74
|
+
return new Error(message, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} langcode
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function assertDraftLangcode(langcode) {
|
|
84
|
+
const value = String(langcode || "").trim();
|
|
85
|
+
if (!LANGCODE_RE.test(value)) {
|
|
86
|
+
throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {object} [draftRevision]
|
|
93
|
+
* @returns {{live: string, working: string}}
|
|
94
|
+
*/
|
|
95
|
+
function requireWorkingPair(draftRevision) {
|
|
96
|
+
const live = String(draftRevision?.liveVid ?? "");
|
|
97
|
+
const working = String(draftRevision?.workingVid ?? "");
|
|
98
|
+
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
99
|
+
throw new Error("Draft continuation requires distinct, verified live and working revision IDs.");
|
|
100
|
+
}
|
|
101
|
+
return { live, working };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object} [draftRevision]
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function requireParagraphRevisionId(draftRevision) {
|
|
109
|
+
const revisionId = String(draftRevision?.revisionId ?? draftRevision?.workingVid ?? "");
|
|
110
|
+
if (!/^[1-9]\d*$/.test(revisionId)) {
|
|
111
|
+
throw new Error("Paragraph translation requires a verified paragraph revision ID.");
|
|
112
|
+
}
|
|
113
|
+
return revisionId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} backend
|
|
118
|
+
* @param {string} entityType
|
|
119
|
+
* @param {string} bundle
|
|
120
|
+
* @param {string} id
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function draftResource(backend, entityType, bundle, id) {
|
|
124
|
+
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
125
|
+
throw new Error("Governed draft translation is implemented for nodes, paragraphs, and media.");
|
|
126
|
+
}
|
|
127
|
+
if (!supportsSentinelDraft(backend)) {
|
|
128
|
+
throw new Error("This backend does not support governed draft continuation.");
|
|
129
|
+
}
|
|
130
|
+
return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One Sentinel HTTP request. Node/media vs paragraph differ only in the
|
|
135
|
+
* If-Match value and optional `X-MCP-Draft-State` — callers build those.
|
|
136
|
+
* @param {object} backend
|
|
137
|
+
* @param {object} spec
|
|
138
|
+
* @param {string} spec.entityType
|
|
139
|
+
* @param {string} spec.bundle
|
|
140
|
+
* @param {string} spec.id
|
|
141
|
+
* @param {string} spec.suffix Path after the entity UUID (`mcp-draft`,
|
|
142
|
+
* `mcp-draft/translations`, `mcp-translations`).
|
|
143
|
+
* @param {object} [spec.options] Passed to JSON:API `rawQuery`.
|
|
144
|
+
* @param {string} spec.missingMessage Absence rewrite for Drupal 404/405.
|
|
145
|
+
* @returns {Promise<*>}
|
|
146
|
+
*/
|
|
147
|
+
async function sentinelDraftRequest(backend, {
|
|
148
|
+
entityType, bundle, id, suffix, options, missingMessage,
|
|
149
|
+
}) {
|
|
150
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
151
|
+
try {
|
|
152
|
+
return await backend.rawQuery({
|
|
153
|
+
path: `${base}/${suffix}`,
|
|
154
|
+
options,
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw missingEndpointError(error, missingMessage);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate or continue a draft, using the same payload and revision precondition.
|
|
163
|
+
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
164
|
+
* @param {object} backend JSON:API backend.
|
|
165
|
+
* @param {object} input Canonical update input plus draftRevision.
|
|
166
|
+
* @param {boolean} preflight Validate without saving.
|
|
167
|
+
* @returns {Promise<object>} Preflight metadata or the written canonical entity.
|
|
168
|
+
*/
|
|
169
|
+
export async function writeDraft(backend, input, preflight = false) {
|
|
170
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
171
|
+
if (entityType === "paragraph") {
|
|
172
|
+
return writeParagraphDraft(backend, input, preflight);
|
|
173
|
+
}
|
|
174
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
175
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
176
|
+
if (relationships) data.relationships = relationships;
|
|
177
|
+
const headers = {
|
|
178
|
+
"If-Match": `"${live}:${working}"`,
|
|
179
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
180
|
+
};
|
|
181
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
182
|
+
if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
|
|
183
|
+
const result = await sentinelDraftRequest(backend, {
|
|
184
|
+
entityType, bundle, id, suffix: "mcp-draft", missingMessage: MISSING_DRAFT_ENDPOINT,
|
|
185
|
+
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
186
|
+
});
|
|
187
|
+
if (preflight) {
|
|
188
|
+
if (result?.meta?.draft_preflight !== true
|
|
189
|
+
|| String(result.meta.live) !== live || String(result.meta.working) !== working) {
|
|
190
|
+
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
191
|
+
}
|
|
192
|
+
if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
|
|
193
|
+
throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
198
|
+
throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
199
|
+
}
|
|
200
|
+
return backend.toCanonical(result.data);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create a target-language translation as an unpublished forward revision.
|
|
205
|
+
* If-Match is `"live"` when there is no working copy, or `"live:working"` when
|
|
206
|
+
* adding the language onto an existing unpublished English draft.
|
|
207
|
+
* @param {object} backend
|
|
208
|
+
* @param {object} input
|
|
209
|
+
* @param {boolean} [preflight]
|
|
210
|
+
* @returns {Promise<object>}
|
|
211
|
+
*/
|
|
212
|
+
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
213
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
214
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
215
|
+
if (entityType === "paragraph") {
|
|
216
|
+
return createParagraphTranslationDraft(backend, {
|
|
217
|
+
entityType, bundle, id, attributes, relationships, draftRevision, langcode,
|
|
218
|
+
}, preflight);
|
|
219
|
+
}
|
|
220
|
+
const live = String(draftRevision?.liveVid ?? "");
|
|
221
|
+
const workingRaw = draftRevision?.workingVid;
|
|
222
|
+
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
223
|
+
? ""
|
|
224
|
+
: String(workingRaw);
|
|
225
|
+
if ((entityType !== "node" && entityType !== "media") || !/^[1-9]\d*$/.test(live)) {
|
|
226
|
+
throw new Error("Translation create requires a verified live revision ID.");
|
|
227
|
+
}
|
|
228
|
+
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
229
|
+
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
230
|
+
}
|
|
231
|
+
const safeAttributes = { ...attributes };
|
|
232
|
+
delete safeAttributes.langcode;
|
|
233
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
234
|
+
if (relationships) data.relationships = relationships;
|
|
235
|
+
const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
|
|
236
|
+
let result;
|
|
237
|
+
try {
|
|
238
|
+
result = await sentinelDraftRequest(backend, {
|
|
239
|
+
entityType, bundle, id, suffix: "mcp-draft/translations",
|
|
240
|
+
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
241
|
+
options: {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: {
|
|
244
|
+
"If-Match": ifMatch,
|
|
245
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
246
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({ data }),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw rewriteTranslationWorkingRevisionError(error);
|
|
253
|
+
}
|
|
254
|
+
if (preflight) {
|
|
255
|
+
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
256
|
+
throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
261
|
+
throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
262
|
+
}
|
|
263
|
+
return backend.toCanonical(result.data);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Live + working revision ids for a node translation create.
|
|
268
|
+
* Prefers Sentinel's mcp-translations inventory over JSON:API
|
|
269
|
+
* `rel:working-copy`, which 403s on some unpublished drafts even when a
|
|
270
|
+
* working revision exists (#282).
|
|
271
|
+
*
|
|
272
|
+
* Missing 404/405 inventory is absence and may fall back to the working-copy
|
|
273
|
+
* alias. Permission, 5xx, and malformed inventory are not absence — they throw.
|
|
274
|
+
* @param {object} backend
|
|
275
|
+
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
276
|
+
* @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
|
|
277
|
+
*/
|
|
278
|
+
export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
|
|
279
|
+
const fromEntity = existing ? entityRevisionId(existing) : null;
|
|
280
|
+
let meta;
|
|
281
|
+
try {
|
|
282
|
+
meta = await readTranslationInventory(backend, { entityType, bundle, id });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!isMissingTranslationEndpoint(error)) throw error;
|
|
285
|
+
if (typeof backend.getEntity !== "function") {
|
|
286
|
+
return { liveVid: fromEntity, workingVid: undefined };
|
|
287
|
+
}
|
|
288
|
+
let workingCopy = null;
|
|
289
|
+
try {
|
|
290
|
+
workingCopy = await backend.getEntity({
|
|
291
|
+
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
workingCopy = null;
|
|
295
|
+
}
|
|
296
|
+
const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
|
|
297
|
+
const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
|
|
298
|
+
return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
|
|
299
|
+
}
|
|
300
|
+
const liveVid = meta.live?.vid ?? fromEntity;
|
|
301
|
+
const workingVid = meta.working?.vid;
|
|
302
|
+
const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
|
|
303
|
+
&& liveVid !== undefined && liveVid !== null
|
|
304
|
+
&& String(workingVid) !== String(liveVid);
|
|
305
|
+
return { liveVid, workingVid: distinct ? workingVid : undefined };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Rewrite Sentinel's live-only 409 into an actionable connector error.
|
|
310
|
+
* @param {unknown} error
|
|
311
|
+
* @returns {Error}
|
|
312
|
+
*/
|
|
313
|
+
export function rewriteTranslationWorkingRevisionError(error) {
|
|
314
|
+
if (/A working revision exists\. Reload and send both revision IDs/i.test(String(error?.message || ""))) {
|
|
315
|
+
return new Error(
|
|
316
|
+
"Translation create sent only the live revision, but a working draft exists. " +
|
|
317
|
+
"The connector should have sent both live and working revision IDs (If-Match). " +
|
|
318
|
+
"Reload with drupal_list_translations and retry. See connector #282.",
|
|
319
|
+
{ cause: error instanceof Error ? error : undefined },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Read live/working translation inventory from Sentinel.
|
|
327
|
+
* @param {object} backend
|
|
328
|
+
* @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
|
|
329
|
+
* @returns {Promise<object>}
|
|
330
|
+
*/
|
|
331
|
+
export async function readTranslationInventory(backend, { entityType, bundle, id, revisionId }) {
|
|
332
|
+
const headers = {};
|
|
333
|
+
if (revisionId !== undefined && revisionId !== null && revisionId !== "") {
|
|
334
|
+
headers["If-Match"] = `"${requireParagraphRevisionId({ revisionId })}"`;
|
|
335
|
+
}
|
|
336
|
+
const result = await sentinelDraftRequest(backend, {
|
|
337
|
+
entityType, bundle, id, suffix: "mcp-translations",
|
|
338
|
+
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
339
|
+
options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
|
|
340
|
+
});
|
|
341
|
+
if (!result?.meta?.live) {
|
|
342
|
+
throw new Error("The site did not return a translation inventory.");
|
|
343
|
+
}
|
|
344
|
+
return result.meta;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read optional inventory, falling back only when the endpoint is unsupported.
|
|
349
|
+
* Permission, transport and malformed-response failures are not absence.
|
|
350
|
+
* @param {object} backend
|
|
351
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
352
|
+
* @returns {Promise<object|null>}
|
|
353
|
+
*/
|
|
354
|
+
export async function readNodeDraftInventory(backend, ref) {
|
|
355
|
+
if ((ref.entityType !== "node" && ref.entityType !== "media")
|
|
356
|
+
|| !supportsSentinelDraft(backend)) return null;
|
|
357
|
+
let inventory;
|
|
358
|
+
try {
|
|
359
|
+
inventory = await readTranslationInventory(backend, ref);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (isMissingTranslationEndpoint(error)) return null;
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
|
|
365
|
+
if (!validVid(inventory.live?.vid)
|
|
366
|
+
|| (inventory.working && (!validVid(inventory.working.vid)
|
|
367
|
+
|| !Array.isArray(inventory.working.translations)
|
|
368
|
+
|| inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
|
|
369
|
+
|| typeof row.status !== "boolean")))) {
|
|
370
|
+
throw new Error("Sentinel returned an invalid revision inventory. Re-read before updating.");
|
|
371
|
+
}
|
|
372
|
+
return inventory;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Ensure inventory discovery does not turn a published language into a draft.
|
|
377
|
+
* Sentinel still validates the revision pair and language on every request.
|
|
378
|
+
* @param {object} inventory
|
|
379
|
+
* @param {string|undefined} langcode
|
|
380
|
+
*/
|
|
381
|
+
export function assertInventoryDraftLanguage(inventory, langcode) {
|
|
382
|
+
const rows = inventory.working?.translations ?? [];
|
|
383
|
+
if (!langcode && rows.length !== 1) {
|
|
384
|
+
throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
|
|
385
|
+
}
|
|
386
|
+
const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
|
|
387
|
+
if (!row || row.status !== false) {
|
|
388
|
+
throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Read one unpublished working translation.
|
|
394
|
+
* @param {object} backend
|
|
395
|
+
* @param {object} input
|
|
396
|
+
* @returns {Promise<object>}
|
|
397
|
+
*/
|
|
398
|
+
export async function readDraftTranslation(backend, input) {
|
|
399
|
+
const { entityType, bundle, id, draftRevision } = input;
|
|
400
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
401
|
+
if (entityType === "paragraph") {
|
|
402
|
+
return readParagraphDraftTranslation(backend, input);
|
|
403
|
+
}
|
|
404
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
405
|
+
const result = await sentinelDraftRequest(backend, {
|
|
406
|
+
entityType, bundle, id, suffix: "mcp-draft",
|
|
407
|
+
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
408
|
+
options: {
|
|
409
|
+
method: "GET",
|
|
410
|
+
headers: {
|
|
411
|
+
"If-Match": `"${live}:${working}"`,
|
|
412
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
if (!result?.data || result.data.id !== id) {
|
|
417
|
+
throw new Error("Draft translation read did not identify the requested entity.");
|
|
418
|
+
}
|
|
419
|
+
return backend.toCanonical(result.data);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Continue an unpublished paragraph translation on a pinned revision.
|
|
424
|
+
* @param {object} backend
|
|
425
|
+
* @param {object} input
|
|
426
|
+
* @param {boolean} [preflight]
|
|
427
|
+
* @returns {Promise<object>}
|
|
428
|
+
*/
|
|
429
|
+
async function writeParagraphDraft(backend, input, preflight = false) {
|
|
430
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
431
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
432
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
433
|
+
const draftState = input.draftState;
|
|
434
|
+
if (typeof draftState !== "string" || !/^[a-f0-9]{64}$/.test(draftState)) {
|
|
435
|
+
throw new Error("Paragraph translation update requires draftState from the previous draft read. Re-read the draft; do not retry old copy with a refreshed token.");
|
|
436
|
+
}
|
|
437
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
438
|
+
if (relationships) data.relationships = relationships;
|
|
439
|
+
const result = await sentinelDraftRequest(backend, {
|
|
440
|
+
entityType, bundle, id, suffix: "mcp-draft", missingMessage: MISSING_DRAFT_ENDPOINT,
|
|
441
|
+
options: {
|
|
442
|
+
method: "PATCH",
|
|
443
|
+
headers: {
|
|
444
|
+
"If-Match": `"${revisionId}"`,
|
|
445
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
446
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
447
|
+
"X-MCP-Draft-State": draftState,
|
|
448
|
+
},
|
|
449
|
+
body: JSON.stringify({ data }),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
if (preflight) {
|
|
453
|
+
if (result?.meta?.draft_preflight !== true
|
|
454
|
+
|| String(result.meta.live) !== revisionId
|
|
455
|
+
|| (result.meta.langcode && String(result.meta.langcode) !== langcode)) {
|
|
456
|
+
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
461
|
+
throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
462
|
+
}
|
|
463
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Create an unpublished paragraph translation on a pinned revision.
|
|
468
|
+
* @param {object} backend
|
|
469
|
+
* @param {object} input
|
|
470
|
+
* @param {boolean} [preflight]
|
|
471
|
+
* @returns {Promise<object>}
|
|
472
|
+
*/
|
|
473
|
+
async function createParagraphTranslationDraft(backend, input, preflight = false) {
|
|
474
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
475
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
476
|
+
const safeAttributes = { ...attributes };
|
|
477
|
+
delete safeAttributes.langcode;
|
|
478
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
479
|
+
if (relationships) data.relationships = relationships;
|
|
480
|
+
const result = await sentinelDraftRequest(backend, {
|
|
481
|
+
entityType, bundle, id, suffix: "mcp-draft/translations",
|
|
482
|
+
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
483
|
+
options: {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: {
|
|
486
|
+
"If-Match": `"${revisionId}"`,
|
|
487
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
488
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
489
|
+
},
|
|
490
|
+
body: JSON.stringify({ data }),
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
if (preflight) {
|
|
494
|
+
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== revisionId) {
|
|
495
|
+
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
500
|
+
throw new Error("Paragraph translation create did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
501
|
+
}
|
|
502
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Read one unpublished paragraph translation of a pinned revision.
|
|
507
|
+
* @param {object} backend
|
|
508
|
+
* @param {object} input
|
|
509
|
+
* @returns {Promise<object>}
|
|
510
|
+
*/
|
|
511
|
+
async function readParagraphDraftTranslation(backend, input) {
|
|
512
|
+
const { entityType, bundle, id, draftRevision } = input;
|
|
513
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
514
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
515
|
+
const result = await sentinelDraftRequest(backend, {
|
|
516
|
+
entityType, bundle, id, suffix: "mcp-draft",
|
|
517
|
+
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
518
|
+
options: {
|
|
519
|
+
method: "GET",
|
|
520
|
+
headers: {
|
|
521
|
+
"If-Match": `"${revisionId}"`,
|
|
522
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
if (!result?.data || result.data.id !== id) {
|
|
527
|
+
throw new Error("Paragraph draft translation read did not identify the requested entity.");
|
|
528
|
+
}
|
|
529
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
530
|
+
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
26
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
26
27
|
import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
|
|
27
|
-
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
|
|
28
|
+
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial, getDataFlowContext } from "./data-flow.js";
|
|
28
29
|
import { clearToken } from "./oauth.js";
|
|
29
30
|
|
|
31
|
+
/** Calls a configured module binding through the registry, without fallback. */
|
|
32
|
+
export async function callBoundModuleTool(site, binding, args, required) {
|
|
33
|
+
// Load at invocation: built-in tool definitions are also consumed by the
|
|
34
|
+
// registry's dispatch middleware, so a static import would create a cycle.
|
|
35
|
+
const { createModuleToolRegistry } = await import("./module-tools.js");
|
|
36
|
+
return createModuleToolRegistry().callBinding(site, binding, args, required);
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* Canonical server-side tool names for governed config operations.
|
|
32
41
|
*
|
|
@@ -50,11 +59,14 @@ const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
|
50
59
|
let rpcId = 0;
|
|
51
60
|
|
|
52
61
|
/**
|
|
53
|
-
*
|
|
62
|
+
* MCP session cache, keyed by site, endpoint, credentials and principal. Holds the `Mcp-Session-Id`
|
|
54
63
|
* issued by the server's `initialize` response; cleared and re-acquired when the
|
|
55
64
|
* server reports the session is gone (expiry).
|
|
56
65
|
*/
|
|
57
66
|
const sessions = new Map();
|
|
67
|
+
// Ephemeral cache identity only, never a persisted password verifier. A keyed
|
|
68
|
+
// digest also prevents offline guessing if a diagnostic exposes a cache key.
|
|
69
|
+
const sessionIdentityKey = randomBytes(32);
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* Resolve a site's server-tools endpoint, or throw a clear, actionable error
|
|
@@ -156,7 +168,7 @@ function parseSse(text) {
|
|
|
156
168
|
* @returns {Promise<string>} The issued MCP session id.
|
|
157
169
|
* @throws {Error} on transport failure, a JSON-RPC error, or a missing session id.
|
|
158
170
|
*/
|
|
159
|
-
async function initializeSession(site, endpoint) {
|
|
171
|
+
async function initializeSession(site, endpoint, key) {
|
|
160
172
|
const payload = {
|
|
161
173
|
jsonrpc: "2.0",
|
|
162
174
|
id: ++rpcId,
|
|
@@ -173,6 +185,8 @@ async function initializeSession(site, endpoint) {
|
|
|
173
185
|
method: "POST",
|
|
174
186
|
headers: await baseHeaders(site, null),
|
|
175
187
|
body: JSON.stringify(payload),
|
|
188
|
+
size: 262144,
|
|
189
|
+
signal: AbortSignal.timeout(15000),
|
|
176
190
|
});
|
|
177
191
|
|
|
178
192
|
let res = await post();
|
|
@@ -205,12 +219,15 @@ async function initializeSession(site, endpoint) {
|
|
|
205
219
|
method: "POST",
|
|
206
220
|
headers: await baseHeaders(site, sessionId),
|
|
207
221
|
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
222
|
+
size: 262144,
|
|
223
|
+
signal: AbortSignal.timeout(15000),
|
|
208
224
|
});
|
|
209
225
|
} catch {
|
|
210
226
|
// Notification is advisory; proceed with the established session.
|
|
211
227
|
}
|
|
212
228
|
|
|
213
|
-
sessions.
|
|
229
|
+
if (sessions.size >= 128) sessions.delete(sessions.keys().next().value);
|
|
230
|
+
sessions.set(key, sessionId);
|
|
214
231
|
return sessionId;
|
|
215
232
|
}
|
|
216
233
|
|
|
@@ -220,9 +237,9 @@ async function initializeSession(site, endpoint) {
|
|
|
220
237
|
* @param {string} endpoint Fully-qualified endpoint URL.
|
|
221
238
|
* @returns {Promise<string>} The active MCP session id.
|
|
222
239
|
*/
|
|
223
|
-
async function ensureSession(site, endpoint) {
|
|
224
|
-
const cached = sessions.get(
|
|
225
|
-
return cached || initializeSession(site, endpoint);
|
|
240
|
+
async function ensureSession(site, endpoint, key) {
|
|
241
|
+
const cached = sessions.get(key);
|
|
242
|
+
return cached || initializeSession(site, endpoint, key);
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
/**
|
|
@@ -253,16 +270,32 @@ function isSessionError(res, body) {
|
|
|
253
270
|
* @returns {Promise<*>} The tool's structured result.
|
|
254
271
|
* @throws {Error} on transport failure, JSON-RPC error, or tool error.
|
|
255
272
|
*/
|
|
256
|
-
export async function callServerTool(site, toolName, args = {}) {
|
|
273
|
+
export async function callServerTool(site, toolName, args = {}, options = {}) {
|
|
274
|
+
return requestServerTool(site, "tools/call", { name: toolName, arguments: args }, options);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Fetch one page of the authenticated module tool catalog. */
|
|
278
|
+
export async function listServerTools(site, cursor) {
|
|
279
|
+
return requestServerTool(site, "tools/list", cursor === undefined ? {} : { cursor }, {
|
|
280
|
+
maxBytes: 262144, preserveErrors: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Shared bounded MCP request transport; retries only explicit auth/session rejection. */
|
|
285
|
+
async function requestServerTool(site, method, params, options) {
|
|
286
|
+
const toolName = params.name ?? method;
|
|
257
287
|
const endpoint = resolveEndpoint(site);
|
|
258
288
|
const payload = {
|
|
259
289
|
jsonrpc: "2.0",
|
|
260
290
|
id: ++rpcId,
|
|
261
|
-
method
|
|
262
|
-
params
|
|
291
|
+
method,
|
|
292
|
+
params,
|
|
263
293
|
};
|
|
264
294
|
|
|
265
|
-
|
|
295
|
+
const sessionKey = createHmac("sha256", sessionIdentityKey).update(JSON.stringify([
|
|
296
|
+
site._name, endpoint, await authHeadersAsync(site), getDataFlowContext()?.principalKey ?? null,
|
|
297
|
+
])).digest("hex");
|
|
298
|
+
let sessionId = await ensureSession(site, endpoint, sessionKey);
|
|
266
299
|
let refreshedAuth = false;
|
|
267
300
|
let reinitedSession = false;
|
|
268
301
|
let paid = false;
|
|
@@ -275,21 +308,22 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
275
308
|
method: "POST",
|
|
276
309
|
headers: await baseHeaders(site, sessionId),
|
|
277
310
|
body: JSON.stringify(payload),
|
|
311
|
+
...(options.maxBytes ? { size: options.maxBytes, signal: AbortSignal.timeout(15000) } : {}),
|
|
278
312
|
});
|
|
279
313
|
const { body, rawText } = await readBody(res);
|
|
280
314
|
|
|
281
315
|
// OAuth sites: a 401 may mean the token expired server-side. Refresh once.
|
|
282
|
-
if (res.status === 401 && site.oauth && !refreshedAuth) {
|
|
316
|
+
if (res.status === 401 && site.oauth && !refreshedAuth && options.retryRejected !== false) {
|
|
283
317
|
refreshedAuth = true;
|
|
284
318
|
clearToken(site);
|
|
285
319
|
continue;
|
|
286
320
|
}
|
|
287
321
|
|
|
288
322
|
// Session expired/unknown: re-initialise once and replay.
|
|
289
|
-
if (isSessionError(res, body) && !reinitedSession) {
|
|
323
|
+
if (isSessionError(res, body) && !reinitedSession && options.retryRejected !== false) {
|
|
290
324
|
reinitedSession = true;
|
|
291
|
-
sessions.delete(
|
|
292
|
-
sessionId = await ensureSession(site, endpoint);
|
|
325
|
+
sessions.delete(sessionKey);
|
|
326
|
+
sessionId = await ensureSession(site, endpoint, sessionKey);
|
|
293
327
|
continue;
|
|
294
328
|
}
|
|
295
329
|
|
|
@@ -308,7 +342,7 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
308
342
|
|
|
309
343
|
// MCP tools/call result: { content: [...], isError?: boolean }.
|
|
310
344
|
const result = body?.result;
|
|
311
|
-
if (result?.isError) {
|
|
345
|
+
if (result?.isError && !options.preserveErrors) {
|
|
312
346
|
const detail = extractTextContent(result) || "tool reported an error";
|
|
313
347
|
const mapped = sourceBudgetDenial(detail);
|
|
314
348
|
if (mapped) throw mapped;
|