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.
- package/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +264 -0
- package/README.md +9 -1
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +63 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +83 -5
- package/scripts/install-commands.js +199 -11
- package/src/index.js +34 -120
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/governance.js +9 -2
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/oauth.js +23 -9
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/principal.js +14 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +233 -0
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +202 -34
- package/src/lib/tool-prompts.js +160 -6
- package/src/lib/verify.js +161 -58
- package/src/lib/workflow-prompts.js +348 -0
- package/src/lib/workflows/builtin.js +134 -0
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/moderation.js +1 -1
- package/src/tools/nodes.js +16 -6
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
package/bin/drupal-mcp-verify.js
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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 {
|
|
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 =
|
|
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
|
|
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}\`.`, "",
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|