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
@@ -16,9 +16,15 @@
16
16
  * forward it unchanged (the JSON:API backend coerces datetime fields as needed).
17
17
  *
18
18
  * Capability degradation: if the Scheduler module is not installed, or the bundle
19
- * does not have the publish_on / unpublish_on fields, the backend write fails with
20
- * an "unknown field" error. We catch that and re-throw a clear, actionable message
21
- * (while still surfacing the underlying backend error) rather than a raw stack.
19
+ * does not have the publish_on / unpublish_on fields, JSON:API rejects the write
20
+ * because the attribute does not exist. We catch that and re-throw a clear,
21
+ * actionable message (while still surfacing the underlying backend error).
22
+ *
23
+ * The connector does not decide who may schedule which transition. Drupal's
24
+ * validation (SchedulerModerationTransitionAccess) and the site's MCP Sentinel
25
+ * policy are the gate; their refusals are surfaced verbatim. The only local
26
+ * refusal is a date with no matching state on a bundle that exposes the state
27
+ * field, because Drupal accepts that write and the schedule then never runs.
22
28
  */
23
29
 
24
30
  import { getSiteConfig } from "../lib/config.js";
@@ -26,18 +32,43 @@ import { resolveBackend } from "../lib/backends/index.js";
26
32
  import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
27
33
  import { entityLooksModerated } from "../lib/moderation-default.js";
28
34
  import { fieldPresence } from "../lib/reports-support.js";
35
+ import { httpStatusOf } from "../lib/error-status.js";
29
36
 
30
37
  /**
31
- * Heuristic: does this backend error look like a missing/unknown field? Scheduler
32
- * fields are absent unless the module is installed and enabled for the bundle, so
33
- * an unknown-field error almost always means a missing Scheduler capability.
38
+ * Does this backend error say a Scheduler attribute does not exist on the
39
+ * bundle? Only Drupal JSON:API's explicit form counts ("The attribute
40
+ * publish_on does not exist on the node--page resource type."). A 422
41
+ * validation detail names the field too (`publish_state: …`) and may say
42
+ * "invalid" or "unknown"; that is a refusal by the site, surfaced verbatim,
43
+ * not a missing capability (#402).
34
44
  *
35
45
  * @param {Error} err - The error thrown by the backend write.
36
46
  * @returns {boolean}
37
47
  */
38
48
  export function looksLikeUnknownField(err) {
39
- const msg = String(err?.message || err || "");
40
- return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on|publish_state|unpublish_state/i.test(msg);
49
+ const msg = String(err?.message || err || "").slice(0, 2000);
50
+ return /\battribute [\w.-]{1,128} does not exist on the [\w.-]{1,128} resource type\b/i.test(msg);
51
+ }
52
+
53
+ /**
54
+ * Wrap a site refusal so the reason is the first thing the caller reads. The
55
+ * original message (with Drupal's or Sentinel's reason) is kept verbatim and
56
+ * the HTTP status is carried over.
57
+ *
58
+ * @param {Error} err - The error thrown by the backend write.
59
+ * @param {string} type - Bundle machine name.
60
+ * @returns {Error}
61
+ */
62
+ function siteRefusal(err, type) {
63
+ const status = httpStatusOf(err);
64
+ const reason = err?.message || String(err);
65
+ const out = new Error(
66
+ `The site refused the schedule on '${type}'${status ? ` (HTTP ${status})` : ""}. ` +
67
+ `Drupal validation or the site's MCP Sentinel policy decides scheduled transitions; the connector does not. Reason: ${reason}`,
68
+ );
69
+ if (status) out.status = status;
70
+ out.cause = err;
71
+ return out;
41
72
  }
42
73
 
43
74
  /**
@@ -59,14 +90,17 @@ function schedulerFieldPresent(entity, schema, field) {
59
90
  * canonical updateEntity. At least one of publishOn / unpublishOn must be given.
60
91
  * Each value is forwarded unchanged (ISO 8601 string or epoch integer).
61
92
  *
62
- * On a moderated bundle, `publishState` / `unpublishState` are required when
63
- * the matching date is set and the Scheduler CM state field exists (#402).
93
+ * `publishState` / `unpublishState` are written as `publish_state` /
94
+ * `unpublish_state`. When the bundle exposes the matching state field, a date
95
+ * without its state is refused locally (#402); every other decision is Drupal's.
64
96
  *
65
97
  * @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
66
- * @returns {Promise<object>} The redacted, updated node descriptor.
67
- * @throws {Error} If neither timestamp is supplied, if the policy forbids the
68
- * write, if a moderated schedule is missing its target state, or — degraded —
69
- * if the Scheduler fields are unknown on the bundle.
98
+ * @returns {Promise<object>} The redacted, updated node descriptor, with
99
+ * `warnings` when a moderated bundle has no state field to pair a date with.
100
+ * @throws {Error} If neither timestamp is supplied, if connector policy forbids
101
+ * the write, if a date is missing its state on a bundle with the state field,
102
+ * if the site refuses the write (reason kept verbatim), or — degraded — if the
103
+ * Scheduler fields are unknown on the bundle.
70
104
  */
71
105
  async function schedulePublish({
72
106
  site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
@@ -96,40 +130,28 @@ async function schedulePublish({
96
130
  }
97
131
  }
98
132
 
99
- const moderated = entityLooksModerated(existing);
100
- if (moderated) {
101
- if (publishOn !== undefined) {
102
- const hasStateField = schedulerFieldPresent(existing, schema, "publish_state");
103
- if (!hasStateField) {
104
- throw new Error(
105
- `Could not schedule publish on moderated '${type}': the publish_state field is not available. ` +
106
- "Moderated bundles need the Scheduler Content Moderation Integration module, and a publishState " +
107
- "(e.g. 'published') whenever publishOn is set. Without that state, cron logs 'Publishing failed' " +
108
- "and the node never goes live. See connector #402."
109
- );
110
- }
111
- if (publishState === undefined) {
112
- throw new Error(
113
- "Moderated content requires publishState (the workflow state to enter at publishOn, e.g. 'published'). " +
114
- "Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
115
- );
116
- }
133
+ // A state field on the bundle means Scheduler Content Moderation Integration
134
+ // is active there. Drupal accepts a date with no state, and the schedule then
135
+ // fails at every cron run, so that one case is refused here (#402).
136
+ const pairs = [
137
+ ["publishOn", publishOn, "publishState", publishState, "publish_state", "published"],
138
+ ["unpublishOn", unpublishOn, "unpublishState", unpublishState, "unpublish_state", "archived"],
139
+ ];
140
+ const warnings = [];
141
+ for (const [dateArg, date, stateArg, state, stateField, example] of pairs) {
142
+ if (date === undefined || state !== undefined) continue;
143
+ if (schedulerFieldPresent(existing, schema, stateField)) {
144
+ throw new Error(
145
+ `'${type}' is a moderated bundle with Scheduler Content Moderation Integration: pass ${stateArg} ` +
146
+ `(the moderation state to enter at ${dateArg}, e.g. '${example}'). A schedule without a state never runs: ` +
147
+ "Drupal accepts it, then cron logs 'Publishing failed' on every run.",
148
+ );
117
149
  }
118
- if (unpublishOn !== undefined) {
119
- const hasStateField = schedulerFieldPresent(existing, schema, "unpublish_state");
120
- if (!hasStateField) {
121
- throw new Error(
122
- `Could not schedule unpublish on moderated '${type}': the unpublish_state field is not available. ` +
123
- "Moderated bundles need the Scheduler Content Moderation Integration module, and an unpublishState " +
124
- "(e.g. 'draft') whenever unpublishOn is set. See connector #402."
125
- );
126
- }
127
- if (unpublishState === undefined) {
128
- throw new Error(
129
- "Moderated content requires unpublishState (the workflow state to enter at unpublishOn, e.g. 'draft'). " +
130
- "Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
131
- );
132
- }
150
+ if (entityLooksModerated(existing)) {
151
+ warnings.push(
152
+ `'${type}' is moderated but exposes no ${stateField} field. Scheduler cannot change a moderated node ` +
153
+ `without the Scheduler Content Moderation Integration module and a ${stateArg}, so this ${dateArg} may never take effect.`,
154
+ );
133
155
  }
134
156
  }
135
157
 
@@ -145,15 +167,20 @@ async function schedulePublish({
145
167
  } catch (err) {
146
168
  if (looksLikeUnknownField(err)) {
147
169
  throw new Error(
148
- `Could not set Scheduler dates on '${type}': the publish_on / unpublish_on fields are not available. ` +
149
- "This tool requires the Drupal Scheduler module to be installed and enabled for this content type. " +
150
- "Moderated bundles also need Scheduler Content Moderation Integration (publish_state / unpublish_state). " +
170
+ `Could not set Scheduler dates on '${type}': a Scheduler field is not available on this bundle. ` +
171
+ "publish_on / unpublish_on need the Drupal Scheduler module enabled for this content type; " +
172
+ "publish_state / unpublish_state also need Scheduler Content Moderation Integration. " +
151
173
  `Backend error: ${err?.message || err}`,
152
174
  );
153
175
  }
176
+ // 403 (access or Sentinel policy) and 422 (validation) are the site's
177
+ // decision on this write. Other failures pass through unchanged.
178
+ const status = httpStatusOf(err);
179
+ if (status === 403 || status === 422) throw siteRefusal(err, type);
154
180
  throw err;
155
181
  }
156
- return updated ? redactCanonicalEntity(updated, sec, "node") : updated;
182
+ const out = updated ? redactCanonicalEntity(updated, sec, "node") : updated;
183
+ return out && warnings.length ? { ...out, warnings } : out;
157
184
  }
158
185
 
159
186
  // ---------------------------------------------------------------------------
@@ -165,15 +192,16 @@ export const definitions = [
165
192
  name: "drupal_schedule_publish",
166
193
  description:
167
194
  "Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
168
- "Sets the publish_on and unpublish_on fields on the node. " +
169
- "On a bundle under Content Moderation, also set publishState / unpublishState " +
170
- "(written as publish_state / unpublish_state) — dates alone never go live. " +
171
- "Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / " +
172
- "unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. " +
173
- "Moderated bundles additionally need Scheduler Content Moderation Integration; when those state fields " +
174
- "exist, the matching state is required whenever a date is set. " +
175
- "Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. " +
176
- "Provide at least one of publishOn or unpublishOn.",
195
+ "Sets the publish_on and unpublish_on fields on the node. Provide at least one of publishOn or unpublishOn. " +
196
+ "Moderated bundles (Content Moderation) also need a target state: pass publishState / unpublishState " +
197
+ "(moderation state machine names, e.g. 'published', 'archived'), written as publish_state / unpublish_state. " +
198
+ "These fields exist only when the Scheduler Content Moderation Integration module is enabled. When the bundle " +
199
+ "exposes a state field and its date is set without the state, the call fails, because Drupal would accept a " +
200
+ "schedule that never runs. When a moderated bundle has no state field, the dates are written and the result " +
201
+ "carries a warning. Whether this account may schedule a given transition is decided by the site (Drupal " +
202
+ "validation and MCP Sentinel policy), not the connector; a refusal returns the site's reason verbatim. " +
203
+ "Without the Scheduler module on the content type, the call fails with a capability error. " +
204
+ "Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged.",
177
205
  inputSchema: {
178
206
  type: "object", required: ["type", "id"],
179
207
  properties: {
@@ -182,8 +210,8 @@ export const definitions = [
182
210
  id: { type: "string", description: "Node UUID" },
183
211
  publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
184
212
  unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
185
- publishState: { type: "string", description: "Moderation state to enter at publishOn (e.g. 'published'). Required on moderated bundles when publishOn is set." },
186
- unpublishState: { type: "string", description: "Moderation state to enter at unpublishOn (e.g. 'draft'). Required on moderated bundles when unpublishOn is set." },
213
+ publishState: { type: "string", description: "Moderation state machine name to enter at publishOn (e.g. 'published'). Written as publish_state. Required when publishOn is set and the bundle has a publish_state field." },
214
+ unpublishState: { type: "string", description: "Moderation state machine name to enter at unpublishOn (e.g. 'archived'). Written as unpublish_state. Required when unpublishOn is set and the bundle has an unpublish_state field." },
187
215
  },
188
216
  },
189
217
  },
@@ -2,8 +2,8 @@
2
2
  * Tool group: content search.
3
3
  *
4
4
  * Search API / Solr does not expose a JSON:API surface by default, so this tool
5
- * provides a best-effort search: a JSON:API title/body CONTAINS match over a
6
- * content type. Results are flagged mode:"fallback" and the response notes that
5
+ * provides a best-effort search: a JSON:API title CONTAINS match over one
6
+ * content type, or over every node bundle when `type` is omitted (#403). Results are flagged mode:"fallback" and the response notes that
7
7
  * true relevance ranking requires a Search API endpoint (or the Drush bridge).
8
8
  * Read-only and redacted per the site policy.
9
9
  */
@@ -11,28 +11,32 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { resolveSecurityConfig, assertReadAllowed, redactCanonicalEntity } from "../lib/security.js";
14
+ import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
14
15
 
15
16
  /**
16
- * Search nodes of a type by a query string (title CONTAINS, best-effort).
17
+ * Search nodes by a query string (title CONTAINS, best-effort). With `type`,
18
+ * searches that bundle; without it, every node bundle the connector may read,
19
+ * merged newest first. A bundle that fails to load fails the search.
17
20
  * @param {object} args - { site?, query, type?, limit? }.
18
- * @returns {Promise<{query, mode, note, results}>}
21
+ * @returns {Promise<{query, type, mode, note, results}>}
19
22
  */
20
- async function search({ site: siteName, query, type = "article", limit = 10 }) {
23
+ async function search({ site: siteName, query, type, limit = 10 }) {
21
24
  if (!query || !String(query).trim()) throw new Error("A non-empty 'query' is required.");
22
25
  const site = getSiteConfig(siteName);
23
26
  const sec = resolveSecurityConfig(site);
24
27
  assertReadAllowed(sec, "node", type);
25
28
  const backend = await resolveBackend(site);
26
- const res = await backend.listEntities({
27
- entityType: "node", bundle: type,
29
+ const descriptor = {
28
30
  filters: [{ field: "title", op: "contains", value: query }],
29
31
  sort: [{ field: "changed", dir: "desc" }],
30
- page: { limit },
31
- });
32
- const results = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
32
+ };
33
+ const entities = type
34
+ ? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
35
+ : await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
36
+ const results = entities.map((e) => redactCanonicalEntity(e, sec, "node"));
33
37
  return {
34
38
  query,
35
- type,
39
+ type: type || null,
36
40
  mode: "fallback",
37
41
  note: "JSON:API title CONTAINS match. Relevance-ranked full-text search requires a Search API/Solr endpoint or the Drush bridge.",
38
42
  results,
@@ -48,7 +52,7 @@ export const definitions = [
48
52
  properties: {
49
53
  site: { type: "string" },
50
54
  query: { type: "string", description: "Search term" },
51
- type: { type: "string", description: "Content type machine name (default: article)" },
55
+ type: { type: "string", description: "Content type machine name. Omit to search every node bundle (merged, newest first); if any bundle cannot be read the search fails and names it." },
52
56
  limit: { type: "number", default: 10 },
53
57
  },
54
58
  },