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.
- package/.agents/commands/drupal-create-translation.md +7 -5
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-get-paragraph.md +5 -3
- package/.agents/commands/drupal-list-translations.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/.agents/commands/drupal-update-paragraph.md +6 -3
- package/CHANGELOG.md +52 -0
- package/README.md +5 -3
- package/package.json +1 -1
- package/scripts/generate-commands.js +63 -4
- package/scripts/install-commands.js +33 -10
- package/src/lib/backends/backend-interface.js +5 -3
- package/src/lib/backends/jsonapi.js +79 -9
- package/src/lib/draft-write.js +220 -11
- package/src/lib/entity-response.js +25 -0
- package/src/lib/patch-preflight.js +122 -11
- package/src/lib/path-alias.js +37 -0
- package/src/tools/entities.js +1 -1
- package/src/tools/nodes.js +91 -21
- package/src/tools/paragraphs.js +51 -11
- package/src/tools/revisions.js +2 -1
- package/src/tools/translations.js +70 -41
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Create a translation as an unpublished non-default draft
|
|
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
|
|
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
|
|
133
|
-
no consuming project has to commit `.claude/` or
|
|
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 ~/.
|
|
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
|
@@ -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
|
|
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`,
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
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)
|
|
13
|
-
* (canonical files)
|
|
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
|
|
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) {
|
|
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
|
-
*
|
|
314
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|