drupal-mcp-connector 2.19.1 → 2.21.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 (63) 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 +264 -0
  20. package/README.md +9 -1
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +63 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +83 -5
  25. package/scripts/install-commands.js +199 -11
  26. package/src/index.js +34 -120
  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/drupal-fetch.js +97 -26
  32. package/src/lib/dry-run-checks.js +78 -0
  33. package/src/lib/error-body.js +448 -0
  34. package/src/lib/error-status.js +38 -0
  35. package/src/lib/governance.js +9 -2
  36. package/src/lib/mcp-server.js +7 -1
  37. package/src/lib/metatag-audit.js +2 -1
  38. package/src/lib/module-tools.js +23 -2
  39. package/src/lib/oauth.js +23 -9
  40. package/src/lib/patch-preflight.js +23 -5
  41. package/src/lib/principal.js +14 -0
  42. package/src/lib/reports-support.js +75 -0
  43. package/src/lib/security.js +233 -0
  44. package/src/lib/sentinel-draft.js +3 -2
  45. package/src/lib/server-tools.js +202 -34
  46. package/src/lib/tool-prompts.js +160 -6
  47. package/src/lib/verify.js +161 -58
  48. package/src/lib/workflow-prompts.js +348 -0
  49. package/src/lib/workflows/builtin.js +134 -0
  50. package/src/tools/config.js +70 -5
  51. package/src/tools/drush.js +191 -17
  52. package/src/tools/entities.js +18 -6
  53. package/src/tools/fields.js +40 -4
  54. package/src/tools/graphql.js +10 -5
  55. package/src/tools/moderation.js +1 -1
  56. package/src/tools/nodes.js +16 -6
  57. package/src/tools/reports-config.js +3 -3
  58. package/src/tools/reports-content.js +34 -26
  59. package/src/tools/reports-extra.js +46 -38
  60. package/src/tools/reports.js +50 -25
  61. package/src/tools/scheduler.js +1 -1
  62. package/src/tools/structure.js +12 -2
  63. package/src/tools/translations.js +5 -1
@@ -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,74 @@
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. Optional serverTools.modules.workflows enables module-owned workflow prompts (drupal-<namespace>-<workflow>); a workflow may only name aliases already under tools. See docs/module-tools.md and docs/module-workflows.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
+ "workflows": {
187
+ "review_and_log": {
188
+ "description": "Review recent activities and, after confirmation, log a follow-up. Write is opt-in and is not retried.",
189
+ "readOnly": false,
190
+ "tools": ["list_activities", "record_activity"],
191
+ "arguments": [
192
+ { "name": "site", "description": "Target site", "required": false }
193
+ ],
194
+ "instructions": "1. Call {tool:list_activities} for recent rows.\n2. Summarize what needs a follow-up.\n3. Ask the person before calling {tool:record_activity}. Do not retry a write."
195
+ }
196
+ }
197
+ },
198
+ "bindings": {
199
+ "configGet": "get_config",
200
+ "configList": "list_config",
201
+ "configSet": "set_config",
202
+ "codegenInspect": "codegen_inspect",
203
+ "codegenDiff": "codegen_diff",
204
+ "codegenPreview": "codegen_preview",
205
+ "sqlQuery": "sql_query"
206
+ }
207
+ }
208
+ }
158
209
  },
159
210
 
160
211
  "_audit_tools": {
161
212
  "_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
213
  },
163
214
 
215
+ "_protected_modules": {
216
+ "_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.",
217
+ "example": { "preset": "development", "protectedModules": ["my_audit_module"], "allowProtectedModuleUninstall": [] }
218
+ },
219
+
220
+ "_core_extension": {
221
+ "_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.",
222
+ "example": { "preset": "config-editor", "allowCoreExtensionChange": false }
223
+ },
224
+
164
225
  "_mcp_client_registration": {
165
226
  "_comment": "Add under your MCP client's mcpServers config (stdio transport)",
166
227
  "drupal": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.19.1",
3
+ "version": "2.21.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,45 @@ 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
+ /** Trailing marker for a module-owned workflow stub. */
49
+ export const WORKFLOW_STUB_MARKER = "<!-- drupal-mcp-connector:module-workflow -->";
50
+
51
+ /**
52
+ * Command filename for a module-owned workflow prompt.
53
+ *
54
+ * @param {string} namespace
55
+ * @param {string} id
56
+ * @returns {string}
57
+ */
58
+ export function workflowCommandFileName(namespace, id) {
59
+ return `drupal-${namespace}-${id}.md`.replace(/_/g, "-");
60
+ }
61
+
62
+ const MODULE_TOOL_NAME = /^drupal_module_(?:read|write|delete)_([a-z][a-z0-9_]*?)__([a-z][a-z0-9_]*)$/;
63
+
64
+ /**
65
+ * Command filename for a module-owned tool: `drupal-<namespace>-<alias>.md`. The
66
+ * operation is left out so the command reads as the module's action; the stub
67
+ * body still names the full tool.
68
+ *
69
+ * Either part may contain `__`, so a tool name cannot always be split back.
70
+ * Pass the configured parts when they are known; the name is parsed only as a
71
+ * fallback, at the first separator.
72
+ *
73
+ * @param {object} def - A tool definition.
74
+ * @param {{namespace: string, alias: string}} [parts] - From local config.
75
+ * @returns {?string} The filename, or null when the name is not a module tool.
76
+ */
77
+ export function moduleCommandFileName(def, parts) {
78
+ const match = MODULE_TOOL_NAME.exec(def?.name ?? "");
79
+ if (!match) return null;
80
+ const [namespace, alias] = parts ? [parts.namespace, parts.alias] : [match[1], match[2]];
81
+ return `drupal-${namespace}-${alias}.md`.replace(/_/g, "-");
82
+ }
83
+
39
84
  /** Collapse to a single-line, double-quoted YAML scalar. */
40
85
  function yamlString(value) {
41
86
  const clean = String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim();
@@ -60,18 +105,20 @@ function argumentHint(params) {
60
105
  * @returns {string} File contents (ends with a trailing newline).
61
106
  */
62
107
  export function renderCommandMarkdown(def, options = {}) {
63
- const params = paramList(def.inputSchema);
108
+ const params = toolParams(def);
64
109
  const required = params.filter((p) => p.required);
65
110
  const optional = params.filter((p) => !p.required);
66
111
  const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
112
+ const isModule = isModuleDefinition(def);
67
113
  const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
68
114
 
69
- const frontmatter = ["---", `description: ${yamlString(def.description)}`];
115
+ const description = toolDescription(def);
116
+ const frontmatter = ["---", `description: ${yamlString(description)}`];
70
117
  if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
71
118
  if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
72
119
  frontmatter.push("---");
73
120
 
74
- const body = [`Call the MCP tool \`${def.name}\`.`, "", def.description];
121
+ const body = [`Call the MCP tool \`${def.name}\`.`, "", description];
75
122
 
76
123
  if (isDestructiveTool(def.name)) {
77
124
  body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
@@ -79,7 +126,7 @@ export function renderCommandMarkdown(def, options = {}) {
79
126
 
80
127
  body.push("");
81
128
  if (params.length === 0) {
82
- body.push("This tool takes no arguments — call it directly.");
129
+ body.push(isModule ? "This tool takes no parameters." : "This tool takes no arguments — call it directly.");
83
130
  } else {
84
131
  body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
85
132
  if (required.length) {
@@ -99,6 +146,8 @@ export function renderCommandMarkdown(def, options = {}) {
99
146
  );
100
147
  }
101
148
 
149
+ if (isModule) body.push("", ...moduleCallNotes(def, params.length > 0), "", MODULE_STUB_MARKER);
150
+
102
151
  return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
103
152
  }
104
153
 
@@ -109,6 +158,35 @@ export function renderCommandMarkdown(def, options = {}) {
109
158
  * @param {object} def - The tool definition.
110
159
  * @returns {string} File contents.
111
160
  */
161
+ /**
162
+ * Filesystem stub for a module-owned workflow prompt.
163
+ *
164
+ * @param {object} workflow - A loaded workflow from the #333 loader.
165
+ * @returns {string}
166
+ */
167
+ export function renderWorkflowCommandMarkdown(workflow) {
168
+ const params = (workflow.arguments ?? []).map((arg) => ({
169
+ name: arg.name,
170
+ required: Boolean(arg.required),
171
+ }));
172
+ const hint = argumentHint(params);
173
+ const tools = (workflow.publicTools ?? []).map((name) => `\`${name}\``).join(", ");
174
+ const body = [
175
+ `# ${workflow.name}`,
176
+ "",
177
+ workflow.description,
178
+ "",
179
+ `This is a workflow prompt. Ask the MCP client for prompt \`${workflow.name}\`.`,
180
+ tools ? `It names these tools: ${tools}.` : "",
181
+ workflow.readOnly
182
+ ? "Read-only: do not write."
183
+ : "Confirm with the person before any write. Module writes are not retried.",
184
+ "",
185
+ WORKFLOW_STUB_MARKER,
186
+ ].filter((line, i, arr) => line !== "" || arr[i - 1] !== "");
187
+ return `---\ndescription: ${yamlString(workflow.description)}\nargument-hint: "${hint}"\n---\n\n${body.join("\n")}\n`;
188
+ }
189
+
112
190
  export function renderClaudeCommandMarkdown(def) {
113
191
  return renderCommandMarkdown(def, {
114
192
  allowedTools: `mcp__drupal__${def.name}`,
@@ -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,10 +30,15 @@ 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,
35
+ WORKFLOW_STUB_MARKER,
28
36
  renderCommandMarkdown,
29
37
  renderClaudeCommandMarkdown,
30
38
  renderCodexSkillMarkdown,
31
39
  renderCodexToolsReference,
40
+ renderWorkflowCommandMarkdown,
41
+ workflowCommandFileName,
32
42
  CODEX_SKILL_NAME,
33
43
  } from "./generate-commands.js";
34
44
 
@@ -63,7 +73,7 @@ const DEFAULT_CLIENTS = ["claude", "grok", "codex"];
63
73
  * Parse CLI flags. Unknown flags throw.
64
74
  *
65
75
  * @param {string[]} argv - Arguments after the script name.
66
- * @returns {{home?: string, clients: string[], help?: boolean}}
76
+ * @returns {{home?: string, clients: string[], modules?: boolean, help?: boolean}}
67
77
  */
68
78
  export function parseArgs(argv) {
69
79
  const out = { clients: [...DEFAULT_CLIENTS] };
@@ -73,6 +83,10 @@ export function parseArgs(argv) {
73
83
  out.help = true;
74
84
  continue;
75
85
  }
86
+ if (a === "--modules") {
87
+ out.modules = true;
88
+ continue;
89
+ }
76
90
  if (a === "--home") {
77
91
  out.home = argv[++i];
78
92
  if (!out.home) throw new Error("--home requires a directory");
@@ -103,6 +117,83 @@ function splitClients(raw) {
103
117
  return names;
104
118
  }
105
119
 
120
+ /**
121
+ * Decide which module tools get a stub. A name that matches a built-in command,
122
+ * or that two module tools share, is refused so no command is shadowed.
123
+ *
124
+ * @param {Array<object>} moduleDefinitions - Definitions from live discovery.
125
+ * @param {Array<object>} [definitions] - Built-in definitions.
126
+ * @param {Map<string, {namespace: string, alias: string}>} [parts] - Configured
127
+ * namespace and alias per tool name, so a `__` inside either is not misread.
128
+ * @returns {{stubs: Array<{file: string, def: object}>, refused: Array<{name: string, file: ?string, reason: string}>}}
129
+ */
130
+ export function planModuleStubs(moduleDefinitions, definitions = allDefinitions, parts = new Map()) {
131
+ const builtIn = new Set(definitions.map(commandFileName));
132
+ const byFile = new Map();
133
+ const refused = [];
134
+ for (const def of moduleDefinitions) {
135
+ const file = moduleCommandFileName(def, parts.get(def.name));
136
+ if (!file) refused.push({ name: def.name, file, reason: "not a module tool name" });
137
+ else if (builtIn.has(file)) refused.push({ name: def.name, file, reason: "matches a built-in command" });
138
+ else byFile.set(file, [...(byFile.get(file) ?? []), def]);
139
+ }
140
+ const stubs = [];
141
+ for (const [file, defs] of byFile) {
142
+ if (defs.length === 1) stubs.push({ file, def: defs[0] });
143
+ else defs.forEach((def) => refused.push({ name: def.name, file, reason: "shared by more than one module tool" }));
144
+ }
145
+ return { stubs, refused };
146
+ }
147
+
148
+ /**
149
+ * Configured tool names that discovery did not return.
150
+ *
151
+ * @param {string[]} configured - Names from local policy.
152
+ * @param {Array<object>} discovered - Definitions from live discovery.
153
+ * @returns {string[]}
154
+ */
155
+ export function missingModuleTools(configured, discovered) {
156
+ const found = new Set(discovered.map((def) => def.name));
157
+ return configured.filter((name) => !found.has(name));
158
+ }
159
+
160
+ /** Whether an installed stub was written for a module-owned tool or workflow. */
161
+ function isModuleStub(path) {
162
+ try {
163
+ const text = readFileSync(path, "utf8");
164
+ return text.includes(MODULE_STUB_MARKER) || text.includes(WORKFLOW_STUB_MARKER);
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Workflow stubs for enabled local definitions whose named tools were discovered.
172
+ *
173
+ * @param {object[]} workflows
174
+ * @param {Set<string>} builtInFiles
175
+ * @param {Set<string>} moduleFiles
176
+ * @returns {{stubs: Array<{file: string, workflow: object}>, refused: Array<object>}}
177
+ */
178
+ export function planWorkflowStubs(workflows, builtInFiles, moduleFiles) {
179
+ const stubs = [];
180
+ const refused = [];
181
+ const byFile = new Map();
182
+ for (const workflow of workflows ?? []) {
183
+ const file = workflowCommandFileName(workflow.namespace, workflow.id);
184
+ if (builtInFiles.has(file) || moduleFiles.has(file)) {
185
+ refused.push({ name: workflow.name, file, reason: "matches a built-in or module-tool command" });
186
+ continue;
187
+ }
188
+ byFile.set(file, [...(byFile.get(file) ?? []), workflow]);
189
+ }
190
+ for (const [file, defs] of byFile) {
191
+ if (defs.length === 1) stubs.push({ file, workflow: defs[0] });
192
+ else defs.forEach((workflow) => refused.push({ name: workflow.name, file, reason: "shared by more than one workflow" }));
193
+ }
194
+ return { stubs, refused };
195
+ }
196
+
106
197
  /**
107
198
  * Write one `drupal-*.md` per tool into each requested client directory,
108
199
  * pruning stale stubs first. Unknown client names fail closed.
@@ -111,12 +202,23 @@ function splitClients(raw) {
111
202
  * @param {string} [options.home] - Install root (default: os.homedir()).
112
203
  * @param {string[]} [options.clients] - Subset of CLIENTS keys.
113
204
  * @param {Array<object>} [options.definitions]
114
- * @returns {Array<{client: string, dir: string, written: string[]}>}
205
+ * @param {Array<object>} [options.moduleDefinitions] - Module tools from live
206
+ * discovery. Omitted: installed module stubs are left as they are.
207
+ * @param {Map<string, {namespace: string, alias: string}>} [options.moduleParts]
208
+ * Configured namespace and alias per module tool name.
209
+ * @param {boolean} [options.pruneModules=true] - Remove module stubs that are
210
+ * not rewritten. Pass false when discovery was incomplete.
211
+ * @returns {Array<{client: string, dir: string, written: string[], moduleWritten: string[], catalogued?: number}>}
115
212
  */
116
213
  export function install(options = {}) {
117
214
  const home = resolve(options.home || homedir());
118
215
  const names = options.clients || DEFAULT_CLIENTS;
119
216
  const definitions = options.definitions || allDefinitions;
217
+ const withModules = Array.isArray(options.moduleDefinitions);
218
+ const moduleStubs = withModules
219
+ ? planModuleStubs(options.moduleDefinitions, definitions, options.moduleParts).stubs
220
+ : [];
221
+ const pruneModules = withModules && options.pruneModules !== false;
120
222
 
121
223
  const results = [];
122
224
  for (const name of names) {
@@ -128,15 +230,24 @@ export function install(options = {}) {
128
230
  const dir = join(home, client.rel, client.skillName);
129
231
  rmSync(dir, { recursive: true, force: true });
130
232
  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"] });
233
+ // The Codex skill is one catalog, so module tools join it rather than
234
+ // getting files of their own. It is rebuilt on every run.
235
+ const catalog = [...definitions, ...moduleStubs.map((stub) => stub.def)];
236
+ writeFileSync(join(dir, "SKILL.md"), client.renderSkill(catalog));
237
+ writeFileSync(join(dir, "references", "tools.md"), client.renderReference(catalog));
238
+ results.push({
239
+ client: name, dir, written: ["SKILL.md", "references/tools.md"],
240
+ catalogued: moduleStubs.length, moduleWritten: [],
241
+ });
134
242
  continue;
135
243
  }
136
244
  const dir = join(home, client.rel);
137
245
  mkdirSync(dir, { recursive: true });
138
246
  for (const f of readdirSync(dir)) {
139
- if (/^drupal-.*\.md$/.test(f)) rmSync(join(dir, f));
247
+ if (!/^drupal-.*\.md$/.test(f)) continue;
248
+ // Module stubs outlive a plain install; only a complete --modules run prunes them.
249
+ if (isModuleStub(join(dir, f)) && !pruneModules) continue;
250
+ rmSync(join(dir, f));
140
251
  }
141
252
  const written = [];
142
253
  for (const def of definitions) {
@@ -144,12 +255,45 @@ export function install(options = {}) {
144
255
  writeFileSync(join(dir, file), client.render(def));
145
256
  written.push(file);
146
257
  }
147
- results.push({ client: name, dir, written });
258
+ const moduleWritten = [];
259
+ for (const { file, def } of moduleStubs) {
260
+ writeFileSync(join(dir, file), client.render(def));
261
+ moduleWritten.push(file);
262
+ }
263
+ const workflowStubs = options.workflowStubs ?? [];
264
+ for (const { file, workflow } of workflowStubs) {
265
+ writeFileSync(join(dir, file), renderWorkflowCommandMarkdown(workflow));
266
+ moduleWritten.push(file);
267
+ }
268
+ results.push({ client: name, dir, written, moduleWritten });
148
269
  }
149
270
  return results;
150
271
  }
151
272
 
152
- const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents]
273
+ /**
274
+ * Discover the module-owned tools the local config approves, as the local
275
+ * operator (no inbound principal). Sources, credentials and governance checks
276
+ * are the ones the server uses; a source that fails returns no tools.
277
+ *
278
+ * @returns {Promise<{definitions: Array<object>, configured: string[], parts: Map<string, {namespace: string, alias: string}>}>}
279
+ */
280
+ export async function discoverModuleDefinitions() {
281
+ // Loaded on demand so a plain install needs no site config or network.
282
+ const { loadLocalSecrets } = await import("../src/lib/load-secrets.js");
283
+ const { listResolvableSiteConfigs } = await import("../src/lib/dispatch.js");
284
+ const { createModuleToolRegistry, configuredModuleTools } = await import("../src/lib/module-tools.js");
285
+ loadLocalSecrets();
286
+ const sites = listResolvableSiteConfigs();
287
+ const definitions = await createModuleToolRegistry().list({ sites, identity: null });
288
+ const tools = configuredModuleTools(sites);
289
+ return {
290
+ definitions,
291
+ configured: tools.map((tool) => tool.name),
292
+ parts: new Map(tools.map(({ name, namespace, alias }) => [name, { namespace, alias }])),
293
+ };
294
+ }
295
+
296
+ const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,codex,agents] [--modules]
153
297
 
154
298
  Copy generated /drupal-* command stubs (and the Codex skill) into operator
155
299
  home directories. Does not write into a project tree. Does not write
@@ -158,6 +302,11 @@ deprecated Codex custom prompts (~/.codex/prompts).
158
302
  --home DIR Install root (default: the current user's home)
159
303
  --clients LIST Comma-separated subset of: claude, grok, codex, agents
160
304
  (default: claude,grok,codex)
305
+ --modules Also write stubs for module-owned tools and workflows.
306
+ Discovers tools from the sources in config/config.json
307
+ (run from that directory). Workflows come from
308
+ serverTools.modules.workflows. Without this flag, installed
309
+ module stubs are left alone.
161
310
  `;
162
311
 
163
312
  const invokedDirectly =
@@ -169,9 +318,48 @@ if (invokedDirectly) {
169
318
  console.error(HELP);
170
319
  process.exit(0);
171
320
  }
321
+ if (opts.modules) {
322
+ const { definitions, configured, parts } = await discoverModuleDefinitions();
323
+ const missing = missingModuleTools(configured, definitions);
324
+ if (configured.length === 0) {
325
+ console.error("[install-commands] --modules: no site configures serverTools.modules; nothing to discover.");
326
+ }
327
+ if (configured.length > 0 && definitions.length === 0) {
328
+ throw new Error(
329
+ `--modules: ${configured.length} module tools are configured but no source returned any. ` +
330
+ "Check the source, its credentials and its governance status. Nothing was written."
331
+ );
332
+ }
333
+ for (const name of missing) {
334
+ console.error(`[install-commands] WARNING: configured but not returned by its source: ${name}`);
335
+ }
336
+ for (const r of planModuleStubs(definitions, allDefinitions, parts).refused) {
337
+ console.error(`[install-commands] WARNING: no stub for ${r.name}: ${r.reason}${r.file ? ` (${r.file})` : ""}`);
338
+ }
339
+ opts.moduleDefinitions = definitions;
340
+ opts.moduleParts = parts;
341
+ // An incomplete listing must not delete stubs for tools that may only be unreachable.
342
+ opts.pruneModules = missing.length === 0;
343
+ const { loadWorkflows, moduleWorkflowProviders } = await import("../src/lib/workflow-prompts.js");
344
+ const { listResolvableSiteConfigs } = await import("../src/lib/dispatch.js");
345
+ const workflows = loadWorkflows(moduleWorkflowProviders(listResolvableSiteConfigs()), {
346
+ tools: definitions,
347
+ taken: new Set(allDefinitions.map((def) => def.name.replace(/_/g, "-"))),
348
+ });
349
+ const builtInFiles = new Set(allDefinitions.map(commandFileName));
350
+ const moduleFiles = new Set(planModuleStubs(definitions, allDefinitions, parts).stubs.map((s) => s.file));
351
+ const planned = planWorkflowStubs(workflows, builtInFiles, moduleFiles);
352
+ for (const r of planned.refused) {
353
+ console.error(`[install-commands] WARNING: no workflow stub for ${r.name}: ${r.reason}${r.file ? ` (${r.file})` : ""}`);
354
+ }
355
+ opts.workflowStubs = planned.stubs;
356
+ }
172
357
  const results = install(opts);
173
358
  for (const r of results) {
174
- console.error(`[install-commands] wrote ${r.written.length} files to ${r.dir} (${r.client})`);
359
+ const extra = !opts.modules ? ""
360
+ : r.catalogued === undefined ? ` + ${r.moduleWritten.length} module stubs`
361
+ : ` (${r.catalogued} module tools in the catalog)`;
362
+ console.error(`[install-commands] wrote ${r.written.length} files${extra} to ${r.dir} (${r.client})`);
175
363
  }
176
364
  } catch (err) {
177
365
  console.error(`[install-commands] ${err.message}`);