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
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import fetch from "node-fetch";
26
- import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
26
+ import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
27
27
  import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
28
28
  import { clearToken } from "./oauth.js";
29
29
 
@@ -164,7 +164,7 @@ async function initializeSession(site, endpoint) {
164
164
  params: {
165
165
  protocolVersion: MCP_PROTOCOL_VERSION,
166
166
  capabilities: {},
167
- clientInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
167
+ clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
168
168
  },
169
169
  };
170
170
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Normalize Sentinel translation-inventory rows for tools and reports.
3
+ *
4
+ * Extra keys (`outdated`, `source`) are passed through only when present so
5
+ * older Sentinel versions do not grow invented `outdated: false`.
6
+ */
7
+
8
+ /**
9
+ * @param {object} row Inventory translation row.
10
+ * @returns {object}
11
+ */
12
+ export function mapTranslationRow(row) {
13
+ if (!row || typeof row !== "object") return row;
14
+ const out = {
15
+ langcode: row.langcode,
16
+ default: Boolean(row.default),
17
+ status: row.status,
18
+ title: row.title,
19
+ moderation_state: row.moderation_state ?? null,
20
+ };
21
+ if (Object.prototype.hasOwnProperty.call(row, "outdated")) {
22
+ out.outdated = Boolean(row.outdated);
23
+ }
24
+ if (typeof row.source === "string" && row.source) {
25
+ out.source = row.source;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * Editorial current row per langcode: working copy wins over live.
32
+ * @param {?object} inventory
33
+ * @returns {object[]}
34
+ */
35
+ export function inventoryTranslationRows(inventory) {
36
+ const byLang = new Map();
37
+ for (const row of inventory?.live?.translations ?? []) {
38
+ if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "live" });
39
+ }
40
+ for (const row of inventory?.working?.translations ?? []) {
41
+ if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "working" });
42
+ }
43
+ return [...byLang.values()];
44
+ }
45
+
46
+ /**
47
+ * Whether inventory has a row matching langcode and/or moderation state.
48
+ * @param {?object} inventory
49
+ * @param {{langcode?: string, state?: string}} match
50
+ * @returns {?object}
51
+ */
52
+ export function inventoryRowMatching(inventory, { langcode, state } = {}) {
53
+ const wantedState = state === undefined || state === null ? null : String(state).toLowerCase();
54
+ return inventoryTranslationRows(inventory).find((row) => {
55
+ if (langcode && row.langcode !== langcode) return false;
56
+ if (wantedState && String(row.moderation_state || "").toLowerCase() !== wantedState) return false;
57
+ return true;
58
+ }) ?? null;
59
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Tool group: GraphQL Compose Codegen (TypeScript / Next.js scaffolds).
3
+ *
4
+ * Thin Drush wrappers around `drupal/graphql_compose_codegen`. The generator
5
+ * lives on the Drupal site; this connector does not reimplement it and does
6
+ * not run GraphQL Code Generator against the SDL.
7
+ *
8
+ * All three tools are reads. `drupal_codegen_generate` always passes
9
+ * `--dry-run` and never `--output-dir`, so artefacts come back as text for
10
+ * the agent to write locally. Disk writes stay a local DDEV/script concern.
11
+ *
12
+ * Capability: missing module or unknown Drush command fails loud. If
13
+ * `drushSsh.allowedCommands` is set, the `graphql-compose-codegen:*`
14
+ * subcommand must be on that list (same exact-match rule as other Drush tools).
15
+ */
16
+
17
+ import { getSiteConfig } from "../lib/config.js";
18
+ import { sshDrush } from "./drush.js";
19
+ import { SecurityError } from "../lib/security.js";
20
+ import { validateMachineName } from "../lib/validate.js";
21
+
22
+ const INSPECT = "graphql-compose-codegen:inspect";
23
+ const DIFF = "graphql-compose-codegen:diff";
24
+ const GENERATE = "graphql-compose-codegen:generate";
25
+
26
+ const GENERATE_TIMEOUT_MS = 60000;
27
+
28
+ /**
29
+ * Parse an optional list of Drupal machine names (array or comma string).
30
+ *
31
+ * @param {string[]|string|undefined} raw Caller value.
32
+ * @param {string} fieldName Error label.
33
+ * @returns {string[]} Validated names, possibly empty.
34
+ */
35
+ function parseMachineNameList(raw, fieldName) {
36
+ if (raw === undefined || raw === null || raw === "") return [];
37
+ const parts = Array.isArray(raw)
38
+ ? raw
39
+ : String(raw).split(",");
40
+ return parts
41
+ .map((v) => String(v).trim())
42
+ .filter(Boolean)
43
+ .map((v) => validateMachineName(v, fieldName));
44
+ }
45
+
46
+ /**
47
+ * Whether a Drush failure looks like the codegen module is absent.
48
+ *
49
+ * @param {Error} err Bridge error.
50
+ * @returns {boolean}
51
+ */
52
+ function looksLikeMissingCodegen(err) {
53
+ const msg = String(err?.message || err || "");
54
+ return /graphql-compose-codegen|gqcc:|no commands defined in the ["']graphql-compose-codegen|command .* does not exist|could not find command/i.test(msg);
55
+ }
56
+
57
+ /**
58
+ * Run a codegen Drush subcommand and return stdout.
59
+ *
60
+ * @param {object} args Tool args.
61
+ * @param {string} [args.site] Site name.
62
+ * @param {string[]|string} [args.bundles] Bundle ids.
63
+ * @param {string[]|string} [args.skipFields] Extra field names to exclude.
64
+ * @param {string} subcommand Drush subcommand (first arg).
65
+ * @param {string[]} extra Extra flags (e.g. --dry-run).
66
+ * @param {number} timeoutMs SSH timeout.
67
+ * @returns {Promise<{output: string, command: string, wroteFiles: false}>}
68
+ */
69
+ async function runGqcc(
70
+ { site: siteName, bundles, skipFields },
71
+ subcommand,
72
+ extra = [],
73
+ timeoutMs = 30000,
74
+ ) {
75
+ const site = getSiteConfig(siteName);
76
+ const names = parseMachineNameList(bundles, "bundles");
77
+ const skip = parseMachineNameList(skipFields, "skipFields");
78
+ const args = [subcommand];
79
+ if (names.length) args.push(`--bundles=${names.join(",")}`);
80
+ if (skip.length) args.push(`--skip-fields=${skip.join(",")}`);
81
+ args.push(...extra);
82
+ try {
83
+ const output = await sshDrush(site, args, timeoutMs);
84
+ return { output, command: args.join(" "), wroteFiles: false };
85
+ } catch (err) {
86
+ if (err instanceof SecurityError) throw err;
87
+ if (looksLikeMissingCodegen(err)) {
88
+ throw new Error(
89
+ "graphql_compose_codegen is not available on this site " +
90
+ `(Drush command "${subcommand}" failed). Install and enable ` +
91
+ "drupal/graphql_compose_codegen, and if drushSsh.allowedCommands is " +
92
+ `set, add "${subcommand}" to that list. ${err.message}`,
93
+ );
94
+ }
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ async function inspect(args) {
100
+ return runGqcc(args, INSPECT);
101
+ }
102
+
103
+ async function diff(args) {
104
+ return runGqcc(args, DIFF);
105
+ }
106
+
107
+ async function generate(args) {
108
+ return runGqcc(args, GENERATE, ["--dry-run"], GENERATE_TIMEOUT_MS);
109
+ }
110
+
111
+ const LIST_PROPS = {
112
+ site: { type: "string" },
113
+ bundles: {
114
+ type: "array",
115
+ items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
116
+ description: "Node/paragraph bundle ids. Omit for every bundle graphql_compose exposes.",
117
+ },
118
+ skipFields: {
119
+ type: "array",
120
+ items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
121
+ description: "Extra field machine names to exclude from the scaffold.",
122
+ },
123
+ };
124
+
125
+ export const definitions = [
126
+ {
127
+ name: "drupal_codegen_inspect",
128
+ description:
129
+ "List node and paragraph bundles and extra fields from graphql_compose_codegen (`drush graphql-compose-codegen:inspect`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:inspect. Does not write files.",
130
+ inputSchema: { type: "object", properties: LIST_PROPS },
131
+ },
132
+ {
133
+ name: "drupal_codegen_diff",
134
+ description:
135
+ "Compare the live graphql_compose schema to the last gqcc:generate snapshot (`drush graphql-compose-codegen:diff`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:diff.",
136
+ inputSchema: { type: "object", properties: LIST_PROPS },
137
+ },
138
+ {
139
+ name: "drupal_codegen_generate",
140
+ description:
141
+ "Return TypeScript/GraphQL scaffold artefacts from graphql_compose_codegen as text (`drush graphql-compose-codegen:generate --dry-run`). Never writes on the Drupal host (no --output-dir). Copy artefacts locally. Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:generate.",
142
+ inputSchema: { type: "object", properties: LIST_PROPS },
143
+ },
144
+ ];
145
+
146
+ export const handlers = {
147
+ drupal_codegen_inspect: inspect,
148
+ drupal_codegen_diff: diff,
149
+ drupal_codegen_generate: generate,
150
+ };
@@ -96,11 +96,18 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
96
96
 
97
97
  const backend = await resolveBackend(site);
98
98
  const schema = await backend.getEntitySchema(entityType, resolvedBundle);
99
+ const translatable = typeof backend.listFieldTranslatability === "function"
100
+ ? await backend.listFieldTranslatability(entityType, resolvedBundle).catch(() => ({}))
101
+ : {};
102
+ const translatableMap = translatable && typeof translatable === "object" ? new Map(Object.entries(translatable)) : new Map();
99
103
 
100
104
  const fields = [
101
105
  ...Object.entries(schema.attributes ?? {}).map(([name, t]) => attributeField(name, t)),
102
106
  ...Object.keys(schema.relationships ?? {}).map((name) => relationshipField(name)),
103
- ].sort((a, b) => a.name.localeCompare(b.name));
107
+ ].sort((a, b) => a.name.localeCompare(b.name)).map((field) => {
108
+ if (!translatableMap.has(field.name)) return field;
109
+ return { ...field, translatable: Boolean(translatableMap.get(field.name)) };
110
+ });
104
111
 
105
112
  const sampledEmpty = fields.length === 0;
106
113
 
@@ -125,11 +132,12 @@ export const definitions = [
125
132
  name: "drupal_describe_fields",
126
133
  description:
127
134
  "Introspect the fields of a Drupal entity type + bundle: returns a per-field " +
128
- "list of { name, type, kind, cardinality?, approximate }. Read-only. Built on " +
135
+ "list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on " +
129
136
  "schema SAMPLING (an existing entity), so results are approximate — only " +
130
137
  "populated fields are visible and required/cardinality/allowedValues are " +
131
- "inferred from value shape. Authoritative field metadata comes from the Drush " +
132
- "bridge (Field API). Use this before creating/updating entities to learn field names.",
138
+ "inferred from value shape. When JSON:API field_config is readable, translatable " +
139
+ "is copied from Field API; omitted means unknown, not false. Authoritative field " +
140
+ "metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.",
133
141
  inputSchema: {
134
142
  type: "object",
135
143
  required: ["site"],
@@ -38,11 +38,12 @@ import * as reportsConfig from "./reports-config.js";
38
38
  import * as reportsContent from "./reports-content.js";
39
39
  import * as auditComposite from "./audit-composite.js";
40
40
  import * as config from "./config.js";
41
+ import * as codegen from "./codegen.js";
41
42
  import { SITE_PARAM } from "../lib/site-target.js";
42
43
 
43
44
  export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
44
45
  revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
45
- reportsLinks, reportsConfig, reportsContent, auditComposite, config];
46
+ reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
46
47
 
47
48
  /**
48
49
  * Stamp the shared `site` description onto every tool that accepts one so
@@ -21,6 +21,9 @@ import {
21
21
  redactCanonicalEntity,
22
22
  } from "../lib/security.js";
23
23
  import { collectEntities } from "../lib/reports-support.js";
24
+ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
25
+ import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
26
+ import { inventoryRowMatching } from "../lib/translation-rows.js";
24
27
 
25
28
  /** Cap for the client-side scan when JSON:API cannot filter the field. */
26
29
  const SAMPLE_CAP = 500;
@@ -54,7 +57,7 @@ function moderationStateOf(entity) {
54
57
  * @returns {Promise<object>} The updated, redacted node.
55
58
  * @throws {SecurityError} If writing node/type is not permitted.
56
59
  */
57
- async function setModerationState({ site: siteName, type, id, state }) {
60
+ async function setModerationState({ site: siteName, type, id, state, langcode }) {
58
61
  if (!state) throw new Error("A moderation 'state' is required (e.g. 'draft', 'published').");
59
62
  const site = getSiteConfig(siteName);
60
63
  const sec = resolveSecurityConfig(site);
@@ -62,6 +65,23 @@ async function setModerationState({ site: siteName, type, id, state }) {
62
65
  const attributes = { moderation_state: state };
63
66
  assertPublishAllowed(sec, attributes);
64
67
  const backend = await resolveBackend(site);
68
+ if (langcode) {
69
+ const targetLang = assertDraftLangcode(langcode);
70
+ let existing = null;
71
+ try {
72
+ existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
73
+ } catch {
74
+ existing = null;
75
+ }
76
+ const patchTarget = await prepareGuardedPatch(backend, {
77
+ entityType: "node", bundle: type, id, existing, attributes, langcode: targetLang,
78
+ });
79
+ const patched = await updateEntityGuarded(backend, {
80
+ entityType: "node", bundle: type, id, attributes, langcode: targetLang,
81
+ ...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
82
+ });
83
+ return redactCanonicalEntity(patched, sec, "node");
84
+ }
65
85
  const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
66
86
  return redactCanonicalEntity(entity, sec, "node");
67
87
  }
@@ -76,12 +96,59 @@ async function setModerationState({ site: siteName, type, id, state }) {
76
96
  *
77
97
  * @param {object} args - { site?, type, state, limit?, offset? }.
78
98
  */
79
- async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
99
+ async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0, langcode }) {
80
100
  const site = getSiteConfig(siteName);
81
101
  const sec = resolveSecurityConfig(site);
82
102
  assertReadAllowed(sec, "node", type);
83
103
  const backend = await resolveBackend(site);
84
104
  const sort = [{ field: "changed", dir: "desc" }];
105
+ const targetLang = langcode ? assertDraftLangcode(langcode) : null;
106
+ if (targetLang) {
107
+ if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
108
+ return {
109
+ type, state, langcode: targetLang, unavailable: true,
110
+ reason: "A langcode filter requires Sentinel's translation inventory.",
111
+ };
112
+ }
113
+ const scanned = await collectEntities(
114
+ backend,
115
+ { entityType: "node", bundle: type, sort },
116
+ SAMPLE_CAP,
117
+ );
118
+ const matches = [];
119
+ for (const entity of scanned) {
120
+ try {
121
+ const inventory = await readTranslationInventory(backend, {
122
+ entityType: "node", bundle: type, id: entity.id,
123
+ });
124
+ const row = inventoryRowMatching(inventory, { langcode: targetLang, state });
125
+ if (row) {
126
+ matches.push({
127
+ ...entity,
128
+ langcode: targetLang,
129
+ fields: { ...entity.fields, moderation_state: row.moderation_state },
130
+ });
131
+ }
132
+ } catch (error) {
133
+ if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
134
+ return {
135
+ type, state, langcode: targetLang, unavailable: true,
136
+ reason: "A langcode filter requires Sentinel's translation inventory.",
137
+ };
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+ const page = matches.slice(offset, offset + limit);
143
+ return {
144
+ type, state, langcode: targetLang, source: "inventory",
145
+ approximate: scanned.length >= SAMPLE_CAP,
146
+ scanned: scanned.length,
147
+ total: matches.length,
148
+ offset, nextOffset: offset + page.length,
149
+ nodes: page.map((e) => redactCanonicalEntity(e, sec, "node")),
150
+ };
151
+ }
85
152
  const canFilter = typeof backend.capabilities === "function"
86
153
  ? Boolean(backend.capabilities()?.filter)
87
154
  : true;
@@ -167,28 +234,30 @@ async function listModerationStates({ site: siteName, type, sample = 50 }) {
167
234
  export const definitions = [
168
235
  {
169
236
  name: "drupal_set_moderation_state",
170
- description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write.",
237
+ description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write. Pass langcode to change one translation via Sentinel; omit it for the default-language / shared-state write. If moderation_state is not translatable, a langcode write is refused.",
171
238
  inputSchema: {
172
239
  type: "object", required: ["type", "id", "state"],
173
240
  properties: {
174
- site: { type: "string" },
175
- type: { type: "string", description: "Content type machine name" },
176
- id: { type: "string", description: "Node UUID" },
177
- state: { type: "string", description: "Target moderation state machine name" },
241
+ site: { type: "string" },
242
+ type: { type: "string", description: "Content type machine name" },
243
+ id: { type: "string", description: "Node UUID" },
244
+ state: { type: "string", description: "Target moderation state machine name" },
245
+ langcode: { type: "string", description: "Target translation (e.g. 'es'). Omit for the default language. Requires Sentinel." },
178
246
  },
179
247
  },
180
248
  },
181
249
  {
182
250
  name: "drupal_content_by_moderation_state",
183
- description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
251
+ description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Pass langcode to match that translation via Sentinel inventory (the editorial work queue). Omit langcode for default-language JSON:API / sampled behavior. Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
184
252
  inputSchema: {
185
253
  type: "object", required: ["type", "state"],
186
254
  properties: {
187
- site: { type: "string" },
188
- type: { type: "string", description: "Content type machine name" },
189
- state: { type: "string", description: "Moderation state machine name" },
190
- limit: { type: "number", default: 20 },
191
- offset: { type: "number", default: 0 },
255
+ site: { type: "string" },
256
+ type: { type: "string", description: "Content type machine name" },
257
+ state: { type: "string", description: "Moderation state machine name" },
258
+ langcode: { type: "string", description: "Match this translation (e.g. 'es'). Requires Sentinel. Omit for default-language listing." },
259
+ limit: { type: "number", default: 20 },
260
+ offset: { type: "number", default: 0 },
192
261
  },
193
262
  },
194
263
  },
@@ -15,10 +15,11 @@ import {
15
15
  } from "../lib/security.js";
16
16
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
17
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omitLiveComputedMetatag } from "../lib/entity-response.js";
18
- import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
18
+ import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } from "../lib/err-relationships.js";
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
22
+ import { paragraphResourceVersion } from "./paragraphs.js";
22
23
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
23
24
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
24
25
  import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
@@ -239,7 +240,51 @@ function pageOf({ limit = 20, offset = 0 }) {
239
240
  * @param {object} args - { site?, type, id }.
240
241
  * @returns {Promise<object|null>} The redacted node, or null if not found.
241
242
  */
242
- async function getNode({ site: siteName, type, id, langcode, resourceVersion }) {
243
+ /**
244
+ * Load pinned paragraph translations (or default-language pins) onto a host.
245
+ * Per-component failures are recorded; they do not fail the host read.
246
+ * @param {object} backend
247
+ * @param {object} sec
248
+ * @param {object} entity
249
+ * @param {?string} langcode
250
+ * @returns {Promise<object>}
251
+ */
252
+ async function withComponents(backend, sec, entity, langcode) {
253
+ const pins = paragraphPinsFromEntity(entity);
254
+ const components = [];
255
+ for (const pin of pins) {
256
+ try {
257
+ let para;
258
+ if (langcode && pin.revisionId) {
259
+ para = await readDraftTranslation(backend, {
260
+ entityType: "paragraph",
261
+ bundle: pin.paragraphType,
262
+ id: pin.id,
263
+ langcode,
264
+ draftRevision: { revisionId: pin.revisionId },
265
+ });
266
+ } else if (pin.revisionId) {
267
+ para = await backend.getEntity({
268
+ entityType: "paragraph",
269
+ bundle: pin.paragraphType,
270
+ id: pin.id,
271
+ resourceVersion: paragraphResourceVersion(pin.revisionId),
272
+ ...(langcode ? { langcode } : {}),
273
+ });
274
+ }
275
+ components.push({
276
+ ...pin,
277
+ entity: para ? redactCanonicalEntity(para, sec, "paragraph") : null,
278
+ ...(para ? {} : { note: "missing revision pin" }),
279
+ });
280
+ } catch (error) {
281
+ components.push({ ...pin, entity: null, error: String(error.message || error) });
282
+ }
283
+ }
284
+ return { ...entity, components };
285
+ }
286
+
287
+ async function getNode({ site: siteName, type, id, langcode, resourceVersion, includeComponents = false }) {
243
288
  const site = getSiteConfig(siteName);
244
289
  const sec = resolveSecurityConfig(site);
245
290
  assertReadAllowed(sec, "node", type);
@@ -253,7 +298,8 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
253
298
  entityType: "node", bundle: type, id, langcode: targetLang,
254
299
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
255
300
  });
256
- return omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
301
+ const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
302
+ return includeComponents ? withComponents(backend, sec, redacted, targetLang) : redacted;
257
303
  }
258
304
  const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
259
305
  if (!liveHas) {
@@ -261,7 +307,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
261
307
  }
262
308
  if (targetLang !== inventory.defaultLangcode) {
263
309
  const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
264
- return {
310
+ const stub = {
265
311
  id, entityType: "node", bundle: type, langcode: targetLang,
266
312
  title: liveRow?.title ?? null,
267
313
  status: liveRow?.status ?? null,
@@ -269,10 +315,13 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
269
315
  _revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
270
316
  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.",
271
317
  };
318
+ return includeComponents ? { ...stub, components: [] } : stub;
272
319
  }
273
320
  }
274
321
  const entity = await backend.getEntity({ entityType: "node", bundle: type, id, resourceVersion });
275
- return entity ? redactCanonicalEntity(entity, sec, "node") : null;
322
+ if (!entity) return null;
323
+ const redacted = redactCanonicalEntity(entity, sec, "node");
324
+ return includeComponents ? withComponents(backend, sec, redacted, langcode || null) : redacted;
276
325
  }
277
326
 
278
327
  /**
@@ -520,7 +569,7 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
520
569
  export const definitions = [
521
570
  {
522
571
  name: "drupal_get_node",
523
- description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English).",
572
+ description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English). Pass includeComponents true to attach pinned paragraph translations under `components` (default false).",
524
573
  inputSchema: {
525
574
  type: "object", required: ["type", "id"],
526
575
  properties: {
@@ -528,6 +577,7 @@ export const definitions = [
528
577
  type: { type: "string", description: "Content type machine name, e.g. 'article'" },
529
578
  id: { type: "string", description: "Node UUID" },
530
579
  langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
580
+ includeComponents: { type: "boolean", default: false, description: "If true, include pinned paragraph translations under `components` (empty array when the host has no ERR fields). Default false so existing callers are unchanged." },
531
581
  },
532
582
  },
533
583
  },