drupal-mcp-connector 0.6.1

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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Input validation and sanitization utilities.
3
+ *
4
+ * All user-supplied values that reach a shell command, file path,
5
+ * or SQL query MUST pass through the appropriate validator here first.
6
+ *
7
+ * Validators throw with descriptive messages on failure — never silently
8
+ * coerce values in ways that could hide injection attempts.
9
+ */
10
+
11
+ import { SecurityError } from "./security.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Machine name validation (Drupal entity types, bundles, module names, roles)
15
+ // Valid: lowercase letters, digits, underscores. Must start with a letter.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const MACHINE_NAME_RE = /^[a-z][a-z0-9_]*$/;
19
+
20
+ /**
21
+ * Validate a Drupal machine name.
22
+ * @param {string} value - The value to validate.
23
+ * @param {string} fieldName - Human-readable name for error messages.
24
+ * @throws {Error} if the value is not a valid machine name.
25
+ */
26
+ export function validateMachineName(value, fieldName = "value") {
27
+ if (typeof value !== "string" || !value.length) {
28
+ throw new Error(`${fieldName} must be a non-empty string.`);
29
+ }
30
+ if (!MACHINE_NAME_RE.test(value)) {
31
+ throw new Error(
32
+ `${fieldName} "${value}" is not a valid Drupal machine name. ` +
33
+ "Must match /^[a-z][a-z0-9_]*$/ (lowercase, digits, underscores only)."
34
+ );
35
+ }
36
+ if (value.length > 128) {
37
+ throw new Error(`${fieldName} exceeds maximum length of 128 characters.`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // UUID validation (all JSON:API IDs are UUIDs)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
47
+
48
+ /**
49
+ * Validate a UUID (all Drupal JSON:API entity IDs are UUIDs).
50
+ * @param {string} value The value to validate.
51
+ * @param {string} [fieldName] Human-readable name for error messages.
52
+ * @returns {string} The validated value.
53
+ * @throws {Error} if the value is not a valid UUID.
54
+ */
55
+ export function validateUuid(value, fieldName = "id") {
56
+ if (typeof value !== "string" || !UUID_RE.test(value)) {
57
+ throw new Error(
58
+ `${fieldName} "${value}" is not a valid UUID. ` +
59
+ "Drupal JSON:API IDs follow the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx."
60
+ );
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // SQL query validation (read-only enforcement for Drush bridge)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const SAFE_SQL_PREFIXES = ["select ", "show ", "describe ", "explain ", "desc "];
70
+
71
+ // Patterns that indicate write operations even within SELECT contexts
72
+ const DANGEROUS_SQL_PATTERNS = [
73
+ /;\s*(insert|update|delete|drop|alter|create|truncate|replace|grant|revoke)\b/i,
74
+ /\binto\s+outfile\b/i,
75
+ /\bload_file\s*\(/i,
76
+ /\bsleep\s*\(/i, // time-based blind injection
77
+ /\bbenchmark\s*\(/i, // timing attack
78
+ /\bload\s+data\b/i,
79
+ ];
80
+
81
+ /**
82
+ * Validate that a SQL query is read-only.
83
+ * Checks both the query prefix AND secondary injection patterns.
84
+ * @param {string} query The SQL query to validate.
85
+ * @returns {string} The validated query.
86
+ * @throws {Error} if the query is empty or exceeds the length cap.
87
+ * @throws {SecurityError} if the query is not read-only or matches a dangerous pattern.
88
+ */
89
+ export function validateSqlQuery(query) {
90
+ if (typeof query !== "string" || !query.trim().length) {
91
+ throw new Error("SQL query must be a non-empty string.");
92
+ }
93
+
94
+ const normalised = query.trim().toLowerCase();
95
+
96
+ if (!SAFE_SQL_PREFIXES.some((prefix) => normalised.startsWith(prefix))) {
97
+ throw new SecurityError(
98
+ "drupal_drush_sql_query only permits SELECT, SHOW, DESCRIBE, and EXPLAIN statements. " +
99
+ "Use the JSON:API tools for write operations."
100
+ );
101
+ }
102
+
103
+ for (const pattern of DANGEROUS_SQL_PATTERNS) {
104
+ if (pattern.test(query)) {
105
+ throw new SecurityError(
106
+ `SQL query contains a disallowed pattern: ${pattern}. ` +
107
+ "Only pure read queries are permitted."
108
+ );
109
+ }
110
+ }
111
+
112
+ if (query.length > 4096) {
113
+ throw new Error("SQL query exceeds maximum length of 4096 characters.");
114
+ }
115
+
116
+ return query;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // SSH argument escaping
121
+ // Prevents command injection when building SSH commands programmatically.
122
+ // Uses single-quote escaping: wrap value in single quotes, escape internal
123
+ // single quotes as '\'' (end quote, escaped quote, start quote).
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Escape a value for safe inclusion as a shell argument.
128
+ * Output is wrapped in single quotes with internal single quotes escaped.
129
+ * @param {string} value
130
+ * @returns {string} Shell-safe single-quoted argument.
131
+ */
132
+ export function sanitizeSshArg(value) {
133
+ if (typeof value !== "string") {
134
+ throw new TypeError(`sanitizeSshArg expected a string, got ${typeof value}`);
135
+ }
136
+ // Single-quote escaping: 'value' with internal ' → '\''
137
+ return `'${value.replace(/'/g, "'\\''")}'`;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // URL / baseUrl validation
142
+ // Enforces HTTPS for non-localhost targets.
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const LOCALHOST_PATTERNS = ["localhost", "127.0.0.1", "::1", ".lndo.site", ".ddev.site", ".local"];
146
+
147
+ /**
148
+ * Validate a Drupal site baseUrl.
149
+ * Warns (but does not throw) for localhost HTTP; rejects non-localhost HTTP.
150
+ * @param {string} url The baseUrl to validate.
151
+ * @param {string} [siteName] Site name for error/warning messages.
152
+ * @returns {string} The url with any trailing slash stripped.
153
+ * @throws {Error} if the value is not an http(s) URL.
154
+ * @throws {SecurityError} if a non-localhost URL uses plain HTTP.
155
+ */
156
+ export function validateBaseUrl(url, siteName = "site") {
157
+ if (typeof url !== "string" || !url.startsWith("http")) {
158
+ throw new Error(`Site "${siteName}": baseUrl must be a valid HTTP/HTTPS URL.`);
159
+ }
160
+
161
+ const isLocalhost = LOCALHOST_PATTERNS.some((p) => url.includes(p));
162
+ const isHttps = url.startsWith("https://");
163
+
164
+ if (!isHttps && !isLocalhost) {
165
+ throw new SecurityError(
166
+ `Site "${siteName}": baseUrl "${url}" uses plain HTTP on a non-localhost host. ` +
167
+ "All non-local Drupal connections must use HTTPS. " +
168
+ "Update baseUrl to https:// to proceed."
169
+ );
170
+ }
171
+
172
+ if (!isHttps && isLocalhost) {
173
+ console.warn(
174
+ `[drupal-mcp-connector] Warning: site "${siteName}" is using plain HTTP (${url}). ` +
175
+ "This is only acceptable for local development."
176
+ );
177
+ }
178
+
179
+ // Strip trailing slash for consistency
180
+ return url.replace(/\/$/, "");
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Pagination limit guard
185
+ // Prevents accidentally requesting thousands of records in a single call.
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const MAX_PAGE_LIMIT = 200;
189
+
190
+ /**
191
+ * Clamp a page limit to a safe maximum, falling back to a default for
192
+ * non-numeric or out-of-range input.
193
+ * @param {*} value Requested limit (any type; coerced to Number).
194
+ * @param {number} [defaultVal] Value returned for invalid/<1 input.
195
+ * @returns {number} A limit in the range [1, MAX_PAGE_LIMIT].
196
+ */
197
+ export function clampLimit(value, defaultVal = 20) {
198
+ const n = Number(value);
199
+ if (!Number.isFinite(n) || n < 1) return defaultVal;
200
+ return Math.min(n, MAX_PAGE_LIMIT);
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Field name sanitization (prevent crafted field names in JSON:API filters)
205
+ // ---------------------------------------------------------------------------
206
+
207
+ const FIELD_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
208
+
209
+ /**
210
+ * Validate a Drupal field machine name used in JSON:API filter parameters.
211
+ * Allows dotted paths (e.g. "field.subfield") for nested references.
212
+ * @param {string} value The field name to validate.
213
+ * @param {string} [fieldName] Human-readable name for error messages.
214
+ * @returns {string} The validated value.
215
+ * @throws {Error} if the value is not a valid field name.
216
+ */
217
+ export function validateFieldName(value, fieldName = "field") {
218
+ if (typeof value !== "string" || !FIELD_NAME_RE.test(value)) {
219
+ throw new Error(
220
+ `${fieldName} "${value}" is not a valid field name. ` +
221
+ "Expected format: field_example or field.subfield"
222
+ );
223
+ }
224
+ return value;
225
+ }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Tool group: Drush bridge.
3
+ *
4
+ * Execute a curated set of Drush commands on a remote Drupal server over SSH.
5
+ *
6
+ * Security model:
7
+ * 1. SSH key auth only — password-based SSH is deliberately unsupported.
8
+ * 2. All command arguments are validated before being passed to SSH.
9
+ * 3. Module names are validated as machine names (a-z, 0-9, _) only.
10
+ * 4. SQL tool enforces SELECT-only allowlist — no DDL/DML permitted.
11
+ * 5. Key path is validated against path traversal.
12
+ * 6. All operations are logged to stderr with site name and command.
13
+ * 7. Write operations assert non-readOnly via the security layer.
14
+ * 8. No shell string interpolation of user-supplied values.
15
+ *
16
+ * Requires per-site "drushSsh" config block — tools fail gracefully if absent.
17
+ */
18
+
19
+ import { Client } from "ssh2";
20
+ import { readFileSync } from "fs";
21
+ import { homedir } from "os";
22
+ import { join, resolve, normalize } from "path";
23
+ import { getSiteConfig } from "../lib/config.js";
24
+ import { resolveSecurityConfig, assertNotReadOnly } from "../lib/security.js";
25
+ import { validateMachineName, validateSqlQuery, sanitizeSshArg } from "../lib/validate.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // SSH configuration helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function getDrushConfig(site) {
32
+ if (!site.drushSsh) {
33
+ throw new Error(
34
+ `Drush bridge not configured for site "${site._name}". ` +
35
+ "Add a \"drushSsh\" block to this site's config. See docs/getting-started.md."
36
+ );
37
+ }
38
+ return site.drushSsh;
39
+ }
40
+
41
+ /**
42
+ * Resolve and validate the SSH key path. Prevents path traversal.
43
+ */
44
+ function resolveKeyPath(rawPath) {
45
+ const expanded = rawPath.startsWith("~")
46
+ ? join(homedir(), rawPath.slice(1))
47
+ : rawPath;
48
+ const resolved = resolve(expanded);
49
+ // Ensure the key is within home directory or /etc/ssh (reasonable locations)
50
+ const homeDir = homedir();
51
+ const allowed = [homeDir, "/etc/ssh", "/run/secrets"];
52
+ const permitted = allowed.some((dir) => resolved.startsWith(dir + "/") || resolved === dir);
53
+ if (!permitted) {
54
+ throw new Error(
55
+ `SSH key path "${resolved}" is outside allowed directories. ` +
56
+ "Keys must be under your home directory or /etc/ssh."
57
+ );
58
+ }
59
+ return resolved;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Core SSH executor
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Run a Drush command via SSH.
68
+ *
69
+ * @param {object} site - resolved site config
70
+ * @param {string[]} drushArgs - Drush subcommand + flags as an array
71
+ * @param {number} timeoutMs - max execution time
72
+ * @returns {Promise<string>} - stdout trimmed
73
+ *
74
+ * Security: args are individually shell-escaped via sanitizeSshArg().
75
+ * No raw user input is ever interpolated directly into the command string.
76
+ */
77
+ function sshDrush(site, drushArgs, timeoutMs = 30000) {
78
+ const sshCfg = getDrushConfig(site);
79
+ const keyPath = resolveKeyPath(sshCfg.keyPath);
80
+
81
+ // Validate drupalRoot is an absolute path with no traversal
82
+ const drupalRoot = normalize(sshCfg.drupalRoot);
83
+ if (!drupalRoot.startsWith("/")) {
84
+ throw new Error("drushSsh.drupalRoot must be an absolute path.");
85
+ }
86
+
87
+ // Build the command: cd to Drupal root, then run vendor drush with escaped args
88
+ const escapedArgs = drushArgs.map(sanitizeSshArg).join(" ");
89
+ const drushBin = `${drupalRoot}/vendor/bin/drush`;
90
+ const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} --yes`;
91
+
92
+ console.error(`[drush-bridge] ${site._name}: drush ${drushArgs.join(" ")}`);
93
+
94
+ return new Promise((resolve, reject) => {
95
+ const conn = new Client();
96
+ let stdout = "";
97
+ let stderr = "";
98
+ let settled = false;
99
+
100
+ function settle(fn, val) {
101
+ if (settled) return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ conn.end();
105
+ fn(val);
106
+ }
107
+
108
+ const timer = setTimeout(
109
+ () => settle(reject, new Error(`Drush timed out after ${timeoutMs / 1000}s`)),
110
+ timeoutMs
111
+ );
112
+
113
+ conn.on("ready", () => {
114
+ conn.exec(command, (err, stream) => {
115
+ if (err) { settle(reject, err); return; }
116
+
117
+ stream.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
118
+ stream.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
119
+
120
+ stream.on("close", (code) => {
121
+ if (code !== 0) {
122
+ settle(reject, new Error(
123
+ `Drush exited ${code}: ${(stderr.trim() || stdout.trim()).slice(0, 500)}`
124
+ ));
125
+ } else {
126
+ settle(resolve, stdout.trim());
127
+ }
128
+ });
129
+ });
130
+ });
131
+
132
+ conn.on("error", (err) => settle(reject, err));
133
+
134
+ conn.connect({
135
+ host: sshCfg.host,
136
+ port: Number(sshCfg.port) || 22,
137
+ username: sshCfg.user,
138
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- SSH private-key path comes from operator-controlled site config
139
+ privateKey: readFileSync(keyPath),
140
+ // Harden: never forward the local SSH agent to the remote host.
141
+ agentForward: false,
142
+ readyTimeout: timeoutMs,
143
+ });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Parse Drush JSON output; fall back to raw string if not JSON.
149
+ */
150
+ function parseDrush(raw) {
151
+ if (!raw) return null;
152
+ try { return JSON.parse(raw); }
153
+ catch { return raw; }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Tool implementations
158
+ // ---------------------------------------------------------------------------
159
+
160
+ // Max watchdog rows fetchable in one call (mirrors the tool input maximum).
161
+ const WATCHDOG_MAX_COUNT = 200;
162
+ // Drupal log severity levels accepted by `drush watchdog:show --severity`.
163
+ const VALID_SEVERITIES = ["emergency", "alert", "critical", "error", "warning", "notice", "info", "debug"];
164
+
165
+ /**
166
+ * Rebuild all Drupal caches (`drush cache:rebuild`).
167
+ * @param {object} args - { site? }.
168
+ * @returns {Promise<{success: boolean, message: string}>}
169
+ * @throws {SecurityError} If the site is read-only.
170
+ */
171
+ async function cacheRebuild({ site: siteName }) {
172
+ const site = getSiteConfig(siteName);
173
+ assertNotReadOnly(resolveSecurityConfig(site), "drush cache:rebuild");
174
+ await sshDrush(site, ["cache:rebuild"]);
175
+ return { success: true, message: "Cache rebuild complete." };
176
+ }
177
+
178
+ /**
179
+ * Run Drupal cron (`drush cron`).
180
+ * @param {object} args - { site? }.
181
+ * @returns {Promise<{success: boolean, output: *}>}
182
+ * @throws {SecurityError} If the site is read-only.
183
+ */
184
+ async function runCron({ site: siteName }) {
185
+ const site = getSiteConfig(siteName);
186
+ assertNotReadOnly(resolveSecurityConfig(site), "drush cron");
187
+ const out = await sshDrush(site, ["cron"]);
188
+ return { success: true, output: parseDrush(out) };
189
+ }
190
+
191
+ /**
192
+ * Report site status (`drush status`): version, DB, paths, active config.
193
+ * @param {object} args - { site? }.
194
+ * @returns {Promise<object>} Parsed status, or { raw } if not JSON.
195
+ */
196
+ async function siteStatus({ site: siteName }) {
197
+ const site = getSiteConfig(siteName);
198
+ const out = await sshDrush(site, ["status", "--format=json"]);
199
+ const data = parseDrush(out);
200
+ return typeof data === "object" ? data : { raw: data };
201
+ }
202
+
203
+ /**
204
+ * Check whether active config matches the sync directory (`drush config:status`).
205
+ * @param {object} args - { site? }.
206
+ * @returns {Promise<object>} { status: "in_sync" } or { status: "out_of_sync", changes }.
207
+ */
208
+ async function configStatus({ site: siteName }) {
209
+ const site = getSiteConfig(siteName);
210
+ const out = await sshDrush(site, ["config:status", "--format=json"]);
211
+ const data = parseDrush(out);
212
+ if (!data || (Array.isArray(data) && !data.length)) {
213
+ return { status: "in_sync", message: "Configuration is in sync." };
214
+ }
215
+ return { status: "out_of_sync", changes: data };
216
+ }
217
+
218
+ /**
219
+ * Export active config to the sync directory (`drush config:export`).
220
+ * @param {object} args - { site? }.
221
+ * @returns {Promise<{success: boolean, message: string}>}
222
+ * @throws {SecurityError} If the site is read-only.
223
+ */
224
+ async function configExport({ site: siteName }) {
225
+ const site = getSiteConfig(siteName);
226
+ assertNotReadOnly(resolveSecurityConfig(site), "drush config:export");
227
+ await sshDrush(site, ["config:export"]);
228
+ return { success: true, message: "Configuration exported to sync directory." };
229
+ }
230
+
231
+ /**
232
+ * Import config from the sync directory into the DB (`drush config:import`).
233
+ * @param {object} args - { site? }.
234
+ * @returns {Promise<{success: boolean, message: string}>}
235
+ * @throws {SecurityError} If the site is read-only.
236
+ */
237
+ async function configImport({ site: siteName }) {
238
+ const site = getSiteConfig(siteName);
239
+ assertNotReadOnly(resolveSecurityConfig(site), "drush config:import");
240
+ await sshDrush(site, ["config:import"]);
241
+ return { success: true, message: "Configuration imported from sync directory." };
242
+ }
243
+
244
+ /**
245
+ * Run pending database updates (`drush updatedb`).
246
+ * @param {object} args - { site? }.
247
+ * @returns {Promise<{success: boolean, updates: *}>}
248
+ * @throws {SecurityError} If the site is read-only.
249
+ */
250
+ async function updateDb({ site: siteName }) {
251
+ const site = getSiteConfig(siteName);
252
+ assertNotReadOnly(resolveSecurityConfig(site), "drush updatedb");
253
+ const out = await sshDrush(site, ["updatedb", "--format=json"]);
254
+ return { success: true, updates: parseDrush(out) };
255
+ }
256
+
257
+ /**
258
+ * List modules (`drush pm:list`), optionally filtered by status.
259
+ * @param {object} args - { site?, status? } where status is "enabled"|"disabled".
260
+ * @returns {Promise<{modules: *}>}
261
+ */
262
+ async function listModules({ site: siteName, status }) {
263
+ const site = getSiteConfig(siteName);
264
+ const args = ["pm:list", "--format=json"];
265
+ if (status === "enabled") args.push("--status=enabled");
266
+ if (status === "disabled") args.push("--status=disabled");
267
+ const out = await sshDrush(site, args);
268
+ return { modules: parseDrush(out) };
269
+ }
270
+
271
+ /**
272
+ * List modules with known security advisories (`drush pm:security`).
273
+ * @param {object} args - { site? }.
274
+ * @returns {Promise<object>} { status: "secure" } or { status: "updates_available", modules }.
275
+ */
276
+ async function securityUpdates({ site: siteName }) {
277
+ const site = getSiteConfig(siteName);
278
+ const out = await sshDrush(site, ["pm:security", "--format=json"]);
279
+ const data = parseDrush(out);
280
+ if (!data || (typeof data === "object" && !Object.keys(data).length)) {
281
+ return { status: "secure", message: "No known security updates." };
282
+ }
283
+ return { status: "updates_available", modules: data };
284
+ }
285
+
286
+ /**
287
+ * Enable a module (`drush pm:enable`). The name is validated as a machine name
288
+ * before reaching SSH, since it is interpolated into the command.
289
+ * @param {object} args - { site?, moduleName }.
290
+ * @returns {Promise<{success: boolean, message: string}>}
291
+ * @throws {SecurityError} If the site is read-only.
292
+ * @throws {Error} If moduleName is not a valid machine name.
293
+ */
294
+ async function enableModule({ site: siteName, moduleName }) {
295
+ const site = getSiteConfig(siteName);
296
+ assertNotReadOnly(resolveSecurityConfig(site), `pm:enable ${moduleName}`);
297
+ validateMachineName(moduleName, "moduleName"); // throws if invalid
298
+ await sshDrush(site, ["pm:enable", moduleName]);
299
+ return { success: true, message: `Module "${moduleName}" enabled.` };
300
+ }
301
+
302
+ /**
303
+ * Uninstall a module (`drush pm:uninstall`). Name is validated as a machine name.
304
+ * @param {object} args - { site?, moduleName }.
305
+ * @returns {Promise<{success: boolean, message: string}>}
306
+ * @throws {SecurityError} If the site is read-only.
307
+ * @throws {Error} If moduleName is not a valid machine name.
308
+ */
309
+ async function disableModule({ site: siteName, moduleName }) {
310
+ const site = getSiteConfig(siteName);
311
+ assertNotReadOnly(resolveSecurityConfig(site), `pm:uninstall ${moduleName}`);
312
+ validateMachineName(moduleName, "moduleName");
313
+ await sshDrush(site, ["pm:uninstall", moduleName]);
314
+ return { success: true, message: `Module "${moduleName}" uninstalled.` };
315
+ }
316
+
317
+ /**
318
+ * List users via Drush (`drush user:list`), filtered by status and/or role.
319
+ * Drush may return an object keyed by uid; it is normalized to an array and
320
+ * sliced to `limit`.
321
+ * @param {object} args - { site?, status?, role?, limit? }.
322
+ * @returns {Promise<{users: object[]}>}
323
+ * @throws {Error} If role is supplied and not a valid machine name.
324
+ */
325
+ async function drushUserList({ site: siteName, status, role, limit = 20 }) {
326
+ const site = getSiteConfig(siteName);
327
+ if (role) validateMachineName(role, "role");
328
+ const args = ["user:list", "--format=json"];
329
+ if (status === "active") args.push("--status=1");
330
+ if (status === "blocked") args.push("--status=0");
331
+ if (role) args.push(`--roles=${role}`);
332
+ const out = await sshDrush(site, args);
333
+ const data = parseDrush(out);
334
+ const users = (typeof data === "object" && !Array.isArray(data))
335
+ ? Object.values(data) : (data ?? []);
336
+ return { users: users.slice(0, limit) };
337
+ }
338
+
339
+ /**
340
+ * Create a user (`drush user:create`) and assign roles via follow-up
341
+ * `user:role:add` calls. Each role is validated as a machine name first.
342
+ * @param {object} args - { site?, name, mail, password, roles? }.
343
+ * @returns {Promise<{success: boolean, message: string}>}
344
+ * @throws {SecurityError} If the site is read-only.
345
+ * @throws {Error} If any role is not a valid machine name.
346
+ */
347
+ async function drushCreateUser({ site: siteName, name, mail, password, roles = [] }) {
348
+ const site = getSiteConfig(siteName);
349
+ assertNotReadOnly(resolveSecurityConfig(site), "user:create");
350
+ // Validate roles are machine names
351
+ for (const role of roles) validateMachineName(role, "role");
352
+ await sshDrush(site, ["user:create", name, `--mail=${mail}`, `--password=${password}`]);
353
+ for (const role of roles) {
354
+ await sshDrush(site, ["user:role:add", role, name]);
355
+ }
356
+ return { success: true, message: `User "${name}" created.` };
357
+ }
358
+
359
+ /**
360
+ * Run a read-only SQL query (`drush sql:query`). The query is validated against
361
+ * a SELECT-only allowlist before execution.
362
+ * @param {object} args - { site?, query }.
363
+ * @returns {Promise<{rows: *}>}
364
+ * @throws {SecurityError} If the query is not read-only.
365
+ */
366
+ async function sqlQuery({ site: siteName, query }) {
367
+ const site = getSiteConfig(siteName);
368
+ // Throws SecurityError if query is not read-only
369
+ validateSqlQuery(query);
370
+ const out = await sshDrush(site, ["sql:query", query]);
371
+ return { rows: parseDrush(out) };
372
+ }
373
+
374
+ /**
375
+ * Fetch recent watchdog/dblog entries (`drush watchdog:show`), filtered by type
376
+ * and/or severity. Count is clamped to WATCHDOG_MAX_COUNT.
377
+ * @param {object} args - { site?, type?, severity?, limit? }.
378
+ * @returns {Promise<{entries: object[]}>}
379
+ * @throws {Error} If type is invalid, or severity is not a recognized level.
380
+ */
381
+ async function watchdog({ site: siteName, type, severity, limit = 20 }) {
382
+ const site = getSiteConfig(siteName);
383
+ if (type) validateMachineName(type, "type");
384
+ if (severity && !VALID_SEVERITIES.includes(severity)) {
385
+ throw new Error(`Invalid severity "${severity}". Must be one of: ${VALID_SEVERITIES.join(", ")}`);
386
+ }
387
+ const args = ["watchdog:show", "--format=json", `--count=${Math.min(Number(limit), WATCHDOG_MAX_COUNT)}`];
388
+ if (type) args.push(`--type=${type}`);
389
+ if (severity) args.push(`--severity=${severity}`);
390
+ const out = await sshDrush(site, args);
391
+ const data = parseDrush(out);
392
+ const entries = (typeof data === "object" && !Array.isArray(data))
393
+ ? Object.values(data) : (data ?? []);
394
+ return { entries };
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Definitions & handlers
399
+ // ---------------------------------------------------------------------------
400
+
401
+ export const definitions = [
402
+ { name: "drupal_drush_cache_rebuild", description: "Run `drush cache:rebuild` via SSH. Clears all Drupal caches. Requires drushSsh config and write access.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
403
+ { name: "drupal_drush_cron", description: "Run Drupal cron via `drush cron`.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
404
+ { name: "drupal_drush_status", description: "Get Drupal site status via `drush status` — version, DB, file paths, active config.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
405
+ { name: "drupal_drush_config_status", description: "Check if active config is in sync with the sync directory. Returns out-of-sync items if any.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
406
+ { name: "drupal_drush_config_export", description: "Export active configuration to the sync directory. Requires write access.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
407
+ { name: "drupal_drush_config_import", description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
408
+ { name: "drupal_drush_updatedb", description: "Run pending database updates via `drush updatedb`. Always run after deploying module updates.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
409
+ { name: "drupal_drush_security_updates", description: "List modules with known security advisories via `drush pm:security`.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
410
+ {
411
+ name: "drupal_drush_module_list",
412
+ description: "List Drupal modules. Filter by enabled or disabled status.",
413
+ inputSchema: { type: "object", properties: { site: { type: "string" }, status: { type: "string", enum: ["enabled", "disabled"] } } },
414
+ },
415
+ {
416
+ name: "drupal_drush_module_enable",
417
+ description: "Enable a Drupal module. Module name validated as machine name. Requires write access.",
418
+ inputSchema: { type: "object", required: ["moduleName"], properties: { site: { type: "string" }, moduleName: { type: "string", pattern: "^[a-z][a-z0-9_]*$" } } },
419
+ },
420
+ {
421
+ name: "drupal_drush_module_disable",
422
+ description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user.",
423
+ inputSchema: { type: "object", required: ["moduleName"], properties: { site: { type: "string" }, moduleName: { type: "string", pattern: "^[a-z][a-z0-9_]*$" } } },
424
+ },
425
+ {
426
+ name: "drupal_drush_user_list",
427
+ description: "List Drupal users via Drush. Filter by active/blocked status or role.",
428
+ inputSchema: { type: "object", properties: { site: { type: "string" }, status: { type: "string", enum: ["active", "blocked"] }, role: { type: "string" }, limit: { type: "number", default: 20 } } },
429
+ },
430
+ {
431
+ name: "drupal_drush_user_create",
432
+ description: "Create a Drupal user and optionally assign roles. Requires write access.",
433
+ inputSchema: { type: "object", required: ["name", "mail", "password"], properties: { site: { type: "string" }, name: { type: "string" }, mail: { type: "string", format: "email" }, password: { type: "string", minLength: 12 }, roles: { type: "array", items: { type: "string" } } } },
434
+ },
435
+ {
436
+ name: "drupal_drush_sql_query",
437
+ description: "Run a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer.",
438
+ inputSchema: { type: "object", required: ["query"], properties: { site: { type: "string" }, query: { type: "string" } } },
439
+ },
440
+ {
441
+ name: "drupal_drush_watchdog",
442
+ description: "Fetch recent Drupal watchdog/dblog entries. Filter by type or severity level.",
443
+ inputSchema: { type: "object", properties: { site: { type: "string" }, type: { type: "string" }, severity: { type: "string", enum: ["emergency","alert","critical","error","warning","notice","info","debug"] }, limit: { type: "number", default: 20, maximum: 200 } } },
444
+ },
445
+ ];
446
+
447
+ export const handlers = {
448
+ drupal_drush_cache_rebuild: cacheRebuild,
449
+ drupal_drush_cron: runCron,
450
+ drupal_drush_status: siteStatus,
451
+ drupal_drush_config_status: configStatus,
452
+ drupal_drush_config_export: configExport,
453
+ drupal_drush_config_import: configImport,
454
+ drupal_drush_updatedb: updateDb,
455
+ drupal_drush_module_list: listModules,
456
+ drupal_drush_security_updates: securityUpdates,
457
+ drupal_drush_module_enable: enableModule,
458
+ drupal_drush_module_disable: disableModule,
459
+ drupal_drush_user_list: drushUserList,
460
+ drupal_drush_user_create: drushCreateUser,
461
+ drupal_drush_sql_query: sqlQuery,
462
+ drupal_drush_watchdog: watchdog,
463
+ };