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
@@ -32,6 +32,38 @@ const INTERNAL_ATTR_RE = /^drupal_internal__/;
32
32
  const COUNT_PAGE_SIZE = 50;
33
33
  const COUNT_MAX_RECORDS = 1000;
34
34
 
35
+ /**
36
+ * JSON:API language headers. Drupal negotiates Content-Language / Accept-Language
37
+ * when URL prefixes are not used on /jsonapi.
38
+ * @param {?string} langcode
39
+ * @returns {object}
40
+ */
41
+ function languageFetchOptions(langcode) {
42
+ if (!langcode) return {};
43
+ return { headers: { "Content-Language": langcode, "Accept-Language": langcode } };
44
+ }
45
+
46
+ /**
47
+ * Fail if JSON:API served a different language than requested.
48
+ * @param {?object} entity
49
+ * @param {?string} langcode
50
+ */
51
+ function assertServedLanguage(entity, langcode) {
52
+ if (!langcode || !entity) return;
53
+ if (!entity.langcode) {
54
+ throw new Error(
55
+ `JSON:API did not report a language for requested langcode "${langcode}". ` +
56
+ "The translation may not exist, or this backend does not negotiate language.",
57
+ );
58
+ }
59
+ if (entity.langcode !== langcode) {
60
+ throw new Error(
61
+ `JSON:API served language "${entity.langcode}" for requested langcode "${langcode}". ` +
62
+ "The translation may not exist, or this site does not negotiate JSON:API by language.",
63
+ );
64
+ }
65
+ }
66
+
35
67
  /**
36
68
  * Whether a JSON:API collection document advertises another page.
37
69
  * `links.next` may be a string href or a `{ href }` link object.
@@ -358,14 +390,16 @@ export class JsonApiBackend extends Backend {
358
390
  * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
359
391
  * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
360
392
  */
361
- async getEntity({ entityType, bundle, id, resourceVersion }) {
393
+ async getEntity({ entityType, bundle, id, resourceVersion, langcode }) {
362
394
  validateUuid(id);
363
395
  let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
364
396
  if (resourceVersion) {
365
397
  path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
366
398
  }
367
- const data = await drupalFetch(this.site, path);
368
- return data?.data ? this.toCanonical(data.data) : null;
399
+ const data = await drupalFetch(this.site, path, languageFetchOptions(langcode));
400
+ const entity = data?.data ? this.toCanonical(data.data) : null;
401
+ assertServedLanguage(entity, langcode);
402
+ return entity;
369
403
  }
370
404
 
371
405
  /**
@@ -478,14 +512,15 @@ export class JsonApiBackend extends Backend {
478
512
  * @param {object} attributes Entity attributes (may include `status`).
479
513
  * @returns {Promise<object>} The JSON:API response body.
480
514
  */
481
- async writeWithModerationFallback(path, method, buildPayload, attributes) {
515
+ async writeWithModerationFallback(path, method, buildPayload, attributes, langcode) {
516
+ const lang = languageFetchOptions(langcode);
482
517
  try {
483
- return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)) });
518
+ return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)), ...lang });
484
519
  } catch (err) {
485
520
  if (!isModeratedStatusError(err) || !("status" in attributes)) throw err;
486
521
  const withoutStatus = { ...attributes };
487
522
  delete withoutStatus.status;
488
- return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)) });
523
+ return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)), ...lang });
489
524
  }
490
525
  }
491
526
 
@@ -515,7 +550,7 @@ export class JsonApiBackend extends Backend {
515
550
  * canonical default (#166 / Drupal #2795279).
516
551
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
517
552
  */
518
- async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion }) {
553
+ async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion, langcode }) {
519
554
  validateUuid(id);
520
555
  const buildPayload = (attrs) => {
521
556
  const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
@@ -527,8 +562,10 @@ export class JsonApiBackend extends Backend {
527
562
  if (resourceVersion) {
528
563
  path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
529
564
  }
530
- const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes);
531
- return this.toCanonical(data.data);
565
+ const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes, langcode);
566
+ const entity = this.toCanonical(data.data);
567
+ assertServedLanguage(entity, langcode);
568
+ return entity;
532
569
  }
533
570
 
534
571
  /**
@@ -704,6 +741,45 @@ export class JsonApiBackend extends Backend {
704
741
  * @param {{entityType: string, bundle: string, fieldName: string}} ref
705
742
  * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
706
743
  */
744
+ /**
745
+ * Map of field machine name → translatable from JSON:API field_config.
746
+ * Base fields (title, moderation_state) are omitted unless exposed as
747
+ * base_field_override. Missing keys must not be treated as false.
748
+ * @param {string} entityType
749
+ * @param {string} bundle
750
+ * @returns {Promise<Object<string, boolean>>}
751
+ */
752
+ async listFieldTranslatability(entityType, bundle) {
753
+ validateMachineName(entityType, "entityType");
754
+ validateMachineName(bundle, "bundle");
755
+ const map = new Map();
756
+ const params = new URLSearchParams();
757
+ params.set("filter[entity_type]", entityType);
758
+ params.set("filter[bundle]", bundle);
759
+ params.set("page[limit]", "50");
760
+ const ingest = (rows) => {
761
+ for (const row of rows) {
762
+ const name = row?.attributes?.field_name;
763
+ if (typeof name === "string" && typeof row.attributes?.translatable === "boolean") {
764
+ map.set(name, row.attributes.translatable);
765
+ }
766
+ }
767
+ };
768
+ try {
769
+ const data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
770
+ ingest(Array.isArray(data?.data) ? data.data : []);
771
+ } catch {
772
+ // field_config may be unexposed.
773
+ }
774
+ try {
775
+ const data = await drupalFetch(this.site, `/jsonapi/base_field_override/base_field_override?${params}`);
776
+ ingest(Array.isArray(data?.data) ? data.data : []);
777
+ } catch {
778
+ // base_field_override is often not JSON:API-exposed.
779
+ }
780
+ return Object.fromEntries(map);
781
+ }
782
+
707
783
  async getFieldDefinition({ entityType, bundle, fieldName }) {
708
784
  validateMachineName(entityType, "entityType");
709
785
  validateMachineName(bundle, "bundle");
@@ -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
  }
package/src/lib/config.js CHANGED
@@ -16,9 +16,26 @@ import { getAccessToken } from "./oauth.js";
16
16
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixed path relative to this module (the package's own package.json), not user input
17
17
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
18
18
 
19
+ /** npm / protocol machine name. Public identifier — not the display name. */
20
+ export const CLIENT_NAME = "drupal-mcp-connector";
21
+
22
+ /** Human-readable product name for docs and MCP `serverInfo.title`. */
23
+ export const CLIENT_TITLE = "Drupal MCP Connector";
24
+
19
25
  /** Connector version, sourced from package.json so it never drifts out of sync. */
20
26
  export const CLIENT_VERSION = pkg.version;
21
27
 
28
+ /**
29
+ * MCP Implementation advertised in the handshake.
30
+ * `name` is the protocol identifier; `title` is the display name.
31
+ * @type {{name: string, title: string, version: string}}
32
+ */
33
+ export const SERVER_INFO = {
34
+ name: CLIENT_NAME,
35
+ title: CLIENT_TITLE,
36
+ version: CLIENT_VERSION,
37
+ };
38
+
22
39
  /**
23
40
  * Identity headers sent on every outbound Drupal request. Lets governance layers
24
41
  * label/identify connector traffic. ON by default; set MCP_CLIENT_ID to override
@@ -26,7 +43,7 @@ export const CLIENT_VERSION = pkg.version;
26
43
  * @returns {Object<string,string>} Header map (empty when the identity is disabled).
27
44
  */
28
45
  export function clientHeaders() {
29
- const id = process.env.MCP_CLIENT_ID ?? `drupal-mcp-connector/${CLIENT_VERSION}`;
46
+ const id = process.env.MCP_CLIENT_ID ?? `${CLIENT_NAME}/${CLIENT_VERSION}`;
30
47
  if (!id) return {};
31
48
  return { "X-MCP-Client": id, "User-Agent": id };
32
49
  }
@@ -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.");
@@ -38,6 +38,28 @@ export function isParagraphResourceType(type) {
38
38
  return typeof type === "string" && type.startsWith("paragraph--");
39
39
  }
40
40
 
41
+ /**
42
+ * Paragraph ERR pins on a canonical host entity.
43
+ * @param {object} entity Canonical node (or other host).
44
+ * @returns {Array<{field: string, id: string, paragraphType: string, revisionId: ?string}>}
45
+ */
46
+ export function paragraphPinsFromEntity(entity) {
47
+ const pins = [];
48
+ const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
49
+ for (const [field, value] of Object.entries(rels)) {
50
+ const list = Array.isArray(value) ? value : value ? [value] : [];
51
+ for (const ref of list) {
52
+ if (!ref?.id) continue;
53
+ const paragraphType = ref.bundle || parseResourceType(ref.type)?.bundle;
54
+ if (ref.entityType !== "paragraph" && !isParagraphResourceType(ref.type)) continue;
55
+ if (!paragraphType) continue;
56
+ const revisionId = ref.meta?.target_revision_id ?? null;
57
+ pins.push({ field, id: ref.id, paragraphType, revisionId });
58
+ }
59
+ }
60
+ return pins;
61
+ }
62
+
41
63
  /**
42
64
  * Split a JSON:API resource type into entity type + bundle.
43
65
  * @param {string} type e.g. "paragraph--capability".
@@ -29,7 +29,7 @@ function resourceUriIsListed(listed, requested) {
29
29
  * Create the server factory shared by HTTP and stdio transports.
30
30
  *
31
31
  * @param {object} surface
32
- * @param {{name: string, version: string}} surface.serverInfo
32
+ * @param {{name: string, title?: string, version: string}} surface.serverInfo
33
33
  * @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
34
34
  * `definitions` is the full static surface (schema projection); the optional
35
35
  * `list` hook decides what is DISCOVERABLE per request (governance + entitlement).
@@ -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
  }
@@ -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