drupal-mcp-connector 2.7.4 → 2.9.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 (152) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +82 -0
  122. package/README.md +23 -12
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/contracts/approval.js +72 -0
  131. package/src/lib/contracts/decisions.js +139 -0
  132. package/src/lib/contracts/drupal.js +525 -0
  133. package/src/lib/contracts/evaluator.js +28 -0
  134. package/src/lib/contracts/evidence-sink.js +67 -0
  135. package/src/lib/contracts/fixtures.js +117 -0
  136. package/src/lib/contracts/index.js +46 -0
  137. package/src/lib/contracts/relay.js +83 -0
  138. package/src/lib/contracts/system-of-record.js +61 -0
  139. package/src/lib/contracts/types.js +242 -0
  140. package/src/lib/contracts/version.js +52 -0
  141. package/src/lib/data-flow.js +437 -0
  142. package/src/lib/dispatch.js +36 -15
  143. package/src/lib/drupal-fetch.js +52 -6
  144. package/src/lib/field-definition.js +285 -0
  145. package/src/lib/patch-preflight.js +157 -66
  146. package/src/lib/security.js +14 -0
  147. package/src/lib/server-tools.js +9 -0
  148. package/src/lib/write-revision.js +82 -2
  149. package/src/tools/bulk.js +4 -3
  150. package/src/tools/entities.js +11 -7
  151. package/src/tools/index.js +3 -2
  152. package/src/tools/nodes.js +55 -43
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.4** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
@@ -49,7 +49,9 @@ Each site declares which backend(s) it exposes via the `api` key:
49
49
  - **Writes go through JSON:API.** Use a JSON:API-enabled site as the write plane; keep GraphQL as a read plane where that suits your architecture.
50
50
  - **`defaultTextFormat` sets the body text format** used by the `body` convenience
51
51
  parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
52
- Individual calls can override it with `format`. Without either, the connector falls
52
+ Individual calls can override it with `format`. Field API `allowed_formats` wins
53
+ when it can be resolved: a single allowed format is the default, and a format
54
+ outside the list is refused. Without a resolved list, the connector falls
53
55
  back to `full_html`, which many governed sites deliberately do not define — and which
54
56
  is the most permissive core format, so setting this per site is the safer posture. A
55
57
  text format is Drupal's HTML-filtering boundary, so it is worth choosing deliberately
@@ -119,20 +121,27 @@ governed call to that tool. These are protocol-native, so they work everywhere t
119
121
  prompts capability is supported — the client renders them per its own convention
120
122
  (e.g. Claude Code shows `/mcp__drupal__drupal-create-node`).
121
123
 
122
- #### Claude Code slash commands (`/drupal-*`)
123
- For the literal bare `/drupal-<tool>` form in **Claude Code specifically**, the
124
- connector also ships generated command files under `.claude/commands/`. Because
125
- Claude Code project commands are per-project, copy them into your own project (or
126
- regenerate from the installed package):
124
+ #### Filesystem slash commands (`/drupal-*`)
125
+ MCP prompts (above) are the protocol-native slash surface. For the literal bare
126
+ `/drupal-<tool>` form, the connector ships generated stubs under
127
+ **`.agents/commands/`** a harness-agnostic tree, not a vendor rule directory.
128
+ Clients that scan `.agents/commands/` (including Grok Build when this repo is
129
+ the project) pick them up automatically.
130
+
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/`:
127
134
 
128
135
  ```bash
129
- mkdir -p .claude/commands
130
- cp node_modules/drupal-mcp-connector/.claude/commands/drupal-*.md .claude/commands/
131
- # …or, from a clone of the connector: npm run generate:commands
136
+ 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 -- --clients claude
139
+ # npm run install:commands -- --home /path/to/fake-home # tests / CI
132
140
  ```
133
141
 
134
- Other MCP agents don't need this step — they get the same coverage from the per-tool
135
- prompts above.
142
+ From an npm install, the same files ship at
143
+ `node_modules/drupal-mcp-connector/.agents/commands/`. Do not copy them into an
144
+ application repo's `.claude/` or `.grok/` directory.
136
145
 
137
146
  ### Security Model
138
147
 
@@ -161,6 +170,7 @@ Additional connector-side gates (2.1+ / 2.2+):
161
170
  - **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
162
171
  - **HTTPS:** non-loopback binds require an inbound OAuth resource server (`auth.issuer` + `auth.audience`), not `MCP_AUTH_TOKEN`. `MCP_AUTH_TOKEN` remains valid on loopback. `MCP_ALLOW_UNAUTHENTICATED=1` is only for a trusted proxy. Non-loopback TLS defaults to 120 req/min rate limiting. Inbound JWT scopes (and optional `auth.grants`) filter which tools, resources, prompts, and sites a principal can discover or invoke.
163
172
  - **GraphQL is off by default.** `drupal_graphql` / introspect require `security.allowGraphql` (true only on the `development` preset). Raw GraphQL results still bypass entity allowlists and field redaction — prefer JSON:API entity tools when connector policy must hold. Mutations also need `allowGraphqlMutations`.
173
+ - **Northbound data-flow budgets (2.8+):** row, byte, page, request, and chained-action counters keyed by inbound principal + authoritative target (same finite defaults as MCP Sentinel), so pagination, retries, batching, or a new chain id cannot reset them. Governed reads carry the declared-ceiling / declared-destination wire contract; tune with `security.declaredCeiling` and `security.readBudgets`.
164
174
 
165
175
  Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
166
176
 
@@ -298,6 +308,7 @@ an operator channel: keep the agent's credentials off it, and pin
298
308
  | [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
299
309
  | [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
300
310
  | [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
311
+ | [Adapter Contracts](docs/adapter-contracts.md) | Provider-neutral evaluator / relay / approval / evidence / SoR contracts and the Drupal conformance kit |
301
312
  | [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
302
313
  | [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
303
314
 
@@ -7,7 +7,7 @@
7
7
  },
8
8
 
9
9
  "_security_options": {
10
- "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. See docs/security-hardening.md."
10
+ "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. Optional security.declaredCeiling (narrow-only X-MCP-Declared-Ceiling) and security.readBudgets (same finite classes as mcp_sentinel) bind northbound data-flow to the inbound principal and target. See docs/security.md and docs/security-hardening.md."
11
11
  },
12
12
 
13
13
  "_governance_tiers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.7.4",
3
+ "version": "2.9.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",
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "src/",
13
13
  "scripts/generate-commands.js",
14
- ".claude/commands/",
14
+ "scripts/install-commands.js",
15
+ ".agents/commands/",
15
16
  "config/config.example.json",
16
17
  "README.md",
17
18
  "CHANGELOG.md",
@@ -50,6 +51,7 @@
50
51
  "start:https": "MCP_TRANSPORT=https node src/index.js",
51
52
  "start:dev": "MCP_TRANSPORT=https MCP_ALLOW_HTTP=1 MCP_PORT=3443 node src/index.js",
52
53
  "generate:commands": "node scripts/generate-commands.js",
54
+ "install:commands": "node scripts/install-commands.js",
53
55
  "lint": "eslint src/",
54
56
  "lint:fix": "eslint src/ --fix",
55
57
  "test": "vitest run",
@@ -1,22 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * generate-commands.js — generate Claude Code slash commands for every tool.
3
+ * generate-commands.js — generate harness-agnostic slash-command stubs for every tool.
4
4
  *
5
- * Writes one `.claude/commands/drupal-<tool>.md` per Drupal tool, giving the
6
- * literal `/drupal-<tool>` slash command in Claude Code (e.g.
7
- * `/drupal-create-node`). Each file is scoped via `allowed-tools` to only its own
8
- * `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
9
- * tool's parameters before making a single call.
5
+ * Writes one `.agents/commands/drupal-<tool>.md` per Drupal tool. The files use
6
+ * protocol tool names (`drupal_list_nodes`) so any MCP client can consume them;
7
+ * they are not a vendor rule tree. Clients that scan `.agents/commands/` (Grok
8
+ * Build, when this repo is the project) pick them up as `/drupal-<tool>`.
10
9
  *
11
- * These files are *client ergonomics* for operators using Claude Code as an MCP
12
- * client against this server not provider-specific project rules. Development
13
- * instructions for any agent live only in AGENTS.md.
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`.
14
12
  *
15
13
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
16
14
  * command set never drifts from the tools. Run: `npm run generate:commands`.
17
15
  *
18
- * Exports `renderCommandMarkdown`, `commandFileName`, and `generate` for tests; the
19
- * file-writing side effect runs only when executed directly.
16
+ * Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`, `commandFileName`,
17
+ * `COMMANDS_DIR`, and `generate` for tests; the file-writing side effect runs only
18
+ * when executed directly.
20
19
  */
21
20
 
22
21
  import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
@@ -26,7 +25,8 @@ import { allDefinitions } from "../src/tools/index.js";
26
25
  import { paramList, toolNameToPromptName } from "../src/lib/tool-prompts.js";
27
26
  import { isDestructiveTool } from "../src/lib/operations.js";
28
27
 
29
- const COMMANDS_DIR = new URL("../.claude/commands/", import.meta.url);
28
+ /** Canonical, harness-agnostic command tree shipped in the repo and the npm package. */
29
+ export const COMMANDS_DIR = new URL("../.agents/commands/", import.meta.url);
30
30
 
31
31
  /** Map a tool definition to its command filename: `drupal_create_node` → `drupal-create-node.md`. */
32
32
  export function commandFileName(def) {
@@ -49,19 +49,26 @@ function argumentHint(params) {
49
49
  * Render the markdown for one tool's slash command.
50
50
  *
51
51
  * @param {object} def - The tool definition ({name, description, inputSchema}).
52
+ * @param {object} [options]
53
+ * @param {string} [options.allowedTools] - Optional Claude Code `allowed-tools` value.
54
+ * @param {string} [options.argumentsPhrase="the arguments supplied with this command"]
55
+ * Phrase used in the parse-arguments instruction. Claude Code install rewrites
56
+ * this to `` `$ARGUMENTS` `` because that client substitutes the placeholder.
52
57
  * @returns {string} File contents (ends with a trailing newline).
53
58
  */
54
- export function renderCommandMarkdown(def) {
59
+ export function renderCommandMarkdown(def, options = {}) {
55
60
  const params = paramList(def.inputSchema);
56
61
  const required = params.filter((p) => p.required);
57
62
  const optional = params.filter((p) => !p.required);
58
63
  const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
64
+ const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
59
65
 
60
66
  const frontmatter = ["---", `description: ${yamlString(def.description)}`];
61
67
  if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
62
- frontmatter.push(`allowed-tools: mcp__drupal__${def.name}`, "---");
68
+ if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
69
+ frontmatter.push("---");
63
70
 
64
- const body = [`Call the \`mcp__drupal__${def.name}\` MCP tool.`, "", def.description];
71
+ const body = [`Call the MCP tool \`${def.name}\`.`, "", def.description];
65
72
 
66
73
  if (isDestructiveTool(def.name)) {
67
74
  body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
@@ -69,9 +76,9 @@ export function renderCommandMarkdown(def) {
69
76
 
70
77
  body.push("");
71
78
  if (params.length === 0) {
72
- body.push("This tool takes no arguments — call it directly (ignore `$ARGUMENTS`).");
79
+ body.push("This tool takes no arguments — call it directly.");
73
80
  } else {
74
- body.push("Parse the request in `$ARGUMENTS` into this tool's parameters:", "");
81
+ body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
75
82
  if (required.length) {
76
83
  body.push("**Required:**");
77
84
  required.forEach((p) => body.push(line(p)));
@@ -83,15 +90,29 @@ export function renderCommandMarkdown(def) {
83
90
  body.push("");
84
91
  }
85
92
  body.push(
86
- "If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not " +
87
- "invent values. Coerce each value to its JSON type (booleans → true/false, numbers → " +
88
- "numeric, object/array → parse JSON), then make the single tool call and summarize the result."
93
+ "If a required parameter is missing, ask before calling — do not invent values. " +
94
+ "Coerce each value to its JSON type (booleans → true/false, numbers → numeric, " +
95
+ "object/array → parse JSON), then make the single tool call and summarize the result."
89
96
  );
90
97
  }
91
98
 
92
99
  return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
93
100
  }
94
101
 
102
+ /**
103
+ * Claude Code adapter: same stub plus `allowed-tools` scoped to that client's
104
+ * MCP tool name, and `$ARGUMENTS` so typed `/drupal-*` args are not dropped.
105
+ *
106
+ * @param {object} def - The tool definition.
107
+ * @returns {string} File contents.
108
+ */
109
+ export function renderClaudeCommandMarkdown(def) {
110
+ return renderCommandMarkdown(def, {
111
+ allowedTools: `mcp__drupal__${def.name}`,
112
+ argumentsPhrase: "`$ARGUMENTS`",
113
+ });
114
+ }
115
+
95
116
  /**
96
117
  * Write one command file per tool, pruning stale `drupal-*.md` first so removed
97
118
  * tools leave no orphans.
@@ -118,5 +139,5 @@ const invokedDirectly =
118
139
  process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
119
140
  if (invokedDirectly) {
120
141
  const written = generate();
121
- console.error(`[generate-commands] wrote ${written.length} command files to .claude/commands/`);
142
+ console.error(`[generate-commands] wrote ${written.length} command files to .agents/commands/`);
122
143
  }
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-commands.js — copy generated `/drupal-*` stubs into an operator's
4
+ * client home directories.
5
+ *
6
+ * The canonical files live in `.agents/commands/` (in-repo, harness-agnostic).
7
+ * Claude Code and Grok only auto-load project commands from vendor paths
8
+ * (`.claude/commands/`, `.grok/commands/`). This script writes the *user-level*
9
+ * copies so `/drupal-*` works in every project without committing a vendor
10
+ * folder to this repo or to a consuming application.
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.
15
+ *
16
+ * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,agents]`
17
+ */
18
+
19
+ import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
20
+ import { homedir } from "os";
21
+ import { join, resolve } from "path";
22
+ import { pathToFileURL } from "url";
23
+
24
+ import { allDefinitions } from "../src/tools/index.js";
25
+ import {
26
+ commandFileName,
27
+ renderCommandMarkdown,
28
+ renderClaudeCommandMarkdown,
29
+ } from "./generate-commands.js";
30
+
31
+ /** Whitelisted install targets. `rel` is under `--home` (default: os.homedir()). */
32
+ export const CLIENTS = {
33
+ claude: {
34
+ rel: ".claude/commands",
35
+ render: renderClaudeCommandMarkdown,
36
+ },
37
+ grok: {
38
+ rel: ".grok/commands",
39
+ render: renderCommandMarkdown,
40
+ },
41
+ agents: {
42
+ rel: ".agents/commands",
43
+ render: renderCommandMarkdown,
44
+ },
45
+ };
46
+
47
+ const DEFAULT_CLIENTS = ["claude", "grok"];
48
+
49
+ /**
50
+ * Parse CLI flags. Unknown flags throw.
51
+ *
52
+ * @param {string[]} argv - Arguments after the script name.
53
+ * @returns {{home?: string, clients: string[], help?: boolean}}
54
+ */
55
+ export function parseArgs(argv) {
56
+ const out = { clients: [...DEFAULT_CLIENTS] };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ if (a === "--help" || a === "-h") {
60
+ out.help = true;
61
+ continue;
62
+ }
63
+ if (a === "--home") {
64
+ out.home = argv[++i];
65
+ if (!out.home) throw new Error("--home requires a directory");
66
+ continue;
67
+ }
68
+ if (a.startsWith("--home=")) {
69
+ out.home = a.slice("--home=".length);
70
+ continue;
71
+ }
72
+ if (a === "--clients") {
73
+ const raw = argv[++i];
74
+ if (!raw) throw new Error("--clients requires a comma-separated list");
75
+ out.clients = splitClients(raw);
76
+ continue;
77
+ }
78
+ if (a.startsWith("--clients=")) {
79
+ out.clients = splitClients(a.slice("--clients=".length));
80
+ continue;
81
+ }
82
+ throw new Error(`Unknown argument: ${a}`);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function splitClients(raw) {
88
+ const names = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
89
+ if (!names.length) throw new Error("--clients requires at least one client");
90
+ return names;
91
+ }
92
+
93
+ /**
94
+ * Write one `drupal-*.md` per tool into each requested client directory,
95
+ * pruning stale stubs first. Unknown client names fail closed.
96
+ *
97
+ * @param {object} [options]
98
+ * @param {string} [options.home] - Install root (default: os.homedir()).
99
+ * @param {string[]} [options.clients] - Subset of CLIENTS keys.
100
+ * @param {Array<object>} [options.definitions]
101
+ * @returns {Array<{client: string, dir: string, written: string[]}>}
102
+ */
103
+ export function install(options = {}) {
104
+ const home = resolve(options.home || homedir());
105
+ const names = options.clients || DEFAULT_CLIENTS;
106
+ const definitions = options.definitions || allDefinitions;
107
+
108
+ const results = [];
109
+ for (const name of names) {
110
+ const client = CLIENTS[name];
111
+ if (!client) {
112
+ throw new Error(`Unknown client "${name}". Allowed: ${Object.keys(CLIENTS).join(", ")}`);
113
+ }
114
+ const dir = join(home, client.rel);
115
+ mkdirSync(dir, { recursive: true });
116
+ for (const f of readdirSync(dir)) {
117
+ if (/^drupal-.*\.md$/.test(f)) rmSync(join(dir, f));
118
+ }
119
+ const written = [];
120
+ for (const def of definitions) {
121
+ const file = commandFileName(def);
122
+ writeFileSync(join(dir, file), client.render(def));
123
+ written.push(file);
124
+ }
125
+ results.push({ client: name, dir, written });
126
+ }
127
+ return results;
128
+ }
129
+
130
+ const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,agents]
131
+
132
+ Copy generated /drupal-* command stubs into operator home directories.
133
+ Does not write into a project tree.
134
+
135
+ --home DIR Install root (default: the current user's home)
136
+ --clients LIST Comma-separated subset of: claude, grok, agents
137
+ (default: claude,grok)
138
+ `;
139
+
140
+ const invokedDirectly =
141
+ process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
142
+ if (invokedDirectly) {
143
+ try {
144
+ const opts = parseArgs(process.argv.slice(2));
145
+ if (opts.help) {
146
+ console.error(HELP);
147
+ process.exit(0);
148
+ }
149
+ const results = install(opts);
150
+ for (const r of results) {
151
+ console.error(`[install-commands] wrote ${r.written.length} files to ${r.dir} (${r.client})`);
152
+ }
153
+ } catch (err) {
154
+ console.error(`[install-commands] ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+ }
@@ -93,7 +93,10 @@ export class Backend {
93
93
 
94
94
  /**
95
95
  * Update an entity.
96
- * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} _input
96
+ * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} _input
97
+ * `resourceVersion` is a JSON:API revision selector (`rel:working-copy`,
98
+ * `rel:latest-version`, `id:<vid>`). Backends that cannot address revisions
99
+ * ignore it.
97
100
  * @returns {Promise<import("../canonical.js").CanonicalEntity>}
98
101
  */
99
102
  async updateEntity(_input) { return notImplemented("updateEntity"); }
@@ -139,6 +142,22 @@ export class Backend {
139
142
  */
140
143
  async getEntitySchema(_entityType, _bundle) { return notImplemented("getEntitySchema"); }
141
144
 
145
+ /**
146
+ * Resolve Field API metadata for one field (internal introspection).
147
+ *
148
+ * `getEntitySchema` is sampling-only and does NOT expose `allowed_formats`.
149
+ * The JSON:API adapter reads `field_config` via an internal fetch — not via
150
+ * agent entity tools (`field_config` is on the agent deny list). Returns
151
+ * null when the definition cannot be resolved. Callers must not invent
152
+ * `allowed_formats` from `defaultTextFormat` or `full_html`.
153
+ *
154
+ * Default: unknown. GraphQL is read-only for writes; write sites use JSON:API.
155
+ *
156
+ * @param {{entityType: string, bundle: string, fieldName: string}} _ref
157
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
158
+ */
159
+ async getFieldDefinition(_ref) { return null; }
160
+
142
161
  /**
143
162
  * List user roles.
144
163
  * @returns {Promise<Array<{id: string, machineName: string, label: string, weight: number}>>}
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
11
11
  import { validateUuid, validateMachineName } from "../validate.js";
12
+ import { parseFieldConfigObject } from "../field-definition.js";
12
13
  import { Backend } from "./backend-interface.js";
13
14
  import {
14
15
  makeCanonicalEntity,
@@ -208,8 +209,10 @@ export class JsonApiBackend extends Backend {
208
209
  if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
209
210
  if (INTERNAL_ATTR_RE.test(k)) {
210
211
  // Paragraph ERR attach needs the current revision id (#192).
212
+ // Node / revisionable writes need the working vs live vid (#166).
211
213
  // Other drupal_internal__* attributes stay stripped.
212
- return entityType === "paragraph" && k === "drupal_internal__revision_id";
214
+ return k === "drupal_internal__vid"
215
+ || (entityType === "paragraph" && k === "drupal_internal__revision_id");
213
216
  }
214
217
  return true;
215
218
  })
@@ -339,17 +342,24 @@ export class JsonApiBackend extends Backend {
339
342
  /**
340
343
  * Update an entity via JSON:API PATCH. Retries without `status` on moderated
341
344
  * bundles — see writeWithModerationFallback.
342
- * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
345
+ * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} input
346
+ * `resourceVersion` is a JSON:API revision selector (e.g. `rel:working-copy`).
347
+ * When set, it is appended so a forward revision is PATCHed instead of the
348
+ * canonical default (#166 / Drupal #2795279).
343
349
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
344
350
  */
345
- async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
351
+ async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion }) {
346
352
  validateUuid(id);
347
353
  const buildPayload = (attrs) => {
348
354
  const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
349
355
  if (relationships) payload.data.relationships = relationships;
350
356
  return payload;
351
357
  };
352
- const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, "PATCH", buildPayload, attributes);
358
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
359
+ if (resourceVersion) {
360
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
361
+ }
362
+ const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes);
353
363
  return this.toCanonical(data.data);
354
364
  }
355
365
 
@@ -512,6 +522,39 @@ export class JsonApiBackend extends Backend {
512
522
  });
513
523
  }
514
524
 
525
+ /**
526
+ * Read Field API metadata from JSON:API `field_config` (chain step 1).
527
+ *
528
+ * This is an internal introspection call, not an agent entity-tool read:
529
+ * `field_config` is on the connector deny list. Do not invent
530
+ * `allowed_formats` from `this.site.defaultTextFormat` or `full_html`.
531
+ * Empty / unreadable `field_config` returns null so the caller can try
532
+ * Drush `config:get field.field.{entityType}.{bundle}.{field}` and, if
533
+ * that also fails, keep the historical default chain only while the list
534
+ * is unknown.
535
+ *
536
+ * @param {{entityType: string, bundle: string, fieldName: string}} ref
537
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
538
+ */
539
+ async getFieldDefinition({ entityType, bundle, fieldName }) {
540
+ validateMachineName(entityType, "entityType");
541
+ validateMachineName(bundle, "bundle");
542
+ validateMachineName(fieldName, "fieldName");
543
+ const params = new URLSearchParams();
544
+ params.set("filter[entity_type]", entityType);
545
+ params.set("filter[bundle]", bundle);
546
+ params.set("filter[field_name]", fieldName);
547
+ params.set("page[limit]", "1");
548
+ let data;
549
+ try {
550
+ data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
551
+ } catch {
552
+ return null;
553
+ }
554
+ const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
555
+ return parseFieldConfigObject(row?.attributes, fieldName);
556
+ }
557
+
515
558
  /**
516
559
  * Describe a bundle's fields by sampling one entity and inferring attribute
517
560
  * types from its values. JSON:API has no schema endpoint, so an empty bundle
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Decide whether the `summary` argument may be written onto `body`.
3
+ *
4
+ * Core `text_with_summary` stores a `summary` property. `text_long` and
5
+ * sampled `text_formatted` do not — Drupal drops an unknown property, so a
6
+ * silent write would lose the teaser. Fail closed when the sampled schema
7
+ * cannot confirm the property exists (#163).
8
+ */
9
+
10
+ /** Warning attached when `summary` is written onto a text_with_summary body. */
11
+ export const SUMMARY_DEPRECATED_WARNING = {
12
+ code: "summary_parameter_deprecated",
13
+ message:
14
+ "The `summary` argument writes body.summary on core text_with_summary. " +
15
+ "Prefer setting the site's dedicated deck/summary field via `fields`.",
16
+ };
17
+
18
+ /**
19
+ * @param {object} backend Backend with `getEntitySchema`.
20
+ * @param {string} bundle Node bundle machine name.
21
+ * @param {string|undefined} summary Caller-supplied summary; undefined = omitted.
22
+ * @returns {Promise<{deprecated: boolean}>} Whether a deprecation notice applies.
23
+ * @throws {Error} When `summary` was supplied and the body field has no
24
+ * summary property, or the schema cannot be determined.
25
+ */
26
+ export async function assertBodySummaryWritable(backend, bundle, summary) {
27
+ if (summary === undefined) return { deprecated: false };
28
+
29
+ let schema;
30
+ try {
31
+ schema = await backend.getEntitySchema("node", bundle);
32
+ } catch (err) {
33
+ const detail = err instanceof Error ? err.message : String(err);
34
+ throw new Error(
35
+ `Cannot write \`summary\`: the body field schema for node.${bundle} could not be determined (${detail}). Set the site's dedicated deck field via \`fields\` instead.`,
36
+ );
37
+ }
38
+
39
+ const bodyType = schema?.attributes?.body;
40
+ if (bodyType === "text_with_summary") {
41
+ return { deprecated: true };
42
+ }
43
+
44
+ const sampled = bodyType ? ` (sampled as ${bodyType})` : "";
45
+ throw new Error(
46
+ `This site's body field has no summary property${sampled}; set the site's deck field via \`fields\`. The \`summary\` argument is only valid for core text_with_summary body fields.`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Attach the `summary` deprecation notice to a write (or dryRun) result.
52
+ *
53
+ * @param {object|null|undefined} result Tool response.
54
+ * @returns {object|null|undefined} Result with `_warnings` when result is an object.
55
+ */
56
+ export function attachSummaryDeprecation(result) {
57
+ if (!result || typeof result !== "object") return result;
58
+ const existing = Array.isArray(result._warnings) ? result._warnings : [];
59
+ return { ...result, _warnings: [...existing, SUMMARY_DEPRECATED_WARNING] };
60
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Approval-interface contract (#181).
3
+ *
4
+ * An approval binds to an action-manifest digest and a single actor. It is
5
+ * one-use. Replay, digest mismatch, or actor mismatch invalidate it.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { ContractError, REASON } from "./decisions.js";
10
+
11
+ /**
12
+ * @typedef {Object} ApprovalInterface
13
+ * @property {(manifest: object, actor?: string) => {approvalId: string, digest: string}} issue
14
+ * @property {(approvalId: string, digest: string, actor?: string) => {approvalId: string, digest: string}} consume
15
+ */
16
+
17
+ /**
18
+ * In-process one-use approval ledger.
19
+ * @returns {ApprovalInterface & {size: () => number}}
20
+ */
21
+ export function createMemoryApproval() {
22
+ const store = new Map();
23
+
24
+ return Object.freeze({
25
+ /**
26
+ * @param {object} manifest
27
+ * @param {string} [actor]
28
+ * @returns {{approvalId: string, digest: string}}
29
+ */
30
+ issue(manifest, actor) {
31
+ if (!manifest?.digest) {
32
+ throw new ContractError("Approval requires a manifest digest.", REASON.APPROVAL_REQUIRED);
33
+ }
34
+ const approvalId = randomUUID();
35
+ store.set(approvalId, {
36
+ digest: manifest.digest,
37
+ actor: actor ?? null,
38
+ used: false,
39
+ });
40
+ return { approvalId, digest: manifest.digest };
41
+ },
42
+
43
+ /**
44
+ * @param {string} approvalId
45
+ * @param {string} digest
46
+ * @param {string} [actor]
47
+ * @returns {{approvalId: string, digest: string}}
48
+ */
49
+ consume(approvalId, digest, actor) {
50
+ if (!approvalId) {
51
+ throw new ContractError("Approval required.", REASON.APPROVAL_REQUIRED);
52
+ }
53
+ const entry = store.get(approvalId);
54
+ if (!entry || entry.used) {
55
+ throw new ContractError("Approval already used or unknown.", REASON.REPLAY);
56
+ }
57
+ if (entry.digest !== digest) {
58
+ throw new ContractError("Approval digest mismatch.", REASON.REPLAY);
59
+ }
60
+ if (entry.actor && entry.actor !== actor) {
61
+ throw new ContractError("Approval actor mismatch.", REASON.REPLAY);
62
+ }
63
+ store.set(approvalId, { ...entry, used: true });
64
+ return { approvalId, digest };
65
+ },
66
+
67
+ /** @returns {number} */
68
+ size() {
69
+ return store.size;
70
+ },
71
+ });
72
+ }