drupal-mcp-connector 2.15.2 → 2.17.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 (41) 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-create-translation.md +3 -3
  6. package/.agents/commands/drupal-describe-fields.md +2 -2
  7. package/.agents/commands/drupal-get-media.md +4 -3
  8. package/.agents/commands/drupal-get-node.md +4 -3
  9. package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
  10. package/.agents/commands/drupal-list-translations.md +3 -3
  11. package/.agents/commands/drupal-report-translation-coverage.md +4 -5
  12. package/.agents/commands/drupal-report-workflow-bottlenecks.md +2 -1
  13. package/.agents/commands/drupal-set-moderation-state.md +4 -3
  14. package/.agents/commands/drupal-update-media.md +5 -4
  15. package/.agents/commands/drupal-update-menu-link.md +2 -1
  16. package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
  17. package/CHANGELOG.md +57 -0
  18. package/README.md +3 -3
  19. package/bin/drupal-mcp-agent.js +2 -2
  20. package/package.json +3 -2
  21. package/scripts/generate-commands.js +2 -2
  22. package/src/index.js +2 -2
  23. package/src/lib/backends/jsonapi.js +85 -9
  24. package/src/lib/canonical.js +10 -5
  25. package/src/lib/config.js +18 -1
  26. package/src/lib/draft-write.js +5 -5
  27. package/src/lib/err-relationships.js +22 -0
  28. package/src/lib/mcp-server.js +1 -1
  29. package/src/lib/node-draft-inventory.js +3 -2
  30. package/src/lib/server-tools.js +2 -2
  31. package/src/lib/translation-rows.js +59 -0
  32. package/src/tools/codegen.js +150 -0
  33. package/src/tools/fields.js +12 -4
  34. package/src/tools/index.js +2 -1
  35. package/src/tools/media.js +65 -8
  36. package/src/tools/moderation.js +82 -13
  37. package/src/tools/nodes.js +56 -6
  38. package/src/tools/reports-content.js +117 -28
  39. package/src/tools/structure.js +6 -3
  40. package/src/tools/taxonomy.js +15 -6
  41. package/src/tools/translations.js +31 -27
@@ -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
  },
@@ -9,12 +9,15 @@
9
9
  * - drupal_create_translation — add a language as an unpublished forward draft
10
10
  *
11
11
  * Continuation of an existing unpublished translation is drupal_update_node
12
- * with `langcode`. Reads of that draft are drupal_get_node / drupal_get_revision
12
+ * with `langcode` (media: drupal_update_media; paragraph: drupal_update_paragraph).
13
+ * Reads of that draft are drupal_get_node / drupal_get_media / drupal_get_paragraph
13
14
  * with `langcode`.
14
15
  *
15
16
  * Paragraph field-value translation uses the same surface with
16
- * entityType "paragraph" and the pinned paragraph revision. Image alt is a
17
- * node relationship (`meta.alt`) with the shared file target unchanged.
17
+ * entityType "paragraph" and the pinned paragraph revision. Media uses the
18
+ * node live/working pair (`entityType: "media"`). Image alt on a media entity
19
+ * is the file relationship (`field_media_image` + `meta.alt`) with the shared
20
+ * file target unchanged.
18
21
  */
19
22
 
20
23
  import { getSiteConfig } from "../lib/config.js";
@@ -34,6 +37,7 @@ import {
34
37
  readTranslationInventory,
35
38
  resolveNodeTranslationPair,
36
39
  } from "../lib/draft-write.js";
40
+ import { mapTranslationRow } from "../lib/translation-rows.js";
37
41
 
38
42
  const LIST_NOTE =
39
43
  "Live languages are those on the default revision. Working languages are the " +
@@ -61,7 +65,8 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
61
65
  assertReadAllowed(sec, entityType, type);
62
66
 
63
67
  const backend = await resolveBackend(site);
64
- if ((entityType === "node" || entityType === "paragraph") && typeof backend.rawQuery === "function") {
68
+ if ((entityType === "node" || entityType === "paragraph" || entityType === "media")
69
+ && typeof backend.rawQuery === "function") {
65
70
  try {
66
71
  const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
67
72
  const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
@@ -75,13 +80,7 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
75
80
  langcodes,
76
81
  live: meta.live ?? null,
77
82
  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
- })),
83
+ translations: (meta.working?.translations ?? meta.live?.translations ?? []).map(mapTranslationRow),
85
84
  note: LIST_NOTE,
86
85
  };
87
86
  } catch (error) {
@@ -132,8 +131,8 @@ async function createTranslation({
132
131
  const sec = resolveSecurityConfig(site);
133
132
  assertWriteAllowed(sec, "update", entityType, type);
134
133
 
135
- if (entityType !== "node" && entityType !== "paragraph") {
136
- throw new Error("Governed translation create is implemented for nodes and paragraphs.");
134
+ if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
135
+ throw new Error("Governed translation create is implemented for nodes, paragraphs, and media.");
137
136
  }
138
137
 
139
138
  const backend = await resolveBackend(site);
@@ -156,12 +155,14 @@ async function createTranslation({
156
155
  draftRevision = await resolveNodeTranslationPair(backend, {
157
156
  entityType, bundle: type, id, existing,
158
157
  });
159
- if (drafted.status === undefined && drafted.moderation_state === undefined) {
160
- drafted.moderation_state = "draft";
158
+ if (entityType === "node") {
159
+ if (drafted.status === undefined && drafted.moderation_state === undefined) {
160
+ drafted.moderation_state = "draft";
161
+ }
162
+ drafted = await applySafeDraftDefault({
163
+ backend, entityType, bundle: type, id, attributes: drafted, existingEntity: existing,
164
+ });
161
165
  }
162
- drafted = await applySafeDraftDefault({
163
- backend, entityType, bundle: type, id, attributes: drafted, existingEntity: existing,
164
- });
165
166
  assertPublishAllowed(sec, drafted);
166
167
  }
167
168
 
@@ -179,7 +180,7 @@ async function createTranslation({
179
180
  entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
180
181
  });
181
182
  const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
182
- if (entityType !== "node") return redacted;
183
+ if (entityType === "paragraph") return redacted;
183
184
  const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
184
185
  const liveVid = draftRevision.liveVid;
185
186
  if (liveVid === undefined || liveVid === null) {
@@ -198,15 +199,16 @@ export const definitions = [
198
199
  {
199
200
  name: "drupal_list_translations",
200
201
  description:
201
- "List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's " +
202
+ "List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's " +
202
203
  "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. " +
204
+ "draft), including core content_translation_outdated and source when the server sends them. " +
205
+ "Core JSON:API alone serves one language and cannot prove others are absent. " +
204
206
  "Defaults to node.",
205
207
  inputSchema: {
206
208
  type: "object", required: ["type", "id"],
207
209
  properties: {
208
210
  site: { type: "string", description: "Named site (omit for default)" },
209
- entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
211
+ entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' or 'media' when listing those." },
210
212
  type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
211
213
  id: { type: "string", description: "Entity UUID" },
212
214
  },
@@ -223,15 +225,17 @@ export const definitions = [
223
225
  "paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
224
226
  "The response includes `_revisions.live` / `_revisions.working` when known. " +
225
227
  "Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
226
- "Continue a node draft with drupal_update_node and langcode; continue a paragraph with " +
227
- "drupal_update_paragraph and langcode. Image alt is a relationship (same file UUID, " +
228
- "meta.alt). For paragraphs pass revisionId as the host pin. Requires Sentinel's " +
229
- "draft-translation endpoint. Publication stays denied for content-tier callers.",
228
+ "Continue a node draft with drupal_update_node and langcode; a paragraph with " +
229
+ "drupal_update_paragraph and langcode; media with drupal_update_media and langcode. " +
230
+ "Image alt is a relationship (same file UUID, meta.alt) — on media that is " +
231
+ "field_media_image, not a node image field. For paragraphs pass revisionId as the " +
232
+ "host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied " +
233
+ "for content-tier callers.",
230
234
  inputSchema: {
231
235
  type: "object", required: ["type", "id", "langcode"],
232
236
  properties: {
233
237
  site: { type: "string" },
234
- entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values." },
238
+ entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values, 'media' for media (name, caption, image alt)." },
235
239
  type: { type: "string", description: "Bundle machine name, e.g. 'basic_page' or 'p_hero'" },
236
240
  id: { type: "string", description: "Entity UUID" },
237
241
  langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },