drupal-mcp-connector 0.9.1 → 1.0.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.
@@ -107,9 +107,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
107
107
  * @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields? }.
108
108
  * @returns {Promise<object>} The created node descriptor from the backend.
109
109
  */
110
- async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {} }) {
110
+ async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
111
111
  const site = getSiteConfig(siteName);
112
- const backend = await resolveBackend(site);
113
112
  const attributes = { title, ...fields };
114
113
  if (moderationState !== undefined) {
115
114
  attributes.moderation_state = moderationState;
@@ -118,6 +117,8 @@ async function createNode({ site: siteName, type, title, body, summary, status,
118
117
  }
119
118
  const bodyAttr = buildBodyAttribute(body, summary);
120
119
  if (bodyAttr) attributes.body = bodyAttr;
120
+ if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes };
121
+ const backend = await resolveBackend(site);
121
122
  return backend.createEntity({ entityType: "node", bundle: type, attributes });
122
123
  }
123
124
 
@@ -132,15 +133,16 @@ async function createNode({ site: siteName, type, title, body, summary, status,
132
133
  * @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields? }.
133
134
  * @returns {Promise<object>} The updated node descriptor.
134
135
  */
135
- async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {} }) {
136
+ async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, dryRun = false }) {
136
137
  const site = getSiteConfig(siteName);
137
- const backend = await resolveBackend(site);
138
138
  const attributes = { ...fields };
139
139
  if (title !== undefined) attributes.title = title;
140
140
  if (moderationState !== undefined) attributes.moderation_state = moderationState;
141
141
  else if (status !== undefined) attributes.status = status;
142
142
  const bodyAttr = buildBodyAttribute(body, summary);
143
143
  if (bodyAttr) attributes.body = bodyAttr;
144
+ if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
145
+ const backend = await resolveBackend(site);
144
146
  return backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
145
147
  }
146
148
 
@@ -151,8 +153,9 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
151
153
  * @param {object} args - { site?, type, id }.
152
154
  * @returns {Promise<{success: boolean, deletedId: string}>}
153
155
  */
154
- async function deleteNode({ site: siteName, type, id }) {
156
+ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
155
157
  const site = getSiteConfig(siteName);
158
+ if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
156
159
  const backend = await resolveBackend(site);
157
160
  await backend.deleteEntity({ entityType: "node", bundle: type, id });
158
161
  return { success: true, deletedId: id };
@@ -219,6 +222,7 @@ export const definitions = [
219
222
  status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
220
223
  moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
221
224
  fields: { type: "object", description: "Additional field values keyed by Drupal machine name" },
225
+ dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
222
226
  },
223
227
  },
224
228
  },
@@ -237,6 +241,7 @@ export const definitions = [
237
241
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
238
242
  moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
239
243
  fields: { type: "object" },
244
+ dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
240
245
  },
241
246
  },
242
247
  },
@@ -249,6 +254,7 @@ export const definitions = [
249
254
  site: { type: "string" },
250
255
  type: { type: "string" },
251
256
  id: { type: "string", description: "Node UUID" },
257
+ dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the delete without committing." },
252
258
  },
253
259
  },
254
260
  },
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Tool group: Paragraphs authoring helper.
3
+ *
4
+ * Paragraphs (the contrib Paragraphs module) are content-fragment entities of
5
+ * the `paragraph` entity type, one bundle per paragraph type (e.g. `text`,
6
+ * `image`, `cta`). They are NOT standalone content: a paragraph only appears on
7
+ * a site when a *host* entity (usually a node) references it from an
8
+ * Entity Reference Revisions (ERR) field. This module gives an authoring agent a
9
+ * focused way to mint a paragraph and fetch it back, plus the relationship data
10
+ * needed to embed it into a host field.
11
+ *
12
+ * Embedding model — IMPORTANT:
13
+ * - Over JSON:API (this connector's default backend) a host references a
14
+ * paragraph from its ERR field by a resource identifier object
15
+ * `{ type: "paragraph--<bundle>", id: "<paragraph-uuid>" }`. Drupal resolves
16
+ * the correct target_id + target_revision_id server-side from the UUID. Drop
17
+ * `relationshipData` (returned by drupal_create_paragraph) into the host's
18
+ * relationships map and call drupal_entity_update / drupal_update_node.
19
+ * - The classic entity-API pair `{ target_id, target_revision_id }` (integer
20
+ * ids) is the REST/Form-API shape, not the JSON:API shape. Those numeric ids
21
+ * are not surfaced by the canonical entity here; prefer the UUID relationship
22
+ * form above when writing through this connector.
23
+ *
24
+ * Both tools are governed: writes assert create permission for the `paragraph`
25
+ * entity type + bundle, reads assert read permission and are redacted per the
26
+ * site security policy. Writes default to whatever the backend default is for
27
+ * the bundle (paragraphs have no independent publish status of their own).
28
+ */
29
+
30
+ import { getSiteConfig } from "../lib/config.js";
31
+ import { resolveBackend } from "../lib/backends/index.js";
32
+ import {
33
+ resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
34
+ } from "../lib/security.js";
35
+
36
+ /**
37
+ * Build the JSON:API resource type string for a paragraph bundle.
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
+ }
44
+
45
+ /**
46
+ * Build the resource-identifier ref used to embed a paragraph in a host ERR /
47
+ * paragraph reference field over JSON:API.
48
+ * @param {string} bundle Paragraph type machine name.
49
+ * @param {string} id Paragraph UUID.
50
+ * @returns {{type: string, id: string}}
51
+ */
52
+ function embedRef(bundle, id) {
53
+ return { type: resourceType(bundle), id };
54
+ }
55
+
56
+ const EMBED_NOTE =
57
+ "Paragraphs are not standalone content: embed this paragraph in a host entity's " +
58
+ "Entity Reference Revisions (paragraph) field. Over JSON:API, add `relationshipData` " +
59
+ "to the host field's relationship (e.g. drupal_entity_update / drupal_update_node with " +
60
+ "relationships: { field_paragraphs: { data: [ relationshipData ] } }). Drupal resolves " +
61
+ "target_id + target_revision_id from the UUID server-side.";
62
+
63
+ /**
64
+ * Create a paragraph entity of the given type and return a ref suitable for
65
+ * embedding it in a host entity's paragraph/ERR field.
66
+ *
67
+ * @param {object} args - { site?, paragraphType, attributes? }.
68
+ * `attributes` are paragraph field values keyed by Drupal machine name
69
+ * (e.g. { field_body: { value, format } }). Use drupal_get_entity_schema for
70
+ * entityType "paragraph" + the bundle to discover available fields.
71
+ * @returns {Promise<{paragraph: object, ref: {id: string, type: string},
72
+ * relationshipData: {type: string, id: string}, note: string}>}
73
+ * The created paragraph descriptor plus the embedding ref/relationship data.
74
+ * @throws {SecurityError} If creating paragraphs of this bundle is not permitted.
75
+ */
76
+ async function createParagraph({ site: siteName, paragraphType, attributes = {} }) {
77
+ const site = getSiteConfig(siteName);
78
+ const sec = resolveSecurityConfig(site);
79
+ assertWriteAllowed(sec, "create", "paragraph", paragraphType);
80
+ const backend = await resolveBackend(site);
81
+ const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
82
+ const bundle = paragraph.bundle || paragraphType;
83
+ const ref = embedRef(bundle, paragraph.id);
84
+ return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
85
+ }
86
+
87
+ /**
88
+ * Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
89
+ * annotate it with the embedding ref.
90
+ *
91
+ * @param {object} args - { site?, paragraphType, id }.
92
+ * @returns {Promise<(object & {ref: {id: string, type: string}})|null>}
93
+ * The redacted paragraph with an embedding `ref`, or null if not found.
94
+ * @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
95
+ */
96
+ async function getParagraph({ site: siteName, paragraphType, id }) {
97
+ const site = getSiteConfig(siteName);
98
+ const sec = resolveSecurityConfig(site);
99
+ assertReadAllowed(sec, "paragraph", paragraphType);
100
+ const backend = await resolveBackend(site);
101
+ const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
102
+ if (!entity) return null;
103
+ const redacted = redactCanonicalEntity(entity, sec, "paragraph");
104
+ return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id) };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Definitions
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export const definitions = [
112
+ {
113
+ name: "drupal_create_paragraph",
114
+ description:
115
+ "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: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
116
+ inputSchema: {
117
+ type: "object", required: ["paragraphType"],
118
+ properties: {
119
+ site: { type: "string", description: "Named site (omit for default)" },
120
+ paragraphType: { type: "string", description: "Paragraph type / bundle machine name, e.g. 'text', 'image', 'cta'" },
121
+ attributes: { type: "object", description: "Paragraph field values keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }" },
122
+ },
123
+ },
124
+ },
125
+ {
126
+ name: "drupal_get_paragraph",
127
+ description:
128
+ "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. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy.",
129
+ inputSchema: {
130
+ type: "object", required: ["paragraphType", "id"],
131
+ properties: {
132
+ site: { type: "string" },
133
+ paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
134
+ id: { type: "string", description: "Paragraph UUID" },
135
+ },
136
+ },
137
+ },
138
+ ];
139
+
140
+ export const handlers = {
141
+ drupal_create_paragraph: createParagraph,
142
+ drupal_get_paragraph: getParagraph,
143
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tool group: Reference resolution.
3
+ *
4
+ * A standalone, read-only helper that turns a human-friendly name or title into
5
+ * a stable Drupal UUID. Editors and agents rarely know UUIDs; they know labels
6
+ * ("the News term", "the About page"). This tool runs a single label filter via
7
+ * the backend's listEntities, ranks the results, and returns the best match plus
8
+ * any ambiguous candidates so the caller (or a human) can disambiguate before
9
+ * wiring the UUID into a create/update relationship.
10
+ *
11
+ * Scope: this NEVER writes. It only reads, and every read is gated by the site
12
+ * security policy (assertReadAllowed) and redacted (redactCanonicalEntity) before
13
+ * a label is derived — so a redacted label surfaces as "[REDACTED]" rather than
14
+ * leaking a protected value.
15
+ */
16
+
17
+ import { getSiteConfig } from "../lib/config.js";
18
+ import { resolveBackend } from "../lib/backends/index.js";
19
+ import { resolveSecurityConfig, assertReadAllowed, redactCanonicalEntity } from "../lib/security.js";
20
+
21
+ /**
22
+ * Pick the label field to filter on for a given entity type.
23
+ *
24
+ * Nodes are keyed by `title`; taxonomy terms and users (and other label-as-name
25
+ * entities) are keyed by `name`. Anything else defaults to `name`, which is the
26
+ * most common Drupal label field outside of content nodes.
27
+ *
28
+ * @param {string} entityType Entity type machine name.
29
+ * @returns {"title"|"name"} The JSON:API field to filter against.
30
+ */
31
+ function labelFieldFor(entityType) {
32
+ return entityType === "node" ? "title" : "name";
33
+ }
34
+
35
+ /**
36
+ * Derive a human label from a (already redacted) canonical entity.
37
+ *
38
+ * Nodes promote their label to the canonical `title`; taxonomy terms / users
39
+ * keep it in `fields.name`. We fall back across both so the resolver returns a
40
+ * meaningful label regardless of entity type. Redaction runs before this, so a
41
+ * protected label is already "[REDACTED]" here.
42
+ *
43
+ * @param {object} entity Redacted canonical entity.
44
+ * @param {string} labelField The field used for filtering ("title"|"name").
45
+ * @returns {?string} The best available label, or null.
46
+ */
47
+ function labelOf(entity, labelField) {
48
+ return (
49
+ entity.title ??
50
+ // eslint-disable-next-line security/detect-object-injection -- labelField is a fixed literal ("title"|"name") from labelFieldFor, not user input
51
+ entity.fields?.[labelField] ??
52
+ entity.fields?.name ??
53
+ entity.fields?.title ??
54
+ null
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Resolve a human name/title to a Drupal UUID.
60
+ *
61
+ * Strategy: filter the entity type/bundle by a label-contains query, redact the
62
+ * results, then rank — an exact (case-insensitive, trimmed) label match wins;
63
+ * otherwise the first contains-match is the best guess and the result is flagged
64
+ * ambiguous when more than one candidate came back without an exact hit.
65
+ *
66
+ * @param {object} args - { site?, entityType, bundle, name, limit? }.
67
+ * @returns {Promise<{resolved: boolean, ambiguous: boolean,
68
+ * match: {id: string, title: ?string}|null, candidates: {id: string, title: ?string}[]}>}
69
+ * The best match and any ambiguous candidates.
70
+ * @throws {SecurityError} If reading the entity type/bundle is not permitted.
71
+ */
72
+ async function resolveReference({ site: siteName, entityType, bundle, name, limit = 10 }) {
73
+ const site = getSiteConfig(siteName);
74
+ const sec = resolveSecurityConfig(site);
75
+ assertReadAllowed(sec, entityType, bundle);
76
+ const backend = await resolveBackend(site);
77
+
78
+ const field = labelFieldFor(entityType);
79
+ const res = await backend.listEntities({
80
+ entityType,
81
+ bundle,
82
+ filters: [{ field, op: "contains", value: name }],
83
+ page: { limit },
84
+ });
85
+
86
+ const candidates = res.entities
87
+ .map((e) => redactCanonicalEntity(e, sec, entityType))
88
+ .map((e) => ({ id: e.id, title: labelOf(e, field) }));
89
+
90
+ if (candidates.length === 0) {
91
+ return { resolved: false, ambiguous: false, match: null, candidates: [] };
92
+ }
93
+
94
+ const needle = String(name).trim().toLowerCase();
95
+ const exact = candidates.find(
96
+ (c) => typeof c.title === "string" && c.title.trim().toLowerCase() === needle
97
+ );
98
+ const match = exact ?? candidates[0];
99
+ const ambiguous = !exact && candidates.length > 1;
100
+
101
+ return { resolved: true, ambiguous, match, candidates };
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Definitions
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export const definitions = [
109
+ {
110
+ name: "drupal_resolve_reference",
111
+ description:
112
+ "Resolve a human name or title to a Drupal entity UUID. Use this before " +
113
+ "creating or updating an entity reference when you only know the label " +
114
+ "(e.g. a taxonomy term name, a user name, or a node title) and not its UUID. " +
115
+ "Read-only: returns the best match { id, title } plus any ambiguous candidates. " +
116
+ "Filters on 'title' for nodes and 'name' for taxonomy_term / user.",
117
+ inputSchema: {
118
+ type: "object",
119
+ required: ["entityType", "bundle", "name"],
120
+ properties: {
121
+ site: { type: "string", description: "Named site (omit for default)" },
122
+ entityType: { type: "string", description: "Entity type machine name, e.g. 'node', 'taxonomy_term', 'user'" },
123
+ bundle: { type: "string", description: "Bundle machine name, e.g. 'article', 'tags', 'user'" },
124
+ name: { type: "string", description: "Human name/title to resolve (matched as a substring)" },
125
+ limit: { type: "number", default: 10, description: "Maximum candidates to consider" },
126
+ },
127
+ },
128
+ },
129
+ ];
130
+
131
+ export const handlers = {
132
+ drupal_resolve_reference: resolveReference,
133
+ };
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Tool group: Additional audit & reporting (reports_extra).
3
+ *
4
+ * Read-only content-quality and referential-integrity audits that complement
5
+ * the core reports module. Each handler asserts read access in-handler and
6
+ * returns a structured finding list. Reports that are bounded by a sampling cap
7
+ * flag `approximate: true` so callers know the result is best-effort rather than
8
+ * an exhaustive scan.
9
+ *
10
+ * This module deliberately does NOT touch src/tools/reports.js — it is additive.
11
+ */
12
+
13
+ import { getSiteConfig } from "../lib/config.js";
14
+ import { resolveBackend } from "../lib/backends/index.js";
15
+ import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
16
+ import { collectEntities, fieldValue } from "../lib/reports-support.js";
17
+
18
+ /**
19
+ * Determine whether a canonical field/relationship value counts as "empty".
20
+ * Handles scalars, JSON:API value-objects ({value}), arrays, and relationship
21
+ * refs ({id} / [{id}, …] / null).
22
+ * @param {*} value A value read off an entity's base props, `fields`, or `relationships`.
23
+ * @returns {boolean} True when the value is absent or carries no content.
24
+ */
25
+ function isEmptyValue(value) {
26
+ if (value === undefined || value === null || value === "") return true;
27
+ if (Array.isArray(value)) return value.length === 0;
28
+ if (typeof value === "object") {
29
+ // Relationship ref ({id, ...}) is non-empty.
30
+ if ("id" in value) return !value.id;
31
+ // JSON:API field value-object ({value, ...}); also accept {target_id}/{uri}.
32
+ if ("value" in value) return value.value === undefined || value.value === null || value.value === "";
33
+ if ("target_id" in value) return value.target_id === undefined || value.target_id === null;
34
+ if ("uri" in value) return !value.uri;
35
+ return Object.keys(value).length === 0;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * Read a field value from a canonical entity, looking in `fields` first and then
42
+ * `relationships` (so a single `field` argument works for both scalar fields and
43
+ * entity-reference fields).
44
+ * @param {object} entity Canonical entity.
45
+ * @param {string} field Field machine name.
46
+ * @returns {{value: *, present: boolean}} The resolved value and whether the key existed at all.
47
+ */
48
+ function readField(entity, field) {
49
+ const fromField = fieldValue(entity, [field]);
50
+ if (fromField !== undefined) return { value: fromField, present: true };
51
+ const rels = new Map(Object.entries(entity.relationships ?? {}));
52
+ if (rels.has(field)) {
53
+ return { value: rels.get(field), present: true };
54
+ }
55
+ return { value: undefined, present: false };
56
+ }
57
+
58
+ /**
59
+ * Flatten a relationship value into an array of { id, entityType, bundle } refs,
60
+ * dropping null/empty entries.
61
+ * @param {*} rel A normalized relationship value (ref, array of refs, or null).
62
+ * @returns {Array<{id: string, entityType: ?string, bundle: ?string}>} Concrete refs, de-duped by id.
63
+ */
64
+ function refsOf(rel) {
65
+ if (!rel) return [];
66
+ const list = Array.isArray(rel) ? rel : [rel];
67
+ const seen = new Set();
68
+ const out = [];
69
+ for (const r of list) {
70
+ if (!r || !r.id || seen.has(r.id)) continue;
71
+ seen.add(r.id);
72
+ out.push(r);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Report implementations
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Unpublished / draft content for a content type.
83
+ *
84
+ * @param {object} args - { site?, type?, limit? }. `type` defaults to "article".
85
+ * @returns {Promise<{contentType: string, approximate: boolean,
86
+ * totalUnpublished: number, findings: object[]}>} Matching unpublished nodes.
87
+ * @throws {SecurityError} If reading the content type is not permitted.
88
+ */
89
+ async function unpublished({ site: siteName, type, limit = 50 }) {
90
+ const site = getSiteConfig(siteName);
91
+ const sec = resolveSecurityConfig(site);
92
+ assertReadAllowed(sec, "node", type);
93
+ const backend = await resolveBackend(site);
94
+ const contentType = type || "article";
95
+ const res = await backend.listEntities({
96
+ entityType: "node", bundle: contentType,
97
+ filters: [{ field: "status", op: "eq", value: false }],
98
+ sort: [{ field: "changed", dir: "desc" }],
99
+ page: { limit },
100
+ });
101
+ return {
102
+ contentType,
103
+ approximate: res.approximate ?? false,
104
+ totalUnpublished: res.page?.total ?? res.entities.length,
105
+ findings: res.entities.map((n) => ({
106
+ id: n.id,
107
+ title: n.title,
108
+ status: "unpublished",
109
+ changed: n.changed,
110
+ path: n.url,
111
+ })),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Entities of a content type where a given field is empty (e.g. a missing meta
117
+ * description or image). Works for scalar fields and entity-reference fields.
118
+ * Sampling-bounded: flags `approximate` when the scan hits the sample cap while
119
+ * more results remain.
120
+ *
121
+ * @param {object} args - { site?, type?, field, sampleSize? }. `field` is required.
122
+ * @returns {Promise<object>} Per-entity findings plus scan metadata.
123
+ * @throws {Error} If no field is supplied.
124
+ * @throws {SecurityError} If reading the content type is not permitted.
125
+ */
126
+ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
127
+ const site = getSiteConfig(siteName);
128
+ const sec = resolveSecurityConfig(site);
129
+ assertReadAllowed(sec, "node", type);
130
+ if (!field) throw new Error("missingField requires a field machine name.");
131
+ const backend = await resolveBackend(site);
132
+ const contentType = type || "article";
133
+ const entities = await collectEntities(
134
+ backend,
135
+ { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
136
+ sampleSize
137
+ );
138
+ const findings = [];
139
+ for (const e of entities) {
140
+ const { value } = readField(e, field);
141
+ if (isEmptyValue(value)) {
142
+ findings.push({ id: e.id, title: e.title, field, status: e.status ? "published" : "unpublished", path: e.url });
143
+ }
144
+ }
145
+ const approximate = entities.length >= sampleSize;
146
+ return {
147
+ contentType,
148
+ field,
149
+ scanned: entities.length,
150
+ sampled: entities.length,
151
+ sampleSize,
152
+ approximate,
153
+ totalMissing: findings.length,
154
+ note: approximate
155
+ ? "Result is sampling-bounded; more entities may exist beyond the sample cap."
156
+ : undefined,
157
+ findings,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Orphaned entity references: sampled entities whose entity-reference fields
163
+ * 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.
167
+ *
168
+ * @param {object} args - { site?, type?, sampleSize? }. `type` defaults to "article".
169
+ * @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
170
+ * @throws {SecurityError} If reading the content type is not permitted.
171
+ */
172
+ async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
173
+ const site = getSiteConfig(siteName);
174
+ const sec = resolveSecurityConfig(site);
175
+ assertReadAllowed(sec, "node", type);
176
+ const backend = await resolveBackend(site);
177
+ const contentType = type || "article";
178
+ const entities = await collectEntities(
179
+ backend,
180
+ { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
181
+ sampleSize
182
+ );
183
+
184
+ // Cache resolution results across all sampled entities so a target is only
185
+ // looked up once (de-dupes both within and across entities).
186
+ const resolution = new Map(); // id -> boolean (true = exists)
187
+ /**
188
+ * Resolve whether a referenced target exists, caching the result.
189
+ * @param {{id: string, entityType: ?string, bundle: ?string}} ref Reference to probe.
190
+ * @returns {Promise<boolean>} True if the target resolves to an entity.
191
+ */
192
+ async function exists(ref) {
193
+ if (resolution.has(ref.id)) return resolution.get(ref.id);
194
+ let ok = false;
195
+ 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;
207
+ }
208
+ resolution.set(ref.id, ok);
209
+ return ok;
210
+ }
211
+
212
+ const findings = [];
213
+ for (const e of entities) {
214
+ for (const [fieldName, rel] of Object.entries(e.relationships ?? {})) {
215
+ for (const ref of refsOf(rel)) {
216
+ const ok = await exists(ref);
217
+ if (!ok) {
218
+ findings.push({
219
+ id: e.id,
220
+ title: e.title,
221
+ field: fieldName,
222
+ targetId: ref.id,
223
+ targetEntityType: ref.entityType,
224
+ targetBundle: ref.bundle,
225
+ });
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ const approximate = entities.length >= sampleSize;
232
+ return {
233
+ contentType,
234
+ scanned: entities.length,
235
+ sampleSize,
236
+ approximate,
237
+ totalOrphaned: findings.length,
238
+ note: approximate
239
+ ? "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.",
241
+ findings,
242
+ };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Definitions
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export const definitions = [
250
+ {
251
+ name: "drupal_report_unpublished",
252
+ description: "List unpublished/draft content of a given type. Returns a finding list with titles, last-changed dates, and paths. Useful for surfacing forgotten drafts.",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ site: { type: "string" },
257
+ type: { type: "string", description: "Content type machine name (default: article)" },
258
+ limit: { type: "number", default: 50, description: "Max unpublished nodes to return" },
259
+ },
260
+ },
261
+ },
262
+ {
263
+ name: "drupal_report_missing_field",
264
+ description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped.",
265
+ inputSchema: {
266
+ type: "object", required: ["field"],
267
+ properties: {
268
+ site: { type: "string" },
269
+ type: { type: "string", description: "Content type machine name (default: article)" },
270
+ field: { type: "string", description: "Field machine name to check for emptiness, e.g. 'field_meta_description', 'field_image'" },
271
+ sampleSize: { type: "number", default: 100, description: "Max entities to scan" },
272
+ },
273
+ },
274
+ },
275
+ {
276
+ 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.",
278
+ inputSchema: {
279
+ type: "object",
280
+ properties: {
281
+ site: { type: "string" },
282
+ type: { type: "string", description: "Content type machine name to scan (default: article)" },
283
+ sampleSize: { type: "number", default: 50, description: "Max entities to scan for broken references" },
284
+ },
285
+ },
286
+ },
287
+ ];
288
+
289
+ export const handlers = {
290
+ drupal_report_unpublished: unpublished,
291
+ drupal_report_missing_field: missingField,
292
+ drupal_report_orphaned_references: orphanedReferences,
293
+ };