drupal-mcp-connector 2.25.0 → 3.0.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-audit-site-health.md +1 -1
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
- package/.agents/commands/drupal-report-alias-coverage.md +4 -2
- package/.agents/commands/drupal-report-broken-embeds.md +4 -2
- package/.agents/commands/drupal-report-broken-links.md +4 -2
- package/.agents/commands/drupal-report-content-by-author.md +4 -2
- package/.agents/commands/drupal-report-duplicate-content.md +4 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-orphan-pages.md +4 -2
- package/.agents/commands/drupal-report-orphaned-references.md +4 -2
- package/.agents/commands/drupal-report-pii-exposure.md +4 -2
- package/.agents/commands/drupal-report-readability.md +4 -2
- package/.agents/commands/drupal-report-recently-published.md +1 -1
- package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
- package/.agents/commands/drupal-report-scheduled-content.md +4 -4
- package/.agents/commands/drupal-report-seo-audit.md +4 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
- package/.agents/commands/drupal-report-stale-content.md +4 -2
- package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -2
- package/.agents/commands/drupal-report-unpublished.md +1 -1
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +114 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/reports-support.js +68 -0
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +17 -9
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +109 -39
- package/src/tools/reports-extra.js +56 -32
- package/src/tools/reports-links.js +13 -13
- package/src/tools/reports.js +65 -42
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +142 -27
- package/src/tools/search.js +16 -12
- package/src/tools/translations.js +7 -1
|
@@ -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
|
+
}
|