drupal-mcp-connector 2.19.0 → 2.20.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 (70) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +254 -0
  20. package/README.md +10 -3
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/dispatch.js +6 -6
  32. package/src/lib/drupal-fetch.js +97 -26
  33. package/src/lib/dry-run-checks.js +78 -0
  34. package/src/lib/error-body.js +448 -0
  35. package/src/lib/error-status.js +38 -0
  36. package/src/lib/errors.js +0 -11
  37. package/src/lib/evidence.js +0 -6
  38. package/src/lib/governance.js +2 -8
  39. package/src/lib/link-checker.js +3 -3
  40. package/src/lib/mcp-server.js +7 -1
  41. package/src/lib/metatag-audit.js +2 -1
  42. package/src/lib/module-tools.js +23 -2
  43. package/src/lib/operations.js +2 -2
  44. package/src/lib/patch-preflight.js +23 -5
  45. package/src/lib/policy-enforcement.js +4 -4
  46. package/src/lib/principal.js +3 -3
  47. package/src/lib/relay/edge.js +2 -2
  48. package/src/lib/reports-support.js +75 -0
  49. package/src/lib/security.js +234 -18
  50. package/src/lib/sentinel-draft.js +3 -2
  51. package/src/lib/server-tools.js +188 -27
  52. package/src/lib/tool-prompts.js +139 -8
  53. package/src/lib/usage.js +0 -9
  54. package/src/lib/verify.js +164 -61
  55. package/src/tools/config.js +70 -5
  56. package/src/tools/drush.js +191 -17
  57. package/src/tools/entities.js +18 -6
  58. package/src/tools/fields.js +40 -4
  59. package/src/tools/graphql.js +10 -5
  60. package/src/tools/nodes.js +16 -6
  61. package/src/tools/paragraphs.js +1 -1
  62. package/src/tools/reports-config.js +3 -3
  63. package/src/tools/reports-content.js +34 -26
  64. package/src/tools/reports-extra.js +46 -38
  65. package/src/tools/reports.js +50 -25
  66. package/src/tools/scheduler.js +1 -1
  67. package/src/tools/structure.js +12 -2
  68. package/src/tools/translations.js +5 -1
  69. package/src/lib/draft-write.js +0 -19
  70. package/src/lib/node-draft-inventory.js +0 -5
package/README.md CHANGED
@@ -68,7 +68,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
68
68
 
69
69
  ## Features
70
70
 
71
- ### 120 Tools Across 26 Modules
71
+ ### 123 Tools Across 27 Modules
72
72
 
73
73
  | Module | Tools |
74
74
  |--------|-------|
@@ -77,6 +77,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
77
77
  | **Users** | List, get, create, update, block/unblock, role management (PII-gated) |
78
78
  | **Media** | List types, CRUD, file upload, orphaned-media detection |
79
79
  | **GraphQL** | Execute a query, schema introspection (mutation-gated) |
80
+ | **Codegen** | Optional graphql_compose_codegen inspect/diff/generate (generate is `--dry-run` text) |
80
81
  | **Entities** | Generic CRUD for *any* Drupal entity type (paragraphs, commerce, webforms, …) |
81
82
  | **Site** | Site info, content-type discovery, configured-site listing |
82
83
  | **Reports** | Content summary, stale content, field completeness, SEO/accessibility audits, taxonomy usage, user activity, revision hotspots (10 read-only reports) |
@@ -99,7 +100,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
99
100
  | **Audit (composite)** | `drupal_audit_site_health` — scored content/links/config dashboard with a roll-up grade |
100
101
  | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
101
102
 
102
- **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that validates the request and returns a preview of exactly what would be written — without committing anything to Drupal.
103
+ **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that runs the connector's checks and returns a preview of the payload, without committing anything to Drupal. The result's `checks` block says what was checked. Field access and entity validation are checked only when Sentinel's non-saving draft endpoint ran; see [docs/tools-reference.md](docs/tools-reference.md#preview-writes-with-dryrun).
103
104
 
104
105
  ### MCP Resources
105
106
  Browsable, always-fresh context the client can read without calling a tool:
@@ -144,8 +145,14 @@ npm run install:commands # copies into ~/.claude/commands, ~/.grok/commands,
144
145
  # npm run install:commands -- --clients claude
145
146
  # npm run install:commands -- --clients codex
146
147
  # npm run install:commands -- --home /path/to/fake-home # tests / CI
148
+ # npm run install:commands -- --modules # also write stubs for module-owned tools
147
149
  ```
148
150
 
151
+ Module-owned tools ([docs/module-tools.md](docs/module-tools.md)) get a prompt
152
+ whenever they are visible to the caller. Their bare `/drupal-<namespace>-<alias>`
153
+ stubs are written only with `--modules`, which discovers them from the sources
154
+ in `config/config.json`.
155
+
149
156
  From an npm install, the same files ship at
150
157
  `node_modules/drupal-mcp-connector/.agents/commands/`. Do not copy them into an
151
158
  application repo's `.claude/` or `.grok/` directory.
@@ -308,7 +315,7 @@ an operator channel: keep the agent's credentials off it, and pin
308
315
  | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
309
316
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
310
317
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
311
- | [Tools Reference](docs/tools-reference.md) | Full reference for all 120 tools |
318
+ | [Tools Reference](docs/tools-reference.md) | Full reference for all 123 tools |
312
319
  | [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
313
320
  | [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
314
321
  | [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
@@ -21,7 +21,7 @@ import process from "node:process";
21
21
  import fetch from "node-fetch";
22
22
  import { verifyStatic, verifyLive } from "../src/lib/verify.js";
23
23
  import { loadConfig, getSiteConfig, resolveOauth } from "../src/lib/config.js";
24
- import { callServerTool } from "../src/lib/server-tools.js";
24
+ import { callServerTool, listServerTools } from "../src/lib/server-tools.js";
25
25
 
26
26
  /** Parses `--flag`, `--key value` and `--key=value`. */
27
27
  function parseArgs(argv) {
@@ -118,12 +118,13 @@ async function main() {
118
118
  ? resolveOauth({ ...(config.sites?.[siteName ?? config.defaultSite] ?? {}), _name: siteName ?? config.defaultSite })
119
119
  : getSiteConfig(siteName);
120
120
  // The real bridge client, so the governed-tool probes exercise the real
121
- // contract (MCP session, tool_api name, tool-level refusal) rather than a
122
- // hand-rolled JSON-RPC body the server would reject as malformed.
121
+ // contract (MCP session, advertised tool name, tool-level refusal) rather
122
+ // than a hand-rolled JSON-RPC body the server would reject as malformed.
123
123
  evidence.push(
124
124
  await verifyLive(site, {
125
125
  transport: fetch,
126
126
  callTool: callServerTool,
127
+ listTools: listServerTools,
127
128
  contentTarget: args["content-target"] ? String(args["content-target"]) : null,
128
129
  contentTargetType: args["content-target-type"] ? String(args["content-target-type"]) : null,
129
130
  }),
@@ -109,7 +109,7 @@
109
109
  },
110
110
  "serverTools": { "url": "/mcp" },
111
111
  "drushSsh": {
112
- "_comment": "Development only. SSH target of the local web container. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available via mcp_sentinel's governed command. To enable it, set rawSql:\"governed\", add \"mcp-sentinel:sql-query\" to allowedCommands, and set allow_raw_sql on the site's policy profile.",
112
+ "_comment": "Development only. SSH target of the local web container. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available through mcp_sentinel's governed SQL tool. To enable it, set rawSql:\"governed\" here and set allow_raw_sql on the site's policy profile. Then choose one path. With serverTools.bindings.sqlQuery mapped to a module tool (see _server_tools), the query goes to mcp_sentinel over the governed MCP endpoint and uses no SSH host, key or connection. Without a bindings object it runs over this SSH bridge, so also add \"mcp-sentinel:sql-query\" to allowedCommands.",
113
113
  "host": "<web-container-ssh-host>",
114
114
  "user": "<web-container-ssh-user>",
115
115
  "keyPath": "~/.ssh/id_ed25519",
@@ -154,13 +154,63 @@
154
154
  },
155
155
 
156
156
  "_server_tools": {
157
- "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer."
157
+ "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer.",
158
+ "_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. See docs/module-tools.md.",
159
+ "_bindings_comment": "Optional. serverTools.bindings keeps a built-in command's public name while its implementation lives in a Drupal module. Each binding names an alias under serverTools.modules.tools on the same site. configGet, configList, configSet serve drupal_config_get/list/set and the config reports; codegenInspect, codegenDiff, codegenPreview serve drupal_codegen_inspect/diff/generate; sqlQuery serves drupal_drush_sql_query. Required alias policy: config and codegen aliases use scope mcp_config; get, list and the three codegen aliases are operation read with capability configRead; configSet is operation write with capability configWrite; sqlQuery is scope mcp_admin, operation read, capability rawSql, and still needs drushSsh.rawSql: \"governed\" on the site. Once a bindings object exists, every one of these commands resolves only through its binding: a missing or mismatched mapping refuses the command, with no fallback to another tool or to SSH. Configure the full set for the commands the site uses. Without a bindings object these commands keep their built-in transport. See docs/module-tools.md.",
160
+ "example": {
161
+ "serverTools": {
162
+ "url": "/mcp",
163
+ "modules": {
164
+ "namespace": "example_site",
165
+ "tools": {
166
+ "list_activities": {
167
+ "name": "tool_api__example_list_activities",
168
+ "scope": "example_read",
169
+ "operation": "read",
170
+ "capabilities": []
171
+ },
172
+ "record_activity": {
173
+ "name": "tool_api__example_record_activity",
174
+ "scope": "example_write",
175
+ "operation": "write",
176
+ "capabilities": []
177
+ },
178
+ "get_config": { "name": "tool_api__mcp_sentinel_config_get", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
179
+ "list_config": { "name": "tool_api__mcp_sentinel_config_list", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
180
+ "set_config": { "name": "tool_api__mcp_sentinel_config_set", "scope": "mcp_config", "operation": "write", "capabilities": ["configWrite"] },
181
+ "codegen_inspect": { "name": "tool_api__graphql_compose_codegen_inspect", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
182
+ "codegen_diff": { "name": "tool_api__graphql_compose_codegen_diff", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
183
+ "codegen_preview": { "name": "tool_api__graphql_compose_codegen_preview", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
184
+ "sql_query": { "name": "tool_api__mcp_sentinel_sql_query", "scope": "mcp_admin", "operation": "read", "capabilities": ["rawSql"] }
185
+ }
186
+ },
187
+ "bindings": {
188
+ "configGet": "get_config",
189
+ "configList": "list_config",
190
+ "configSet": "set_config",
191
+ "codegenInspect": "codegen_inspect",
192
+ "codegenDiff": "codegen_diff",
193
+ "codegenPreview": "codegen_preview",
194
+ "sqlQuery": "sql_query"
195
+ }
196
+ }
197
+ }
158
198
  },
159
199
 
160
200
  "_audit_tools": {
161
201
  "_comment": "The audit tool groups (drupal_report_* links/config/content, drupal_audit_site_health) are read-only. Privileged audits (404 log, config drift/best-practices, module/permission/status) are self-sufficient via the connector's own drush bridge (drushSsh) — no companion module required; the config-inspection audits additionally prefer the governed config server-tool when serverTools is configured. They report 'unavailable' when no source is configured for the site. drupal_report_broken_links does no outbound HTTP unless called with checkLive:true; see each site's optional 'audit' block for live-check limits."
162
202
  },
163
203
 
204
+ "_protected_modules": {
205
+ "_comment": "drupal_drush_module_disable refuses governance, integrity, secrets, auth and API modules on every preset (mcp_sentinel, audit_chain, field_guard, file_gate, key, encrypt, simple_oauth, consumers, jsonapi, serialization, mcp_server, mcp_server_tool_bridge, tool, content_moderation, workflows). Two optional per-site keys under security change the list. protectedModules adds modules and cannot shrink the default. allowProtectedModuleUninstall is the explicit opt-out and removes only the modules it names. A malformed value in either key blocks every uninstall on that site. Neither key opens readOnly, allowDestructive or drushSsh.allowedCommands. An uninstall that would cascade to dependents is refused. See docs/security.md#protected-modules.",
206
+ "example": { "preset": "development", "protectedModules": ["my_audit_module"], "allowProtectedModuleUninstall": [] }
207
+ },
208
+
209
+ "_core_extension": {
210
+ "_comment": "core.extension lists installed modules and themes, so changing it can uninstall a protected module. On every preset drupal_config_set refuses a write to core.extension, and drupal_drush_config_import first runs `drush config:status` and imports nothing when core.extension differs or the status cannot be read. If drushSsh.allowedCommands is set it must list config:status as well as config:import. One optional per-site key under security opens both: allowCoreExtensionChange, true or false, default false. Any other value keeps both refused. With it set, drupal_config_set still refuses a value that removes or alters an installed protected module, and drupal_drush_config_import runs unchecked because the connector cannot read the sync directory. The key opens no other gate. A config import run outside the connector is out of its reach; add core.extension to denied_config_types on the MCP Sentinel policy profile for the source-side control. See docs/security.md#changes-to-coreextension.",
211
+ "example": { "preset": "config-editor", "allowCoreExtensionChange": false }
212
+ },
213
+
164
214
  "_mcp_client_registration": {
165
215
  "_comment": "Add under your MCP client's mcpServers config (stdio transport)",
166
216
  "drupal": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -15,6 +15,10 @@
15
15
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
16
16
  * command set never drifts from the tools. Run: `npm run generate:commands`.
17
17
  *
18
+ * Module-owned tools are discovered at runtime and have no committed stub. The
19
+ * installer writes them on request (`install:commands -- --modules`), using
20
+ * `moduleCommandFileName` and the `MODULE_STUB_MARKER` exported here.
21
+ *
18
22
  * Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`,
19
23
  * `renderCodexSkillMarkdown`, `renderCodexToolsReference`, `CODEX_SKILL_NAME`,
20
24
  * `commandFileName`, `COMMANDS_DIR`, and `generate` for tests; the file-writing
@@ -25,7 +29,9 @@ import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs"
25
29
  import { pathToFileURL } from "url";
26
30
 
27
31
  import { allDefinitions } from "../src/tools/index.js";
28
- import { paramList, toolNameToPromptName } from "../src/lib/tool-prompts.js";
32
+ import {
33
+ isModuleDefinition, moduleCallNotes, toolDescription, toolNameToPromptName, toolParams,
34
+ } from "../src/lib/tool-prompts.js";
29
35
  import { isDestructiveTool } from "../src/lib/operations.js";
30
36
 
31
37
  /** Canonical, harness-agnostic command tree shipped in the repo and the npm package. */
@@ -36,6 +42,31 @@ export function commandFileName(def) {
36
42
  return `${toolNameToPromptName(def.name)}.md`;
37
43
  }
38
44
 
45
+ /** Trailing marker that tells the installer a stub belongs to a module-owned tool. */
46
+ export const MODULE_STUB_MARKER = "<!-- drupal-mcp-connector:module-tool -->";
47
+
48
+ const MODULE_TOOL_NAME = /^drupal_module_(?:read|write|delete)_([a-z][a-z0-9_]*?)__([a-z][a-z0-9_]*)$/;
49
+
50
+ /**
51
+ * Command filename for a module-owned tool: `drupal-<namespace>-<alias>.md`. The
52
+ * operation is left out so the command reads as the module's action; the stub
53
+ * body still names the full tool.
54
+ *
55
+ * Either part may contain `__`, so a tool name cannot always be split back.
56
+ * Pass the configured parts when they are known; the name is parsed only as a
57
+ * fallback, at the first separator.
58
+ *
59
+ * @param {object} def - A tool definition.
60
+ * @param {{namespace: string, alias: string}} [parts] - From local config.
61
+ * @returns {?string} The filename, or null when the name is not a module tool.
62
+ */
63
+ export function moduleCommandFileName(def, parts) {
64
+ const match = MODULE_TOOL_NAME.exec(def?.name ?? "");
65
+ if (!match) return null;
66
+ const [namespace, alias] = parts ? [parts.namespace, parts.alias] : [match[1], match[2]];
67
+ return `drupal-${namespace}-${alias}.md`.replace(/_/g, "-");
68
+ }
69
+
39
70
  /** Collapse to a single-line, double-quoted YAML scalar. */
40
71
  function yamlString(value) {
41
72
  const clean = String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim();
@@ -60,18 +91,20 @@ function argumentHint(params) {
60
91
  * @returns {string} File contents (ends with a trailing newline).
61
92
  */
62
93
  export function renderCommandMarkdown(def, options = {}) {
63
- const params = paramList(def.inputSchema);
94
+ const params = toolParams(def);
64
95
  const required = params.filter((p) => p.required);
65
96
  const optional = params.filter((p) => !p.required);
66
97
  const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
98
+ const isModule = isModuleDefinition(def);
67
99
  const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
68
100
 
69
- const frontmatter = ["---", `description: ${yamlString(def.description)}`];
101
+ const description = toolDescription(def);
102
+ const frontmatter = ["---", `description: ${yamlString(description)}`];
70
103
  if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
71
104
  if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
72
105
  frontmatter.push("---");
73
106
 
74
- const body = [`Call the MCP tool \`${def.name}\`.`, "", def.description];
107
+ const body = [`Call the MCP tool \`${def.name}\`.`, "", description];
75
108
 
76
109
  if (isDestructiveTool(def.name)) {
77
110
  body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
@@ -79,7 +112,7 @@ export function renderCommandMarkdown(def, options = {}) {
79
112
 
80
113
  body.push("");
81
114
  if (params.length === 0) {
82
- body.push("This tool takes no arguments — call it directly.");
115
+ body.push(isModule ? "This tool takes no parameters." : "This tool takes no arguments — call it directly.");
83
116
  } else {
84
117
  body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
85
118
  if (required.length) {
@@ -99,6 +132,8 @@ export function renderCommandMarkdown(def, options = {}) {
99
132
  );
100
133
  }
101
134
 
135
+ if (isModule) body.push("", ...moduleCallNotes(def, params.length > 0), "", MODULE_STUB_MARKER);
136
+
102
137
  return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
103
138
  }
104
139
 
@@ -14,10 +14,15 @@
14
14
  * Pass `--clients` to subset. Never writes into a project tree. Codex custom
15
15
  * prompts (`~/.codex/prompts`) are not written — they are deprecated.
16
16
  *
17
- * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,codex,agents]`
17
+ * Module-owned tools (docs/module-tools.md) are written only with `--modules`.
18
+ * That run discovers the tools the local config approves, from the configured
19
+ * sources, and writes `drupal-<namespace>-<alias>.md` stubs. Run it from the
20
+ * directory that holds `config/config.json`.
21
+ *
22
+ * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,codex,agents] [--modules]`
18
23
  */
19
24
 
20
- import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
25
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync } from "fs";
21
26
  import { homedir } from "os";
22
27
  import { join, resolve } from "path";
23
28
  import { pathToFileURL } from "url";
@@ -25,6 +30,8 @@ import { pathToFileURL } from "url";
25
30
  import { allDefinitions } from "../src/tools/index.js";
26
31
  import {
27
32
  commandFileName,
33
+ moduleCommandFileName,
34
+ MODULE_STUB_MARKER,
28
35
  renderCommandMarkdown,
29
36
  renderClaudeCommandMarkdown,
30
37
  renderCodexSkillMarkdown,
@@ -63,7 +70,7 @@ const DEFAULT_CLIENTS = ["claude", "grok", "codex"];
63
70
  * Parse CLI flags. Unknown flags throw.
64
71
  *
65
72
  * @param {string[]} argv - Arguments after the script name.
66
- * @returns {{home?: string, clients: string[], help?: boolean}}
73
+ * @returns {{home?: string, clients: string[], modules?: boolean, help?: boolean}}
67
74
  */
68
75
  export function parseArgs(argv) {
69
76
  const out = { clients: [...DEFAULT_CLIENTS] };
@@ -73,6 +80,10 @@ export function parseArgs(argv) {
73
80
  out.help = true;
74
81
  continue;
75
82
  }
83
+ if (a === "--modules") {
84
+ out.modules = true;
85
+ continue;
86
+ }
76
87
  if (a === "--home") {
77
88
  out.home = argv[++i];
78
89
  if (!out.home) throw new Error("--home requires a directory");
@@ -103,6 +114,55 @@ function splitClients(raw) {
103
114
  return names;
104
115
  }
105
116
 
117
+ /**
118
+ * Decide which module tools get a stub. A name that matches a built-in command,
119
+ * or that two module tools share, is refused so no command is shadowed.
120
+ *
121
+ * @param {Array<object>} moduleDefinitions - Definitions from live discovery.
122
+ * @param {Array<object>} [definitions] - Built-in definitions.
123
+ * @param {Map<string, {namespace: string, alias: string}>} [parts] - Configured
124
+ * namespace and alias per tool name, so a `__` inside either is not misread.
125
+ * @returns {{stubs: Array<{file: string, def: object}>, refused: Array<{name: string, file: ?string, reason: string}>}}
126
+ */
127
+ export function planModuleStubs(moduleDefinitions, definitions = allDefinitions, parts = new Map()) {
128
+ const builtIn = new Set(definitions.map(commandFileName));
129
+ const byFile = new Map();
130
+ const refused = [];
131
+ for (const def of moduleDefinitions) {
132
+ const file = moduleCommandFileName(def, parts.get(def.name));
133
+ if (!file) refused.push({ name: def.name, file, reason: "not a module tool name" });
134
+ else if (builtIn.has(file)) refused.push({ name: def.name, file, reason: "matches a built-in command" });
135
+ else byFile.set(file, [...(byFile.get(file) ?? []), def]);
136
+ }
137
+ const stubs = [];
138
+ for (const [file, defs] of byFile) {
139
+ if (defs.length === 1) stubs.push({ file, def: defs[0] });
140
+ else defs.forEach((def) => refused.push({ name: def.name, file, reason: "shared by more than one module tool" }));
141
+ }
142
+ return { stubs, refused };
143
+ }
144
+
145
+ /**
146
+ * Configured tool names that discovery did not return.
147
+ *
148
+ * @param {string[]} configured - Names from local policy.
149
+ * @param {Array<object>} discovered - Definitions from live discovery.
150
+ * @returns {string[]}
151
+ */
152
+ export function missingModuleTools(configured, discovered) {
153
+ const found = new Set(discovered.map((def) => def.name));
154
+ return configured.filter((name) => !found.has(name));
155
+ }
156
+
157
+ /** Whether an installed stub was written for a module-owned tool. */
158
+ function isModuleStub(path) {
159
+ try {
160
+ return readFileSync(path, "utf8").includes(MODULE_STUB_MARKER);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
106
166
  /**
107
167
  * Write one `drupal-*.md` per tool into each requested client directory,
108
168
  * pruning stale stubs first. Unknown client names fail closed.
@@ -111,12 +171,23 @@ function splitClients(raw) {
111
171
  * @param {string} [options.home] - Install root (default: os.homedir()).
112
172
  * @param {string[]} [options.clients] - Subset of CLIENTS keys.
113
173
  * @param {Array<object>} [options.definitions]
114
- * @returns {Array<{client: string, dir: string, written: string[]}>}
174
+ * @param {Array<object>} [options.moduleDefinitions] - Module tools from live
175
+ * discovery. Omitted: installed module stubs are left as they are.
176
+ * @param {Map<string, {namespace: string, alias: string}>} [options.moduleParts]
177
+ * Configured namespace and alias per module tool name.
178
+ * @param {boolean} [options.pruneModules=true] - Remove module stubs that are
179
+ * not rewritten. Pass false when discovery was incomplete.
180
+ * @returns {Array<{client: string, dir: string, written: string[], moduleWritten: string[], catalogued?: number}>}
115
181
  */
116
182
  export function install(options = {}) {
117
183
  const home = resolve(options.home || homedir());
118
184
  const names = options.clients || DEFAULT_CLIENTS;
119
185
  const definitions = options.definitions || allDefinitions;
186
+ const withModules = Array.isArray(options.moduleDefinitions);
187
+ const moduleStubs = withModules
188
+ ? planModuleStubs(options.moduleDefinitions, definitions, options.moduleParts).stubs
189
+ : [];
190
+ const pruneModules = withModules && options.pruneModules !== false;
120
191
 
121
192
  const results = [];
122
193
  for (const name of names) {
@@ -128,15 +199,24 @@ export function install(options = {}) {
128
199
  const dir = join(home, client.rel, client.skillName);
129
200
  rmSync(dir, { recursive: true, force: true });
130
201
  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"] });
202
+ // The Codex skill is one catalog, so module tools join it rather than
203
+ // getting files of their own. It is rebuilt on every run.
204
+ const catalog = [...definitions, ...moduleStubs.map((stub) => stub.def)];
205
+ writeFileSync(join(dir, "SKILL.md"), client.renderSkill(catalog));
206
+ writeFileSync(join(dir, "references", "tools.md"), client.renderReference(catalog));
207
+ results.push({
208
+ client: name, dir, written: ["SKILL.md", "references/tools.md"],
209
+ catalogued: moduleStubs.length, moduleWritten: [],
210
+ });
134
211
  continue;
135
212
  }
136
213
  const dir = join(home, client.rel);
137
214
  mkdirSync(dir, { recursive: true });
138
215
  for (const f of readdirSync(dir)) {
139
- if (/^drupal-.*\.md$/.test(f)) rmSync(join(dir, f));
216
+ if (!/^drupal-.*\.md$/.test(f)) continue;
217
+ // Module stubs outlive a plain install; only a complete --modules run prunes them.
218
+ if (isModuleStub(join(dir, f)) && !pruneModules) continue;
219
+ rmSync(join(dir, f));
140
220
  }
141
221
  const written = [];
142
222
  for (const def of definitions) {
@@ -144,12 +224,40 @@ export function install(options = {}) {
144
224
  writeFileSync(join(dir, file), client.render(def));
145
225
  written.push(file);
146
226
  }
147
- results.push({ client: name, dir, written });
227
+ const moduleWritten = [];
228
+ for (const { file, def } of moduleStubs) {
229
+ writeFileSync(join(dir, file), client.render(def));
230
+ moduleWritten.push(file);
231
+ }
232
+ results.push({ client: name, dir, written, moduleWritten });
148
233
  }
149
234
  return results;
150
235
  }
151
236
 
152
- const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents]
237
+ /**
238
+ * Discover the module-owned tools the local config approves, as the local
239
+ * operator (no inbound principal). Sources, credentials and governance checks
240
+ * are the ones the server uses; a source that fails returns no tools.
241
+ *
242
+ * @returns {Promise<{definitions: Array<object>, configured: string[], parts: Map<string, {namespace: string, alias: string}>}>}
243
+ */
244
+ export async function discoverModuleDefinitions() {
245
+ // Loaded on demand so a plain install needs no site config or network.
246
+ const { loadLocalSecrets } = await import("../src/lib/load-secrets.js");
247
+ const { listResolvableSiteConfigs } = await import("../src/lib/dispatch.js");
248
+ const { createModuleToolRegistry, configuredModuleTools } = await import("../src/lib/module-tools.js");
249
+ loadLocalSecrets();
250
+ const sites = listResolvableSiteConfigs();
251
+ const definitions = await createModuleToolRegistry().list({ sites, identity: null });
252
+ const tools = configuredModuleTools(sites);
253
+ return {
254
+ definitions,
255
+ configured: tools.map((tool) => tool.name),
256
+ parts: new Map(tools.map(({ name, namespace, alias }) => [name, { namespace, alias }])),
257
+ };
258
+ }
259
+
260
+ const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents] [--modules]
153
261
 
154
262
  Copy generated /drupal-* command stubs (and the Codex skill) into operator
155
263
  home directories. Does not write into a project tree. Does not write
@@ -158,6 +266,9 @@ deprecated Codex custom prompts (~/.codex/prompts).
158
266
  --home DIR Install root (default: the current user's home)
159
267
  --clients LIST Comma-separated subset of: claude, grok, codex, agents
160
268
  (default: claude,grok,codex)
269
+ --modules Also write stubs for module-owned tools. Discovers them from
270
+ the sources in config/config.json (run from that directory).
271
+ Without this flag, installed module stubs are left alone.
161
272
  `;
162
273
 
163
274
  const invokedDirectly =
@@ -169,9 +280,35 @@ if (invokedDirectly) {
169
280
  console.error(HELP);
170
281
  process.exit(0);
171
282
  }
283
+ if (opts.modules) {
284
+ const { definitions, configured, parts } = await discoverModuleDefinitions();
285
+ const missing = missingModuleTools(configured, definitions);
286
+ if (configured.length === 0) {
287
+ console.error("[install-commands] --modules: no site configures serverTools.modules; nothing to discover.");
288
+ }
289
+ if (configured.length > 0 && definitions.length === 0) {
290
+ throw new Error(
291
+ `--modules: ${configured.length} module tools are configured but no source returned any. ` +
292
+ "Check the source, its credentials and its governance status. Nothing was written."
293
+ );
294
+ }
295
+ for (const name of missing) {
296
+ console.error(`[install-commands] WARNING: configured but not returned by its source: ${name}`);
297
+ }
298
+ for (const r of planModuleStubs(definitions, allDefinitions, parts).refused) {
299
+ console.error(`[install-commands] WARNING: no stub for ${r.name}: ${r.reason}${r.file ? ` (${r.file})` : ""}`);
300
+ }
301
+ opts.moduleDefinitions = definitions;
302
+ opts.moduleParts = parts;
303
+ // An incomplete listing must not delete stubs for tools that may only be unreachable.
304
+ opts.pruneModules = missing.length === 0;
305
+ }
172
306
  const results = install(opts);
173
307
  for (const r of results) {
174
- console.error(`[install-commands] wrote ${r.written.length} files to ${r.dir} (${r.client})`);
308
+ const extra = !opts.modules ? ""
309
+ : r.catalogued === undefined ? ` + ${r.moduleWritten.length} module stubs`
310
+ : ` (${r.catalogued} module tools in the catalog)`;
311
+ console.error(`[install-commands] wrote ${r.written.length} files${extra} to ${r.dir} (${r.client})`);
175
312
  }
176
313
  } catch (err) {
177
314
  console.error(`[install-commands] ${err.message}`);
package/src/index.js CHANGED
@@ -59,7 +59,7 @@ import {
59
59
  // Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
60
60
  import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
61
61
  import { createModuleToolRegistry, isModuleTool } from "./lib/module-tools.js";
62
- import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
62
+ import { buildToolPrompts, createPromptSurface } from "./lib/tool-prompts.js";
63
63
 
64
64
  // Apply config/secrets.map (or the shipped example table) before any site
65
65
  // resolution. MCP clients spawn this file directly; the shell launcher is
@@ -313,17 +313,14 @@ const buildConnectorServer = createConnectorServerFactory({
313
313
  },
314
314
  read: readResource,
315
315
  },
316
- prompts: {
317
- definitions: ALL_PROMPTS,
318
- list: async () => {
319
- const identity = getRequestIdentity();
320
- const tools = await discoverableTools();
321
- return filterPromptsByPrincipal(ALL_PROMPTS, identity, tools);
322
- },
323
- get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
324
- ? getPromptMessages(name, args)
325
- : getToolPromptMessages(name, args, definitionsByName),
326
- },
316
+ prompts: createPromptSurface({
317
+ staticPrompts: ALL_PROMPTS,
318
+ discover: discoverableTools,
319
+ filter: (prompts, tools) => filterPromptsByPrincipal(prompts, getRequestIdentity(), tools),
320
+ workflowNames: WORKFLOW_PROMPT_NAMES,
321
+ workflowMessages: getPromptMessages,
322
+ definitionsByName,
323
+ }),
327
324
  });
328
325
 
329
326
  // ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { drupalGraphqlFetch } from "../drupal-fetch.js";
11
+ import { describeGraphqlErrors } from "../error-body.js";
11
12
  import { graphqlTypeToEntity } from "./graphql-names.js";
12
13
 
13
14
  // Introspection is intentionally nested four `ofType` levels deep: a field type
@@ -150,9 +151,16 @@ class SchemaMap {
150
151
  * Build a SchemaMap from a raw introspection response.
151
152
  * @param {object} introspection The `{ data: { __schema } }` response.
152
153
  * @returns {SchemaMap}
154
+ * @throws {Error} When the response has no schema. The message carries the
155
+ * server's cleaned, bounded errors (see `describeGraphqlErrors`).
153
156
  */
154
157
  function buildSchemaMap(introspection) {
155
- const schema = introspection.data.__schema;
158
+ const schema = introspection?.data?.__schema;
159
+ if (!schema) {
160
+ // A 200 with `errors` and no schema (introspection off, access denied).
161
+ const detail = describeGraphqlErrors(introspection?.errors) || "the response has no schema";
162
+ throw new Error(`GraphQL introspection failed: ${detail}`);
163
+ }
156
164
  const typesByName = new Map(schema.types.map((t) => [t.name, t]));
157
165
  const queryType = typesByName.get(schema.queryType.name);
158
166
  const map = new SchemaMap();
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { drupalGraphqlFetch } from "../drupal-fetch.js";
15
+ import { describeGraphqlErrors } from "../error-body.js";
15
16
  import { Backend } from "./backend-interface.js";
16
17
  import { BackendCapabilityError } from "./errors.js";
17
18
  import { loadSchemaMap } from "./graphql-schema.js";
@@ -129,14 +130,15 @@ export class GraphqlBackend extends Backend {
129
130
  * them so a backend failure surfaces as an error, not a silently-empty result.
130
131
  * @param {string} query GraphQL query document.
131
132
  * @returns {Promise<object>} The raw `{ data, errors? }` response.
132
- * @throws {Error} When the response contains GraphQL errors.
133
+ * @throws {Error} When the response contains GraphQL errors. The messages are
134
+ * cleaned and bounded (see `describeGraphqlErrors`).
133
135
  */
134
136
  async _query(query) {
135
137
  const json = await drupalGraphqlFetch(this.site, { query });
136
138
  if (json?.errors?.length) {
137
139
  throw new Error(
138
140
  `GraphQL query failed on site "${this.site._name}": ` +
139
- json.errors.map((e) => e.message).join("; ")
141
+ describeGraphqlErrors(json.errors)
140
142
  );
141
143
  }
142
144
  return json;
@@ -11,6 +11,8 @@
11
11
  import { drupalFetch } from "../drupal-fetch.js";
12
12
  import { drupalGraphqlFetch } from "../drupal-fetch.js";
13
13
  import { clearToken } from "../oauth.js";
14
+ import { describeGraphqlErrors } from "../error-body.js";
15
+ import { httpStatusOf } from "../error-status.js";
14
16
  import { JsonApiBackend } from "./jsonapi.js";
15
17
  import { GraphqlBackend } from "./graphql.js";
16
18
  import { BackendResolutionError } from "./errors.js";
@@ -43,9 +45,11 @@ export function _clearBackendCache() {
43
45
  */
44
46
  export function isAuthError(err) {
45
47
  const msg = String(err?.message || "");
48
+ // The response status, never a bare "401" in the path or the detail (#355).
49
+ const status = httpStatusOf(err);
46
50
  return (
47
- /\b401\b/.test(msg) ||
48
- (/\b403\b/.test(msg) && /token|oauth|credential|scope/i.test(msg)) ||
51
+ status === 401 ||
52
+ (status === 403 && /token|oauth|credential|scope/i.test(msg)) ||
49
53
  /invalid_client|invalid_grant|unauthorized_client|invalid_token/i.test(msg) ||
50
54
  /\bunauthorized\b/i.test(msg)
51
55
  );
@@ -121,7 +125,9 @@ async function probeProtocol(name, site) {
121
125
  if (name === "graphql") {
122
126
  const json = await drupalGraphqlFetch(site, { query: "{ __typename }" });
123
127
  if (json && !json.errors) return { ok: true, error: null };
124
- return { ok: false, error: new Error(json?.errors?.[0]?.message || "GraphQL probe returned errors") };
128
+ // The probe error ends up in the resolution error the client sees, and
129
+ // isAuthError() reads it: cleaned and bounded, keywords intact (#356).
130
+ return { ok: false, error: new Error(describeGraphqlErrors(json?.errors?.[0]) || "GraphQL probe returned errors") };
125
131
  }
126
132
  return { ok: false, error: new Error(`unknown backend "${name}"`) };
127
133
  } catch (error) {
@@ -804,6 +804,8 @@ export class JsonApiBackend extends Backend {
804
804
  * Describe a bundle's fields by sampling one entity and inferring attribute
805
805
  * types from its values. JSON:API has no schema endpoint, so an empty bundle
806
806
  * yields a `note` and empty maps.
807
+ * JSON:API leaves a field the account may not view out of the resource, so
808
+ * such a field is not in the result; an empty field keeps its key (#337).
807
809
  * @param {string} entityType
808
810
  * @param {string} bundle
809
811
  * @returns {Promise<{entityType: string, bundle: string, resourceType?: string, note?: string, attributes: object, relationships: object}>}