drupal-mcp-connector 2.7.1 → 2.7.2

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.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial."
2
+ description: "Report each configured site's source-governance condition. Always probes GET /drupal-mcp/readiness (even when this client does not require governance) and surfaces the server's reason verbatim. Never reports ok:true unless that check ran. Callable even while governed paths are denied — this is the diagnostic for that denial."
3
3
  argument-hint: "[site]"
4
4
  allowed-tools: mcp__drupal__drupal_governance_status
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_governance_status` MCP tool.
8
8
 
9
- Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial.
9
+ Report each configured site's source-governance condition. Always probes GET /drupal-mcp/readiness (even when this client does not require governance) and surfaces the server's reason verbatim. Never reports ok:true unless that check ran. Callable even while governed paths are denied — this is the diagnostic for that denial.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. Flags 'approximate' when sampling-bounded."
2
+ description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded."
3
3
  argument-hint: "[site] [type] [sampleSize]"
4
4
  allowed-tools: mcp__drupal__drupal_report_orphaned_references
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_report_orphaned_references` MCP tool.
8
8
 
9
- Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. Flags 'approximate' when sampling-bounded.
9
+ Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.2] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - **Orphan-reference report no longer treats 403 / policy denial as a missing
14
+ target (#205).** `drupal_report_orphaned_references` used to count any
15
+ non-OK probe as an orphan. On a site whose policy denies `user`, every
16
+ `uid` / `revision_uid` became a false finding — 64 "orphans" across 32
17
+ healthy nodes in the report that produced this issue. Only a 404 (or an
18
+ unaddressable ref) is an orphan. Denied targets are a third state:
19
+ `unverifiable` plus `reason: "target entity type denied by policy"`.
20
+ Author base fields are skipped when the policy denies `user`, so the
21
+ auditor presets stop manufacturing corruption.
22
+ - **PATCH-blocked message names a pending draft when one is visible
23
+ (#201 follow-up).** The preflight added in 2.7.1 always said the blocking
24
+ row was invisible and needed revision surgery. That is right for a stray
25
+ revision with no content_moderation working copy, and wrong for an
26
+ ordinary open draft — the common case, and the dangerous advice. The
27
+ preflight now loads `rel:working-copy`: if it resolves, the error is
28
+ "This node has a pending draft (vid N). Publish or discard it before a
29
+ canonical PATCH." Surgery is mentioned only when the working copy does
30
+ not resolve and the guard still fires.
31
+ - **`drupal_governance_status` no longer reports `ok: true` without a
32
+ check (#208).** When the client did not set `requireGovernance`, the
33
+ diagnostic skipped the readiness probe and returned
34
+ `{ required: false, ok: true, checkedAt: null }` while the same site
35
+ 503'd every governed request with `designated_consumer_disabled`. It
36
+ now always probes `GET /drupal-mcp/readiness`, sets `checked: true` and
37
+ `checkedAt`, and surfaces the server's reason verbatim. `ok: true` only
38
+ after that check. Unresolved site configs report `checked: false` and
39
+ omit `checkedAt`.
40
+
10
41
  ## [2.7.1] - 2026-08-18
11
42
 
12
43
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.1** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.2** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -162,20 +162,23 @@ export async function assertSourceGovernance(site) {
162
162
 
163
163
  /**
164
164
  * Per-site governance condition for operator diagnostics. No secrets: only
165
- * the site name, whether governance is required, the verdict, and the reason.
165
+ * the site name, whether this client requires governance, whether the
166
+ * readiness endpoint was probed, the verdict, and the server's reason.
167
+ *
168
+ * Always probes `GET /drupal-mcp/readiness`, even when `requireGovernance`
169
+ * is off — the server may still refuse governed paths (#208). Never reports
170
+ * `ok: true` without a check.
166
171
  *
167
172
  * @param {Array<object>} sites Resolved site configs.
168
- * @returns {Promise<Array<{site: string, required: boolean, ok: boolean, reason: string|null, checkedAt: number|null}>>}
173
+ * @returns {Promise<Array<{site: string, required: boolean, checked: boolean, ok: boolean, reason: string|null, checkedAt: number}>>}
169
174
  */
170
175
  export async function governanceStatus(sites) {
171
176
  return Promise.all(sites.map(async (site) => {
172
- if (!requiresGovernance(site)) {
173
- return { site: site._name, required: false, ok: true, reason: null, checkedAt: null };
174
- }
175
177
  const result = await verifySourceGovernance(site);
176
178
  return {
177
179
  site: site._name,
178
- required: true,
180
+ required: requiresGovernance(site),
181
+ checked: true,
179
182
  ok: result.ok,
180
183
  reason: result.reason,
181
184
  checkedAt: result.checkedAt,
@@ -28,8 +28,8 @@ export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
28
28
  const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
29
29
 
30
30
  /**
31
- * Actionable replacement for core's "has a working copy" 400.
32
- * Clearing the blocking row is revision surgery outside JSON:API.
31
+ * Actionable replacement for core's "has a working copy" 400 when no
32
+ * content_moderation working copy is addressable a stray revision row.
33
33
  */
34
34
  export const PATCH_BLOCKED_MESSAGE =
35
35
  "This entity cannot be updated over JSON:API because the stored entity is not " +
@@ -38,15 +38,58 @@ export const PATCH_BLOCKED_MESSAGE =
38
38
  "Clearing it requires revision surgery outside JSON:API (Drush / the entity API). " +
39
39
  "See connector #201. Do not retry the same canonical PATCH.";
40
40
 
41
+ /**
42
+ * Read a revision id off a working-copy body (canonical or raw-ish).
43
+ * @param {?object} workingCopy
44
+ * @returns {?number|string}
45
+ */
46
+ function workingCopyVid(workingCopy) {
47
+ if (!workingCopy || typeof workingCopy !== "object") return null;
48
+ const fields = workingCopy.fields && typeof workingCopy.fields === "object"
49
+ ? workingCopy.fields
50
+ : {};
51
+ const attrs = workingCopy.attributes && typeof workingCopy.attributes === "object"
52
+ ? workingCopy.attributes
53
+ : {};
54
+ const raw = workingCopy.vid
55
+ ?? fields.drupal_internal__vid
56
+ ?? attrs.drupal_internal__vid
57
+ ?? workingCopy.drupal_internal__vid;
58
+ if (raw === undefined || raw === null || raw === "") return null;
59
+ const n = Number(raw);
60
+ return Number.isFinite(n) ? n : raw;
61
+ }
62
+
63
+ /**
64
+ * Operator message for a core working-copy 400.
65
+ * A resolvable working copy is an ordinary pending draft — do not prescribe
66
+ * revision surgery. Surgery is only for the invisible-row case (#201 follow-up).
67
+ * @param {?object} [workingCopy]
68
+ * @returns {string}
69
+ */
70
+ export function patchBlockedMessage(workingCopy) {
71
+ if (workingCopy) {
72
+ const vid = workingCopyVid(workingCopy);
73
+ const which = vid !== null && vid !== undefined ? ` (vid ${vid})` : "";
74
+ return `This node has a pending draft${which}. Publish or discard it `
75
+ + "before a canonical PATCH.";
76
+ }
77
+ return PATCH_BLOCKED_MESSAGE;
78
+ }
79
+
41
80
  /**
42
81
  * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
43
82
  */
44
83
  export class PatchBlockedError extends Error {
45
- /** @param {?Error} [cause] The original Drupal 400. */
46
- constructor(cause) {
47
- super(PATCH_BLOCKED_MESSAGE);
84
+ /**
85
+ * @param {?Error} [cause] The original Drupal 400.
86
+ * @param {{workingCopy?: ?object}} [options]
87
+ */
88
+ constructor(cause, { workingCopy } = {}) {
89
+ super(patchBlockedMessage(workingCopy ?? null));
48
90
  this.name = "PatchBlockedError";
49
91
  this.code = PATCH_BLOCKED_CODE;
92
+ if (workingCopy) this.workingCopyVid = workingCopyVid(workingCopy);
50
93
  if (cause) this.cause = cause;
51
94
  }
52
95
  }
@@ -62,13 +105,36 @@ export function isWorkingCopyPatchError(err) {
62
105
 
63
106
  /**
64
107
  * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
65
- * return the original value.
108
+ * return the original value. Pass `workingCopy` when the pending draft is
109
+ * addressable so the message does not prescribe revision surgery.
66
110
  * @param {unknown} err
111
+ * @param {{workingCopy?: ?object}} [options]
67
112
  * @returns {unknown}
68
113
  */
69
- export function rewriteWorkingCopyPatchError(err) {
114
+ export function rewriteWorkingCopyPatchError(err, { workingCopy } = {}) {
70
115
  if (!isWorkingCopyPatchError(err)) return err;
71
- return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
116
+ return new PatchBlockedError(
117
+ err instanceof Error ? err : new Error(String(err)),
118
+ { workingCopy },
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Load `rel:working-copy` so a blocked PATCH can name a pending draft.
124
+ * @param {object} backend
125
+ * @param {{entityType: string, bundle: string, id: string}} ref
126
+ * @returns {Promise<?object>}
127
+ */
128
+ async function loadWorkingCopy(backend, { entityType, bundle, id }) {
129
+ if (typeof backend?.getEntity !== "function") return null;
130
+ try {
131
+ const wc = await backend.getEntity({
132
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
133
+ });
134
+ return wc || null;
135
+ } catch {
136
+ return null;
137
+ }
72
138
  }
73
139
 
74
140
  /**
@@ -151,7 +217,11 @@ export async function preflightPatchWritable({
151
217
  );
152
218
  } catch (err) {
153
219
  if (isWorkingCopyPatchError(err)) {
154
- throw new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
220
+ const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
221
+ throw new PatchBlockedError(
222
+ err instanceof Error ? err : new Error(String(err)),
223
+ { workingCopy },
224
+ );
155
225
  }
156
226
  if (isProbePassedWithoutSave(err)) {
157
227
  return { probed: true, writable: true };
@@ -171,6 +241,8 @@ export async function updateEntityGuarded(backend, input) {
171
241
  try {
172
242
  return await backend.updateEntity(input);
173
243
  } catch (err) {
174
- throw rewriteWorkingCopyPatchError(err);
244
+ if (!isWorkingCopyPatchError(err)) throw err;
245
+ const workingCopy = await loadWorkingCopy(backend, input);
246
+ throw rewriteWorkingCopyPatchError(err, { workingCopy });
175
247
  }
176
248
  }
@@ -12,9 +12,49 @@
12
12
 
13
13
  import { getSiteConfig } from "../lib/config.js";
14
14
  import { resolveBackend } from "../lib/backends/index.js";
15
- import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
15
+ import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
16
16
  import { collectEntities, fieldValue } from "../lib/reports-support.js";
17
17
 
18
+ /** Author base fields that only ever point at `user`. */
19
+ const AUTHOR_BASE_FIELDS = new Set(["uid", "revision_uid"]);
20
+
21
+ const POLICY_DENIED_REASON = "target entity type denied by policy";
22
+ const ACCESS_DENIED_REASON = "target access denied";
23
+ const UNVERIFIED_REASON = "target could not be verified";
24
+
25
+ /**
26
+ * Whether connector policy forbids reading this entity type.
27
+ * @param {object} sec Resolved security config.
28
+ * @param {string} entityType Entity type machine name.
29
+ * @returns {boolean}
30
+ */
31
+ function isEntityTypeDenied(sec, entityType) {
32
+ if (!entityType) return false;
33
+ try {
34
+ assertEntityTypeAllowed(sec, entityType);
35
+ return false;
36
+ } catch {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Classify a getEntity failure. Only a 404 (or an unaddressable ref) is an
43
+ * orphan. Connector policy, Drupal 401/403, and other failures are
44
+ * unverifiable — and must not share one reason.
45
+ * @param {unknown} err
46
+ * @returns {"missing"|"forbidden"|"failed"}
47
+ */
48
+ function classifyTargetError(err) {
49
+ const msg = String(err?.message || err || "");
50
+ const statusMatch = msg.match(/\bDrupal (\d{3})\b/i);
51
+ const status = statusMatch ? Number(statusMatch[1]) : NaN;
52
+ if (status === 404) return "missing";
53
+ if (status === 401 || status === 403) return "forbidden";
54
+ if (/\b404\b/.test(msg) && !/\b40[13]\b/.test(msg)) return "missing";
55
+ return "failed";
56
+ }
57
+
18
58
  /**
19
59
  * Determine whether a canonical field/relationship value counts as "empty".
20
60
  * Handles scalars, JSON:API value-objects ({value}), arrays, and relationship
@@ -161,9 +201,9 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
161
201
  /**
162
202
  * Orphaned entity references: sampled entities whose entity-reference fields
163
203
  * point at targets that no longer exist. Best-effort — each distinct referenced
164
- * target is probed once via getEntity; a null result or a fetch error is treated
165
- * as an unresolved (orphaned) target. Sampling-bounded, so `approximate` is set
166
- * when the entity scan is capped.
204
+ * target is probed once via getEntity. Only a 404 / unaddressable ref is an
205
+ * orphan; 401/403 and connector policy denials are unverifiable (#205).
206
+ * Sampling-bounded, so `approximate` is set when the entity scan is capped.
167
207
  *
168
208
  * @param {object} args - { site?, type?, sampleSize? }. `type` defaults to "article".
169
209
  * @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
@@ -181,40 +221,56 @@ async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
181
221
  sampleSize
182
222
  );
183
223
 
224
+ const userDenied = isEntityTypeDenied(sec, "user");
225
+
184
226
  // Cache resolution results across all sampled entities so a target is only
185
227
  // looked up once (de-dupes both within and across entities).
186
- const resolution = new Map(); // id -> boolean (true = exists)
228
+ const resolution = new Map(); // id -> "ok" | "missing" | "denied" | "forbidden" | "failed"
187
229
  /**
188
- * Resolve whether a referenced target exists, caching the result.
230
+ * Resolve a referenced target, caching the classification.
189
231
  * @param {{id: string, entityType: ?string, bundle: ?string}} ref Reference to probe.
190
- * @returns {Promise<boolean>} True if the target resolves to an entity.
232
+ * @param {string} fieldName Host field that holds the ref.
233
+ * @returns {Promise<"ok"|"missing"|"denied"|"forbidden"|"failed">}
191
234
  */
192
- async function exists(ref) {
235
+ async function classifyRef(ref, fieldName) {
193
236
  if (resolution.has(ref.id)) return resolution.get(ref.id);
194
- let ok = false;
237
+
238
+ const typeDenied = isEntityTypeDenied(sec, ref.entityType)
239
+ || (AUTHOR_BASE_FIELDS.has(fieldName) && userDenied);
240
+ if (typeDenied) {
241
+ resolution.set(ref.id, "denied");
242
+ return "denied";
243
+ }
244
+
245
+ if (!ref.entityType || !ref.bundle) {
246
+ resolution.set(ref.id, "missing");
247
+ return "missing";
248
+ }
249
+
195
250
  try {
196
- // entityType/bundle are derived from JSON:API "type"; both required to fetch.
197
- if (ref.entityType && ref.bundle) {
198
- const target = await backend.getEntity({ entityType: ref.entityType, bundle: ref.bundle, id: ref.id });
199
- ok = Boolean(target);
200
- } else {
201
- // Cannot address the target without a concrete type+bundle; treat as
202
- // unresolved rather than silently passing.
203
- ok = false;
204
- }
205
- } catch {
206
- ok = false;
251
+ const target = await backend.getEntity({
252
+ entityType: ref.entityType, bundle: ref.bundle, id: ref.id,
253
+ });
254
+ const state = target ? "ok" : "missing";
255
+ resolution.set(ref.id, state);
256
+ return state;
257
+ } catch (err) {
258
+ const state = classifyTargetError(err);
259
+ resolution.set(ref.id, state);
260
+ return state;
207
261
  }
208
- resolution.set(ref.id, ok);
209
- return ok;
210
262
  }
211
263
 
212
264
  const findings = [];
265
+ let unverifiable = 0;
266
+ let deniedByPolicy = 0;
267
+ let accessDenied = 0;
213
268
  for (const e of entities) {
214
269
  for (const [fieldName, rel] of Object.entries(e.relationships ?? {})) {
215
270
  for (const ref of refsOf(rel)) {
216
- const ok = await exists(ref);
217
- if (!ok) {
271
+ const state = await classifyRef(ref, fieldName);
272
+ if (state === "ok") continue;
273
+ if (state === "missing") {
218
274
  findings.push({
219
275
  id: e.id,
220
276
  title: e.title,
@@ -223,21 +279,34 @@ async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
223
279
  targetEntityType: ref.entityType,
224
280
  targetBundle: ref.bundle,
225
281
  });
282
+ continue;
226
283
  }
284
+ unverifiable += 1;
285
+ if (state === "denied") deniedByPolicy += 1;
286
+ if (state === "forbidden") accessDenied += 1;
227
287
  }
228
288
  }
229
289
  }
230
290
 
231
291
  const approximate = entities.length >= sampleSize;
292
+ const orphaned = findings.length;
293
+ let reason;
294
+ if (deniedByPolicy > 0) reason = POLICY_DENIED_REASON;
295
+ else if (accessDenied > 0) reason = ACCESS_DENIED_REASON;
296
+ else if (unverifiable > 0) reason = UNVERIFIED_REASON;
232
297
  return {
233
298
  contentType,
234
299
  scanned: entities.length,
235
300
  sampleSize,
236
301
  approximate,
237
- totalOrphaned: findings.length,
302
+ orphaned,
303
+ unverifiable,
304
+ totalOrphaned: orphaned,
305
+ reason,
238
306
  note: approximate
239
307
  ? "Best-effort: reference integrity is checked over a sampling-bounded set of entities."
240
- : "Best-effort: each referenced target is probed once via JSON:API.",
308
+ : "Best-effort: each referenced target is probed once via JSON:API. "
309
+ + "401/403 and policy-denied types are unverifiable, not orphans.",
241
310
  findings,
242
311
  };
243
312
  }
@@ -274,7 +343,7 @@ export const definitions = [
274
343
  },
275
344
  {
276
345
  name: "drupal_report_orphaned_references",
277
- description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. Flags 'approximate' when sampling-bounded.",
346
+ description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.",
278
347
  inputSchema: {
279
348
  type: "object",
280
349
  properties: {
package/src/tools/site.js CHANGED
@@ -61,14 +61,6 @@ async function listConfiguredSites() {
61
61
  return visibleSiteTargets(getRequestIdentity(), resolvable, names);
62
62
  }
63
63
 
64
- /**
65
- * Per-site source-governance condition (#176). The one governed-path
66
- * diagnostic that stays callable while governance is failing, so an operator
67
- * can see WHICH required condition failed. Never includes credentials.
68
- *
69
- * @param {object} args - { site? } (a named site narrows the report).
70
- * @returns {Promise<{sites: object[]}>} required/ok/reason per site.
71
- */
72
64
  /**
73
65
  * Classify a getSiteConfig failure so the diagnostic reason matches the cause.
74
66
  * @param {string} message
@@ -81,6 +73,14 @@ export function classifySiteResolutionFailure(message) {
81
73
  return "site_unresolved";
82
74
  }
83
75
 
76
+ /**
77
+ * Per-site source-governance condition (#176, #208). Stays callable while
78
+ * governed paths are denied. Always probes the source readiness endpoint
79
+ * for resolved sites; never reports ok:true without that check.
80
+ *
81
+ * @param {object} [args] - { site? } (a named site narrows the report).
82
+ * @returns {Promise<{sites: object[]}>} required/checked/ok/reason per site.
83
+ */
84
84
  async function getGovernanceStatus({ site: siteName } = {}) {
85
85
  const identity = getRequestIdentity();
86
86
  const configured = listSiteNames();
@@ -96,10 +96,10 @@ async function getGovernanceStatus({ site: siteName } = {}) {
96
96
  unresolved.push({
97
97
  site: name,
98
98
  required: null,
99
+ checked: false,
99
100
  ok: false,
100
101
  reason: classifySiteResolutionFailure(detail),
101
102
  detail,
102
- checkedAt: null,
103
103
  });
104
104
  }
105
105
  }
@@ -129,7 +129,7 @@ export const definitions = [
129
129
  },
130
130
  {
131
131
  name: "drupal_governance_status",
132
- description: "Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial.",
132
+ description: "Report each configured site's source-governance condition. Always probes GET /drupal-mcp/readiness (even when this client does not require governance) and surfaces the server's reason verbatim. Never reports ok:true unless that check ran. Callable even while governed paths are denied — this is the diagnostic for that denial.",
133
133
  inputSchema: {
134
134
  type: "object",
135
135
  properties: { site: { type: "string" } },