drupal-mcp-connector 2.26.0 → 3.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.
Files changed (37) hide show
  1. package/.agents/commands/drupal-audit-site-health.md +1 -1
  2. package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
  3. package/.agents/commands/drupal-report-alias-coverage.md +4 -2
  4. package/.agents/commands/drupal-report-broken-embeds.md +4 -2
  5. package/.agents/commands/drupal-report-broken-links.md +4 -2
  6. package/.agents/commands/drupal-report-content-by-author.md +4 -2
  7. package/.agents/commands/drupal-report-duplicate-content.md +4 -2
  8. package/.agents/commands/drupal-report-missing-field.md +2 -2
  9. package/.agents/commands/drupal-report-orphan-pages.md +4 -2
  10. package/.agents/commands/drupal-report-orphaned-references.md +4 -2
  11. package/.agents/commands/drupal-report-pii-exposure.md +4 -2
  12. package/.agents/commands/drupal-report-readability.md +4 -2
  13. package/.agents/commands/drupal-report-recently-published.md +1 -1
  14. package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
  15. package/.agents/commands/drupal-report-scheduled-content.md +4 -4
  16. package/.agents/commands/drupal-report-seo-audit.md +4 -2
  17. package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
  18. package/.agents/commands/drupal-report-stale-content.md +4 -2
  19. package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
  20. package/.agents/commands/drupal-report-translation-coverage.md +4 -2
  21. package/.agents/commands/drupal-report-unpublished.md +1 -1
  22. package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
  23. package/.agents/commands/drupal-schedule-publish.md +4 -4
  24. package/.agents/commands/drupal-search-content.md +1 -1
  25. package/.agents/commands/drupal-search.md +1 -1
  26. package/CHANGELOG.md +46 -0
  27. package/package.json +1 -1
  28. package/src/lib/backends/jsonapi.js +19 -7
  29. package/src/lib/reports-support.js +68 -0
  30. package/src/tools/audit-composite.js +21 -11
  31. package/src/tools/nodes.js +12 -6
  32. package/src/tools/reports-content.js +106 -38
  33. package/src/tools/reports-extra.js +56 -32
  34. package/src/tools/reports-links.js +13 -13
  35. package/src/tools/reports.js +65 -42
  36. package/src/tools/scheduler.js +90 -62
  37. package/src/tools/search.js +16 -12
@@ -13,7 +13,7 @@
13
13
  import { getSiteConfig } from "../lib/config.js";
14
14
  import { resolveBackend } from "../lib/backends/index.js";
15
15
  import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
16
- import { collectEntities, fieldPresence } from "../lib/reports-support.js";
16
+ import { collectEntities, fieldPresence, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
17
17
  import { httpStatusOf } from "../lib/error-status.js";
18
18
 
19
19
  /** Author base fields that only ever point at `user`. */
@@ -100,11 +100,14 @@ function refsOf(rel) {
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
102
  /**
103
- * Unpublished / draft content for a content type.
103
+ * Unpublished / draft content. With `type`, reads that bundle. Without it,
104
+ * reads every node bundle the connector may read, merges by `changed`
105
+ * (newest first) and keeps `limit` findings; a bundle that fails to load is
106
+ * listed in `bundleErrors` (#403).
104
107
  *
105
- * @param {object} args - { site?, type?, limit? }. `type` defaults to "article".
106
- * @returns {Promise<{contentType: string, approximate: boolean,
107
- * totalUnpublished: number, findings: object[]}>} Matching unpublished nodes.
108
+ * @param {object} args - { site?, type?, limit? }.
109
+ * @returns {Promise<{contentType: ?string, approximate: boolean,
110
+ * totalUnpublished: number, findings: object[], bundleErrors?: object[]}>} Matching unpublished nodes.
108
111
  * @throws {SecurityError} If reading the content type is not permitted.
109
112
  */
110
113
  async function unpublished({ site: siteName, type, limit = 50 }) {
@@ -112,24 +115,45 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
112
115
  const sec = resolveSecurityConfig(site);
113
116
  assertReadAllowed(sec, "node", type);
114
117
  const backend = await resolveBackend(site);
115
- const contentType = type || "article";
116
- const res = await backend.listEntities({
117
- entityType: "node", bundle: contentType,
118
- filters: [{ field: "status", op: "eq", value: false }],
119
- sort: [{ field: "changed", dir: "desc" }],
120
- page: { limit },
121
- });
118
+ const bundles = type ? [type] : await readableNodeBundles(backend, sec);
119
+ const findings = [];
120
+ const bundleErrors = [];
121
+ let approximate = false;
122
+ let totalUnpublished = 0;
123
+ for (const bundle of bundles) {
124
+ let res;
125
+ try {
126
+ res = await backend.listEntities({
127
+ entityType: "node", bundle,
128
+ filters: [{ field: "status", op: "eq", value: false }],
129
+ sort: [{ field: "changed", dir: "desc" }],
130
+ page: { limit },
131
+ });
132
+ } catch (err) {
133
+ if (type) throw err;
134
+ bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
135
+ continue;
136
+ }
137
+ approximate = approximate || (res.approximate ?? false);
138
+ totalUnpublished += res.page?.total ?? res.entities.length;
139
+ for (const n of res.entities) {
140
+ findings.push({
141
+ id: n.id,
142
+ title: n.title,
143
+ status: "unpublished",
144
+ changed: n.changed,
145
+ path: n.url,
146
+ ...(type ? {} : { contentType: bundle }),
147
+ });
148
+ }
149
+ }
150
+ if (!type) findings.sort((x, y) => String(y.changed ?? "").localeCompare(String(x.changed ?? "")));
122
151
  return {
123
- contentType,
124
- approximate: res.approximate ?? false,
125
- totalUnpublished: res.page?.total ?? res.entities.length,
126
- findings: res.entities.map((n) => ({
127
- id: n.id,
128
- title: n.title,
129
- status: "unpublished",
130
- changed: n.changed,
131
- path: n.url,
132
- })),
152
+ contentType: type || null,
153
+ approximate,
154
+ totalUnpublished,
155
+ findings: findings.slice(0, limit),
156
+ ...(bundleErrors.length ? { bundleErrors } : {}),
133
157
  };
134
158
  }
135
159
 
@@ -153,12 +177,12 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
153
177
  * @throws {SecurityError} If reading the content type is not permitted.
154
178
  */
155
179
  async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
180
+ const contentType = requireContentType(type, "drupal_report_missing_field");
156
181
  const site = getSiteConfig(siteName);
157
182
  const sec = resolveSecurityConfig(site);
158
- assertReadAllowed(sec, "node", type);
183
+ assertReadAllowed(sec, "node", contentType);
159
184
  if (!field) throw new Error("missingField requires a field machine name.");
160
185
  const backend = await resolveBackend(site);
161
- const contentType = type || "article";
162
186
  const entities = await collectEntities(
163
187
  backend,
164
188
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -213,16 +237,16 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
213
237
  * orphan; 401/403 and connector policy denials are unverifiable (#205).
214
238
  * Sampling-bounded, so `approximate` is set when the entity scan is capped.
215
239
  *
216
- * @param {object} args - { site?, type?, sampleSize? }. `type` defaults to "article".
240
+ * @param {object} args - { site?, type, sampleSize? }. `type` is required.
217
241
  * @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
218
242
  * @throws {SecurityError} If reading the content type is not permitted.
219
243
  */
220
244
  async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
245
+ const contentType = requireContentType(type, "drupal_report_orphaned_references");
221
246
  const site = getSiteConfig(siteName);
222
247
  const sec = resolveSecurityConfig(site);
223
- assertReadAllowed(sec, "node", type);
248
+ assertReadAllowed(sec, "node", contentType);
224
249
  const backend = await resolveBackend(site);
225
- const contentType = type || "article";
226
250
  const entities = await collectEntities(
227
251
  backend,
228
252
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -331,7 +355,7 @@ export const definitions = [
331
355
  type: "object",
332
356
  properties: {
333
357
  site: { type: "string" },
334
- type: { type: "string", description: "Content type machine name (default: article)" },
358
+ type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
335
359
  limit: { type: "number", default: 50, description: "Max unpublished nodes to return" },
336
360
  },
337
361
  },
@@ -340,10 +364,10 @@ export const definitions = [
340
364
  name: "drupal_report_missing_field",
341
365
  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. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).",
342
366
  inputSchema: {
343
- type: "object", required: ["field"],
367
+ type: "object", required: ["field", "type"],
344
368
  properties: {
345
369
  site: { type: "string" },
346
- type: { type: "string", description: "Content type machine name (default: article)" },
370
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
347
371
  field: { type: "string", description: "Field machine name to check for emptiness, e.g. 'field_meta_description', 'field_image'" },
348
372
  sampleSize: { type: "number", default: 100, description: "Max entities to scan" },
349
373
  },
@@ -353,10 +377,10 @@ export const definitions = [
353
377
  name: "drupal_report_orphaned_references",
354
378
  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.",
355
379
  inputSchema: {
356
- type: "object",
380
+ type: "object", required: ["type"],
357
381
  properties: {
358
382
  site: { type: "string" },
359
- type: { type: "string", description: "Content type machine name to scan (default: article)" },
383
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
360
384
  sampleSize: { type: "number", default: 50, description: "Max entities to scan for broken references" },
361
385
  },
362
386
  },
@@ -18,7 +18,7 @@
18
18
  import { getSiteConfig } from "../lib/config.js";
19
19
  import { resolveBackend } from "../lib/backends/index.js";
20
20
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
21
- import { collectEntities, gatedReport, fieldValue } from "../lib/reports-support.js";
21
+ import { collectEntities, gatedReport, fieldValue, requireContentType } from "../lib/reports-support.js";
22
22
  import { runPrivileged } from "../lib/audit-sources.js";
23
23
  import { sshDrush, parseDrush } from "./drush.js";
24
24
  import {
@@ -258,11 +258,11 @@ async function redirectHealth({ site: siteName, limit = 1000 }) {
258
258
  * @returns {Promise<object>} Link inventory plus optional live results.
259
259
  */
260
260
  async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive = false, includeExternal = false }) {
261
+ const contentType = requireContentType(type, "drupal_report_broken_links");
261
262
  const site = getSiteConfig(siteName);
262
263
  const sec = resolveSecurityConfig(site);
263
- assertReadAllowed(sec, "node", type);
264
+ assertReadAllowed(sec, "node", contentType);
264
265
  const backend = await resolveBackend(site);
265
- const contentType = type || "article";
266
266
  const baseUrl = site.baseUrl;
267
267
  const baseHost = hostOf(baseUrl);
268
268
 
@@ -345,11 +345,11 @@ async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive =
345
345
  * @returns {Promise<object>} Alias-coverage findings.
346
346
  */
347
347
  async function aliasCoverage({ site: siteName, type, sampleSize = 200 }) {
348
+ const contentType = requireContentType(type, "drupal_report_alias_coverage");
348
349
  const site = getSiteConfig(siteName);
349
350
  const sec = resolveSecurityConfig(site);
350
- assertReadAllowed(sec, "node", type);
351
+ assertReadAllowed(sec, "node", contentType);
351
352
  const backend = await resolveBackend(site);
352
- const contentType = type || "article";
353
353
 
354
354
  const nodes = await collectEntities(
355
355
  backend,
@@ -463,11 +463,11 @@ async function menuIntegrity({ site: siteName, limit = 1000 }) {
463
463
  * @returns {Promise<object>} Embed inventory and any malformed embeds.
464
464
  */
465
465
  async function brokenEmbeds({ site: siteName, type, sampleSize = 100 }) {
466
+ const contentType = requireContentType(type, "drupal_report_broken_embeds");
466
467
  const site = getSiteConfig(siteName);
467
468
  const sec = resolveSecurityConfig(site);
468
- assertReadAllowed(sec, "node", type);
469
+ assertReadAllowed(sec, "node", contentType);
469
470
  const backend = await resolveBackend(site);
470
- const contentType = type || "article";
471
471
 
472
472
  const entities = await collectEntities(
473
473
  backend,
@@ -538,10 +538,10 @@ export const definitions = [
538
538
  name: "drupal_report_broken_links",
539
539
  description: "Inventory links in published body content (internal/external/images), aggregate external hosts, and flag malformed hrefs. With checkLive:true, verifies links via bounded, SSRF-guarded outbound HTTP (internal always; external only if includeExternal and host-allowlisted). No network egress unless checkLive is set.",
540
540
  inputSchema: {
541
- type: "object",
541
+ type: "object", required: ["type"],
542
542
  properties: {
543
543
  site: { type: "string" },
544
- type: { type: "string", description: "Content type (default: article)" },
544
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
545
545
  sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
546
546
  checkLive: { type: "boolean", default: false, description: "Perform live HTTP checks (off by default)" },
547
547
  includeExternal: { type: "boolean", default: false, description: "When checkLive, also check allowlisted external hosts" },
@@ -552,10 +552,10 @@ export const definitions = [
552
552
  name: "drupal_report_alias_coverage",
553
553
  description: "URL-alias coverage for a content type: nodes whose canonical URL is still /node/N (no alias / pathauto gap), plus conflicting aliases (one alias mapped to multiple system paths) when the path_alias entity is exposed.",
554
554
  inputSchema: {
555
- type: "object",
555
+ type: "object", required: ["type"],
556
556
  properties: {
557
557
  site: { type: "string" },
558
- type: { type: "string", description: "Content type (default: article)" },
558
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
559
559
  sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
560
560
  },
561
561
  },
@@ -575,10 +575,10 @@ export const definitions = [
575
575
  name: "drupal_report_broken_embeds",
576
576
  description: "Scan published body content for embedded entities (media/entity embeds) and report usage by type, flagging embeds with a missing/malformed data-entity-uuid. Full target-existence verification is gated on a future server-tool.",
577
577
  inputSchema: {
578
- type: "object",
578
+ type: "object", required: ["type"],
579
579
  properties: {
580
580
  site: { type: "string" },
581
- type: { type: "string", description: "Content type (default: article)" },
581
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
582
582
  sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
583
583
  },
584
584
  },
@@ -11,7 +11,7 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
14
- import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince } from "../lib/reports-support.js";
14
+ import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
15
15
  import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -68,11 +68,11 @@ async function contentSummary({ site: siteName }) {
68
68
  * @throws {SecurityError} If reading the content type is not permitted.
69
69
  */
70
70
  async function staleContent({ site: siteName, type, days = 180, status, limit = 50 }) {
71
+ const contentType = requireContentType(type, "drupal_report_stale_content");
71
72
  const site = getSiteConfig(siteName);
72
73
  const sec = resolveSecurityConfig(site);
73
- assertReadAllowed(sec, "node", type);
74
+ assertReadAllowed(sec, "node", contentType);
74
75
  const backend = await resolveBackend(site);
75
- const contentType = type || "article";
76
76
  // `changed` is an integer Unix timestamp (seconds). Filter with epoch seconds,
77
77
  // not an ISO string — PostgreSQL rejects the string against an integer column.
78
78
  const cutoffMs = Date.now() - days * 86400000;
@@ -112,11 +112,11 @@ async function staleContent({ site: siteName, type, days = 180, status, limit =
112
112
  * @throws {SecurityError} If reading the content type is not permitted.
113
113
  */
114
114
  async function contentByAuthor({ site: siteName, type, limit = 100 }) {
115
+ const contentType = requireContentType(type, "drupal_report_content_by_author");
115
116
  const site = getSiteConfig(siteName);
116
117
  const sec = resolveSecurityConfig(site);
117
- assertReadAllowed(sec, "node", type);
118
+ assertReadAllowed(sec, "node", contentType);
118
119
  const backend = await resolveBackend(site);
119
- const contentType = type || "article";
120
120
  const entities = await collectEntities(
121
121
  backend,
122
122
  { entityType: "node", bundle: contentType, include: ["uid"] },
@@ -141,7 +141,10 @@ async function contentByAuthor({ site: siteName, type, limit = 100 }) {
141
141
  }
142
142
 
143
143
  /**
144
- * Recently published content of a given type, newest first.
144
+ * Recently published content, newest first. With `type`, reads that bundle.
145
+ * Without it, reads every node bundle the connector may read, merges by
146
+ * `created`, and keeps the newest `limit`; a bundle that fails to load is
147
+ * listed in `bundleErrors` (#403).
145
148
  *
146
149
  * @param {object} args - { site?, type?, limit? }.
147
150
  * @returns {Promise<object>} The most recently created published nodes.
@@ -152,22 +155,42 @@ async function recentlyPublished({ site: siteName, type, limit = 20 }) {
152
155
  const sec = resolveSecurityConfig(site);
153
156
  assertReadAllowed(sec, "node", type);
154
157
  const backend = await resolveBackend(site);
155
- const res = await backend.listEntities({
156
- entityType: "node", bundle: type || "article",
157
- filters: [{ field: "status", op: "eq", value: true }],
158
- sort: [{ field: "created", dir: "desc" }],
159
- page: { limit },
160
- });
158
+ const bundles = type ? [type] : await readableNodeBundles(backend, sec);
159
+ const nodes = [];
160
+ const bundleErrors = [];
161
+ let approximate = false;
162
+ for (const bundle of bundles) {
163
+ let res;
164
+ try {
165
+ res = await backend.listEntities({
166
+ entityType: "node", bundle,
167
+ filters: [{ field: "status", op: "eq", value: true }],
168
+ sort: [{ field: "created", dir: "desc" }],
169
+ page: { limit },
170
+ });
171
+ } catch (err) {
172
+ if (type) throw err;
173
+ bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
174
+ continue;
175
+ }
176
+ approximate = approximate || (res.approximate ?? false);
177
+ for (const n of res.entities) {
178
+ nodes.push({
179
+ id: n.id,
180
+ title: n.title,
181
+ created: n.created,
182
+ changed: n.changed,
183
+ path: n.url,
184
+ ...(type ? {} : { contentType: bundle }),
185
+ });
186
+ }
187
+ }
188
+ if (!type) nodes.sort((x, y) => String(y.created ?? "").localeCompare(String(x.created ?? "")));
161
189
  return {
162
- contentType: type || "article",
163
- approximate: res.approximate ?? false,
164
- nodes: res.entities.map((n) => ({
165
- id: n.id,
166
- title: n.title,
167
- created: n.created,
168
- changed: n.changed,
169
- path: n.url,
170
- })),
190
+ contentType: type || null,
191
+ approximate,
192
+ nodes: nodes.slice(0, limit),
193
+ ...(bundleErrors.length ? { bundleErrors } : {}),
171
194
  };
172
195
  }
173
196
 
@@ -255,12 +278,13 @@ async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 10
255
278
  * Per-term counts that error out are reported as null nodeCount.
256
279
  *
257
280
  * @param {object} args - { site?, vocabulary, contentType?, referenceField?, limit? }.
258
- * `referenceField` defaults to `field_{vocabulary}`; `contentType` to "article".
281
+ * `referenceField` defaults to `field_{vocabulary}`; `contentType` is required.
259
282
  * @returns {Promise<object>} Terms sorted by usage, plus an unused-term count.
260
283
  * @throws {Error} If no vocabulary is supplied.
261
284
  * @throws {SecurityError} If reading the vocabulary is not permitted.
262
285
  */
263
286
  async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenceField, limit = 100 }) {
287
+ const ctype = requireContentType(contentType, "drupal_report_taxonomy_usage", "contentType");
264
288
  const site = getSiteConfig(siteName);
265
289
  const sec = resolveSecurityConfig(site);
266
290
  assertReadAllowed(sec, "taxonomy_term", vocabulary);
@@ -272,7 +296,6 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
272
296
  limit
273
297
  );
274
298
  const field = referenceField || `field_${vocabulary}`;
275
- const ctype = contentType || "article";
276
299
  let approximate = false;
277
300
  const results = await Promise.all(terms.map(async (term) => {
278
301
  try {
@@ -306,9 +329,10 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
306
329
  * @throws {SecurityError} If reading the content type is not permitted.
307
330
  */
308
331
  async function revisionHotspots({ site: siteName, type, limit = 20 }) {
332
+ const contentType = requireContentType(type, "drupal_report_revision_hotspots");
309
333
  const site = getSiteConfig(siteName);
310
334
  const sec = resolveSecurityConfig(site);
311
- assertReadAllowed(sec, "node", type);
335
+ assertReadAllowed(sec, "node", contentType);
312
336
  const backend = await resolveBackend(site);
313
337
  if (!backend.capabilities().revisions) {
314
338
  return gatedReport(
@@ -317,7 +341,6 @@ async function revisionHotspots({ site: siteName, type, limit = 20 }) {
317
341
  "This backend does not expose entity revisions. Use a JSON:API site."
318
342
  );
319
343
  }
320
- const contentType = type || "article";
321
344
  const recent = await backend.listEntities({
322
345
  entityType: "node", bundle: contentType,
323
346
  sort: [{ field: "changed", dir: "desc" }],
@@ -431,11 +454,11 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
431
454
  * @throws {SecurityError} If reading the content type is not permitted.
432
455
  */
433
456
  async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
457
+ const contentType = requireContentType(type, "drupal_report_seo_audit");
434
458
  const site = getSiteConfig(siteName);
435
459
  const sec = resolveSecurityConfig(site);
436
- assertReadAllowed(sec, "node", type);
460
+ assertReadAllowed(sec, "node", contentType);
437
461
  const backend = await resolveBackend(site);
438
- const contentType = type || "article";
439
462
  const issueKeys = ["missingMetaDescription", "titleTooLong", "titleTooShort", "thinContent"];
440
463
  const issues = new Map(issueKeys.map((k) => [k, []]));
441
464
  const entities = await collectEntities(
@@ -524,11 +547,11 @@ function resolveJsonapiMetaSource(entities) {
524
547
  * @throws {SecurityError} If reading the content type is not permitted.
525
548
  */
526
549
  async function accessibilityAudit({ site: siteName, type, sampleSize = 100 }) {
550
+ const contentType = requireContentType(type, "drupal_report_accessibility_audit");
527
551
  const site = getSiteConfig(siteName);
528
552
  const sec = resolveSecurityConfig(site);
529
- assertReadAllowed(sec, "node", type);
553
+ assertReadAllowed(sec, "node", contentType);
530
554
  const backend = await resolveBackend(site);
531
- const contentType = type || "article";
532
555
  const issueKeys = ["imagesWithoutAlt", "inlineH1", "nonDescriptiveLinkText", "tablesWithoutCaption"];
533
556
  const issues = new Map(issueKeys.map((k) => [k, []]));
534
557
  const badLinkText = />\s*(click here|read more|learn more|here|more|link)\s*</i;
@@ -572,10 +595,10 @@ export const definitions = [
572
595
  name: "drupal_report_stale_content",
573
596
  description: "Find content that hasn't been updated in N days. Returns a sorted list with titles, status, and days-since-update.",
574
597
  inputSchema: {
575
- type: "object",
598
+ type: "object", required: ["type"],
576
599
  properties: {
577
600
  site: { type: "string" },
578
- type: { type: "string", description: "Content type (default: article)" },
601
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
579
602
  days: { type: "number", default: 180, description: "Stale threshold in days" },
580
603
  status: { type: "boolean", description: "Filter by publish status" },
581
604
  limit: { type: "number", default: 50 },
@@ -586,10 +609,10 @@ export const definitions = [
586
609
  name: "drupal_report_content_by_author",
587
610
  description: "Count nodes per author for a given content type. Returns author UUIDs and counts sorted by most prolific.",
588
611
  inputSchema: {
589
- type: "object",
612
+ type: "object", required: ["type"],
590
613
  properties: {
591
614
  site: { type: "string" },
592
- type: { type: "string", description: "Content type (default: article)" },
615
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
593
616
  limit: { type: "number", default: 100, description: "Max nodes to scan" },
594
617
  },
595
618
  },
@@ -601,7 +624,7 @@ export const definitions = [
601
624
  type: "object",
602
625
  properties: {
603
626
  site: { type: "string" },
604
- type: { type: "string", description: "Content type (default: article)" },
627
+ type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
605
628
  limit: { type: "number", default: 20 },
606
629
  },
607
630
  },
@@ -623,11 +646,11 @@ export const definitions = [
623
646
  name: "drupal_report_taxonomy_usage",
624
647
  description: "Count how many nodes use each term in a vocabulary. Identifies over-used, under-used, and orphaned terms.",
625
648
  inputSchema: {
626
- type: "object", required: ["vocabulary"],
649
+ type: "object", required: ["vocabulary", "contentType"],
627
650
  properties: {
628
651
  site: { type: "string" },
629
652
  vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags', 'category'" },
630
- contentType: { type: "string", description: "Content type to count references from (default: article)" },
653
+ contentType: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
631
654
  referenceField: { type: "string", description: "Field referencing the vocabulary (default: field_{vocabulary})" },
632
655
  limit: { type: "number", default: 100 },
633
656
  },
@@ -637,10 +660,10 @@ export const definitions = [
637
660
  name: "drupal_report_revision_hotspots",
638
661
  description: "Find nodes with the most revision activity — useful for spotting churn or content that needs editorial process review. Requires Drupal 9.3+ JSON:API revisions.",
639
662
  inputSchema: {
640
- type: "object",
663
+ type: "object", required: ["type"],
641
664
  properties: {
642
665
  site: { type: "string" },
643
- type: { type: "string", description: "Content type (default: article)" },
666
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
644
667
  limit: { type: "number", default: 20 },
645
668
  },
646
669
  },
@@ -661,10 +684,10 @@ export const definitions = [
661
684
  name: "drupal_report_seo_audit",
662
685
  description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.",
663
686
  inputSchema: {
664
- type: "object",
687
+ type: "object", required: ["type"],
665
688
  properties: {
666
689
  site: { type: "string" },
667
- type: { type: "string", description: "Content type (default: article)" },
690
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
668
691
  sampleSize: { type: "number", default: 100 },
669
692
  },
670
693
  },
@@ -673,10 +696,10 @@ export const definitions = [
673
696
  name: "drupal_report_accessibility_audit",
674
697
  description: "Accessibility audit for body content: images without alt text, inline H1 tags, non-descriptive link text ('click here', 'read more'), and tables without captions.",
675
698
  inputSchema: {
676
- type: "object",
699
+ type: "object", required: ["type"],
677
700
  properties: {
678
701
  site: { type: "string" },
679
- type: { type: "string", description: "Content type (default: article)" },
702
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
680
703
  sampleSize: { type: "number", default: 100 },
681
704
  },
682
705
  },