drupal-mcp-connector 2.17.0 → 2.19.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.
@@ -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
- * Per-site MCP session cache, keyed by site._name. Holds the `Mcp-Session-Id`
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.set(site._name, sessionId);
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(site._name);
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: "tools/call",
262
- params: { name: toolName, arguments: args },
291
+ method,
292
+ params,
263
293
  };
264
294
 
265
- let sessionId = await ensureSession(site, endpoint);
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(site._name);
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
- await callTool(site, CONFIG_SET_TOOL, { name: "system.site", data: { name: "verification probe" } });
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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Tool group: GraphQL Compose Codegen (TypeScript / Next.js scaffolds).
3
3
  *
4
- * Thin Drush wrappers around `drupal/graphql_compose_codegen`. The generator
4
+ * Compatibility adapters through configured module bindings, or legacy Drush.
5
+ * Thin wrappers around `drupal/graphql_compose_codegen`. The generator
5
6
  * lives on the Drupal site; this connector does not reimplement it and does
6
7
  * not run GraphQL Code Generator against the SDL.
7
8
  *
@@ -16,6 +17,7 @@
16
17
 
17
18
  import { getSiteConfig } from "../lib/config.js";
18
19
  import { sshDrush } from "./drush.js";
20
+ import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
19
21
  import { SecurityError } from "../lib/security.js";
20
22
  import { validateMachineName } from "../lib/validate.js";
21
23
 
@@ -75,6 +77,18 @@ async function runGqcc(
75
77
  const site = getSiteConfig(siteName);
76
78
  const names = parseMachineNameList(bundles, "bundles");
77
79
  const skip = parseMachineNameList(skipFields, "skipFields");
80
+ if (site.serverTools?.bindings !== undefined) {
81
+ const binding = new Map([
82
+ [INSPECT, "codegenInspect"], [DIFF, "codegenDiff"], [GENERATE, "codegenPreview"],
83
+ ]).get(subcommand);
84
+ const result = toolResultData(await callBoundModuleTool(site, binding, {
85
+ bundles: names, skip_fields: skip,
86
+ }, { operation: "read", scope: "mcp_config", capabilities: ["configRead"] }));
87
+ if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
88
+ throw new SecurityError("Codegen module returned an invalid result. No SSH fallback was attempted.");
89
+ }
90
+ return { output: JSON.stringify(result.data), command: `module:${binding}`, wroteFiles: false };
91
+ }
78
92
  const args = [subcommand];
79
93
  if (names.length) args.push(`--bundles=${names.join(",")}`);
80
94
  if (skip.length) args.push(`--skip-fields=${skip.join(",")}`);
@@ -126,19 +140,19 @@ export const definitions = [
126
140
  {
127
141
  name: "drupal_codegen_inspect",
128
142
  description:
129
- "List node and paragraph bundles and extra fields from graphql_compose_codegen (`drush graphql-compose-codegen:inspect`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:inspect. Does not write files.",
143
+ "List node and paragraph bundles and extra fields from graphql_compose_codegen (`drush graphql-compose-codegen:inspect`). Uses the configured module binding; unbound sites require the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:inspect. Does not write files.",
130
144
  inputSchema: { type: "object", properties: LIST_PROPS },
131
145
  },
132
146
  {
133
147
  name: "drupal_codegen_diff",
134
148
  description:
135
- "Compare the live graphql_compose schema to the last gqcc:generate snapshot (`drush graphql-compose-codegen:diff`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:diff.",
149
+ "Compare the live graphql_compose schema to the last gqcc:generate snapshot (`drush graphql-compose-codegen:diff`). Uses the configured module binding; unbound sites require the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:diff.",
136
150
  inputSchema: { type: "object", properties: LIST_PROPS },
137
151
  },
138
152
  {
139
153
  name: "drupal_codegen_generate",
140
154
  description:
141
- "Return TypeScript/GraphQL scaffold artefacts from graphql_compose_codegen as text (`drush graphql-compose-codegen:generate --dry-run`). Never writes on the Drupal host (no --output-dir). Copy artefacts locally. Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:generate.",
155
+ "Return TypeScript/GraphQL scaffold artefacts from graphql_compose_codegen as text (`drush graphql-compose-codegen:generate --dry-run`). Never writes on the Drupal host (no --output-dir). Copy artefacts locally. Uses the configured module binding; unbound sites require the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:generate.",
142
156
  inputSchema: { type: "object", properties: LIST_PROPS },
143
157
  },
144
158
  ];
@@ -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 callServerTool(site, SERVER_TOOLS.configGet, { name });
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 callServerTool(site, SERVER_TOOLS.configList, args);
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 callServerTool(site, SERVER_TOOLS.configSet, { name, data: value });
86
+ return configTool(site, "configSet", { name, data: value }, "write", "configWrite");
77
87
  }
78
88
 
79
89
  // ---------------------------------------------------------------------------
@@ -20,6 +20,7 @@
20
20
  * Requires per-site "drushSsh" config block — tools fail gracefully if absent.
21
21
  */
22
22
 
23
+ import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
23
24
  import { Client } from "ssh2";
24
25
  import { readFileSync } from "fs";
25
26
  import { homedir } from "os";
@@ -456,6 +457,15 @@ async function sqlQuery({ site: siteName, query }) {
456
457
  assertGovernedRawSql(sshCfg, site._name);
457
458
  // Fast local reject only; mcp_sentinel's guard is the authority.
458
459
  validateSqlQuery(query);
460
+ if (site.serverTools?.bindings !== undefined) {
461
+ const result = toolResultData(await callBoundModuleTool(site, "sqlQuery", { query }, {
462
+ operation: "read", scope: "mcp_admin", capabilities: ["rawSql"],
463
+ }));
464
+ if (result?.success !== true || !result.data || !Array.isArray(result.data.rows)) {
465
+ throw new SecurityError("SQL module returned an invalid governed result. No SSH fallback was attempted.");
466
+ }
467
+ return result.data;
468
+ }
459
469
  const out = await sshDrush(site, ["mcp-sentinel:sql-query", query]);
460
470
  // The command emits a JSON object; a non-JSON reply means the command was
461
471
  // not found (mcp_sentinel absent or too old), which must not be reported as
@@ -534,7 +544,7 @@ export const definitions = [
534
544
  },
535
545
  {
536
546
  name: "drupal_drush_sql_query",
537
- description: "Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql=\"governed\" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection.",
547
+ description: "Run a single read-only SELECT through the configured sqlQuery module binding, or the legacy governed Drush command on unbound sites. Requires the site to set drushSsh.rawSql=\"governed\" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection.",
538
548
  inputSchema: { type: "object", required: ["query"], properties: { site: { type: "string" }, query: { type: "string" } } },
539
549
  },
540
550
  {
@@ -15,9 +15,9 @@ import {
15
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
16
16
  } from "../lib/security.js";
17
17
  import {
18
- assertDraftLangcode, readDraftTranslation, readTranslationInventory, writeDraft,
19
- } from "../lib/draft-write.js";
20
- import { assertInventoryDraftLanguage } from "../lib/node-draft-inventory.js";
18
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory,
19
+ } from "../lib/sentinel-draft.js";
20
+ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { entityRevisionId } from "../lib/write-revision.js";
22
22
 
23
23
  /**
@@ -138,26 +138,22 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
138
138
  const backend = await resolveBackend(site);
139
139
  if (langcode) {
140
140
  const targetLang = assertDraftLangcode(langcode);
141
- const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
142
- assertInventoryDraftLanguage(inventory, targetLang);
143
- const liveVid = inventory.live?.vid;
144
- const workingVid = inventory.working?.vid;
145
- if (!liveVid || !workingVid || String(workingVid) === String(liveVid)) {
146
- throw new Error(
147
- "No unpublished working translation for this language. " +
148
- "Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted.",
149
- );
150
- }
151
- const result = await writeDraft(backend, {
152
- entityType: "media", bundle: type, id, attributes, langcode: targetLang,
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,
153
148
  ...(relationships ? { relationships } : {}),
154
- draftRevision: { liveVid, workingVid },
149
+ langcode: targetLang,
150
+ ...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
155
151
  });
156
152
  const redacted = redactCanonicalEntity(result, sec, "media");
157
- const working = entityRevisionId(result) ?? workingVid;
153
+ const working = entityRevisionId(result) ?? patchTarget.workingVid;
158
154
  return {
159
155
  ...redacted,
160
- _revisions: { live: liveVid, working },
156
+ _revisions: { live: patchTarget.liveVid, working },
161
157
  };
162
158
  }
163
159
  // #171: pre-read so an unrequested published-state flip is reported, not silent.
@@ -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 { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
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
- if (langcode) {
69
- const targetLang = assertDraftLangcode(langcode);
70
- let existing = null;
71
- try {
72
- existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
73
- } catch {
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 entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
86
- return redactCanonicalEntity(entity, sec, "node");
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 (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
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 (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
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.",
@@ -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-write.js";
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";
@@ -46,7 +46,7 @@ import {
46
46
  assertDraftLangcode,
47
47
  readDraftTranslation,
48
48
  writeDraft,
49
- } from "../lib/draft-write.js";
49
+ } from "../lib/sentinel-draft.js";
50
50
 
51
51
  /**
52
52
  * Build the resource-identifier ref used to embed a paragraph in a host ERR /
@@ -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 { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
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 && (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function")) {
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 (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
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
- const missingEndpoint = /does not provide Sentinel's governed draft-translation endpoint/;
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 (!missingEndpoint.test(String(error?.message))) throw error;
243
+ if (!isMissingTranslationEndpoint(error)) throw error;
243
244
  }
244
245
  }
245
246
 
@@ -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 updateEntity to make them the
15
- * new current revision (assertWriteAllowed gates it; internal/immutable
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 { readNodeDraftInventory } from "../lib/node-draft-inventory.js";
27
- import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
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 updateEntity so they
290
- * become the new current revision. Internal/immutable bookkeeping fields
291
- * (drupal_internal__*, revision metadata, computed path, timestamps) are
292
- * stripped before the write so only restorable content is sent.
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
- await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
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,
@@ -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-write.js";
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";
@@ -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-write.js";
15
+ import { assertDraftLangcode } from "../lib/sentinel-draft.js";
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Implementations