drupal-mcp-connector 2.16.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/.agents/commands/drupal-create-translation.md +3 -3
- package/.agents/commands/drupal-get-media.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- package/.agents/commands/drupal-update-media.md +5 -4
- package/CHANGELOG.md +52 -0
- package/README.md +5 -0
- package/package.json +4 -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/canonical.js +10 -5
- 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 -46
- 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 +61 -8
- 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 +31 -21
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;
|
package/src/lib/verify.js
CHANGED
|
@@ -738,7 +738,13 @@ export async function verifyLive(site, { transport, callTool = null, contentTarg
|
|
|
738
738
|
*/
|
|
739
739
|
const attemptConfigWrite = async () => {
|
|
740
740
|
try {
|
|
741
|
-
|
|
741
|
+
// The negative probe must reach source authorization, not merely prove
|
|
742
|
+
// that the connector hides out-of-tier tools from discovery.
|
|
743
|
+
const tool = site.serverTools?.bindings === undefined ? CONFIG_SET_TOOL
|
|
744
|
+
: (await import("./module-tools.js")).resolveModuleBinding(site, "configSet", {
|
|
745
|
+
operation: "write", scope: "mcp_config", capabilities: ["configWrite"],
|
|
746
|
+
}).policy.name;
|
|
747
|
+
await callTool(site, tool, { name: "system.site", data: { name: "verification probe" } });
|
|
742
748
|
return { served: true, outcome: "served", detail: "accepted" };
|
|
743
749
|
} catch (err) {
|
|
744
750
|
// Not every throw is a refusal — see classifyBridgeError.
|
package/src/tools/config.js
CHANGED
|
@@ -22,7 +22,17 @@ import {
|
|
|
22
22
|
assertConfigScope,
|
|
23
23
|
hasScope,
|
|
24
24
|
} from "../lib/security.js";
|
|
25
|
-
import { callServerTool, SERVER_TOOLS } from "../lib/server-tools.js";
|
|
25
|
+
import { callServerTool, callBoundModuleTool, SERVER_TOOLS } from "../lib/server-tools.js";
|
|
26
|
+
|
|
27
|
+
/** Compatibility names use an approved module binding when configured. */
|
|
28
|
+
async function configTool(site, binding, args, operation, capability) {
|
|
29
|
+
if (site.serverTools?.bindings !== undefined) {
|
|
30
|
+
return callBoundModuleTool(site, binding, args, {
|
|
31
|
+
operation, scope: "mcp_config", capabilities: [capability],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return callServerTool(site, new Map(Object.entries(SERVER_TOOLS)).get(binding), args);
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
// ---------------------------------------------------------------------------
|
|
28
38
|
// Config tools (governed via the server-tool bridge)
|
|
@@ -38,7 +48,7 @@ async function configGet({ site: siteName, name }) {
|
|
|
38
48
|
const site = getSiteConfig(siteName);
|
|
39
49
|
assertConfigScope(site, `config:get ${name}`);
|
|
40
50
|
assertConfigReadAllowed(resolveSecurityConfig(site));
|
|
41
|
-
return
|
|
51
|
+
return configTool(site, "configGet", { name }, "read", "configRead");
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
/**
|
|
@@ -52,7 +62,7 @@ async function configList({ site: siteName, prefix }) {
|
|
|
52
62
|
assertConfigScope(site, "config:list");
|
|
53
63
|
assertConfigReadAllowed(resolveSecurityConfig(site));
|
|
54
64
|
const args = prefix ? { prefix } : {};
|
|
55
|
-
return
|
|
65
|
+
return configTool(site, "configList", args, "read", "configRead");
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -73,7 +83,7 @@ async function configSet({ site: siteName, name, value }) {
|
|
|
73
83
|
assertConfigScope(site, `config:set ${name}`);
|
|
74
84
|
assertNotReadOnly(sec, `config:set ${name}`);
|
|
75
85
|
assertConfigWriteAllowed(sec);
|
|
76
|
-
return
|
|
86
|
+
return configTool(site, "configSet", { name, data: value }, "write", "configWrite");
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
// ---------------------------------------------------------------------------
|
package/src/tools/media.js
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
15
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
16
16
|
} from "../lib/security.js";
|
|
17
|
+
import {
|
|
18
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory,
|
|
19
|
+
} from "../lib/sentinel-draft.js";
|
|
20
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { entityRevisionId } from "../lib/write-revision.js";
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* List all media types (bundles of the media entity type).
|
|
@@ -51,14 +56,39 @@ async function listMedia({ site: siteName, type, status, name, limit = 20, offse
|
|
|
51
56
|
|
|
52
57
|
/**
|
|
53
58
|
* Fetch a single media entity by UUID, redacted per policy.
|
|
54
|
-
*
|
|
59
|
+
* Pass langcode to read an unpublished working translation via Sentinel.
|
|
60
|
+
* @param {object} args - { site?, type, id, langcode? }.
|
|
55
61
|
* @returns {Promise<object|null>} The redacted media entity, or null.
|
|
56
62
|
*/
|
|
57
|
-
async function getMedia({ site: siteName, type, id }) {
|
|
63
|
+
async function getMedia({ site: siteName, type, id, langcode }) {
|
|
58
64
|
const site = getSiteConfig(siteName);
|
|
59
65
|
const sec = resolveSecurityConfig(site);
|
|
60
66
|
assertReadAllowed(sec, "media", type);
|
|
61
67
|
const backend = await resolveBackend(site);
|
|
68
|
+
if (langcode) {
|
|
69
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
70
|
+
const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
|
|
71
|
+
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
72
|
+
if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
|
|
73
|
+
const entity = await readDraftTranslation(backend, {
|
|
74
|
+
entityType: "media", bundle: type, id, langcode: targetLang,
|
|
75
|
+
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
76
|
+
});
|
|
77
|
+
return redactCanonicalEntity(entity, sec, "media");
|
|
78
|
+
}
|
|
79
|
+
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
80
|
+
if (!liveHas) return null;
|
|
81
|
+
if (targetLang !== inventory.defaultLangcode) {
|
|
82
|
+
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
83
|
+
return {
|
|
84
|
+
id, entityType: "media", bundle: type, langcode: targetLang,
|
|
85
|
+
status: liveRow?.status ?? null,
|
|
86
|
+
fields: { name: liveRow?.name ?? null },
|
|
87
|
+
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
88
|
+
note: "Published non-default translations are listed on the live revision; full field reads of an unpublished working translation use langcode against the working draft.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
62
92
|
const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
|
|
63
93
|
return entity ? redactCanonicalEntity(entity, sec, "media") : null;
|
|
64
94
|
}
|
|
@@ -91,12 +121,13 @@ async function createMedia({ site: siteName, type, name, status = false, fields
|
|
|
91
121
|
/**
|
|
92
122
|
* Update a media entity (partial — omitted fields are left untouched; `status`
|
|
93
123
|
* is strictly opt-in, #171). Reference-shaped `fields` values are routed to
|
|
94
|
-
* relationships — see splitReferenceFields.
|
|
124
|
+
* relationships — see splitReferenceFields. Pass langcode to continue an
|
|
125
|
+
* unpublished working translation via Sentinel (not a canonical live PATCH).
|
|
95
126
|
*
|
|
96
|
-
* @param {object} args - { site?, type, id, name?, status?, fields? }.
|
|
127
|
+
* @param {object} args - { site?, type, id, name?, status?, fields?, langcode? }.
|
|
97
128
|
* @returns {Promise<object>} The updated media descriptor.
|
|
98
129
|
*/
|
|
99
|
-
async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
|
|
130
|
+
async function updateMedia({ site: siteName, type, id, name, status, fields = {}, langcode }) {
|
|
100
131
|
const site = getSiteConfig(siteName);
|
|
101
132
|
const sec = resolveSecurityConfig(site);
|
|
102
133
|
assertWriteAllowed(sec, "update", "media", type);
|
|
@@ -105,6 +136,26 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
105
136
|
if (status !== undefined) attributes.status = status;
|
|
106
137
|
assertPublishAllowed(sec, attributes);
|
|
107
138
|
const backend = await resolveBackend(site);
|
|
139
|
+
if (langcode) {
|
|
140
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
141
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
142
|
+
entityType: "media", bundle: type, id, attributes,
|
|
143
|
+
...(relationships ? { relationships } : {}),
|
|
144
|
+
langcode: targetLang,
|
|
145
|
+
});
|
|
146
|
+
const result = await updateEntityGuarded(backend, {
|
|
147
|
+
entityType: "media", bundle: type, id, attributes,
|
|
148
|
+
...(relationships ? { relationships } : {}),
|
|
149
|
+
langcode: targetLang,
|
|
150
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
151
|
+
});
|
|
152
|
+
const redacted = redactCanonicalEntity(result, sec, "media");
|
|
153
|
+
const working = entityRevisionId(result) ?? patchTarget.workingVid;
|
|
154
|
+
return {
|
|
155
|
+
...redacted,
|
|
156
|
+
_revisions: { live: patchTarget.liveVid, working },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
108
159
|
// #171: pre-read so an unrequested published-state flip is reported, not silent.
|
|
109
160
|
let existing = null;
|
|
110
161
|
if (status === undefined) {
|
|
@@ -241,13 +292,14 @@ export const definitions = [
|
|
|
241
292
|
},
|
|
242
293
|
{
|
|
243
294
|
name: "drupal_get_media",
|
|
244
|
-
description: "Fetch a single media entity by UUID and media type.",
|
|
295
|
+
description: "Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel.",
|
|
245
296
|
inputSchema: {
|
|
246
297
|
type: "object", required: ["type", "id"],
|
|
247
298
|
properties: {
|
|
248
299
|
site: { type: "string" },
|
|
249
300
|
type: { type: "string" },
|
|
250
301
|
id: { type: "string", description: "Media entity UUID" },
|
|
302
|
+
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
251
303
|
},
|
|
252
304
|
},
|
|
253
305
|
},
|
|
@@ -267,7 +319,7 @@ export const definitions = [
|
|
|
267
319
|
},
|
|
268
320
|
{
|
|
269
321
|
name: "drupal_update_media",
|
|
270
|
-
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.",
|
|
322
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.",
|
|
271
323
|
inputSchema: {
|
|
272
324
|
type: "object", required: ["type", "id"],
|
|
273
325
|
properties: {
|
|
@@ -276,7 +328,8 @@ export const definitions = [
|
|
|
276
328
|
id: { type: "string" },
|
|
277
329
|
name: { type: "string" },
|
|
278
330
|
status: { type: "boolean", description: "Published flag. Only sent when provided; requires allowPublish when true." },
|
|
279
|
-
fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
|
|
331
|
+
fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically. Image alt is meta.alt on field_media_image with the existing file UUID." },
|
|
332
|
+
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
280
333
|
},
|
|
281
334
|
},
|
|
282
335
|
},
|
package/src/tools/moderation.js
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
} from "../lib/security.js";
|
|
23
23
|
import { collectEntities } from "../lib/reports-support.js";
|
|
24
24
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
assertDraftLangcode, isMissingTranslationEndpoint, readTranslationInventory, supportsSentinelDraft,
|
|
27
|
+
} from "../lib/sentinel-draft.js";
|
|
26
28
|
import { inventoryRowMatching } from "../lib/translation-rows.js";
|
|
27
29
|
|
|
28
30
|
/** Cap for the client-side scan when JSON:API cannot filter the field. */
|
|
@@ -65,25 +67,23 @@ async function setModerationState({ site: siteName, type, id, state, langcode })
|
|
|
65
67
|
const attributes = { moderation_state: state };
|
|
66
68
|
assertPublishAllowed(sec, attributes);
|
|
67
69
|
const backend = await resolveBackend(site);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
existing = null;
|
|
75
|
-
}
|
|
76
|
-
const patchTarget = await prepareGuardedPatch(backend, {
|
|
77
|
-
entityType: "node", bundle: type, id, existing, attributes, langcode: targetLang,
|
|
78
|
-
});
|
|
79
|
-
const patched = await updateEntityGuarded(backend, {
|
|
80
|
-
entityType: "node", bundle: type, id, attributes, langcode: targetLang,
|
|
81
|
-
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
82
|
-
});
|
|
83
|
-
return redactCanonicalEntity(patched, sec, "node");
|
|
70
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : undefined;
|
|
71
|
+
let existing = null;
|
|
72
|
+
try {
|
|
73
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
existing = null;
|
|
84
76
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
77
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
78
|
+
entityType: "node", bundle: type, id, existing, attributes,
|
|
79
|
+
...(targetLang ? { langcode: targetLang } : {}),
|
|
80
|
+
});
|
|
81
|
+
const patched = await updateEntityGuarded(backend, {
|
|
82
|
+
entityType: "node", bundle: type, id, attributes,
|
|
83
|
+
...(targetLang ? { langcode: targetLang } : {}),
|
|
84
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
85
|
+
});
|
|
86
|
+
return redactCanonicalEntity(patched, sec, "node");
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
@@ -104,7 +104,7 @@ async function contentByModerationState({ site: siteName, type, state, limit = 2
|
|
|
104
104
|
const sort = [{ field: "changed", dir: "desc" }];
|
|
105
105
|
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
106
106
|
if (targetLang) {
|
|
107
|
-
if (
|
|
107
|
+
if (!supportsSentinelDraft(backend)) {
|
|
108
108
|
return {
|
|
109
109
|
type, state, langcode: targetLang, unavailable: true,
|
|
110
110
|
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
@@ -130,7 +130,7 @@ async function contentByModerationState({ site: siteName, type, state, limit = 2
|
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
132
|
} catch (error) {
|
|
133
|
-
if (
|
|
133
|
+
if (isMissingTranslationEndpoint(error)) {
|
|
134
134
|
return {
|
|
135
135
|
type, state, langcode: targetLang, unavailable: true,
|
|
136
136
|
reason: "A langcode filter requires Sentinel's translation inventory.",
|
package/src/tools/nodes.js
CHANGED
|
@@ -18,7 +18,7 @@ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omit
|
|
|
18
18
|
import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
-
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft
|
|
21
|
+
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/sentinel-draft.js";
|
|
22
22
|
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
23
23
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
24
24
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
21
|
import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
|
|
22
22
|
import { gatedReport } from "../lib/reports-support.js";
|
|
23
|
-
import { callServerTool, SERVER_TOOLS, toolResultData } from "../lib/server-tools.js";
|
|
23
|
+
import { callServerTool, callBoundModuleTool, SERVER_TOOLS, toolResultData } from "../lib/server-tools.js";
|
|
24
24
|
import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
|
|
25
25
|
import { sshDrush, parseDrush } from "./drush.js";
|
|
26
26
|
|
|
@@ -65,6 +65,11 @@ function configSourceAvailable(site) {
|
|
|
65
65
|
* @returns {Promise<*>} Parsed config object (or null/text).
|
|
66
66
|
*/
|
|
67
67
|
async function readConfig(site, name) {
|
|
68
|
+
if (site.serverTools?.bindings !== undefined) {
|
|
69
|
+
return toolResultData(await callBoundModuleTool(site, "configGet", { name }, {
|
|
70
|
+
operation: "read", scope: "mcp_config", capabilities: ["configRead"],
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
68
73
|
if (serverToolsConfigured(site)) {
|
|
69
74
|
try { return toolResultData(await callServerTool(site, SERVER_TOOLS.configGet, { name })); }
|
|
70
75
|
catch (err) { if (!drushConfigured(site)) throw err; }
|
|
@@ -82,6 +87,11 @@ async function readConfig(site, name) {
|
|
|
82
87
|
* @returns {Promise<string[]>} Config object names.
|
|
83
88
|
*/
|
|
84
89
|
async function listConfigNames(site, prefix) {
|
|
90
|
+
if (site.serverTools?.bindings !== undefined) {
|
|
91
|
+
return pickConfigNames(toolResultData(await callBoundModuleTool(site, "configList", { prefix }, {
|
|
92
|
+
operation: "read", scope: "mcp_config", capabilities: ["configRead"],
|
|
93
|
+
})));
|
|
94
|
+
}
|
|
85
95
|
if (serverToolsConfigured(site)) {
|
|
86
96
|
try { return pickConfigNames(toolResultData(await callServerTool(site, SERVER_TOOLS.configList, { prefix }))); }
|
|
87
97
|
catch (err) { if (!drushConfigured(site)) throw err; }
|
|
@@ -17,7 +17,9 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
17
17
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
18
18
|
import { collectEntities, fieldValue, daysSince } from "../lib/reports-support.js";
|
|
19
19
|
import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
assertDraftLangcode, isMissingTranslationEndpoint, readTranslationInventory, supportsSentinelDraft,
|
|
22
|
+
} from "../lib/sentinel-draft.js";
|
|
21
23
|
import { inventoryRowMatching, inventoryTranslationRows, mapTranslationRow } from "../lib/translation-rows.js";
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
@@ -116,7 +118,7 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
116
118
|
sampleSize
|
|
117
119
|
);
|
|
118
120
|
|
|
119
|
-
if (targetLang && (
|
|
121
|
+
if (targetLang && !supportsSentinelDraft(backend)) {
|
|
120
122
|
return {
|
|
121
123
|
contentType,
|
|
122
124
|
unavailable: true,
|
|
@@ -139,7 +141,7 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
139
141
|
if (!row) continue;
|
|
140
142
|
state = row.moderation_state;
|
|
141
143
|
} catch (error) {
|
|
142
|
-
if (
|
|
144
|
+
if (isMissingTranslationEndpoint(error)) {
|
|
143
145
|
return {
|
|
144
146
|
contentType,
|
|
145
147
|
unavailable: true,
|
|
@@ -207,8 +209,7 @@ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
|
207
209
|
sampleSize
|
|
208
210
|
);
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
212
|
+
if (!supportsSentinelDraft(backend)) {
|
|
212
213
|
return {
|
|
213
214
|
contentType,
|
|
214
215
|
scanned: nodes.length,
|
|
@@ -239,7 +240,7 @@ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
|
239
240
|
outdated: rows.some((row) => row.outdated === true),
|
|
240
241
|
});
|
|
241
242
|
} catch (error) {
|
|
242
|
-
if (!
|
|
243
|
+
if (!isMissingTranslationEndpoint(error)) throw error;
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
|
package/src/tools/revisions.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* full chronological history enumeration requires the Drush bridge.
|
|
12
12
|
* - drupal_get_revision fetches one specific version (by vid or alias).
|
|
13
13
|
* - drupal_revert_revision is a GOVERNED WRITE: it reads the target revision
|
|
14
|
-
* and replays its editable attributes through
|
|
15
|
-
*
|
|
14
|
+
* and replays its editable attributes through prepareGuardedPatch /
|
|
15
|
+
* updateEntityGuarded (assertWriteAllowed gates it; internal/immutable
|
|
16
16
|
* bookkeeping fields are never written back).
|
|
17
17
|
*
|
|
18
18
|
* Reads are redacted per the site's security policy.
|
|
@@ -23,9 +23,11 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
23
23
|
import {
|
|
24
24
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
|
-
import {
|
|
27
|
-
|
|
26
|
+
import {
|
|
27
|
+
assertDraftLangcode, readDraftTranslation, readNodeDraftInventory, readTranslationInventory,
|
|
28
|
+
} from "../lib/sentinel-draft.js";
|
|
28
29
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
30
|
+
import { changedAheadOfRevision, prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
29
31
|
|
|
30
32
|
// Attributes that describe the entity's identity / revision bookkeeping / paths.
|
|
31
33
|
// These are read-only or server-managed and must NOT be replayed on a revert.
|
|
@@ -123,20 +125,6 @@ function summarizeRevision(resource) {
|
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
/**
|
|
127
|
-
* Whether the default revision's `changed` is later than its own
|
|
128
|
-
* `revision_timestamp` — the readable fingerprint of a #201 node.
|
|
129
|
-
* @param {?object} summary
|
|
130
|
-
* @returns {boolean}
|
|
131
|
-
*/
|
|
132
|
-
function changedAheadOfRevision(summary) {
|
|
133
|
-
if (!summary?.changed || !summary?.revisionTimestamp) return false;
|
|
134
|
-
const changed = Date.parse(summary.changed);
|
|
135
|
-
const rev = Date.parse(summary.revisionTimestamp);
|
|
136
|
-
if (!Number.isFinite(changed) || !Number.isFinite(rev)) return false;
|
|
137
|
-
return changed > rev;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
128
|
const LIST_REVISIONS_BASE_NOTE =
|
|
141
129
|
"JSON:API only addresses revisions by id (id:<vid>) or the rel:latest-version / " +
|
|
142
130
|
"rel:working-copy aliases; it cannot enumerate the full chronological history. " +
|
|
@@ -286,10 +274,11 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
|
|
|
286
274
|
|
|
287
275
|
/**
|
|
288
276
|
* Revert an entity to a prior revision (GOVERNED WRITE). Reads the target
|
|
289
|
-
* revision, then replays its editable attributes through
|
|
290
|
-
* become the new current revision. Internal/
|
|
291
|
-
* (drupal_internal__*, revision metadata,
|
|
292
|
-
* stripped before the write so only
|
|
277
|
+
* revision, then replays its editable attributes through prepareGuardedPatch
|
|
278
|
+
* / updateEntityGuarded so they become the new current revision. Internal /
|
|
279
|
+
* immutable bookkeeping fields (drupal_internal__*, revision metadata,
|
|
280
|
+
* computed path, timestamps) are stripped before the write so only
|
|
281
|
+
* restorable content is sent.
|
|
293
282
|
*
|
|
294
283
|
* This does not delete any history — the revert produces a NEW revision whose
|
|
295
284
|
* content matches the target, preserving the audit trail.
|
|
@@ -328,7 +317,19 @@ async function revertRevision({ site: siteName, type, id, version }) {
|
|
|
328
317
|
)
|
|
329
318
|
);
|
|
330
319
|
|
|
331
|
-
|
|
320
|
+
let existing = null;
|
|
321
|
+
try {
|
|
322
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
323
|
+
} catch {
|
|
324
|
+
existing = null;
|
|
325
|
+
}
|
|
326
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
327
|
+
entityType: "node", bundle: type, id, existing, attributes,
|
|
328
|
+
});
|
|
329
|
+
await updateEntityGuarded(backend, {
|
|
330
|
+
entityType: "node", bundle: type, id, attributes,
|
|
331
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
332
|
+
});
|
|
332
333
|
|
|
333
334
|
return {
|
|
334
335
|
success: true,
|
package/src/tools/structure.js
CHANGED
|
@@ -19,7 +19,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
19
19
|
import {
|
|
20
20
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
21
21
|
} from "../lib/security.js";
|
|
22
|
-
import { assertDraftLangcode } from "../lib/draft
|
|
22
|
+
import { assertDraftLangcode } from "../lib/sentinel-draft.js";
|
|
23
23
|
|
|
24
24
|
const MENU_LINK_TYPE = "menu_link_content";
|
|
25
25
|
const BLOCK_TYPE = "block_content";
|
package/src/tools/taxonomy.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
13
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
|
|
14
14
|
} from "../lib/security.js";
|
|
15
|
-
import { assertDraftLangcode } from "../lib/draft
|
|
15
|
+
import { assertDraftLangcode } from "../lib/sentinel-draft.js";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Implementations
|