drupal-mcp-connector 2.14.1 → 2.15.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.
@@ -21,8 +21,10 @@
21
21
  import { getSiteConfig } from "../lib/config.js";
22
22
  import { resolveBackend } from "../lib/backends/index.js";
23
23
  import {
24
- resolveSecurityConfig, assertReadAllowed, assertWriteAllowed,
24
+ resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
25
25
  } from "../lib/security.js";
26
+ import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
27
+ import { omitLiveComputedMetatag } from "../lib/entity-response.js";
26
28
 
27
29
  // Attributes that describe the entity's identity / revision bookkeeping / paths.
28
30
  // These are read-only or server-managed and must NOT be replayed on a revert.
@@ -205,12 +207,41 @@ async function listRevisions({ site: siteName, type, id }) {
205
207
  * @returns {Promise<?object>} The revision descriptor, or null if not found.
206
208
  * @throws {SecurityError} If reading nodes/bundle is not permitted.
207
209
  */
208
- async function getRevision({ site: siteName, type, id, version }) {
210
+ async function getRevision({ site: siteName, type, id, version, langcode }) {
209
211
  const site = getSiteConfig(siteName);
210
212
  const sec = resolveSecurityConfig(site);
211
213
  assertReadAllowed(sec, "node", type);
212
214
  const backend = await resolveBackend(site);
213
215
 
216
+ if (langcode) {
217
+ const targetLang = assertDraftLangcode(langcode);
218
+ const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
219
+ const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
220
+ if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || workingRow.status !== false) {
221
+ throw new Error("No unpublished working translation is addressable for this revision.");
222
+ }
223
+ const entity = await readDraftTranslation(backend, {
224
+ entityType: "node", bundle: type, id, langcode: targetLang,
225
+ draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
226
+ });
227
+ const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
228
+ return {
229
+ entityType: "node",
230
+ bundle: type,
231
+ id,
232
+ vid: inventory.working.vid,
233
+ langcode: targetLang,
234
+ status: redacted.status,
235
+ attributes: {
236
+ title: redacted.title,
237
+ langcode: redacted.langcode,
238
+ status: redacted.status,
239
+ ...redacted.fields,
240
+ },
241
+ relationships: redacted.relationships ?? {},
242
+ };
243
+ }
244
+
214
245
  const resource = await fetchRevisionResource(backend, "node", type, id, version);
215
246
  if (!resource) return null;
216
247
 
@@ -314,13 +345,14 @@ export const definitions = [
314
345
  {
315
346
  name: "drupal_get_revision",
316
347
  description:
317
- "Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Read-only; attributes are redacted per security policy.",
348
+ "Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Pass langcode to read the unpublished working translation of that node (Sentinel). Read-only; attributes are redacted per security policy.",
318
349
  inputSchema: {
319
350
  type: "object", required: ["type", "id", "version"],
320
351
  properties: {
321
352
  site: { type: "string" },
322
353
  type: { type: "string", description: "Content type machine name" },
323
354
  id: { type: "string", description: "Node UUID" },
355
+ langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
324
356
  version: {
325
357
  type: ["string", "number"],
326
358
  description: "Numeric vid, 'id:<vid>', 'rel:latest-version', or 'rel:working-copy'.",
@@ -1,57 +1,55 @@
1
1
  /**
2
2
  * Tool group: Content translations (multilingual / content_translation).
3
3
  *
4
- * Drupal core JSON:API does not model translations as standalone resources —
5
- * a translatable entity carries a `langcode` attribute and Drupal serves the
6
- * negotiated/default-language variant of a resource at its canonical path. To
7
- * surface multilingual handling cleanly we expose:
4
+ * Core JSON:API does not add a translation by PATCHing `langcode` on the
5
+ * canonical entity — that mutates the default language. Governed node
6
+ * translations go through Sentinel's draft-translation surface:
8
7
  *
9
- * - drupal_list_translations — read the entity and report its langcode(s).
10
- * - drupal_create_translation — governed write: set/replace a translation by
11
- * PATCHing the entity with the target `langcode` plus the supplied fields.
8
+ * - drupal_list_translations — inventory of live and working languages
9
+ * - drupal_create_translation — add a language as an unpublished forward draft
12
10
  *
13
- * Requirements (server side):
14
- * - The Drupal `content_translation` module must be enabled and the target
15
- * entity type/bundle configured as translatable, otherwise create attempts
16
- * are rejected by Drupal and listing only ever reports the single language.
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
13
+ * with `langcode`.
17
14
  *
18
- * Limitation: core JSON:API exposes the resource in one language at a time and
19
- * does not enumerate every available translation as a distinct resource. This
20
- * module therefore reports the langcode(s) it can observe on the returned
21
- * resource. Enumerating ALL translations of an entity requires either the
22
- * contrib "JSON:API Translation" module or the Drush bridge, neither of which
23
- * is assumed here — see the `note` field on the list result.
24
- *
25
- * Reads are redacted per the site security policy; the create path is a
26
- * governed write (asserts the "update" operation — a translation is a facet of
27
- * an existing entity, not a new entity).
15
+ * 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.
28
18
  */
29
19
 
30
20
  import { getSiteConfig } from "../lib/config.js";
31
21
  import { resolveBackend } from "../lib/backends/index.js";
32
22
  import {
33
- resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
23
+ resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertPublishAllowed,
24
+ redactCanonicalEntity,
34
25
  } from "../lib/security.js";
35
26
  import { validateUuid, validateMachineName } from "../lib/validate.js";
27
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
28
+ import { omitLiveComputedMetatag } from "../lib/entity-response.js";
29
+ import { entityRevisionId } from "../lib/write-revision.js";
30
+ import { paragraphRevisionId } from "../lib/err-relationships.js";
31
+ import {
32
+ assertDraftLangcode,
33
+ createTranslationDraft,
34
+ readTranslationInventory,
35
+ resolveNodeTranslationPair,
36
+ } from "../lib/draft-write.js";
36
37
 
37
38
  const LIST_NOTE =
38
- "Core JSON:API serves one language per resource and does not enumerate every " +
39
- "translation. langcodes reflects only the language(s) observable on the fetched " +
40
- "resource. Full enumeration requires the JSON:API Translation contrib module or " +
41
- "the Drush bridge. Creating translations requires content_translation enabled and " +
42
- "the bundle configured as translatable.";
39
+ "Live languages are those on the default revision. Working languages are the " +
40
+ "unpublished forward revision when the principal can view it. Core JSON:API " +
41
+ "alone cannot enumerate translations; this inventory requires Sentinel's " +
42
+ "mcp-translations endpoint.";
43
+
44
+ const FALLBACK_NOTE =
45
+ "Sentinel's translation inventory is unavailable. Core JSON:API served one " +
46
+ "language for this resource; it does not prove other translations are absent.";
43
47
 
44
48
  /**
45
- * List the translation langcode(s) observable for an entity.
46
- *
47
- * Reads the entity through the backend's validated rawQuery (so path segments
48
- * are checked and content_moderation handling is reused) and reports its
49
- * `langcode`. The entity type defaults to "node"; pass `entityType` for others.
49
+ * List live and working translation langcodes for an entity.
50
50
  *
51
51
  * @param {object} args - { site?, entityType?, type, id }.
52
- * `type` is the bundle machine name; `entityType` defaults to "node".
53
52
  * @returns {Promise<object|null>} A translation summary, or null if not found.
54
- * @throws {SecurityError} If reading the type/bundle is not permitted.
55
53
  */
56
54
  async function listTranslations({ site: siteName, entityType = "node", type, id }) {
57
55
  validateMachineName(entityType, "entityType");
@@ -63,6 +61,36 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
63
61
  assertReadAllowed(sec, entityType, type);
64
62
 
65
63
  const backend = await resolveBackend(site);
64
+ if ((entityType === "node" || entityType === "paragraph") && typeof backend.rawQuery === "function") {
65
+ try {
66
+ const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
67
+ const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
68
+ const workingLangs = (meta.working?.translations ?? []).map((row) => row.langcode);
69
+ const langcodes = [...new Set([...liveLangs, ...workingLangs])];
70
+ return {
71
+ id,
72
+ entityType,
73
+ bundle: type,
74
+ defaultLangcode: meta.defaultLangcode ?? liveLangs[0] ?? null,
75
+ langcodes,
76
+ live: meta.live ?? null,
77
+ 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
+ })),
85
+ note: LIST_NOTE,
86
+ };
87
+ } catch (error) {
88
+ if (!/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
89
+ throw error;
90
+ }
91
+ }
92
+ }
93
+
66
94
  const res = await backend.rawQuery({ path: `/jsonapi/${entityType}/${type}/${id}` });
67
95
  const data = res?.data;
68
96
  if (!data) return null;
@@ -78,69 +106,108 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
78
106
  defaultLangcode,
79
107
  langcodes,
80
108
  translations,
81
- note: LIST_NOTE,
109
+ note: FALLBACK_NOTE,
82
110
  };
83
111
  }
84
112
 
85
113
  /**
86
- * Create (or replace) a translation of an entity for a target language.
87
- *
88
- * Governed write. Implemented as a PATCH (via the backend's updateEntity) that
89
- * sets the target `langcode` alongside the supplied attributes. With
90
- * content_translation enabled and the bundle marked translatable, Drupal stores
91
- * the supplied fields against the requested language. If the bundle is not
92
- * translatable, Drupal rejects the write and the error surfaces to the caller.
114
+ * Create a translation as an unpublished non-default draft revision.
93
115
  *
94
- * The result is redacted per the site policy.
116
+ * Does not PATCH canonical langcode. Requires Sentinel's translation endpoint.
117
+ * An existing translation is a conflict. English live fields stay unchanged.
95
118
  *
96
119
  * @param {object} args - { site?, entityType?, type, id, langcode, attributes? }.
97
- * `type` is the bundle; `entityType` defaults to "node"; `langcode` is the
98
- * target language (e.g. "de"); `attributes` are the translated field values.
99
- * @returns {Promise<object>} The updated/redacted entity descriptor.
100
- * @throws {SecurityError} If writing the type/bundle is not permitted.
101
- * @throws {Error} If langcode/id/type are invalid, or Drupal rejects the write.
120
+ * @returns {Promise<object>} The created translation, redacted.
102
121
  */
103
- async function createTranslation({ site: siteName, entityType = "node", type, id, langcode, attributes = {} }) {
122
+ async function createTranslation({
123
+ site: siteName, entityType = "node", type, id, langcode, attributes = {},
124
+ relationships, revisionId, dryRun = false,
125
+ }) {
104
126
  validateMachineName(entityType, "entityType");
105
127
  validateMachineName(type, "type");
106
128
  validateUuid(id);
107
- // langcode is interpolated into the JSON:API payload and selects the language
108
- // variant; validate it as a machine-name-like token (e.g. "en", "pt_br",
109
- // "zh_hans") to block injection / malformed values.
110
- validateMachineName(langcode, "langcode");
129
+ const targetLang = assertDraftLangcode(langcode);
111
130
 
112
131
  const site = getSiteConfig(siteName);
113
132
  const sec = resolveSecurityConfig(site);
114
- // A translation is a facet of an existing entity → "update", not "create".
115
133
  assertWriteAllowed(sec, "update", entityType, type);
116
134
 
135
+ if (entityType !== "node" && entityType !== "paragraph") {
136
+ throw new Error("Governed translation create is implemented for nodes and paragraphs.");
137
+ }
138
+
117
139
  const backend = await resolveBackend(site);
118
- const updated = await backend.updateEntity({
119
- entityType,
120
- bundle: type,
121
- id,
122
- attributes: { ...attributes, langcode },
140
+ const existing = await backend.getEntity({ entityType, bundle: type, id });
141
+ if (!existing) {
142
+ throw new Error("The entity was not found.");
143
+ }
144
+
145
+ let draftRevision;
146
+ let drafted = { ...attributes };
147
+ delete drafted.langcode;
148
+ if (entityType === "paragraph") {
149
+ const pinned = revisionId ?? paragraphRevisionId(existing);
150
+ if (pinned === null || pinned === undefined || pinned === "") {
151
+ throw new Error("Paragraph translation create requires a paragraph revision ID (the host pin).");
152
+ }
153
+ draftRevision = { revisionId: pinned };
154
+ assertPublishAllowed(sec, drafted);
155
+ } else {
156
+ draftRevision = await resolveNodeTranslationPair(backend, {
157
+ entityType, bundle: type, id, existing,
158
+ });
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
+ });
165
+ assertPublishAllowed(sec, drafted);
166
+ }
167
+
168
+ if (dryRun) {
169
+ await createTranslationDraft(backend, {
170
+ entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
171
+ }, true);
172
+ return {
173
+ dryRun: true, operation: "create_translation", entityType, bundle: type, id,
174
+ langcode: targetLang, attributes: drafted, ...(relationships ? { relationships } : {}),
175
+ };
176
+ }
177
+
178
+ const created = await createTranslationDraft(backend, {
179
+ entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
123
180
  });
124
- return redactCanonicalEntity(updated, sec, entityType);
181
+ const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
182
+ if (entityType !== "node") return redacted;
183
+ const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
184
+ const liveVid = draftRevision.liveVid;
185
+ if (liveVid === undefined || liveVid === null) {
186
+ if (workingVid === undefined || workingVid === null) return redacted;
187
+ }
188
+ return {
189
+ ...redacted,
190
+ _revisions: {
191
+ ...(liveVid !== undefined && liveVid !== null ? { live: liveVid } : {}),
192
+ ...(workingVid !== undefined && workingVid !== null ? { working: workingVid } : {}),
193
+ },
194
+ };
125
195
  }
126
196
 
127
- // ---------------------------------------------------------------------------
128
- // Tool definitions
129
- // ---------------------------------------------------------------------------
130
-
131
197
  export const definitions = [
132
198
  {
133
199
  name: "drupal_list_translations",
134
200
  description:
135
- "List the translation langcode(s) for a Drupal entity (multilingual / content_translation). " +
136
- "Reports the language(s) observable on the resource. Core JSON:API serves one language per " +
137
- "resource and does not enumerate all translations — see the returned note. Defaults to node.",
201
+ "List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's " +
202
+ "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
+ "Defaults to node.",
138
205
  inputSchema: {
139
206
  type: "object", required: ["type", "id"],
140
207
  properties: {
141
208
  site: { type: "string", description: "Named site (omit for default)" },
142
209
  entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
143
- type: { type: "string", description: "Bundle machine name, e.g. 'article'" },
210
+ type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
144
211
  id: { type: "string", description: "Entity UUID" },
145
212
  },
146
213
  },
@@ -148,28 +215,35 @@ export const definitions = [
148
215
  {
149
216
  name: "drupal_create_translation",
150
217
  description:
151
- "Create or replace a translation of a Drupal entity for a target language (governed write). " +
152
- "Sets the given langcode plus the supplied translated field values. Requires the content_translation " +
153
- "module enabled and the bundle configured as translatable; otherwise Drupal rejects the write. " +
154
- "Defaults to node.",
218
+ "Create a translation as an unpublished non-default draft (governed write). " +
219
+ "Adds the target language beside the default language; it does not PATCH langcode on " +
220
+ "the canonical entity. When an English working draft already exists, both live and " +
221
+ "working revision IDs are sent (If-Match) so Sentinel will add the language on that " +
222
+ "draft (#282). English live title, body, status, alias, default revision, and " +
223
+ "paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
224
+ "The response includes `_revisions.live` / `_revisions.working` when known. " +
225
+ "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.",
155
230
  inputSchema: {
156
231
  type: "object", required: ["type", "id", "langcode"],
157
232
  properties: {
158
- site: { type: "string" },
159
- entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
160
- type: { type: "string", description: "Bundle machine name, e.g. 'article'" },
161
- id: { type: "string", description: "Entity UUID" },
162
- langcode: { type: "string", description: "Target language code, e.g. 'de', 'fr', 'pt_br'" },
163
- attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
233
+ site: { type: "string" },
234
+ entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values." },
235
+ type: { type: "string", description: "Bundle machine name, e.g. 'basic_page' or 'p_hero'" },
236
+ id: { type: "string", description: "Entity UUID" },
237
+ langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },
238
+ attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
239
+ relationships: { type: "object", description: "JSON:API relationships. Use for image alt (same file UUID, meta.alt)." },
240
+ revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required for Home-shaped non-default pins." },
241
+ dryRun: { type: "boolean", description: "Validate without saving" },
164
242
  },
165
243
  },
166
244
  },
167
245
  ];
168
246
 
169
- // ---------------------------------------------------------------------------
170
- // Handler map
171
- // ---------------------------------------------------------------------------
172
-
173
247
  export const handlers = {
174
248
  drupal_list_translations: listTranslations,
175
249
  drupal_create_translation: createTranslation,