drupal-mcp-connector 2.14.2 → 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.
@@ -1,23 +1,25 @@
1
1
  ---
2
- description: "Create a translation as an unpublished non-default draft revision (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. English live title, body, status, alias, and default revision stay unchanged. An existing translation is a conflict, not an overwrite. Continue the draft with drupal_update_node and langcode. Requires Sentinel's draft-translation endpoint and a translatable bundle. Paragraph field values are not translated on this path. Defaults to node. Publication stays denied for content-tier callers."
3
- argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [dryRun]"
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."
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 revision (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. English live title, body, status, alias, and default revision stay unchanged. An existing translation is a conflict, not an overwrite. Continue the draft with drupal_update_node and langcode. Requires Sentinel's draft-translation endpoint and a translatable bundle. Paragraph field values are not translated on this path. Defaults to node. 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; 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.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
12
12
  **Required:**
13
- - `type` (string): Bundle machine name, e.g. 'basic_page'
13
+ - `type` (string): Bundle machine name, e.g. 'basic_page' or 'p_hero'
14
14
  - `id` (string): Entity UUID
15
15
  - `langcode` (string): Target language code, e.g. 'es', 'de', 'pt-br'
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'.
19
+ - `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values.
20
20
  - `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
21
+ - `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
22
+ - `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
21
23
  - `dryRun` (boolean (true/false)): Validate without saving
22
24
 
23
25
  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.
@@ -19,7 +19,7 @@ Parse the arguments supplied with this command into this tool's parameters:
19
19
  - `langcode` (string): Target language for an unpublished working translation (nodes). Continues that translation via Sentinel.
20
20
  - `attributes` (object (pass as JSON))
21
21
  - `relationships` (object (pass as JSON))
22
- - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
22
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
23
23
  - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
24
24
 
25
25
  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: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
3
- argument-hint: "<paragraphType> <id> [site]"
2
+ description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
3
+ argument-hint: "<paragraphType> <id> [site] [langcode] [revisionId]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_get_paragraph`.
7
7
 
8
- Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
8
+ Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -15,5 +15,7 @@ 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 for the unpublished working translation (e.g. 'es').
19
+ - `revisionId` (string): Paragraph revision id the host already pins.
18
20
 
19
21
  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. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). 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 or paragraph. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). 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. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). 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 or paragraph. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). 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
 
@@ -23,8 +23,8 @@ Parse the arguments supplied with this command into this tool's parameters:
23
23
  - `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
24
24
  - `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.
25
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
26
- - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot.
27
- - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
26
+ - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused.
27
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
28
28
  - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
29
29
 
30
30
  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 an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
3
- argument-hint: "<paragraphType> <id> [site] [attributes]"
2
+ description: "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Pass langcode to continue an unpublished paragraph translation via Sentinel (the host pin, not a live English mutation). Omitting langcode still uses canonical JSON:API and remains gated on published-host children. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
3
+ argument-hint: "<paragraphType> <id> [site] [attributes] [langcode] [revisionId] [draftState]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_paragraph`.
7
7
 
8
- Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
8
+ Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Pass langcode to continue an unpublished paragraph translation via Sentinel (the host pin, not a live English mutation). Omitting langcode still uses canonical JSON:API and remains gated on published-host children. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -16,5 +16,8 @@ Parse the arguments supplied with this command into this tool's parameters:
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
18
  - `attributes` (object (pass as JSON)): Paragraph field values to change, keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }
19
+ - `langcode` (string): Target language for an unpublished paragraph translation (e.g. 'es'). Continues Sentinel /mcp-draft; does not create a missing translation.
20
+ - `revisionId` (string): Paragraph revision id the host already pins. Required when that pin is not the default revision.
21
+ - `draftState` (string): Required with langcode. Opaque draftState from the previous paragraph draft read/create/update; prevents overwriting newer edits.
19
22
 
20
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,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.15.0] - 2026-09-10
11
+
12
+ ### Fixed
13
+ - Paragraph translation updates require the `draftState` returned by a previous draft read. Pass it unchanged to Sentinel for stale-write protection. Shared-status paragraphs remain pending for human editing (#280).
14
+ - **Omit computed `metatag` on unpublished working-translation bodies (#283).**
15
+ JSON:API's computed `metatag` array is resolved from the live default
16
+ revision, so a Spanish draft response could still show English title and
17
+ description. The stored override (`field_metatags`) is unchanged. Draft
18
+ translation create/read now drop `fields.metatag` and set `_metatagOmitted`.
19
+
20
+ - **`drupal_create_translation` sends live and working revision IDs when a draft exists (#282).**
21
+ Sentinel's translation POST 409s with "A working revision exists" if If-Match
22
+ is live-only. The connector now reads Sentinel's translation inventory for
23
+ the live/working pair (falling back to `rel:working-copy`) and sends
24
+ `If-Match: "live:working"`. The response includes `_revisions` so the caller
25
+ can see which revision the language landed on.
26
+
27
+ - **`dryRun` fails when a published-node write would hit Sentinel's stale-copy check (#273).**
28
+ The id-mismatch PATCH probe never reaches entity validation / presave, so it
29
+ could not see MCP Sentinel's default-revision stale-version refusal. When
30
+ `rel:latest-version` and `rel:working-copy` share a vid and the default
31
+ revision's `changed` is later than `revision_timestamp` (`possiblyPatchBlocked`),
32
+ `drupal_update_node` / `drupal_entity_update` (including `dryRun`) now refuse
33
+ with a stable `STALE_COPY` error. The same message is rewritten if Sentinel
34
+ still returns it on the saving write. Governance is unchanged: the draft and
35
+ publish gates are not bypassed.
36
+ - **Title-only node updates keep the existing URL alias; failed restorations error (#274).**
37
+ `getPathInfo` now fills `pid` / `aliasId` from the `path_alias` row when an
38
+ unpublished default or forward revision omits them on the computed `path`
39
+ field. After `drupal_update_node`, the connector re-reads the alias and, if
40
+ Pathauto still drifted it, PATCHes the existing `path_alias` entity. If the
41
+ alias still does not match, the tool throws instead of returning success or
42
+ creating a rename redirect. Title-only edits pin the live alias as before.
43
+
44
+ ### Added
45
+ - **Codex skill target for `npm run install:commands` (#263).**
46
+ The installer writes `$HOME/.agents/skills/drupal-mcp/SKILL.md` (plus
47
+ `references/tools.md`) so Drupal MCP tools are discoverable in Codex. Custom
48
+ prompts under `~/.codex/prompts` are not written — OpenAI deprecated that
49
+ surface in favor of skills. Default clients are now `claude,grok,codex`.
50
+ Test with `--home` on an isolated directory.
51
+
52
+ - **Unpublished paragraph field-value translation and image alt (#280).**
53
+ `drupal_create_translation` accepts `entityType: "paragraph"` and
54
+ `revisionId` (the host pin) and POSTs Sentinel's paragraph
55
+ `/mcp-draft/translations` surface. Continue with `drupal_update_paragraph`
56
+ + `langcode`; read with `drupal_get_paragraph` + `langcode`. Omitting
57
+ `langcode` on `drupal_update_paragraph` still uses canonical JSON:API.
58
+ Node translation writes forward image relationships so alt can change
59
+ while the file UUID stays the same. Replacing the file remains a
60
+ server refusal. Node-only translation create is unchanged.
61
+
10
62
  ## [2.14.2] - 2026-09-10
11
63
 
12
64
  ### Fixed
package/README.md CHANGED
@@ -129,13 +129,15 @@ Clients that scan `.agents/commands/` (including Grok Build when this repo is
129
129
  the project) pick them up automatically.
130
130
 
131
131
  Clients that only scan a vendor home path (Claude Code `~/.claude/commands`,
132
- Grok `~/.grok/commands`) install from that tree into the **operator home**, so
133
- no consuming project has to commit `.claude/` or `.grok/`:
132
+ Grok `~/.grok/commands`, Codex `$HOME/.agents/skills`) install from that tree
133
+ into the **operator home**, so no consuming project has to commit `.claude/` or
134
+ `.grok/`. Codex uses a **skill** (`drupal-mcp`), not deprecated custom prompts:
134
135
 
135
136
  ```bash
136
137
  npm run generate:commands # refresh .agents/commands/ (from a clone)
137
- npm run install:commands # copies into ~/.claude/commands and ~/.grok/commands
138
+ npm run install:commands # copies into ~/.claude/commands, ~/.grok/commands, and ~/.agents/skills/drupal-mcp/
138
139
  # npm run install:commands -- --clients claude
140
+ # npm run install:commands -- --clients codex
139
141
  # npm run install:commands -- --home /path/to/fake-home # tests / CI
140
142
  ```
141
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.14.2",
3
+ "version": "2.15.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,14 +8,17 @@
8
8
  * Build, when this repo is the project) pick them up as `/drupal-<tool>`.
9
9
  *
10
10
  * Clients that only scan a vendor home directory (Claude Code `~/.claude/commands`,
11
- * Grok `~/.grok/commands`) get the same files via `npm run install:commands`.
11
+ * Grok `~/.grok/commands`, Codex `$HOME/.agents/skills`) get the same files via
12
+ * `npm run install:commands`. Codex receives one skill (`drupal-mcp`), not
13
+ * per-tool custom prompts.
12
14
  *
13
15
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
14
16
  * command set never drifts from the tools. Run: `npm run generate:commands`.
15
17
  *
16
- * Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`, `commandFileName`,
17
- * `COMMANDS_DIR`, and `generate` for tests; the file-writing side effect runs only
18
- * when executed directly.
18
+ * Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`,
19
+ * `renderCodexSkillMarkdown`, `renderCodexToolsReference`, `CODEX_SKILL_NAME`,
20
+ * `commandFileName`, `COMMANDS_DIR`, and `generate` for tests; the file-writing
21
+ * side effect runs only when executed directly.
19
22
  */
20
23
 
21
24
  import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
@@ -113,6 +116,62 @@ export function renderClaudeCommandMarkdown(def) {
113
116
  });
114
117
  }
115
118
 
119
+ /** Codex user-skill directory name under `$HOME/.agents/skills/`. */
120
+ export const CODEX_SKILL_NAME = "drupal-mcp";
121
+
122
+ /**
123
+ * Codex skill adapter (OpenAI Skills). Custom `~/.codex/prompts` slash
124
+ * commands are deprecated; Codex discovers `$HOME/.agents/skills/<name>/SKILL.md`.
125
+ * One skill covers the whole Drupal MCP tool surface so the skills list stays
126
+ * inside Codex's discovery budget.
127
+ *
128
+ * @param {Array<object>} [definitions]
129
+ * @returns {string} SKILL.md contents.
130
+ */
131
+ export function renderCodexSkillMarkdown(definitions = allDefinitions) {
132
+ const count = definitions.length;
133
+ return [
134
+ "---",
135
+ `name: ${yamlString(CODEX_SKILL_NAME)}`,
136
+ `description: ${yamlString(
137
+ "Use Drupal MCP tools (drupal_*) via drupal-mcp-connector to read or write Drupal content, media, taxonomy, reports, and config. Trigger when the user asks to work with a Drupal site over MCP.",
138
+ )}`,
139
+ "---",
140
+ "",
141
+ "Call Drupal MCP tools by their protocol names (`drupal_list_nodes`, `drupal_update_node`, …).",
142
+ "The MCP server is configured separately (`codex mcp add drupal`); this skill does not replace that connection and does not use deprecated Codex custom prompts.",
143
+ "",
144
+ "Parse the user's request into the tool's parameters. If a required parameter is missing, ask before calling.",
145
+ "Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the tool call and summarize the result.",
146
+ "",
147
+ "Destructive tools permanently change or delete data — confirm with the user before calling.",
148
+ "Do not invent REST paths, Drush commands, or Drupal admin URLs when an MCP tool exists.",
149
+ "",
150
+ `The connector currently exposes **${count}** tools. Full names and descriptions: [references/tools.md](references/tools.md).`,
151
+ "",
152
+ ].join("\n");
153
+ }
154
+
155
+ /**
156
+ * Progressive-disclosure catalog for the Codex Drupal skill.
157
+ * @param {Array<object>} [definitions]
158
+ * @returns {string} references/tools.md contents.
159
+ */
160
+ export function renderCodexToolsReference(definitions = allDefinitions) {
161
+ const lines = [
162
+ "# Drupal MCP tools",
163
+ "",
164
+ "Protocol tool names for `drupal-mcp-connector`. Call these via MCP; do not invent REST paths.",
165
+ "",
166
+ ];
167
+ for (const def of definitions) {
168
+ const desc = String(def.description || "").replace(/\s+/g, " ").trim();
169
+ lines.push(`- \`${def.name}\`${desc ? ` — ${desc}` : ""}`);
170
+ }
171
+ lines.push("");
172
+ return lines.join("\n");
173
+ }
174
+
116
175
  /**
117
176
  * Write one command file per tool, pruning stale `drupal-*.md` first so removed
118
177
  * tools leave no orphans.
@@ -9,11 +9,12 @@
9
9
  * copies so `/drupal-*` works in every project without committing a vendor
10
10
  * folder to this repo or to a consuming application.
11
11
  *
12
- * Default targets: `~/.claude/commands` (Claude adapter) and `~/.grok/commands`
13
- * (canonical files). Pass `--clients` to subset. Never writes into a project
14
- * tree.
12
+ * Default targets: `~/.claude/commands` (Claude adapter), `~/.grok/commands`
13
+ * (canonical files), and `$HOME/.agents/skills/drupal-mcp/` (Codex skill).
14
+ * Pass `--clients` to subset. Never writes into a project tree. Codex custom
15
+ * prompts (`~/.codex/prompts`) are not written — they are deprecated.
15
16
  *
16
- * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,agents]`
17
+ * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,codex,agents]`
17
18
  */
18
19
 
19
20
  import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
@@ -26,6 +27,9 @@ import {
26
27
  commandFileName,
27
28
  renderCommandMarkdown,
28
29
  renderClaudeCommandMarkdown,
30
+ renderCodexSkillMarkdown,
31
+ renderCodexToolsReference,
32
+ CODEX_SKILL_NAME,
29
33
  } from "./generate-commands.js";
30
34
 
31
35
  /** Whitelisted install targets. `rel` is under `--home` (default: os.homedir()). */
@@ -42,9 +46,18 @@ export const CLIENTS = {
42
46
  rel: ".agents/commands",
43
47
  render: renderCommandMarkdown,
44
48
  },
49
+ // Codex discovers user skills at `$HOME/.agents/skills/<name>/SKILL.md`.
50
+ // One skill for the whole tool surface — not one skill per tool.
51
+ codex: {
52
+ rel: ".agents/skills",
53
+ kind: "skill",
54
+ skillName: CODEX_SKILL_NAME,
55
+ renderSkill: renderCodexSkillMarkdown,
56
+ renderReference: renderCodexToolsReference,
57
+ },
45
58
  };
46
59
 
47
- const DEFAULT_CLIENTS = ["claude", "grok"];
60
+ const DEFAULT_CLIENTS = ["claude", "grok", "codex"];
48
61
 
49
62
  /**
50
63
  * Parse CLI flags. Unknown flags throw.
@@ -111,6 +124,15 @@ export function install(options = {}) {
111
124
  if (!client) {
112
125
  throw new Error(`Unknown client "${name}". Allowed: ${Object.keys(CLIENTS).join(", ")}`);
113
126
  }
127
+ if (client.kind === "skill") {
128
+ const dir = join(home, client.rel, client.skillName);
129
+ rmSync(dir, { recursive: true, force: true });
130
+ mkdirSync(join(dir, "references"), { recursive: true });
131
+ writeFileSync(join(dir, "SKILL.md"), client.renderSkill(definitions));
132
+ writeFileSync(join(dir, "references", "tools.md"), client.renderReference(definitions));
133
+ results.push({ client: name, dir, written: ["SKILL.md", "references/tools.md"] });
134
+ continue;
135
+ }
114
136
  const dir = join(home, client.rel);
115
137
  mkdirSync(dir, { recursive: true });
116
138
  for (const f of readdirSync(dir)) {
@@ -127,14 +149,15 @@ export function install(options = {}) {
127
149
  return results;
128
150
  }
129
151
 
130
- const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,agents]
152
+ const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents]
131
153
 
132
- Copy generated /drupal-* command stubs into operator home directories.
133
- Does not write into a project tree.
154
+ Copy generated /drupal-* command stubs (and the Codex skill) into operator
155
+ home directories. Does not write into a project tree. Does not write
156
+ deprecated Codex custom prompts (~/.codex/prompts).
134
157
 
135
158
  --home DIR Install root (default: the current user's home)
136
- --clients LIST Comma-separated subset of: claude, grok, agents
137
- (default: claude,grok)
159
+ --clients LIST Comma-separated subset of: claude, grok, codex, agents
160
+ (default: claude,grok,codex)
138
161
  `;
139
162
 
140
163
  const invokedDirectly =
@@ -79,10 +79,12 @@ export class Backend {
79
79
  * for callers that must round-trip the alias `pid` on an in-place update (the
80
80
  * canonical entity only exposes `path.alias` as `url`). Optional capability:
81
81
  * the default returns nulls so read-only/path-less backends are safe. See the 1.5.1 alias fix.
82
- * @param {{entityType: string, bundle: string, id: string}} _ref
83
- * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
82
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} _ref
83
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
84
84
  */
85
- async getPathInfo(_ref) { return { alias: null, pid: null, langcode: null, drupalId: null }; }
85
+ async getPathInfo(_ref) {
86
+ return { alias: null, pid: null, langcode: null, drupalId: null, aliasId: null };
87
+ }
86
88
 
87
89
  /**
88
90
  * Create an entity.
@@ -17,6 +17,7 @@ import {
17
17
  normalizeRelationship,
18
18
  BASE_ATTRIBUTE_FIELDS,
19
19
  } from "../canonical.js";
20
+ import { isPositiveNid, normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../path-alias.js";
20
21
 
21
22
  // Drupal exposes internal identifiers under drupal_internal__* attributes.
22
23
  // They are dropped from canonical `fields` except for the identifiers that
@@ -310,19 +311,88 @@ export class JsonApiBackend extends Backend {
310
311
  * (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
311
312
  * — so this method exposes it. Returns nulls for entities/backends without a
312
313
  * path field. See the 1.5.1 alias fix.
313
- * @param {{entityType: string, bundle: string, id: string}} ref
314
- * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string)}>}
314
+ *
315
+ * Unpublished default / forward revisions often omit `pid` on the computed
316
+ * `path` field even when a `path_alias` row exists. When the node numeric id
317
+ * is known, this method also looks up that row (aliases are not revisioned)
318
+ * so title-only edits can pin the live alias (#274).
319
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
320
+ * @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
315
321
  */
316
- async getPathInfo({ entityType, bundle, id }) {
322
+ async getPathInfo({ entityType, bundle, id, resourceVersion }) {
317
323
  validateUuid(id);
318
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
324
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
325
+ if (resourceVersion) {
326
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
327
+ }
328
+ const data = await drupalFetch(this.site, path);
319
329
  const attrs = data?.data?.attributes ?? {};
320
- const path = attrs.path ?? null;
330
+ const nodePath = attrs.path ?? null;
331
+ const drupalId = attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null;
332
+ const langcode = nodePath?.langcode ?? attrs.langcode ?? null;
333
+ let alias = nodePath?.alias ?? null;
334
+ let pid = nodePath?.pid ?? null;
335
+ let aliasId = null;
336
+
337
+ if (entityType === "node" && isPositiveNid(drupalId)) {
338
+ const row = await this.lookupPathAliasRow(`/node/${Number(drupalId)}`, {
339
+ langcode,
340
+ preferredAlias: alias,
341
+ });
342
+ if (row) {
343
+ aliasId = row.id;
344
+ if (pid === undefined || pid === null) pid = row.pid;
345
+ // Pathauto / unpublished computed fields can omit alias; the row is
346
+ // the router-visible value.
347
+ if (!alias) alias = row.alias;
348
+ }
349
+ }
350
+
351
+ return { alias, pid, langcode, drupalId, aliasId };
352
+ }
353
+
354
+ /**
355
+ * Load the path_alias row for a node source path. Best-effort: missing
356
+ * JSON:API exposure or an empty collection returns null.
357
+ * @param {string} sourcePath Drupal system path, e.g. `/node/44`.
358
+ * @param {{langcode?: ?string, preferredAlias?: ?string}} [opts]
359
+ * @returns {Promise<?{id: string, alias: ?string, pid: ?(number|string), langcode: ?string}>}
360
+ */
361
+ async lookupPathAliasRow(sourcePath, opts = {}) {
362
+ if (!/^\/node\/[1-9]\d*$/.test(sourcePath)) return null;
363
+ const params = new URLSearchParams();
364
+ params.set("filter[path]", sourcePath);
365
+ if (opts.langcode) params.set("filter[langcode]", String(opts.langcode));
366
+ let data;
367
+ try {
368
+ data = await drupalFetch(
369
+ this.site,
370
+ `${this.resourcePath(PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE)}?${params}`,
371
+ );
372
+ } catch {
373
+ return null;
374
+ }
375
+ const rows = Array.isArray(data?.data) ? data.data : [];
376
+ if (!rows.length) return null;
377
+ const preferred = normalizeAlias(opts.preferredAlias);
378
+ let picked = rows[0];
379
+ if (preferred) {
380
+ for (const row of rows) {
381
+ const attrs = row && typeof row === "object" ? row.attributes : null;
382
+ const rowAlias = attrs && typeof attrs === "object" ? attrs.alias : null;
383
+ if (normalizeAlias(rowAlias) === preferred) {
384
+ picked = row;
385
+ break;
386
+ }
387
+ }
388
+ }
389
+ if (!picked?.id) return null;
390
+ const a = picked.attributes && typeof picked.attributes === "object" ? picked.attributes : {};
321
391
  return {
322
- alias: path?.alias ?? null,
323
- pid: path?.pid ?? null,
324
- langcode: path?.langcode ?? attrs.langcode ?? null,
325
- drupalId: attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null,
392
+ id: picked.id,
393
+ alias: a.alias ?? null,
394
+ pid: a.drupal_internal__id ?? a.pid ?? null,
395
+ langcode: a.langcode ?? null,
326
396
  };
327
397
  }
328
398