drupal-mcp-connector 2.7.1 → 2.7.3
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/.claude/commands/drupal-governance-status.md +2 -2
- package/.claude/commands/drupal-report-orphaned-references.md +2 -2
- package/CHANGELOG.md +45 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.js +5 -2
- package/src/lib/governance.js +9 -6
- package/src/lib/load-secrets.js +40 -5
- package/src/lib/patch-preflight.js +82 -10
- package/src/tools/reports-extra.js +96 -27
- package/src/tools/site.js +10 -10
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Report each configured site's source-governance condition
|
|
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
|
|
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,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.7.3] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Launcher and startup name a secret-table / config mismatch (#211).**
|
|
14
|
+
The shipped Keychain table matches `config/config.example.json`. A
|
|
15
|
+
`config.json` that uses different `clientSecretEnv` names without a
|
|
16
|
+
`config/secrets.map` left the table and the config each valid and
|
|
17
|
+
jointly inert. Per-item Keychain misses stay silent (break-glass).
|
|
18
|
+
When the table matches **no** named secret, `bin/drupal-mcp-launch.sh`
|
|
19
|
+
and `src/lib/load-secrets.js` now print one stderr line that names
|
|
20
|
+
the unmapped variables and says `secrets.map` is absent (or that the
|
|
21
|
+
map does not name them). Fail-closed start when every named secret is
|
|
22
|
+
unset is unchanged.
|
|
23
|
+
|
|
24
|
+
## [2.7.2] - 2026-08-18
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **Orphan-reference report no longer treats 403 / policy denial as a missing
|
|
28
|
+
target (#205).** `drupal_report_orphaned_references` used to count any
|
|
29
|
+
non-OK probe as an orphan. On a site whose policy denies `user`, every
|
|
30
|
+
`uid` / `revision_uid` became a false finding — 64 "orphans" across 32
|
|
31
|
+
healthy nodes in the report that produced this issue. Only a 404 (or an
|
|
32
|
+
unaddressable ref) is an orphan. Denied targets are a third state:
|
|
33
|
+
`unverifiable` plus `reason: "target entity type denied by policy"`.
|
|
34
|
+
Author base fields are skipped when the policy denies `user`, so the
|
|
35
|
+
auditor presets stop manufacturing corruption.
|
|
36
|
+
- **PATCH-blocked message names a pending draft when one is visible
|
|
37
|
+
(#201 follow-up).** The preflight added in 2.7.1 always said the blocking
|
|
38
|
+
row was invisible and needed revision surgery. That is right for a stray
|
|
39
|
+
revision with no content_moderation working copy, and wrong for an
|
|
40
|
+
ordinary open draft — the common case, and the dangerous advice. The
|
|
41
|
+
preflight now loads `rel:working-copy`: if it resolves, the error is
|
|
42
|
+
"This node has a pending draft (vid N). Publish or discard it before a
|
|
43
|
+
canonical PATCH." Surgery is mentioned only when the working copy does
|
|
44
|
+
not resolve and the guard still fires.
|
|
45
|
+
- **`drupal_governance_status` no longer reports `ok: true` without a
|
|
46
|
+
check (#208).** When the client did not set `requireGovernance`, the
|
|
47
|
+
diagnostic skipped the readiness probe and returned
|
|
48
|
+
`{ required: false, ok: true, checkedAt: null }` while the same site
|
|
49
|
+
503'd every governed request with `designated_consumer_disabled`. It
|
|
50
|
+
now always probes `GET /drupal-mcp/readiness`, sets `checked: true` and
|
|
51
|
+
`checkedAt`, and surfaces the server's reason verbatim. `ok: true` only
|
|
52
|
+
after that check. Unresolved site configs report `checked: false` and
|
|
53
|
+
omit `checkedAt`.
|
|
54
|
+
|
|
10
55
|
## [2.7.1] - 2026-08-18
|
|
11
56
|
|
|
12
57
|
### 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.
|
|
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.3** (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
package/src/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
34
34
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
35
35
|
|
|
36
36
|
import { listSiteNames, getTlsConfig, loadConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
37
|
-
import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
|
|
37
|
+
import { loadLocalSecrets, secretLoadFatalMessage, secretTableMismatchMessage } from "./lib/load-secrets.js";
|
|
38
38
|
import {
|
|
39
39
|
makeBearerCheck,
|
|
40
40
|
resolveInboundAuthConfig,
|
|
@@ -68,7 +68,10 @@ if (secretFatal) {
|
|
|
68
68
|
console.error(`[drupal-mcp-connector] FATAL: ${secretFatal}`);
|
|
69
69
|
process.exit(1);
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
const secretMismatch = secretTableMismatchMessage(secretLoad);
|
|
72
|
+
if (secretMismatch) {
|
|
73
|
+
console.error(`[drupal-mcp-connector] WARNING: ${secretMismatch}`);
|
|
74
|
+
} else if (secretLoad.unset.length) {
|
|
72
75
|
console.error(
|
|
73
76
|
"[drupal-mcp-connector] WARNING: config.json names secret env vars that are unset: " +
|
|
74
77
|
`${secretLoad.unset.join(", ")}. Those sites will fail closed.`
|
package/src/lib/governance.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
180
|
+
required: requiresGovernance(site),
|
|
181
|
+
checked: true,
|
|
179
182
|
ok: result.ok,
|
|
180
183
|
reason: result.reason,
|
|
181
184
|
checkedAt: result.checkedAt,
|
package/src/lib/load-secrets.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* The default table matches config/config.example.json. A gitignored
|
|
10
10
|
* config/secrets.map replaces that table for a deployment whose env-var
|
|
11
11
|
* names differ. Per-item Keychain misses stay silent (inert break-glass).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Zero overlap between the table and the names in config.json is a
|
|
13
|
+
* distinct case — the two files are jointly inert — and the caller should
|
|
14
|
+
* say so. If every named secret is still unset after this step, the caller
|
|
15
|
+
* must refuse to start.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import { execFileSync } from "node:child_process";
|
|
@@ -101,7 +103,7 @@ export function lookupKeychainItem(item) {
|
|
|
101
103
|
* @param {NodeJS.ProcessEnv} [options.env] Mutated when a lookup succeeds.
|
|
102
104
|
* @param {typeof readFileSync} [options.readFile]
|
|
103
105
|
* @param {(item: string) => string} [options.lookup]
|
|
104
|
-
* @returns {{pairs: number, resolved: number, named: string[], unset: string[]}}
|
|
106
|
+
* @returns {{pairs: number, resolved: number, named: string[], unset: string[], tableVars: string[], source: "map"|"default"}}
|
|
105
107
|
*/
|
|
106
108
|
export function loadLocalSecrets({
|
|
107
109
|
cwd = process.cwd(),
|
|
@@ -109,11 +111,13 @@ export function loadLocalSecrets({
|
|
|
109
111
|
readFile = readFileSync,
|
|
110
112
|
lookup = lookupKeychainItem,
|
|
111
113
|
} = {}) {
|
|
114
|
+
let source = "map";
|
|
112
115
|
let pairs;
|
|
113
116
|
try {
|
|
114
117
|
pairs = parseSecretMap(readFile(join(cwd, "config", "secrets.map"), "utf8"));
|
|
115
118
|
} catch {
|
|
116
119
|
pairs = DEFAULT_SECRET_PAIRS;
|
|
120
|
+
source = "default";
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
let resolved = 0;
|
|
@@ -140,16 +144,47 @@ export function loadLocalSecrets({
|
|
|
140
144
|
|
|
141
145
|
const envMap = new Map(Object.entries(env));
|
|
142
146
|
const unset = named.filter((name) => !envMap.get(name));
|
|
143
|
-
|
|
147
|
+
const tableVars = pairs.map(([varName]) => varName);
|
|
148
|
+
return { pairs: pairs.length, resolved, named, unset, tableVars, source };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One-line diagnosis when the secret table and config.json share no names.
|
|
153
|
+
* Null when there is any overlap, or when config names no secrets.
|
|
154
|
+
* Per-item Keychain misses are not a mismatch.
|
|
155
|
+
* @param {{named: string[], tableVars?: string[], source?: "map"|"default"}} loaded
|
|
156
|
+
* @returns {string|null}
|
|
157
|
+
*/
|
|
158
|
+
export function secretTableMismatchMessage(loaded) {
|
|
159
|
+
if (!loaded.named.length) return null;
|
|
160
|
+
const table = new Set(loaded.tableVars || []);
|
|
161
|
+
const unmapped = loaded.named.filter((name) => !table.has(name));
|
|
162
|
+
if (unmapped.length !== loaded.named.length) return null;
|
|
163
|
+
const source = loaded.source === "map"
|
|
164
|
+
? "config/secrets.map does not name them"
|
|
165
|
+
: "using shipped defaults; config/secrets.map is absent";
|
|
166
|
+
const unset = Array.isArray(loaded.unset) ? loaded.unset : [];
|
|
167
|
+
const closer = unset.length
|
|
168
|
+
? "Unset: " + unset.join(", ") + ". Those sites will fail closed."
|
|
169
|
+
: "Named secrets already in the environment stay set; the table will not populate any of them.";
|
|
170
|
+
return (
|
|
171
|
+
"no secret-table entries match clientSecretEnv/apiTokenEnv names in config.json " +
|
|
172
|
+
"(" + unmapped.join(", ") + "); " + source + ". " +
|
|
173
|
+
closer
|
|
174
|
+
);
|
|
144
175
|
}
|
|
145
176
|
|
|
146
177
|
/**
|
|
147
178
|
* Refuse to boot a server that can only advertise diagnostic tools.
|
|
148
|
-
* @param {{named: string[], unset: string[]}} loaded
|
|
179
|
+
* @param {{named: string[], unset: string[], tableVars?: string[], source?: "map"|"default"}} loaded
|
|
149
180
|
* @returns {string|null} Fatal message, or null when start is allowed.
|
|
150
181
|
*/
|
|
151
182
|
export function secretLoadFatalMessage(loaded) {
|
|
152
183
|
if (!loaded.named.length || loaded.unset.length !== loaded.named.length) return null;
|
|
184
|
+
const mismatch = secretTableMismatchMessage(loaded);
|
|
185
|
+
if (mismatch) {
|
|
186
|
+
return mismatch + " Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) or export them before launch.";
|
|
187
|
+
}
|
|
153
188
|
return (
|
|
154
189
|
`every clientSecretEnv/apiTokenEnv named in config.json is unset (${loaded.unset.join(", ")}). ` +
|
|
155
190
|
"Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) " +
|
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
*
|
|
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 ->
|
|
228
|
+
const resolution = new Map(); // id -> "ok" | "missing" | "denied" | "forbidden" | "failed"
|
|
187
229
|
/**
|
|
188
|
-
* Resolve
|
|
230
|
+
* Resolve a referenced target, caching the classification.
|
|
189
231
|
* @param {{id: string, entityType: ?string, bundle: ?string}} ref Reference to probe.
|
|
190
|
-
* @
|
|
232
|
+
* @param {string} fieldName Host field that holds the ref.
|
|
233
|
+
* @returns {Promise<"ok"|"missing"|"denied"|"forbidden"|"failed">}
|
|
191
234
|
*/
|
|
192
|
-
async function
|
|
235
|
+
async function classifyRef(ref, fieldName) {
|
|
193
236
|
if (resolution.has(ref.id)) return resolution.get(ref.id);
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
217
|
-
if (
|
|
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
|
-
|
|
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
|
|
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" } },
|