drupal-mcp-connector 2.16.0 → 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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; continue a paragraph with drupal_update_paragraph and langcode. Image alt is a relationship (same file UUID, meta.alt). For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
2
+ description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
3
3
  argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [relationships] [revisionId] [dryRun]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_create_translation`.
7
7
 
8
- Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; continue a paragraph with drupal_update_paragraph and langcode. Image alt is a relationship (same file UUID, meta.alt). For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
8
+ Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -16,7 +16,7 @@ Parse the arguments supplied with this command into this tool's parameters:
16
16
 
17
17
  **Optional:**
18
18
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
19
- - `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values.
19
+ - `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values, 'media' for media (name, caption, image alt).
20
20
  - `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
21
21
  - `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
22
22
  - `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Fetch a single media entity by UUID and media type."
3
- argument-hint: "<type> <id> [site]"
2
+ description: "Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel."
3
+ argument-hint: "<type> <id> [site] [langcode]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_get_media`.
7
7
 
8
- Fetch a single media entity by UUID and media type.
8
+ Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -15,5 +15,6 @@ Parse the arguments supplied with this command into this tool's parameters:
15
15
 
16
16
  **Optional:**
17
17
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
18
+ - `langcode` (string): Target language (e.g. 'es') to read the unpublished working translation instead of the default language.
18
19
 
19
20
  If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node."
2
+ description: "List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node."
3
3
  argument-hint: "<type> <id> [site] [entityType]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_list_translations`.
7
7
 
8
- List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node.
8
+ List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -15,6 +15,6 @@ Parse the arguments supplied with this command into this tool's parameters:
15
15
 
16
16
  **Optional:**
17
17
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
18
- - `entityType` (string): Entity type machine name. Default: 'node'.
18
+ - `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' or 'media' when listing those.
19
19
 
20
20
  If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched."
3
- argument-hint: "<type> <id> [site] [name] [status] [fields]"
2
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first."
3
+ argument-hint: "<type> <id> [site] [name] [status] [fields] [langcode]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_media`.
7
7
 
8
- Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.
8
+ Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -17,6 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
17
17
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
18
18
  - `name` (string)
19
19
  - `status` (boolean (true/false)): Published flag. Only sent when provided; requires allowPublish when true.
20
- - `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
20
+ - `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically. Image alt is meta.alt on field_media_image with the existing file UUID.
21
+ - `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
21
22
 
22
23
  If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.17.0] - 2026-09-11
11
+
12
+ ### Added
13
+ - **Governed media draft translation (#296).** `drupal_create_translation` and
14
+ `drupal_list_translations` accept `entityType: "media"`. Continue with
15
+ `drupal_update_media` + `langcode`; read with `drupal_get_media` + `langcode`.
16
+ Same Sentinel `/mcp-draft` live/working pair as nodes (d.o #3622412). Image
17
+ alt/title are kept on the file relationship (`meta.alt` / `meta.title`) so a
18
+ translator can read the source. Canonical media writes without `langcode` are
19
+ unchanged. Requires MCP Sentinel with media draft routes.
20
+
21
+ ### Security
22
+ - Bump transitive `hono` 4.13.1 → 4.13.7 (via `@modelcontextprotocol/node`)
23
+ and override it to `^4.13.5`, clearing three moderate advisories:
24
+ GHSA-gqvv-2mrq-wpjv (`toSSG()` path traversal), GHSA-g6gw-c38x-mqfc
25
+ (`parseBody()` unbounded nesting), and GHSA-crvj-82cr-hjcx (query parser
26
+ after URL fragment). `npm audit` reports 0 vulnerabilities (#299).
27
+
10
28
  ## [2.16.0] - 2026-09-11
11
29
 
12
30
  ### Added
@@ -1620,6 +1638,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1620
1638
  - User tools gained explicit PII-access assertions.
1621
1639
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1622
1640
 
1641
+ [2.17.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.17.0
1623
1642
  [2.16.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.16.0
1624
1643
  [2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2
1625
1644
  [2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -83,6 +83,7 @@
83
83
  },
84
84
  "overrides": {
85
85
  "@hono/node-server": "^2.0.5",
86
+ "hono": "^4.13.5",
86
87
  "postcss": "^8.5.18"
87
88
  }
88
89
  }
@@ -65,14 +65,19 @@ export function normalizeRelationship(ref) {
65
65
  // JSON:API encodes type as "entityType--bundle"; split into the two parts.
66
66
  const [entityType = null, bundle = null] = (ref.type || "").split("--");
67
67
  const out = { id: ref.id, entityType, bundle };
68
- // ERR identifiers carry the revision id in JSON:API `meta` (#192). Dropping
69
- // it made every canonical re-read look like a plain {id, type} even when the
70
- // write sent target_revision_id.
68
+ // ERR identifiers carry the revision id in JSON:API `meta` (#192). Image
69
+ // fields carry alt/title the same way (#296). Dropping either made a
70
+ // canonical re-read look like a plain {id, type}.
71
71
  if (ref.meta && typeof ref.meta === "object") {
72
- const vid = new Map(Object.entries(ref.meta)).get("target_revision_id");
72
+ const entries = new Map(Object.entries(ref.meta));
73
+ const meta = {};
74
+ const vid = entries.get("target_revision_id");
73
75
  if (vid !== undefined && vid !== null && vid !== "") {
74
- out.meta = { target_revision_id: vid };
76
+ meta.target_revision_id = vid;
75
77
  }
78
+ if (typeof entries.get("alt") === "string") meta.alt = entries.get("alt");
79
+ if (typeof entries.get("title") === "string") meta.title = entries.get("title");
80
+ if (Object.keys(meta).length > 0) out.meta = meta;
76
81
  }
77
82
  return out;
78
83
  }
@@ -46,7 +46,7 @@ function requireWorkingPair(draftRevision) {
46
46
  const live = String(draftRevision?.liveVid ?? "");
47
47
  const working = String(draftRevision?.workingVid ?? "");
48
48
  if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
49
- throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
49
+ throw new Error("Draft continuation requires distinct, verified live and working revision IDs.");
50
50
  }
51
51
  return { live, working };
52
52
  }
@@ -59,8 +59,8 @@ function requireWorkingPair(draftRevision) {
59
59
  * @returns {string}
60
60
  */
61
61
  function draftResource(backend, entityType, bundle, id) {
62
- if (entityType !== "node" && entityType !== "paragraph") {
63
- throw new Error("Governed draft translation is implemented for nodes and paragraphs.");
62
+ if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
63
+ throw new Error("Governed draft translation is implemented for nodes, paragraphs, and media.");
64
64
  }
65
65
  if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
66
66
  throw new Error("This backend does not support governed draft continuation.");
@@ -150,8 +150,8 @@ export async function createTranslationDraft(backend, input, preflight = false)
150
150
  const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
151
151
  ? ""
152
152
  : String(workingRaw);
153
- if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
154
- throw new Error("Translation create requires a verified live node revision ID.");
153
+ if ((entityType !== "node" && entityType !== "media") || !/^[1-9]\d*$/.test(live)) {
154
+ throw new Error("Translation create requires a verified live revision ID.");
155
155
  }
156
156
  if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
157
157
  throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
@@ -9,7 +9,8 @@ import { readTranslationInventory } from "./draft-write.js";
9
9
  * @returns {Promise<object|null>}
10
10
  */
11
11
  export async function readNodeDraftInventory(backend, ref) {
12
- if (ref.entityType !== "node" || typeof backend.rawQuery !== "function"
12
+ if ((ref.entityType !== "node" && ref.entityType !== "media")
13
+ || typeof backend.rawQuery !== "function"
13
14
  || typeof backend.resourcePath !== "function") return null;
14
15
  let inventory;
15
16
  try {
@@ -24,7 +25,7 @@ export async function readNodeDraftInventory(backend, ref) {
24
25
  || !Array.isArray(inventory.working.translations)
25
26
  || inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
26
27
  || typeof row.status !== "boolean")))) {
27
- throw new Error("Sentinel returned an invalid node revision inventory. Re-read before updating.");
28
+ throw new Error("Sentinel returned an invalid revision inventory. Re-read before updating.");
28
29
  }
29
30
  return inventory;
30
31
  }
@@ -14,6 +14,11 @@ import {
14
14
  resolveSecurityConfig, redactCanonicalEntity,
15
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
16
16
  } from "../lib/security.js";
17
+ import {
18
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory, writeDraft,
19
+ } from "../lib/draft-write.js";
20
+ import { assertInventoryDraftLanguage } from "../lib/node-draft-inventory.js";
21
+ import { entityRevisionId } from "../lib/write-revision.js";
17
22
 
18
23
  /**
19
24
  * List all media types (bundles of the media entity type).
@@ -51,14 +56,39 @@ async function listMedia({ site: siteName, type, status, name, limit = 20, offse
51
56
 
52
57
  /**
53
58
  * Fetch a single media entity by UUID, redacted per policy.
54
- * @param {object} args - { site?, type, id }.
59
+ * Pass langcode to read an unpublished working translation via Sentinel.
60
+ * @param {object} args - { site?, type, id, langcode? }.
55
61
  * @returns {Promise<object|null>} The redacted media entity, or null.
56
62
  */
57
- async function getMedia({ site: siteName, type, id }) {
63
+ async function getMedia({ site: siteName, type, id, langcode }) {
58
64
  const site = getSiteConfig(siteName);
59
65
  const sec = resolveSecurityConfig(site);
60
66
  assertReadAllowed(sec, "media", type);
61
67
  const backend = await resolveBackend(site);
68
+ if (langcode) {
69
+ const targetLang = assertDraftLangcode(langcode);
70
+ const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
71
+ const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
72
+ if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
73
+ const entity = await readDraftTranslation(backend, {
74
+ entityType: "media", bundle: type, id, langcode: targetLang,
75
+ draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
76
+ });
77
+ return redactCanonicalEntity(entity, sec, "media");
78
+ }
79
+ const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
80
+ if (!liveHas) return null;
81
+ if (targetLang !== inventory.defaultLangcode) {
82
+ const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
83
+ return {
84
+ id, entityType: "media", bundle: type, langcode: targetLang,
85
+ status: liveRow?.status ?? null,
86
+ fields: { name: liveRow?.name ?? null },
87
+ _revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
88
+ note: "Published non-default translations are listed on the live revision; full field reads of an unpublished working translation use langcode against the working draft.",
89
+ };
90
+ }
91
+ }
62
92
  const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
63
93
  return entity ? redactCanonicalEntity(entity, sec, "media") : null;
64
94
  }
@@ -91,12 +121,13 @@ async function createMedia({ site: siteName, type, name, status = false, fields
91
121
  /**
92
122
  * Update a media entity (partial — omitted fields are left untouched; `status`
93
123
  * is strictly opt-in, #171). Reference-shaped `fields` values are routed to
94
- * relationships — see splitReferenceFields.
124
+ * relationships — see splitReferenceFields. Pass langcode to continue an
125
+ * unpublished working translation via Sentinel (not a canonical live PATCH).
95
126
  *
96
- * @param {object} args - { site?, type, id, name?, status?, fields? }.
127
+ * @param {object} args - { site?, type, id, name?, status?, fields?, langcode? }.
97
128
  * @returns {Promise<object>} The updated media descriptor.
98
129
  */
99
- async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
130
+ async function updateMedia({ site: siteName, type, id, name, status, fields = {}, langcode }) {
100
131
  const site = getSiteConfig(siteName);
101
132
  const sec = resolveSecurityConfig(site);
102
133
  assertWriteAllowed(sec, "update", "media", type);
@@ -105,6 +136,30 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
105
136
  if (status !== undefined) attributes.status = status;
106
137
  assertPublishAllowed(sec, attributes);
107
138
  const backend = await resolveBackend(site);
139
+ if (langcode) {
140
+ const targetLang = assertDraftLangcode(langcode);
141
+ const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
142
+ assertInventoryDraftLanguage(inventory, targetLang);
143
+ const liveVid = inventory.live?.vid;
144
+ const workingVid = inventory.working?.vid;
145
+ if (!liveVid || !workingVid || String(workingVid) === String(liveVid)) {
146
+ throw new Error(
147
+ "No unpublished working translation for this language. " +
148
+ "Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted.",
149
+ );
150
+ }
151
+ const result = await writeDraft(backend, {
152
+ entityType: "media", bundle: type, id, attributes, langcode: targetLang,
153
+ ...(relationships ? { relationships } : {}),
154
+ draftRevision: { liveVid, workingVid },
155
+ });
156
+ const redacted = redactCanonicalEntity(result, sec, "media");
157
+ const working = entityRevisionId(result) ?? workingVid;
158
+ return {
159
+ ...redacted,
160
+ _revisions: { live: liveVid, working },
161
+ };
162
+ }
108
163
  // #171: pre-read so an unrequested published-state flip is reported, not silent.
109
164
  let existing = null;
110
165
  if (status === undefined) {
@@ -241,13 +296,14 @@ export const definitions = [
241
296
  },
242
297
  {
243
298
  name: "drupal_get_media",
244
- description: "Fetch a single media entity by UUID and media type.",
299
+ description: "Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel.",
245
300
  inputSchema: {
246
301
  type: "object", required: ["type", "id"],
247
302
  properties: {
248
303
  site: { type: "string" },
249
304
  type: { type: "string" },
250
305
  id: { type: "string", description: "Media entity UUID" },
306
+ langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
251
307
  },
252
308
  },
253
309
  },
@@ -267,7 +323,7 @@ export const definitions = [
267
323
  },
268
324
  {
269
325
  name: "drupal_update_media",
270
- description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.",
326
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.",
271
327
  inputSchema: {
272
328
  type: "object", required: ["type", "id"],
273
329
  properties: {
@@ -276,7 +332,8 @@ export const definitions = [
276
332
  id: { type: "string" },
277
333
  name: { type: "string" },
278
334
  status: { type: "boolean", description: "Published flag. Only sent when provided; requires allowPublish when true." },
279
- fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
335
+ fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically. Image alt is meta.alt on field_media_image with the existing file UUID." },
336
+ langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
280
337
  },
281
338
  },
282
339
  },
@@ -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";
@@ -62,7 +65,8 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
62
65
  assertReadAllowed(sec, entityType, type);
63
66
 
64
67
  const backend = await resolveBackend(site);
65
- if ((entityType === "node" || entityType === "paragraph") && typeof backend.rawQuery === "function") {
68
+ if ((entityType === "node" || entityType === "paragraph" || entityType === "media")
69
+ && typeof backend.rawQuery === "function") {
66
70
  try {
67
71
  const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
68
72
  const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
@@ -127,8 +131,8 @@ async function createTranslation({
127
131
  const sec = resolveSecurityConfig(site);
128
132
  assertWriteAllowed(sec, "update", entityType, type);
129
133
 
130
- if (entityType !== "node" && entityType !== "paragraph") {
131
- 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.");
132
136
  }
133
137
 
134
138
  const backend = await resolveBackend(site);
@@ -151,12 +155,14 @@ async function createTranslation({
151
155
  draftRevision = await resolveNodeTranslationPair(backend, {
152
156
  entityType, bundle: type, id, existing,
153
157
  });
154
- if (drafted.status === undefined && drafted.moderation_state === undefined) {
155
- 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
+ });
156
165
  }
157
- drafted = await applySafeDraftDefault({
158
- backend, entityType, bundle: type, id, attributes: drafted, existingEntity: existing,
159
- });
160
166
  assertPublishAllowed(sec, drafted);
161
167
  }
162
168
 
@@ -174,7 +180,7 @@ async function createTranslation({
174
180
  entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
175
181
  });
176
182
  const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
177
- if (entityType !== "node") return redacted;
183
+ if (entityType === "paragraph") return redacted;
178
184
  const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
179
185
  const liveVid = draftRevision.liveVid;
180
186
  if (liveVid === undefined || liveVid === null) {
@@ -193,7 +199,7 @@ export const definitions = [
193
199
  {
194
200
  name: "drupal_list_translations",
195
201
  description:
196
- "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 " +
197
203
  "translation inventory when available (live default revision vs unpublished working " +
198
204
  "draft), including core content_translation_outdated and source when the server sends them. " +
199
205
  "Core JSON:API alone serves one language and cannot prove others are absent. " +
@@ -202,7 +208,7 @@ export const definitions = [
202
208
  type: "object", required: ["type", "id"],
203
209
  properties: {
204
210
  site: { type: "string", description: "Named site (omit for default)" },
205
- 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." },
206
212
  type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
207
213
  id: { type: "string", description: "Entity UUID" },
208
214
  },
@@ -219,15 +225,17 @@ export const definitions = [
219
225
  "paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
220
226
  "The response includes `_revisions.live` / `_revisions.working` when known. " +
221
227
  "Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
222
- "Continue a node draft with drupal_update_node and langcode; continue a paragraph with " +
223
- "drupal_update_paragraph and langcode. Image alt is a relationship (same file UUID, " +
224
- "meta.alt). For paragraphs pass revisionId as the host pin. Requires Sentinel's " +
225
- "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.",
226
234
  inputSchema: {
227
235
  type: "object", required: ["type", "id", "langcode"],
228
236
  properties: {
229
237
  site: { type: "string" },
230
- 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)." },
231
239
  type: { type: "string", description: "Bundle machine name, e.g. 'basic_page' or 'p_hero'" },
232
240
  id: { type: "string", description: "Entity UUID" },
233
241
  langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },