drupal-mcp-connector 2.15.2 → 2.16.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 (34) hide show
  1. package/.agents/commands/drupal-codegen-diff.md +17 -0
  2. package/.agents/commands/drupal-codegen-generate.md +17 -0
  3. package/.agents/commands/drupal-codegen-inspect.md +17 -0
  4. package/.agents/commands/drupal-content-by-moderation-state.md +4 -3
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-get-node.md +4 -3
  7. package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
  8. package/.agents/commands/drupal-list-translations.md +2 -2
  9. package/.agents/commands/drupal-report-translation-coverage.md +4 -5
  10. package/.agents/commands/drupal-report-workflow-bottlenecks.md +2 -1
  11. package/.agents/commands/drupal-set-moderation-state.md +4 -3
  12. package/.agents/commands/drupal-update-menu-link.md +2 -1
  13. package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
  14. package/CHANGELOG.md +38 -0
  15. package/README.md +3 -3
  16. package/bin/drupal-mcp-agent.js +2 -2
  17. package/package.json +2 -2
  18. package/scripts/generate-commands.js +2 -2
  19. package/src/index.js +2 -2
  20. package/src/lib/backends/jsonapi.js +85 -9
  21. package/src/lib/config.js +18 -1
  22. package/src/lib/err-relationships.js +22 -0
  23. package/src/lib/mcp-server.js +1 -1
  24. package/src/lib/server-tools.js +2 -2
  25. package/src/lib/translation-rows.js +59 -0
  26. package/src/tools/codegen.js +150 -0
  27. package/src/tools/fields.js +12 -4
  28. package/src/tools/index.js +2 -1
  29. package/src/tools/moderation.js +82 -13
  30. package/src/tools/nodes.js +56 -6
  31. package/src/tools/reports-content.js +117 -28
  32. package/src/tools/structure.js +6 -3
  33. package/src/tools/taxonomy.js +15 -6
  34. package/src/tools/translations.js +4 -8
@@ -17,6 +17,8 @@ import { resolveBackend } from "../lib/backends/index.js";
17
17
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
18
18
  import { collectEntities, fieldValue, daysSince } from "../lib/reports-support.js";
19
19
  import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
20
+ import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
21
+ import { inventoryRowMatching, inventoryTranslationRows, mapTranslationRow } from "../lib/translation-rows.js";
20
22
 
21
23
  // ---------------------------------------------------------------------------
22
24
  // Shared text helpers
@@ -99,13 +101,14 @@ async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
99
101
  * @param {object} args - { site?, type?, days?, states?, sampleSize? }.
100
102
  * @returns {Promise<object>} Stuck-content findings.
101
103
  */
102
- async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200 }) {
104
+ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200, langcode }) {
103
105
  const site = getSiteConfig(siteName);
104
106
  const sec = resolveSecurityConfig(site);
105
107
  assertReadAllowed(sec, "node", type);
106
108
  const backend = await resolveBackend(site);
107
109
  const contentType = type || "article";
108
110
  const targetStates = (states && states.length ? states : ["draft", "needs_review", "review"]).map((s) => s.toLowerCase());
111
+ const targetLang = langcode ? assertDraftLangcode(langcode) : null;
109
112
 
110
113
  const nodes = await collectEntities(
111
114
  backend,
@@ -113,17 +116,56 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
113
116
  sampleSize
114
117
  );
115
118
 
119
+ if (targetLang && (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function")) {
120
+ return {
121
+ contentType,
122
+ unavailable: true,
123
+ reason: "A langcode filter requires Sentinel's translation inventory.",
124
+ };
125
+ }
126
+
116
127
  let sawState = false;
117
128
  const findings = [];
129
+ let inventoried = 0;
118
130
  for (const n of nodes) {
119
- const state = scalar(n, ["moderation_state"]);
131
+ let state = scalar(n, ["moderation_state"]);
132
+ if (targetLang) {
133
+ try {
134
+ const inventory = await readTranslationInventory(backend, {
135
+ entityType: "node", bundle: contentType, id: n.id,
136
+ });
137
+ inventoried += 1;
138
+ const row = inventoryRowMatching(inventory, { langcode: targetLang });
139
+ if (!row) continue;
140
+ state = row.moderation_state;
141
+ } catch (error) {
142
+ if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
143
+ return {
144
+ contentType,
145
+ unavailable: true,
146
+ reason: "A langcode filter requires Sentinel's translation inventory.",
147
+ };
148
+ }
149
+ throw error;
150
+ }
151
+ }
120
152
  if (state === undefined || state === null) continue;
121
153
  sawState = true;
122
154
  const age = daysSince(n.changed);
123
155
  if (targetStates.includes(String(state).toLowerCase()) && age !== null && age > days) {
124
- findings.push({ id: n.id, title: n.title, state, daysInState: age, path: n.url });
156
+ findings.push({
157
+ id: n.id, title: n.title, state, daysInState: age, path: n.url,
158
+ ...(targetLang ? { langcode: targetLang } : {}),
159
+ });
125
160
  }
126
161
  }
162
+ if (targetLang && inventoried === 0 && nodes.length > 0) {
163
+ return {
164
+ contentType,
165
+ unavailable: true,
166
+ reason: "A langcode filter requires Sentinel's translation inventory.",
167
+ };
168
+ }
127
169
 
128
170
  if (!sawState) {
129
171
  return { contentType, gated: true, reason: "No moderation_state field exposed (content_moderation not enabled or not in the API).", scanned: nodes.length };
@@ -144,16 +186,16 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
144
186
  // ---------------------------------------------------------------------------
145
187
 
146
188
  /**
147
- * Report content distribution by language for a content type and flag languages
148
- * that lag the most-populated language — a coverage signal for multilingual
149
- * sites. (Exact per-node missing-translation detection requires translation
150
- * metadata the canonical model doesn't carry; this aggregate is the best-effort
151
- * stand-in.)
189
+ * Per-node translation coverage from Sentinel inventory.
152
190
  *
153
- * @param {object} args - { site?, type?, gapThreshold?, sampleSize? }.
154
- * @returns {Promise<object>} Per-language counts and lagging languages.
191
+ * JSON:API serves one language per resource, so a histogram of `n.langcode`
192
+ * looks like 100% default language even when translations exist. Without
193
+ * Sentinel the report is `unavailable` rather than that misleading chart.
194
+ *
195
+ * @param {object} args - { site?, type?, sampleSize? }.
196
+ * @returns {Promise<object>} Coverage, or unavailable.
155
197
  */
156
- async function translationCoverage({ site: siteName, type, gapThreshold = 0.5, sampleSize = 500 }) {
198
+ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
157
199
  const site = getSiteConfig(siteName);
158
200
  const sec = resolveSecurityConfig(site);
159
201
  assertReadAllowed(sec, "node", type);
@@ -165,24 +207,71 @@ async function translationCoverage({ site: siteName, type, gapThreshold = 0.5, s
165
207
  sampleSize
166
208
  );
167
209
 
168
- const byLang = new Map();
210
+ const missingEndpoint = /does not provide Sentinel's governed draft-translation endpoint/;
211
+ if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
212
+ return {
213
+ contentType,
214
+ scanned: nodes.length,
215
+ unavailable: true,
216
+ reason:
217
+ "Translation coverage requires Sentinel's GET .../mcp-translations inventory. " +
218
+ "JSON:API only shows the default language; a language histogram would be misleading.",
219
+ };
220
+ }
221
+
222
+ const findings = [];
223
+ let inventoried = 0;
169
224
  for (const n of nodes) {
170
- const lang = n.langcode || "und";
171
- byLang.set(lang, (byLang.get(lang) || 0) + 1);
225
+ try {
226
+ const inventory = await readTranslationInventory(backend, {
227
+ entityType: "node", bundle: contentType, id: n.id,
228
+ });
229
+ inventoried += 1;
230
+ const rows = inventoryTranslationRows(inventory);
231
+ const defaultLangcode = inventory.defaultLangcode ?? n.langcode ?? null;
232
+ const nonDefault = rows.filter((row) => row.langcode !== defaultLangcode);
233
+ findings.push({
234
+ id: n.id,
235
+ title: n.title,
236
+ defaultLangcode,
237
+ languages: rows.map(mapTranslationRow),
238
+ missingNonDefault: nonDefault.length === 0,
239
+ outdated: rows.some((row) => row.outdated === true),
240
+ });
241
+ } catch (error) {
242
+ if (!missingEndpoint.test(String(error?.message))) throw error;
243
+ }
244
+ }
245
+
246
+ if (inventoried === 0) {
247
+ return {
248
+ contentType,
249
+ scanned: nodes.length,
250
+ unavailable: true,
251
+ reason:
252
+ "Translation coverage requires Sentinel's GET .../mcp-translations inventory. " +
253
+ "JSON:API only shows the default language; a language histogram would be misleading.",
254
+ };
255
+ }
256
+
257
+ const languageCounts = new Map();
258
+ for (const finding of findings) {
259
+ for (const row of finding.languages) {
260
+ languageCounts.set(row.langcode, (languageCounts.get(row.langcode) || 0) + 1);
261
+ }
172
262
  }
173
- const counts = [...byLang.entries()].map(([langcode, count]) => ({ langcode, count })).sort((a, b) => b.count - a.count);
174
- const top = counts[0]?.count ?? 0;
175
- const lagging = counts
176
- .filter((c) => top > 0 && c.count / top < gapThreshold)
177
- .map((c) => ({ langcode: c.langcode, count: c.count, coverage: Number((c.count / top).toFixed(2)) }));
178
263
 
179
264
  return {
180
265
  contentType,
181
266
  scanned: nodes.length,
182
- approximate: nodes.length >= sampleSize,
183
- languages: counts,
184
- laggingLanguages: lagging,
185
- note: "Coverage is a distribution-by-language signal; exact per-node missing translations require translation metadata not in the canonical model.",
267
+ inventoried,
268
+ approximate: nodes.length >= sampleSize || inventoried < nodes.length,
269
+ languages: [...languageCounts.entries()]
270
+ .map(([langcode, count]) => ({ langcode, count }))
271
+ .sort((a, b) => b.count - a.count),
272
+ missing: findings.filter((f) => f.missingNonDefault).map((f) => ({ id: f.id, title: f.title })),
273
+ outdated: findings.filter((f) => f.outdated).map((f) => ({ id: f.id, title: f.title })),
274
+ findings,
186
275
  };
187
276
  }
188
277
 
@@ -555,20 +644,20 @@ export const definitions = [
555
644
  type: { type: "string", description: "Content type (default: article)" },
556
645
  days: { type: "number", default: 30, description: "Days-in-state threshold" },
557
646
  states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
647
+ langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
558
648
  sampleSize: { type: "number", default: 200 },
559
649
  },
560
650
  },
561
651
  },
562
652
  {
563
653
  name: "drupal_report_translation_coverage",
564
- description: "Report content distribution by language for a content type and flag languages lagging the most-populated language — a multilingual coverage signal.",
654
+ description: "Per-node translation coverage from Sentinel's inventory (missing non-default language, outdated core flag, language counts). Without Sentinel the report is unavailable — JSON:API only shows the default language, so a histogram would be misleading.",
565
655
  inputSchema: {
566
656
  type: "object",
567
657
  properties: {
568
- site: { type: "string" },
569
- type: { type: "string", description: "Content type (default: article)" },
570
- gapThreshold: { type: "number", default: 0.5, description: "Flag languages below this fraction of the top language" },
571
- sampleSize: { type: "number", default: 500 },
658
+ site: { type: "string" },
659
+ type: { type: "string", description: "Content type (default: article)" },
660
+ sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
572
661
  },
573
662
  },
574
663
  },
@@ -19,6 +19,7 @@ import { resolveBackend } from "../lib/backends/index.js";
19
19
  import {
20
20
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
21
21
  } from "../lib/security.js";
22
+ import { assertDraftLangcode } from "../lib/draft-write.js";
22
23
 
23
24
  const MENU_LINK_TYPE = "menu_link_content";
24
25
  const BLOCK_TYPE = "block_content";
@@ -150,12 +151,13 @@ async function createMenuLink({ site: siteName, title, link, menu, weight, paren
150
151
  * @throws {Error} If id is missing.
151
152
  * @throws {SecurityError} If updating menu_link_content is not permitted.
152
153
  */
153
- async function updateMenuLink({ site: siteName, id, title, link, menu, weight, parent, enabled }) {
154
+ async function updateMenuLink({ site: siteName, id, title, link, menu, weight, parent, enabled, langcode }) {
154
155
  if (!id) throw new Error("A menu link 'id' (UUID) is required to update an existing menu link.");
155
156
  const site = getSiteConfig(siteName);
156
157
  const sec = resolveSecurityConfig(site);
157
158
  assertWriteAllowed(sec, "update", MENU_LINK_TYPE, MENU_LINK_TYPE);
158
159
  const backend = await resolveBackend(site);
160
+ const language = langcode ? { langcode: assertDraftLangcode(langcode) } : {};
159
161
  const attributes = {};
160
162
  if (title !== undefined) attributes.title = title;
161
163
  if (link !== undefined) attributes.link = { uri: link };
@@ -165,12 +167,12 @@ async function updateMenuLink({ site: siteName, id, title, link, menu, weight, p
165
167
  if (enabled !== undefined) {
166
168
  attributes.enabled = enabled;
167
169
  } else {
168
- const current = await backend.getEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id });
170
+ const current = await backend.getEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, ...language });
169
171
  const currentEnabled = current?.fields?.enabled;
170
172
  attributes.enabled = currentEnabled === undefined ? true : currentEnabled;
171
173
  }
172
174
  return writeMenuLinkWithRetry(() =>
173
- backend.updateEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, attributes }));
175
+ backend.updateEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, attributes, ...language }));
174
176
  }
175
177
 
176
178
  // ---------------------------------------------------------------------------
@@ -273,6 +275,7 @@ export const definitions = [
273
275
  weight: { type: "number", description: "New ordering weight. Omit to leave unchanged." },
274
276
  parent: { type: "string", description: "New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged." },
275
277
  enabled: { type: "boolean", description: "Enable/disable the link. Omit to preserve the current state." },
278
+ langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language. Does not create a missing translation." },
276
279
  },
277
280
  },
278
281
  },
@@ -12,6 +12,7 @@ import {
12
12
  resolveSecurityConfig, redactCanonicalEntity,
13
13
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
14
14
  } from "../lib/security.js";
15
+ import { assertDraftLangcode } from "../lib/draft-write.js";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Implementations
@@ -58,12 +59,15 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
58
59
  * @param {object} args - { site?, vocabulary, id }.
59
60
  * @returns {Promise<object|null>} The redacted term, or null if not found.
60
61
  */
61
- async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
62
+ async function getTaxonomyTerm({ site: siteName, vocabulary, id, langcode }) {
62
63
  const site = getSiteConfig(siteName);
63
64
  const sec = resolveSecurityConfig(site);
64
65
  assertReadAllowed(sec, "taxonomy_term", vocabulary);
65
66
  const backend = await resolveBackend(site);
66
- const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
67
+ const entity = await backend.getEntity({
68
+ entityType: "taxonomy_term", bundle: vocabulary, id,
69
+ ...(langcode ? { langcode: assertDraftLangcode(langcode) } : {}),
70
+ });
67
71
  return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
68
72
  }
69
73
 
@@ -93,7 +97,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
93
97
  * @param {object} args - { site?, vocabulary, id, name?, description?, weight? }.
94
98
  * @returns {Promise<object>} The updated term descriptor.
95
99
  */
96
- async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
100
+ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight, langcode }) {
97
101
  const site = getSiteConfig(siteName);
98
102
  assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
99
103
  const backend = await resolveBackend(site);
@@ -101,7 +105,10 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
101
105
  if (name !== undefined) attributes.name = name;
102
106
  if (weight !== undefined) attributes.weight = weight;
103
107
  if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
104
- return backend.updateEntity({ entityType: "taxonomy_term", bundle: vocabulary, id, attributes });
108
+ return backend.updateEntity({
109
+ entityType: "taxonomy_term", bundle: vocabulary, id, attributes,
110
+ ...(langcode ? { langcode: assertDraftLangcode(langcode) } : {}),
111
+ });
105
112
  }
106
113
 
107
114
  /**
@@ -147,13 +154,14 @@ export const definitions = [
147
154
  },
148
155
  {
149
156
  name: "drupal_get_taxonomy_term",
150
- description: "Fetch a single taxonomy term by UUID.",
157
+ description: "Fetch a single taxonomy term by UUID. Pass langcode to request that translation; omit for the default language. JSON:API must negotiate language or the tool errors if a different language is served.",
151
158
  inputSchema: {
152
159
  type: "object", required: ["vocabulary", "id"],
153
160
  properties: {
154
161
  site: { type: "string" },
155
162
  vocabulary: { type: "string" },
156
163
  id: { type: "string", description: "Term UUID" },
164
+ langcode: { type: "string", description: "Translation language (e.g. 'es'). Omit for the default language." },
157
165
  },
158
166
  },
159
167
  },
@@ -174,7 +182,7 @@ export const definitions = [
174
182
  },
175
183
  {
176
184
  name: "drupal_update_taxonomy_term",
177
- description: "Update an existing taxonomy term's name, description, or weight.",
185
+ description: "Update an existing taxonomy term's name, description, or weight. Pass langcode to update an existing translation; this does not create a missing translation.",
178
186
  inputSchema: {
179
187
  type: "object", required: ["vocabulary", "id"],
180
188
  properties: {
@@ -184,6 +192,7 @@ export const definitions = [
184
192
  name: { type: "string" },
185
193
  description: { type: "string" },
186
194
  weight: { type: "number" },
195
+ langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language." },
187
196
  },
188
197
  },
189
198
  },
@@ -34,6 +34,7 @@ import {
34
34
  readTranslationInventory,
35
35
  resolveNodeTranslationPair,
36
36
  } from "../lib/draft-write.js";
37
+ import { mapTranslationRow } from "../lib/translation-rows.js";
37
38
 
38
39
  const LIST_NOTE =
39
40
  "Live languages are those on the default revision. Working languages are the " +
@@ -75,13 +76,7 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
75
76
  langcodes,
76
77
  live: meta.live ?? null,
77
78
  working: meta.working ?? null,
78
- translations: (meta.working?.translations ?? meta.live?.translations ?? []).map((row) => ({
79
- langcode: row.langcode,
80
- default: Boolean(row.default),
81
- status: row.status,
82
- title: row.title,
83
- moderation_state: row.moderation_state,
84
- })),
79
+ translations: (meta.working?.translations ?? meta.live?.translations ?? []).map(mapTranslationRow),
85
80
  note: LIST_NOTE,
86
81
  };
87
82
  } catch (error) {
@@ -200,7 +195,8 @@ export const definitions = [
200
195
  description:
201
196
  "List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's " +
202
197
  "translation inventory when available (live default revision vs unpublished working " +
203
- "draft). Core JSON:API alone serves one language and cannot prove others are absent. " +
198
+ "draft), including core content_translation_outdated and source when the server sends them. " +
199
+ "Core JSON:API alone serves one language and cannot prove others are absent. " +
204
200
  "Defaults to node.",
205
201
  inputSchema: {
206
202
  type: "object", required: ["type", "id"],