drupal-mcp-connector 2.7.0 → 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.
- package/.claude/commands/drupal-bulk-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-governance-status.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-report-orphaned-references.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/governance.js +9 -6
- package/src/lib/patch-preflight.js +248 -0
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/reports-extra.js +96 -27
- package/src/tools/revisions.js +42 -7
- package/src/tools/site.js +10 -10
package/src/tools/paragraphs.js
CHANGED
|
@@ -9,17 +9,24 @@
|
|
|
9
9
|
* focused way to mint a paragraph and fetch it back, plus the relationship data
|
|
10
10
|
* needed to embed it into a host field.
|
|
11
11
|
*
|
|
12
|
-
* Embedding model — IMPORTANT:
|
|
13
|
-
* - Over JSON:API
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
12
|
+
* Embedding model — IMPORTANT (#192):
|
|
13
|
+
* - Over JSON:API a host references a paragraph from its ERR field by a
|
|
14
|
+
* resource identifier `{ type: "paragraph--<bundle>", id, meta: {
|
|
15
|
+
* target_revision_id } }`. Drupal's ERR item is empty unless both
|
|
16
|
+
* `target_id` and `target_revision_id` are set; JSON:API only receives the
|
|
17
|
+
* revision id from `meta.target_revision_id`. `{ type, id }` alone persists
|
|
18
|
+
* an empty field — it is not a no-op, and it is worse than omitting the
|
|
19
|
+
* field (which would inherit the previous revision's refs).
|
|
20
|
+
* - `relationshipData` from these tools includes that meta key. Host writes
|
|
21
|
+
* (`drupal_entity_update` / `drupal_update_node` / bulk update) also resolve
|
|
22
|
+
* a missing vid before PATCH and fail the whole write if they cannot.
|
|
19
23
|
* - The classic entity-API pair `{ target_id, target_revision_id }` (integer
|
|
20
|
-
* ids) is the REST/Form-API shape, not the JSON:API shape.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
24
|
+
* ids) is the REST/Form-API shape, not the JSON:API shape.
|
|
25
|
+
* - Creating paragraphs and then attaching them is two calls. A content-tier
|
|
26
|
+
* agent cannot delete orphans if the host PATCH is then rejected (#201).
|
|
27
|
+
* Probe the host first: `drupal_list_revisions` (`possiblyPatchBlocked`)
|
|
28
|
+
* and `dryRun` on the host update. Preflight inside `update_node` does not
|
|
29
|
+
* un-orphan work that already happened.
|
|
23
30
|
*
|
|
24
31
|
* Both tools are governed: writes assert create permission for the `paragraph`
|
|
25
32
|
* entity type + bundle, reads assert read permission and are redacted per the
|
|
@@ -32,33 +39,35 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
32
39
|
import {
|
|
33
40
|
resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
|
|
34
41
|
} from "../lib/security.js";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* @param {string} bundle Paragraph type machine name.
|
|
39
|
-
* @returns {string} e.g. "paragraph--text".
|
|
40
|
-
*/
|
|
41
|
-
function resourceType(bundle) {
|
|
42
|
-
return `paragraph--${bundle}`;
|
|
43
|
-
}
|
|
42
|
+
import {
|
|
43
|
+
embedParagraphRef, paragraphRevisionId, resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
44
|
+
} from "../lib/err-relationships.js";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Build the resource-identifier ref used to embed a paragraph in a host ERR /
|
|
47
|
-
* paragraph reference field over JSON:API.
|
|
48
|
+
* paragraph reference field over JSON:API. Includes `meta.target_revision_id`
|
|
49
|
+
* when a vid is known (#192).
|
|
48
50
|
* @param {string} bundle Paragraph type machine name.
|
|
49
51
|
* @param {string} id Paragraph UUID.
|
|
50
|
-
* @
|
|
52
|
+
* @param {number|string|null|undefined} [revisionId] Current revision id.
|
|
53
|
+
* @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
|
|
51
54
|
*/
|
|
52
|
-
function embedRef(bundle, id) {
|
|
53
|
-
return
|
|
55
|
+
export function embedRef(bundle, id, revisionId) {
|
|
56
|
+
return embedParagraphRef(bundle, id, revisionId);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
const EMBED_NOTE =
|
|
57
60
|
"Paragraphs are not standalone content: embed this paragraph in a host entity's " +
|
|
58
|
-
"Entity Reference Revisions (paragraph) field. Over JSON:API
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
61
|
+
"Entity Reference Revisions (paragraph) field. Over JSON:API the resource identifier " +
|
|
62
|
+
"MUST include meta.target_revision_id — Drupal's ERR item is empty unless both " +
|
|
63
|
+
"target_id and target_revision_id are set, and JSON:API only receives the revision " +
|
|
64
|
+
"id from that meta key. relationshipData from this tool includes it. " +
|
|
65
|
+
"drupal_entity_update / drupal_update_node also resolve a missing vid before PATCH " +
|
|
66
|
+
"and fail the write if they cannot. An empty array is an explicit clear. " +
|
|
67
|
+
"Before creating paragraphs to attach to a published moderated node, call " +
|
|
68
|
+
"drupal_list_revisions (inspect possiblyPatchBlocked) and dryRun the host update — " +
|
|
69
|
+
"the host write is rejected after dependents exist, and content-tier cannot delete " +
|
|
70
|
+
"the orphans (#201).";
|
|
62
71
|
|
|
63
72
|
/**
|
|
64
73
|
* Create a paragraph entity of the given type and return a ref suitable for
|
|
@@ -68,10 +77,11 @@ const EMBED_NOTE =
|
|
|
68
77
|
* `attributes` are paragraph field values keyed by Drupal machine name
|
|
69
78
|
* (e.g. { field_body: { value, format } }). Use drupal_get_entity_schema for
|
|
70
79
|
* entityType "paragraph" + the bundle to discover available fields.
|
|
71
|
-
* @returns {Promise<{paragraph: object, ref:
|
|
72
|
-
*
|
|
73
|
-
*
|
|
80
|
+
* @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
|
|
81
|
+
* The created paragraph descriptor plus the embedding ref/relationship data
|
|
82
|
+
* (including `meta.target_revision_id`).
|
|
74
83
|
* @throws {SecurityError} If creating paragraphs of this bundle is not permitted.
|
|
84
|
+
* @throws {Error} If the created paragraph has no readable revision id.
|
|
75
85
|
*/
|
|
76
86
|
async function createParagraph({ site: siteName, paragraphType, attributes = {} }) {
|
|
77
87
|
const site = getSiteConfig(siteName);
|
|
@@ -80,7 +90,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
80
90
|
const backend = await resolveBackend(site);
|
|
81
91
|
const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
|
|
82
92
|
const bundle = paragraph.bundle || paragraphType;
|
|
83
|
-
const
|
|
93
|
+
const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
94
|
+
if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
|
|
95
|
+
const ref = embedRef(bundle, paragraph.id, revisionId);
|
|
84
96
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
85
97
|
}
|
|
86
98
|
|
|
@@ -94,10 +106,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
94
106
|
* @param {object} args - { site?, paragraphType, id, attributes? }.
|
|
95
107
|
* `attributes` are the paragraph field values to change, keyed by Drupal
|
|
96
108
|
* machine name (e.g. { field_body: { value, format } }).
|
|
97
|
-
* @returns {Promise<{paragraph: object, ref:
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @throws {Error} If id is missing.
|
|
109
|
+
* @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
|
|
110
|
+
* The updated paragraph plus the embedding ref (with current revision id).
|
|
111
|
+
* @throws {Error} If id is missing or the revision id cannot be read.
|
|
101
112
|
* @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
|
|
102
113
|
*/
|
|
103
114
|
async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
|
|
@@ -108,16 +119,18 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
|
|
|
108
119
|
const backend = await resolveBackend(site);
|
|
109
120
|
const paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
|
|
110
121
|
const bundle = paragraph.bundle || paragraphType;
|
|
111
|
-
const
|
|
122
|
+
const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
123
|
+
if (revisionId === null) throw missingParagraphRevisionError(id, "Updated");
|
|
124
|
+
const ref = embedRef(bundle, paragraph.id, revisionId);
|
|
112
125
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/**
|
|
116
129
|
* Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
|
|
117
|
-
* annotate it with the embedding ref.
|
|
130
|
+
* annotate it with the embedding ref (including `meta.target_revision_id`).
|
|
118
131
|
*
|
|
119
132
|
* @param {object} args - { site?, paragraphType, id }.
|
|
120
|
-
* @returns {Promise<(object & {ref:
|
|
133
|
+
* @returns {Promise<(object & {ref: object})|null>}
|
|
121
134
|
* The redacted paragraph with an embedding `ref`, or null if not found.
|
|
122
135
|
* @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
|
|
123
136
|
*/
|
|
@@ -129,7 +142,8 @@ async function getParagraph({ site: siteName, paragraphType, id }) {
|
|
|
129
142
|
const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
|
|
130
143
|
if (!entity) return null;
|
|
131
144
|
const redacted = redactCanonicalEntity(entity, sec, "paragraph");
|
|
132
|
-
|
|
145
|
+
const revisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
|
|
146
|
+
return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, revisionId) };
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
// ---------------------------------------------------------------------------
|
|
@@ -140,7 +154,7 @@ export const definitions = [
|
|
|
140
154
|
{
|
|
141
155
|
name: "drupal_create_paragraph",
|
|
142
156
|
description:
|
|
143
|
-
"Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id:
|
|
157
|
+
"Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
|
|
144
158
|
inputSchema: {
|
|
145
159
|
type: "object", required: ["paragraphType"],
|
|
146
160
|
properties: {
|
|
@@ -153,7 +167,7 @@ export const definitions = [
|
|
|
153
167
|
{
|
|
154
168
|
name: "drupal_update_paragraph",
|
|
155
169
|
description:
|
|
156
|
-
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to
|
|
170
|
+
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
|
|
157
171
|
inputSchema: {
|
|
158
172
|
type: "object", required: ["paragraphType", "id"],
|
|
159
173
|
properties: {
|
|
@@ -167,7 +181,7 @@ export const definitions = [
|
|
|
167
181
|
{
|
|
168
182
|
name: "drupal_get_paragraph",
|
|
169
183
|
description:
|
|
170
|
-
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field.
|
|
184
|
+
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
|
|
171
185
|
inputSchema: {
|
|
172
186
|
type: "object", required: ["paragraphType", "id"],
|
|
173
187
|
properties: {
|
|
@@ -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/revisions.js
CHANGED
|
@@ -112,6 +112,7 @@ function summarizeRevision(resource) {
|
|
|
112
112
|
return {
|
|
113
113
|
vid: attrs.get("drupal_internal__vid") ?? null,
|
|
114
114
|
revisionTimestamp: attrs.get("revision_timestamp") ?? null,
|
|
115
|
+
changed: attrs.get("changed") ?? null,
|
|
115
116
|
revisionLog: attrs.get("revision_log") ?? attrs.get("revision_log_message") ?? null,
|
|
116
117
|
status: attrs.get("status") ?? null,
|
|
117
118
|
title: attrs.get("title") ?? null,
|
|
@@ -119,6 +120,39 @@ function summarizeRevision(resource) {
|
|
|
119
120
|
};
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Whether the default revision's `changed` is later than its own
|
|
125
|
+
* `revision_timestamp` — the readable fingerprint of a #201 node.
|
|
126
|
+
* @param {?object} summary
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
function changedAheadOfRevision(summary) {
|
|
130
|
+
if (!summary?.changed || !summary?.revisionTimestamp) return false;
|
|
131
|
+
const changed = Date.parse(summary.changed);
|
|
132
|
+
const rev = Date.parse(summary.revisionTimestamp);
|
|
133
|
+
if (!Number.isFinite(changed) || !Number.isFinite(rev)) return false;
|
|
134
|
+
return changed > rev;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const LIST_REVISIONS_BASE_NOTE =
|
|
138
|
+
"JSON:API only addresses revisions by id (id:<vid>) or the rel:latest-version / " +
|
|
139
|
+
"rel:working-copy aliases; it cannot enumerate the full chronological history. " +
|
|
140
|
+
"Use drupal_get_revision with a specific vid to inspect a known revision, or the " +
|
|
141
|
+
"Drush bridge for complete revision-history enumeration. " +
|
|
142
|
+
"drupal_report_revision_hotspots can surface per-node revision counts.";
|
|
143
|
+
|
|
144
|
+
const LIST_REVISIONS_NULL_WC_NOTE =
|
|
145
|
+
" workingCopy: null is not proof the node is PATCH-able. Drupal core rejects " +
|
|
146
|
+
"PATCH when the stored entity is not the latest revision even when " +
|
|
147
|
+
"content_moderation exposes no pending revision (connector #201, core #2795279). " +
|
|
148
|
+
"Inspect possiblyPatchBlocked before creating dependent paragraphs; dryRun on " +
|
|
149
|
+
"the host update runs the same guard.";
|
|
150
|
+
|
|
151
|
+
const LIST_REVISIONS_FINGERPRINT_NOTE =
|
|
152
|
+
" The default revision's changed timestamp is later than its own " +
|
|
153
|
+
"revision_timestamp — a readable fingerprint that a newer revision row may " +
|
|
154
|
+
"exist without a content_moderation working copy.";
|
|
155
|
+
|
|
122
156
|
/**
|
|
123
157
|
* List the addressable revisions of an entity: the latest default revision and
|
|
124
158
|
* the working-copy (forward) revision. Full chronological enumeration is not
|
|
@@ -145,19 +179,20 @@ async function listRevisions({ site: siteName, type, id }) {
|
|
|
145
179
|
.then(summarizeRevision)
|
|
146
180
|
.catch(() => null);
|
|
147
181
|
|
|
182
|
+
const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
|
|
183
|
+
let note = LIST_REVISIONS_BASE_NOTE;
|
|
184
|
+
if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
|
|
185
|
+
if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
|
|
186
|
+
|
|
148
187
|
return {
|
|
149
188
|
entityType: "node",
|
|
150
189
|
bundle: type,
|
|
151
190
|
id,
|
|
152
191
|
latestVersion,
|
|
153
192
|
workingCopy,
|
|
193
|
+
possiblyPatchBlocked,
|
|
154
194
|
fullHistoryAvailable: false,
|
|
155
|
-
note
|
|
156
|
-
"JSON:API only addresses revisions by id (id:<vid>) or the rel:latest-version / " +
|
|
157
|
-
"rel:working-copy aliases; it cannot enumerate the full chronological history. " +
|
|
158
|
-
"Use drupal_get_revision with a specific vid to inspect a known revision, or the " +
|
|
159
|
-
"Drush bridge for complete revision-history enumeration. " +
|
|
160
|
-
"drupal_report_revision_hotspots can surface per-node revision counts.",
|
|
195
|
+
note,
|
|
161
196
|
};
|
|
162
197
|
}
|
|
163
198
|
|
|
@@ -266,7 +301,7 @@ export const definitions = [
|
|
|
266
301
|
{
|
|
267
302
|
name: "drupal_list_revisions",
|
|
268
303
|
description:
|
|
269
|
-
"Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links.
|
|
304
|
+
"Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
|
|
270
305
|
inputSchema: {
|
|
271
306
|
type: "object", required: ["type", "id"],
|
|
272
307
|
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" } },
|