drupal-mcp-connector 2.25.0 → 2.26.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.
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Operator install wizard (DEV-758).
3
+ *
4
+ * Collects transport, host, auth, and client id; prints Cursor + Claude Code
5
+ * mcpServers snippets; optionally writes project files; then runs whoami +
6
+ * contract_ready (or a clear skip when the host is unreachable).
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import process from "node:process";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { stdin as input, stdout as output } from "node:process";
15
+ import fetch from "node-fetch";
16
+ import { CLIENT_VERSION } from "./config.js";
17
+ import { formatTransportPresetHelp, isReservedDocumentationHost, resolveTransportPreset } from "./transports.js";
18
+ import { detectVersionSkew, extractReportedVersions, versionSkewCheck } from "./version-skew.js";
19
+
20
+ /** Short probe timeout so an unreachable host cannot hang the wizard. */
21
+ export const WIZARD_PROBE_TIMEOUT_MS = 4_000;
22
+
23
+ const USAGE = `drupal-mcp-connector wizard|init
24
+
25
+ Interactive (default) or --yes/--non-interactive install helper.
26
+ Prints Cursor (.cursor/mcp.json) and Claude Code (mcpServers) snippets.
27
+ Writes files only with --write (never clobbers without confirm unless --yes).
28
+
29
+ Remote HTTPS is for a connector you already host on a private network
30
+ (Tailscale / local VPN). This package does not provide a public SaaS
31
+ remote endpoint.
32
+
33
+ Transport presets (DEV-763): local-stdio | tailscale | public-https
34
+ (public-https is gated/later — not a hosted SaaS URL).
35
+ ${formatTransportPresetHelp()}
36
+
37
+ --preset local-stdio|tailscale|public-https
38
+ Named transport preset (sets --transport)
39
+ --transport stdio|https MCP transport (default: stdio)
40
+ --host <url> Drupal base URL (stdio) or MCP URL (https)
41
+ --drupal-url <url> Drupal site for whoami/contract_ready (https)
42
+ --auth token|oauth Auth mode (default: token; public-https forces oauth)
43
+ --client-id <id> Agent client id (default: content-agent)
44
+ --clients cursor,claude Which snippets to emit (default: both)
45
+ --server-name <id> mcpServers key (default: drupal)
46
+ --site <name> Site name for whoami / grants hint
47
+ --grant-sites <a,b> auth.grants site allowlist hint
48
+ --token-env <name> Env var name for a sealed token
49
+ --write Write project (or --scope user) config files
50
+ --scope project|user Where --write lands (default: project)
51
+ --output <dir> Project root for writes (default: cwd)
52
+ --yes, --non-interactive No prompts; flags/env supply every answer
53
+ --skip-check Do not probe whoami / contract_ready
54
+ --json Machine-readable result on stdout
55
+ --help Show this message
56
+
57
+ After a green run: npx drupal-mcp-connector doctor, then the README
58
+ two-minute happy path (drupal_mcp_whoami → drupal_list_sites →
59
+ unpublished read / dryRun create).
60
+
61
+ Env (same meaning as the flags): MCP_WIZARD_TRANSPORT, MCP_WIZARD_HOST,
62
+ MCP_WIZARD_DRUPAL_URL, MCP_WIZARD_AUTH, MCP_WIZARD_CLIENT_ID,
63
+ MCP_WIZARD_CLIENTS, MCP_WIZARD_SERVER_NAME, MCP_WIZARD_SITE,
64
+ MCP_WIZARD_GRANT_SITES, MCP_WIZARD_TOKEN_ENV, MCP_WIZARD_WRITE,
65
+ MCP_WIZARD_SCOPE, MCP_WIZARD_OUTPUT, MCP_WIZARD_YES,
66
+ MCP_WIZARD_SKIP_CHECK, MCP_WIZARD_PRESET.
67
+ `;
68
+
69
+ const DEFAULTS = {
70
+ transport: "stdio",
71
+ host: "https://drupal.example.com",
72
+ auth: "token",
73
+ clientId: "content-agent",
74
+ clients: ["cursor", "claude"],
75
+ serverName: "drupal",
76
+ site: "production",
77
+ grantSites: ["production"],
78
+ tokenEnv: "DRUPAL_API_TOKEN",
79
+ write: false,
80
+ scope: "project",
81
+ };
82
+
83
+ const UNREACHABLE_CODES = new Set([
84
+ "ENOTFOUND",
85
+ "EAI_AGAIN",
86
+ "ECONNREFUSED",
87
+ "ECONNRESET",
88
+ "ETIMEDOUT",
89
+ "ENETUNREACH",
90
+ "EHOSTUNREACH",
91
+ "EPIPE",
92
+ ]);
93
+
94
+ /**
95
+ * Parses `--flag`, `--key value` and `--key=value`.
96
+ * @param {string[]} argv
97
+ * @returns {Record<string, string|boolean|string[]>}
98
+ */
99
+ export function parseWizardArgs(argv) {
100
+ const args = { _: [] };
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const arg = argv[i];
103
+ if (!arg.startsWith("--")) {
104
+ args._.push(arg);
105
+ continue;
106
+ }
107
+ const [key, inline] = arg.slice(2).split("=");
108
+ if (inline !== undefined) {
109
+ args[key] = inline;
110
+ } else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
111
+ args[key] = argv[++i];
112
+ } else {
113
+ args[key] = true;
114
+ }
115
+ }
116
+ return args;
117
+ }
118
+
119
+ /**
120
+ * CLI entry used by `bin/drupal-mcp-wizard.js` and `src/index.js`.
121
+ * @param {string[]} argv
122
+ * @param {{fetchImpl?: typeof fetch, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, cwd?: string, now?: () => number}} [options]
123
+ * @returns {Promise<number>} Process exit code.
124
+ */
125
+ export async function runWizardCli(argv, options = {}) {
126
+ const args = parseWizardArgs(argv);
127
+ if (args.help) {
128
+ process.stdout.write(USAGE);
129
+ return 0;
130
+ }
131
+
132
+ let answers;
133
+ try {
134
+ answers = await resolveWizardAnswers(args, options);
135
+ } catch (err) {
136
+ process.stderr.write(`wizard: ${err?.message ?? err}\n`);
137
+ return 1;
138
+ }
139
+
140
+ const configs = buildClientConfigs(answers);
141
+ const selected = selectedClients(answers);
142
+ const writes = writeClientConfigs(answers, configs, selected);
143
+ const checks = args["skip-check"] || answers.skipCheck
144
+ ? skippedChecks("skipped by --skip-check")
145
+ : await runReadinessChecks(answers, options);
146
+
147
+ const result = { answers: publicAnswers(answers), configs, writes, checks };
148
+ if (args.json) {
149
+ process.stdout.write(`${JSON.stringify(jsonResult(result, selected), null, 2)}\n`);
150
+ process.stderr.write(`${formatWizardReport(result, selected)}\n`);
151
+ } else {
152
+ process.stdout.write(`${formatWizardReport(result, selected)}\n`);
153
+ }
154
+ return 0;
155
+ }
156
+
157
+ /**
158
+ * Resolve answers from flags, env, defaults; prompt when interactive.
159
+ * @param {Record<string, string|boolean|string[]>} args
160
+ * @param {{stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, cwd?: string}} [options]
161
+ * @returns {Promise<object>}
162
+ */
163
+ export async function resolveWizardAnswers(args, options = {}) {
164
+ const yes = Boolean(args.yes || args["non-interactive"] || envTruthy("MCP_WIZARD_YES"));
165
+ const cwd = options.cwd || process.cwd();
166
+ const outputDir = String(args.output || process.env.MCP_WIZARD_OUTPUT || cwd);
167
+
168
+ let writeConfirmedInteractive = false;
169
+ const asked = {
170
+ preset: pick("preset", args, "MCP_WIZARD_PRESET", ""),
171
+ transport: pick("transport", args, "MCP_WIZARD_TRANSPORT", DEFAULTS.transport),
172
+ host: pick("host", args, "MCP_WIZARD_HOST", DEFAULTS.host),
173
+ drupalUrl: pick("drupal-url", args, "MCP_WIZARD_DRUPAL_URL", ""),
174
+ auth: pick("auth", args, "MCP_WIZARD_AUTH", DEFAULTS.auth),
175
+ clientId: pick("client-id", args, "MCP_WIZARD_CLIENT_ID", DEFAULTS.clientId),
176
+ clients: pick("clients", args, "MCP_WIZARD_CLIENTS", DEFAULTS.clients.join(",")),
177
+ serverName: pick("server-name", args, "MCP_WIZARD_SERVER_NAME", DEFAULTS.serverName),
178
+ site: pick("site", args, "MCP_WIZARD_SITE", DEFAULTS.site),
179
+ grantSites: pick("grant-sites", args, "MCP_WIZARD_GRANT_SITES", DEFAULTS.grantSites.join(",")),
180
+ tokenEnv: pick("token-env", args, "MCP_WIZARD_TOKEN_ENV", DEFAULTS.tokenEnv),
181
+ scope: pick("scope", args, "MCP_WIZARD_SCOPE", DEFAULTS.scope),
182
+ write: args.write === true || envTruthy("MCP_WIZARD_WRITE"),
183
+ skipCheck: args["skip-check"] === true || envTruthy("MCP_WIZARD_SKIP_CHECK"),
184
+ };
185
+
186
+ if (!yes && process.stdin.isTTY) {
187
+ const rl = createInterface({
188
+ input: options.stdin || input,
189
+ output: options.stdout || output,
190
+ });
191
+ try {
192
+ process.stdout.write(
193
+ `Drupal MCP Connector v${CLIENT_VERSION} — setup wizard\n\n` +
194
+ "Transport presets: Local stdio / Tailscale VPN / Public HTTPS.\n" +
195
+ "Remote HTTPS is Tailscale / local-VPN only. Public HTTPS is gated/later —\n" +
196
+ "this package does not ship a public SaaS remote endpoint.\n\n",
197
+ );
198
+ asked.preset = await ask(rl, "Preset [local-stdio/tailscale/public-https] (blank = use --transport)", asked.preset);
199
+ asked.transport = await ask(rl, "Transport [stdio/https]", asked.transport);
200
+ asked.host = await ask(
201
+ rl,
202
+ asked.transport === "https" ? "MCP host URL" : "Drupal site URL",
203
+ asked.host,
204
+ );
205
+ if (asked.transport === "https") {
206
+ asked.drupalUrl = await ask(rl, "Drupal site URL for readiness (optional)", asked.drupalUrl);
207
+ }
208
+ asked.auth = await ask(rl, "Auth mode [token/oauth]", asked.auth);
209
+ asked.clientId = await ask(rl, "Agent client id", asked.clientId);
210
+ asked.clients = await ask(rl, "Clients [cursor,claude]", asked.clients);
211
+ asked.grantSites = await ask(rl, "auth.grants site allowlist hint", asked.grantSites);
212
+ if (!asked.write) {
213
+ asked.write = /^y(es)?$/i.test(await ask(rl, "Write project MCP config files? [y/N]", "n"));
214
+ }
215
+ if (asked.write && !yes) {
216
+ writeConfirmedInteractive = /^y(es)?$/i.test(
217
+ await ask(rl, "Confirm write/merge of MCP config files? [y/N]", "n"),
218
+ );
219
+ if (!writeConfirmedInteractive) {
220
+ asked.write = false;
221
+ }
222
+ }
223
+ } finally {
224
+ rl.close();
225
+ }
226
+ }
227
+
228
+ let transportChoice = asked.transport;
229
+ let preset = null;
230
+ if (asked.preset) {
231
+ const resolved = resolveTransportPreset(asked.preset);
232
+ preset = resolved.preset;
233
+ transportChoice = resolved.transport;
234
+ if (args.transport && String(args.transport) !== resolved.transport) {
235
+ throw new Error(
236
+ `--preset ${preset.id} implies --transport ${resolved.transport} ` +
237
+ `(got --transport ${args.transport}).`,
238
+ );
239
+ }
240
+ }
241
+ const transport = normalizeChoice(transportChoice, ["stdio", "https"], "transport");
242
+ let auth = normalizeChoice(asked.auth, ["token", "oauth"], "auth");
243
+ if (preset?.id === "public-https") {
244
+ if (auth === "token" && args.auth && String(args.auth) === "token") {
245
+ throw new Error(
246
+ "--preset public-https requires --auth oauth (MCP_AUTH_TOKEN is loopback-only; inbound OAuth is required for network HTTPS).",
247
+ );
248
+ }
249
+ auth = "oauth";
250
+ }
251
+ if (transport === "stdio" && auth === "oauth") {
252
+ throw new Error(
253
+ "stdio + --auth oauth needs a site config oauth block (clientId / clientSecretEnv). " +
254
+ "Use --auth token for env-based sealed tokens with stdio, or add an oauth block to config/config.json after init.",
255
+ );
256
+ }
257
+ const scope = normalizeChoice(asked.scope, ["project", "user"], "scope");
258
+ const host = normalizeHttpUrl(asked.host, "host");
259
+ const drupalUrl = asked.drupalUrl
260
+ ? normalizeHttpUrl(asked.drupalUrl, "drupal-url")
261
+ : (transport === "stdio" ? host : "");
262
+ const mcpUrl = transport === "https" ? ensureMcpPath(host) : "";
263
+ const clients = parseList(asked.clients).filter((name) => name === "cursor" || name === "claude");
264
+ if (!clients.length) {
265
+ throw new Error("--clients must include cursor and/or claude.");
266
+ }
267
+ const serverName = String(asked.serverName || DEFAULTS.serverName);
268
+ if (!/^[A-Za-z0-9._-]+$/.test(serverName)) {
269
+ throw new Error("--server-name must be [A-Za-z0-9._-]+.");
270
+ }
271
+
272
+ return {
273
+ preset: preset ? preset.id : (transport === "https" ? "tailscale" : "local-stdio"),
274
+ gated: Boolean(preset?.gated),
275
+ transport,
276
+ host,
277
+ drupalUrl,
278
+ mcpUrl,
279
+ auth,
280
+ clientId: String(asked.clientId || DEFAULTS.clientId),
281
+ clients,
282
+ serverName,
283
+ site: String(asked.site || DEFAULTS.site),
284
+ grantSites: parseList(asked.grantSites),
285
+ tokenEnv: String(asked.tokenEnv || DEFAULTS.tokenEnv),
286
+ write: Boolean(asked.write),
287
+ scope,
288
+ output: outputDir,
289
+ skipCheck: Boolean(asked.skipCheck),
290
+ yes: yes || writeConfirmedInteractive,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Build Cursor + Claude mcpServers documents from answers.
296
+ * @param {object} answers
297
+ * @returns {{cursor: object, claude: object}}
298
+ */
299
+ export function buildClientConfigs(answers) {
300
+ return {
301
+ cursor: {
302
+ label: "Cursor",
303
+ projectPath: join(answers.output, ".cursor", "mcp.json"),
304
+ userPath: join(homedir(), ".cursor", "mcp.json"),
305
+ config: wrapServer(answers.serverName, buildServerEntry(answers, "cursor")),
306
+ },
307
+ claude: {
308
+ label: "Claude Code",
309
+ projectPath: join(answers.output, ".mcp.json"),
310
+ userPath: join(homedir(), ".claude.json"),
311
+ config: wrapServer(answers.serverName, buildServerEntry(answers, "claude")),
312
+ },
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Probe whoami + contract_ready. Never throws.
318
+ * @param {object} answers
319
+ * @param {{fetchImpl?: typeof fetch}} [options]
320
+ * @returns {Promise<{whoami: object, contract_ready: object}>}
321
+ */
322
+ export async function runReadinessChecks(answers, options = {}) {
323
+ const fetchImpl = options.fetchImpl || fetch;
324
+ const target = answers.drupalUrl || answers.host;
325
+ if (!target) {
326
+ return skippedChecks("no host URL to probe");
327
+ }
328
+ if (isReservedDocumentationHost(target)) {
329
+ return skippedChecks(
330
+ `documentation-reserved host (${hostnameOf(target)}); not a live probe. ` +
331
+ "Point --host at a real Tailscale/VPN/local Drupal URL and re-run.",
332
+ );
333
+ }
334
+
335
+ const reach = await probeUrl(target, fetchImpl);
336
+ if (reach.unreachable) {
337
+ const skip = skipUnreachable(target, reach.detail);
338
+ return {
339
+ whoami: { ...skip, preview: localWhoami(answers) },
340
+ contract_ready: skip,
341
+ version_skew: versionSkewCheck(detectVersionSkew()),
342
+ };
343
+ }
344
+
345
+ const whoami = {
346
+ status: "skip",
347
+ title: "whoami",
348
+ detail: "Skipped — wizard does not load the generated client config; call drupal_mcp_whoami from the client after registering the snippet.",
349
+ next: "Register the snippet, restart the client, then call drupal_mcp_whoami against this host.",
350
+ report: localWhoami(answers),
351
+ };
352
+
353
+ const readinessUrl = `${stripSlash(target)}/drupal-mcp/readiness`;
354
+ const readiness = await probeUrl(readinessUrl, fetchImpl);
355
+ return {
356
+ whoami,
357
+ contract_ready: interpretReadiness(readiness, readinessUrl),
358
+ version_skew: versionSkewCheck(detectVersionSkew(
359
+ extractReportedVersions(readiness.body, null),
360
+ )),
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Write selected client configs when `--write` is set.
366
+ * @param {object} answers
367
+ * @param {{cursor: object, claude: object}} configs
368
+ * @param {string[]} selected
369
+ * @returns {object[]}
370
+ */
371
+ export function writeClientConfigs(answers, configs, selected) {
372
+ if (!answers.write) {
373
+ return selected.map((id) => ({
374
+ id,
375
+ path: writePath(answers, configs[id]),
376
+ status: "printed",
377
+ detail: "not written (pass --write to save; --yes to clobber/merge without a prompt)",
378
+ }));
379
+ }
380
+ if (!answers.yes) {
381
+ return selected.map((id) => ({
382
+ id,
383
+ path: writePath(answers, configs[id]),
384
+ status: "skipped",
385
+ detail: "refusing to write without confirm; re-run with --yes to merge/clobber",
386
+ }));
387
+ }
388
+
389
+ return selected.map((id) => {
390
+ const client = clientConfig(configs, id);
391
+ return writeOne(writePath(answers, client), serverEntry(client.config, answers.serverName), answers.serverName, id);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Human report: snippets, paths, checks, next fix.
397
+ * @param {{answers: object, configs: object, writes: object[], checks: object}} result
398
+ * @param {string[]} selected
399
+ * @returns {string}
400
+ */
401
+ export function formatWizardReport(result, selected) {
402
+ const { answers, configs, writes, checks } = result;
403
+ const lines = [
404
+ `Drupal MCP Connector wizard v${CLIENT_VERSION}`,
405
+ "",
406
+ "Remote HTTPS is Tailscale / local-VPN only — not a public SaaS path.",
407
+ `preset=${answers.preset || "local-stdio"}${answers.gated ? " [gated/later — not hosted SaaS]" : ""}`,
408
+ "",
409
+ `transport=${answers.transport} auth=${answers.auth} client_id=${answers.clientId}`,
410
+ `host=${answers.host}`,
411
+ ];
412
+ if (answers.mcpUrl) lines.push(`mcp_url=${answers.mcpUrl}`);
413
+ if (answers.drupalUrl) lines.push(`drupal_url=${answers.drupalUrl}`);
414
+ lines.push("", "Allowlist hints (already in this package):");
415
+ lines.push(` MCP_CLIENT_ID / X-MCP-Client log label: ${answers.clientId}`);
416
+ lines.push(" inbound HTTPS auth.grants (config.json) — client id → site names:");
417
+ lines.push(` ${JSON.stringify({ [answers.clientId]: answers.grantSites })}`);
418
+ lines.push(" MCP_CLIENT_ID is not an access grant. See config/config.example.json and docs/integration-contract.md.");
419
+ if (answers.auth === "oauth") {
420
+ lines.push(" OAuth client secret stays in clientSecretEnv / a secrets manager — never in mcp.json.");
421
+ }
422
+ if (answers.transport === "https" && answers.auth === "token") {
423
+ lines.push(" MCP_AUTH_TOKEN is loopback-only. Network-facing HTTPS needs inbound OAuth (auth.issuer + audience).");
424
+ }
425
+ lines.push("");
426
+
427
+ for (const id of selected) {
428
+ const client = clientConfig(configs, id);
429
+ const write = writes.find((row) => row.id === id);
430
+ lines.push(`--- ${client.label} ---`);
431
+ lines.push(`project: ${client.projectPath}`);
432
+ lines.push(`user: ${client.userPath}`);
433
+ if (write) lines.push(`write: [${write.status}] ${write.path}${write.detail ? ` — ${write.detail}` : ""}`);
434
+ lines.push(JSON.stringify(client.config, null, 2));
435
+ lines.push("");
436
+ }
437
+
438
+ if (selected.includes("claude") && answers.transport === "stdio") {
439
+ lines.push("Claude Code one-liner (stdio):");
440
+ lines.push(` claude mcp add ${answers.serverName} --scope user -e DRUPAL_BASE_URL=${answers.drupalUrl} -e MCP_CLIENT_ID=${answers.clientId} -- npx -y drupal-mcp-connector`);
441
+ lines.push("");
442
+ }
443
+
444
+ lines.push("Post-config checks:");
445
+ lines.push(formatCheck(checks.whoami));
446
+ lines.push(formatCheck(checks.contract_ready));
447
+ if (checks.version_skew) lines.push(formatCheck(checks.version_skew));
448
+ lines.push("");
449
+ lines.push("Next: register the snippet, restart the client, then call drupal_mcp_whoami.");
450
+ lines.push("Then: npx drupal-mcp-connector doctor · README Two-minute happy path");
451
+ lines.push(" (drupal_mcp_whoami → drupal_list_sites → unpublished drupal_list_nodes / dryRun create).");
452
+ lines.push("Full client notes: docs/mcp-clients.md · verify: npm run verify");
453
+ return lines.join("\n");
454
+ }
455
+
456
+ /**
457
+ * @param {string[]} argv
458
+ * @returns {string}
459
+ */
460
+ export function wizardUsage() {
461
+ return USAGE;
462
+ }
463
+
464
+ /**
465
+ * @param {object} answers
466
+ * @returns {string[]}
467
+ */
468
+ function selectedClients(answers) {
469
+ return answers.clients;
470
+ }
471
+
472
+ /**
473
+ * @param {object} answers
474
+ * @param {"cursor"|"claude"} flavor
475
+ * @returns {object}
476
+ */
477
+ function buildServerEntry(answers, flavor) {
478
+ if (answers.transport === "https") {
479
+ const entry = { url: answers.mcpUrl };
480
+ if (flavor === "claude") entry.type = "http";
481
+ if (answers.auth === "token") {
482
+ // Build placeholder without a `${` token in source (code-quality / non-template string).
483
+ const tokenRef = flavor === "cursor"
484
+ ? ("$" + "{env:MCP_AUTH_TOKEN}")
485
+ : ("$" + "{MCP_AUTH_TOKEN}");
486
+ entry.headers = { Authorization: `Bearer ${tokenRef}` };
487
+ }
488
+ return entry;
489
+ }
490
+
491
+ const env = {
492
+ DRUPAL_BASE_URL: answers.drupalUrl,
493
+ MCP_CLIENT_ID: answers.clientId,
494
+ };
495
+ if (answers.auth === "token") {
496
+ env[answers.tokenEnv] = flavor === "cursor"
497
+ ? `\${env:${answers.tokenEnv}}`
498
+ : `\${${answers.tokenEnv}}`;
499
+ }
500
+ return {
501
+ command: "npx",
502
+ args: ["-y", "drupal-mcp-connector"],
503
+ env,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * @param {string} serverName
509
+ * @param {object} entry
510
+ * @returns {{mcpServers: Record<string, object>}}
511
+ */
512
+ function wrapServer(serverName, entry) {
513
+ return { mcpServers: Object.fromEntries([[serverName, entry]]) };
514
+ }
515
+
516
+ /**
517
+ * @param {string} url
518
+ * @param {typeof fetch} fetchImpl
519
+ * @returns {Promise<{unreachable: boolean, status: number|null, body: object|null, detail: string}>}
520
+ */
521
+ async function probeUrl(url, fetchImpl) {
522
+ try {
523
+ const res = await fetchImpl(url, {
524
+ method: "GET",
525
+ headers: { Accept: "application/json" },
526
+ signal: AbortSignal.timeout(WIZARD_PROBE_TIMEOUT_MS),
527
+ });
528
+ let body = null;
529
+ try {
530
+ body = await res.json();
531
+ } catch {
532
+ body = null;
533
+ }
534
+ return { unreachable: false, status: res.status, body, detail: `HTTP ${res.status}` };
535
+ } catch (err) {
536
+ if (isUnreachableError(err)) {
537
+ return { unreachable: true, status: null, body: null, detail: humanUnreachable(err) };
538
+ }
539
+ return { unreachable: true, status: null, body: null, detail: shortError(err) };
540
+ }
541
+ }
542
+
543
+ /**
544
+ * @param {object} readiness
545
+ * @param {string} url
546
+ * @returns {object}
547
+ */
548
+ function interpretReadiness(readiness, url) {
549
+ if (readiness.unreachable) {
550
+ return skipUnreachable(url, readiness.detail);
551
+ }
552
+ if (readiness.status === 200 && readiness.body?.contract_ready === true) {
553
+ return {
554
+ status: "pass",
555
+ title: "contract_ready",
556
+ detail: `${url} reports contract_ready`,
557
+ next: null,
558
+ };
559
+ }
560
+ if (readiness.status === 404) {
561
+ return {
562
+ status: "fail",
563
+ title: "contract_ready",
564
+ detail: `${url} returned 404`,
565
+ next: "Install MCP Sentinel (or confirm the Drupal URL). Readiness is GET /drupal-mcp/readiness.",
566
+ };
567
+ }
568
+ const reason = readiness.body?.reason || readiness.detail;
569
+ return {
570
+ status: "fail",
571
+ title: "contract_ready",
572
+ detail: `${url} is not ready (${reason})`,
573
+ next: "Fix the source governance contract (docs/integration-contract.md) and re-run the wizard.",
574
+ };
575
+ }
576
+
577
+ /**
578
+ * @param {string} url
579
+ * @param {string} detail
580
+ * @returns {object}
581
+ */
582
+ function skipUnreachable(url, detail) {
583
+ return {
584
+ status: "skip",
585
+ title: "unreachable",
586
+ detail: `host unreachable (${url}): ${detail}`,
587
+ next: "Confirm Tailscale/VPN/local routing and the URL, then re-run. Snippets above are still valid.",
588
+ };
589
+ }
590
+
591
+ /**
592
+ * @param {string} reason
593
+ * @returns {{whoami: object, contract_ready: object}}
594
+ */
595
+ function skippedChecks(reason) {
596
+ const skip = { status: "skip", title: "skipped", detail: reason, next: "Re-run without --skip-check against a reachable host." };
597
+ return {
598
+ whoami: skip,
599
+ contract_ready: skip,
600
+ version_skew: versionSkewCheck(detectVersionSkew()),
601
+ };
602
+ }
603
+
604
+ /**
605
+ * @param {object} answers
606
+ * @returns {object}
607
+ */
608
+ function localWhoami(answers) {
609
+ return {
610
+ site: answers.site,
611
+ target: { name: answers.site, baseUrl: answers.drupalUrl || answers.host, source: "wizard" },
612
+ principal: { clientId: answers.clientId, scopes: answers.auth === "oauth" ? ["mcp_read", "mcp_write"] : [] },
613
+ tier: answers.auth === "oauth" ? "content" : "unknown",
614
+ preset: null,
615
+ scopes: answers.auth === "oauth" ? ["mcp_read", "mcp_write"] : [],
616
+ note: "Wizard preview — call drupal_mcp_whoami after the client can reach Drupal.",
617
+ };
618
+ }
619
+
620
+ /**
621
+ * @param {object} answers
622
+ * @returns {object}
623
+ */
624
+ function publicAnswers(answers) {
625
+ return {
626
+ preset: answers.preset || null,
627
+ gated: Boolean(answers.gated),
628
+ transport: answers.transport,
629
+ host: answers.host,
630
+ mcpUrl: answers.mcpUrl || null,
631
+ drupalUrl: answers.drupalUrl || null,
632
+ auth: answers.auth,
633
+ clientId: answers.clientId,
634
+ clients: answers.clients,
635
+ serverName: answers.serverName,
636
+ site: answers.site,
637
+ grantSites: answers.grantSites,
638
+ tokenEnv: answers.tokenEnv,
639
+ write: answers.write,
640
+ scope: answers.scope,
641
+ };
642
+ }
643
+
644
+ /**
645
+ * @param {object} result
646
+ * @param {string[]} selected
647
+ * @returns {object}
648
+ */
649
+ function jsonResult(result, selected) {
650
+ const clients = {};
651
+ for (const id of selected) {
652
+ const client = clientConfig(result.configs, id);
653
+ clients[id] = {
654
+ label: client.label,
655
+ projectPath: client.projectPath,
656
+ userPath: client.userPath,
657
+ config: client.config,
658
+ };
659
+ }
660
+ return {
661
+ tool: "drupal-mcp-wizard",
662
+ version: CLIENT_VERSION,
663
+ answers: result.answers,
664
+ clients,
665
+ writes: result.writes,
666
+ checks: result.checks,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * @param {object} answers
672
+ * @param {object} client
673
+ * @returns {string}
674
+ */
675
+ function writePath(answers, client) {
676
+ return answers.scope === "user" ? client.userPath : client.projectPath;
677
+ }
678
+
679
+ /**
680
+ * @param {string} filePath
681
+ * @param {object} incoming
682
+ * @param {string} serverName
683
+ * @param {string} id
684
+ * @returns {object}
685
+ */
686
+ function writeOne(filePath, entry, serverName, id) {
687
+ try {
688
+ let existing = {};
689
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator --output/--scope path
690
+ if (existsSync(filePath)) {
691
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator --output/--scope path
692
+ existing = JSON.parse(readFileSync(filePath, "utf8"));
693
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
694
+ return { id, path: filePath, status: "skipped", detail: "existing file is not a JSON object; refusing to clobber" };
695
+ }
696
+ }
697
+ const merged = mergeMcpServer(existing, serverName, entry);
698
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator --output/--scope path
699
+ mkdirSync(dirname(filePath), { recursive: true });
700
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- operator --output/--scope path
701
+ writeFileSync(filePath, `${JSON.stringify(merged, null, 2)}\n`);
702
+ return { id, path: filePath, status: "written", detail: "merged mcpServers entry" };
703
+ } catch (err) {
704
+ return { id, path: filePath, status: "failed", detail: shortError(err) };
705
+ }
706
+ }
707
+
708
+ /**
709
+ * @param {{cursor: object, claude: object}} configs
710
+ * @param {string} id
711
+ * @returns {object}
712
+ */
713
+ function clientConfig(configs, id) {
714
+ if (id === "cursor") return configs.cursor;
715
+ if (id === "claude") return configs.claude;
716
+ throw new Error(`unknown client ${id}`);
717
+ }
718
+
719
+ /**
720
+ * @param {{mcpServers?: Record<string, object>}} config
721
+ * @param {string} serverName
722
+ * @returns {object}
723
+ */
724
+ function serverEntry(config, serverName) {
725
+ const servers = config.mcpServers || {};
726
+ const named = Object.entries(servers).find(([key]) => key === serverName);
727
+ if (!named) throw new Error(`missing mcpServers.${serverName}`);
728
+ return named[1];
729
+ }
730
+
731
+ /**
732
+ * @param {object} existing
733
+ * @param {string} serverName
734
+ * @param {object} entry
735
+ * @returns {object}
736
+ */
737
+ function mergeMcpServer(existing, serverName, entry) {
738
+ const current = existing.mcpServers && typeof existing.mcpServers === "object" && !Array.isArray(existing.mcpServers)
739
+ ? existing.mcpServers
740
+ : {};
741
+ const servers = Object.fromEntries([
742
+ ...Object.entries(current).filter(([key]) => key !== serverName),
743
+ [serverName, entry],
744
+ ]);
745
+ return { ...existing, mcpServers: servers };
746
+ }
747
+
748
+ /**
749
+ * @param {object} check
750
+ * @returns {string}
751
+ */
752
+ function formatCheck(check) {
753
+ const mark = { pass: "PASS", fail: "FAIL", skip: "SKIP" };
754
+ const tag = mark[check.status] || check.status.toUpperCase();
755
+ const title = check.title || "check";
756
+ const lines = [` [${tag}] ${title} — ${check.detail}`];
757
+ const next = check.next || check.nextFix;
758
+ if (next) lines.push(` next: ${next}`);
759
+ return lines.join("\n");
760
+ }
761
+
762
+ /**
763
+ * @param {unknown} err
764
+ * @returns {boolean}
765
+ */
766
+ function isUnreachableError(err) {
767
+ const name = err?.name;
768
+ if (name === "AbortError" || name === "TimeoutError") return true;
769
+ const code = err?.cause?.code || err?.code;
770
+ if (typeof code === "string" && UNREACHABLE_CODES.has(code)) return true;
771
+ const message = String(err?.message ?? err);
772
+ return /fetch failed|ECONNREFUSED|ENOTFOUND|network|socket/i.test(message);
773
+ }
774
+
775
+ /**
776
+ * @param {unknown} err
777
+ * @returns {string}
778
+ */
779
+ function humanUnreachable(err) {
780
+ const code = err?.cause?.code || err?.code || err?.name || "network error";
781
+ return String(code);
782
+ }
783
+
784
+ /**
785
+ * @param {unknown} err
786
+ * @returns {string}
787
+ */
788
+ function shortError(err) {
789
+ return String(err?.message ?? err).replace(/\s+/g, " ").slice(0, 200);
790
+ }
791
+
792
+ /**
793
+ * @param {string} value
794
+ * @param {string[]} allowed
795
+ * @param {string} flag
796
+ * @returns {string}
797
+ */
798
+ function normalizeChoice(value, allowed, flag) {
799
+ const choice = String(value || "").trim().toLowerCase();
800
+ if (!allowed.includes(choice)) {
801
+ throw new Error(`--${flag} must be ${allowed.join("|")} (got ${JSON.stringify(value)}).`);
802
+ }
803
+ return choice;
804
+ }
805
+
806
+ /**
807
+ * @param {string} value
808
+ * @param {string} flag
809
+ * @returns {string}
810
+ */
811
+ function normalizeHttpUrl(value, flag) {
812
+ let parsed;
813
+ try {
814
+ parsed = new URL(String(value).trim());
815
+ } catch {
816
+ throw new Error(`--${flag} must be an http(s) URL.`);
817
+ }
818
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
819
+ throw new Error(`--${flag} must be an http(s) URL.`);
820
+ }
821
+ return parsed.toString().replace(/\/$/, "");
822
+ }
823
+
824
+ /**
825
+ * @param {string} url
826
+ * @returns {string}
827
+ */
828
+ function ensureMcpPath(url) {
829
+ const parsed = new URL(url);
830
+ if (parsed.pathname === "/" || parsed.pathname === "") {
831
+ parsed.pathname = "/mcp";
832
+ }
833
+ return parsed.toString().replace(/\/$/, "");
834
+ }
835
+
836
+ /**
837
+ * @param {string} url
838
+ * @returns {string}
839
+ */
840
+ function stripSlash(url) {
841
+ return String(url).replace(/\/$/, "");
842
+ }
843
+
844
+ /**
845
+ * @param {string} url
846
+ * @returns {string}
847
+ */
848
+ function hostnameOf(url) {
849
+ try {
850
+ return new URL(url).hostname;
851
+ } catch {
852
+ return url;
853
+ }
854
+ }
855
+
856
+ export { isReservedDocumentationHost };
857
+
858
+ /**
859
+ * @param {string} raw
860
+ * @returns {string[]}
861
+ */
862
+ function parseList(raw) {
863
+ return String(raw || "")
864
+ .split(",")
865
+ .map((part) => part.trim())
866
+ .filter(Boolean);
867
+ }
868
+
869
+ /**
870
+ * @param {string} flag
871
+ * @param {Record<string, string|boolean|string[]>} args
872
+ * @param {string} envName
873
+ * @param {string} fallback
874
+ * @returns {string}
875
+ */
876
+ function pick(flag, args, envName, fallback) {
877
+ if (args[flag] !== undefined && args[flag] !== true) return String(args[flag]);
878
+ const fromEnv = process.env[envName];
879
+ if (fromEnv) return fromEnv;
880
+ return fallback;
881
+ }
882
+
883
+ /**
884
+ * @param {string} name
885
+ * @returns {boolean}
886
+ */
887
+ function envTruthy(name) {
888
+ const raw = process.env[name];
889
+ return raw === "1" || raw === "true" || raw === "yes";
890
+ }
891
+
892
+ /**
893
+ * @param {import("node:readline/promises").Interface} rl
894
+ * @param {string} question
895
+ * @param {string} fallback
896
+ * @returns {Promise<string>}
897
+ */
898
+ async function ask(rl, question, fallback) {
899
+ const suffix = fallback ? ` (${fallback})` : "";
900
+ const answer = await rl.question(`${question}${suffix}: `);
901
+ return answer.trim() || fallback;
902
+ }