drupal-mcp-connector 2.24.2 → 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.
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-list-menu-links.md +2 -2
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-update-menu-link.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +87 -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 +71 -8
- 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/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/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 +5 -3
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +4 -2
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +97 -10
- package/src/tools/structure.js +4 -4
- package/src/tools/translations.js +7 -1
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption doctor (DEV-759, DEV-766).
|
|
3
|
+
*
|
|
4
|
+
* Ordered gates. Prints one primary failure plus the exact next fix.
|
|
5
|
+
* Never includes tokens, secrets, or Authorization values in the report.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fetch from "node-fetch";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import {
|
|
11
|
+
resolveApiToken,
|
|
12
|
+
resolveOauth,
|
|
13
|
+
authHeadersAsync,
|
|
14
|
+
clientHeaders,
|
|
15
|
+
CLIENT_VERSION,
|
|
16
|
+
getSiteConfig,
|
|
17
|
+
} from "./config.js";
|
|
18
|
+
import { resolveSecurityConfig, getSecuritySummary } from "./security.js";
|
|
19
|
+
import { detectVersionSkew, extractReportedVersions, versionSkewCheck } from "./version-skew.js";
|
|
20
|
+
import { formatTransportPresetHelp, isReservedDocumentationHost, isTailscaleMagicDnsHost } from "./transports.js";
|
|
21
|
+
|
|
22
|
+
/** Doctor checks in the required order. */
|
|
23
|
+
export const DOCTOR_CHECK_IDS = [
|
|
24
|
+
"reachability",
|
|
25
|
+
"auth",
|
|
26
|
+
"agent_client",
|
|
27
|
+
"allowlist",
|
|
28
|
+
"readiness",
|
|
29
|
+
"version_skew",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Readiness reasons that mean the agent client is not registered. */
|
|
33
|
+
const CLIENT_NOT_REGISTERED = new Set([
|
|
34
|
+
"designated_consumer_missing",
|
|
35
|
+
"unknown_agent_client",
|
|
36
|
+
"client_not_registered",
|
|
37
|
+
"unknown_client",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const PASS = "pass";
|
|
41
|
+
const FAIL = "fail";
|
|
42
|
+
const SKIPPED = "skipped";
|
|
43
|
+
|
|
44
|
+
/** Probe timeout. Matches wizard (do not hang CI). */
|
|
45
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
46
|
+
|
|
47
|
+
export const DOCTOR_USAGE = `drupal-mcp-connector doctor
|
|
48
|
+
|
|
49
|
+
Diagnose the first failing connection gate and print the exact next fix.
|
|
50
|
+
|
|
51
|
+
--config <path> Config file (default: config/config.json or env).
|
|
52
|
+
--site <name> Site key inside that config.
|
|
53
|
+
--host <url> Override Drupal base URL.
|
|
54
|
+
--client-id <id> Agent client id (X-MCP-Client / MCP_CLIENT_ID).
|
|
55
|
+
--preset <id> Transport preset hint (local-stdio|tailscale|public-https).
|
|
56
|
+
--json Machine-readable report (no secrets).
|
|
57
|
+
--help Show this message.
|
|
58
|
+
|
|
59
|
+
Exit 0 only when no check failed. Skipped optional gates (e.g. undetectable
|
|
60
|
+
version skew) do not fail the run.
|
|
61
|
+
|
|
62
|
+
Transport presets:
|
|
63
|
+
${formatTransportPresetHelp()}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse `--flag`, `--key value`, and `--key=value`.
|
|
68
|
+
* @param {string[]} argv
|
|
69
|
+
* @returns {object}
|
|
70
|
+
*/
|
|
71
|
+
export function parseDoctorArgs(argv) {
|
|
72
|
+
const args = { _: [] };
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const arg = argv[i];
|
|
75
|
+
if (!arg.startsWith("--")) {
|
|
76
|
+
args._.push(arg);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const [key, inline] = arg.slice(2).split("=");
|
|
80
|
+
if (inline !== undefined) {
|
|
81
|
+
args[key] = inline;
|
|
82
|
+
} else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
83
|
+
args[key] = argv[++i];
|
|
84
|
+
} else {
|
|
85
|
+
args[key] = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Collect secret strings that must never appear in doctor output.
|
|
93
|
+
* @param {object} site
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function secretValuesFromSite(site) {
|
|
97
|
+
return [
|
|
98
|
+
site?.apiToken,
|
|
99
|
+
site?.password,
|
|
100
|
+
site?.oauth?.clientSecret,
|
|
101
|
+
].filter((value) => typeof value === "string" && value.length >= 4);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replace known secret substrings in a JSON-able value.
|
|
106
|
+
* @param {*} value
|
|
107
|
+
* @param {string[]} secrets
|
|
108
|
+
* @returns {*}
|
|
109
|
+
*/
|
|
110
|
+
export function scrubSecrets(value, secrets) {
|
|
111
|
+
if (!secrets.length) return value;
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
let out = value;
|
|
114
|
+
for (const secret of secrets) {
|
|
115
|
+
if (secret && out.includes(secret)) out = out.split(secret).join("[redacted]");
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(value)) return value.map((item) => scrubSecrets(item, secrets));
|
|
120
|
+
if (value && typeof value === "object") {
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(value).map(([key, item]) => [key, scrubSecrets(item, secrets)]),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} id
|
|
130
|
+
* @param {string} title
|
|
131
|
+
* @param {"pass"|"fail"|"skipped"} status
|
|
132
|
+
* @param {string} detail
|
|
133
|
+
* @param {string|null} [nextFix]
|
|
134
|
+
*/
|
|
135
|
+
function check(id, title, status, detail, nextFix = null) {
|
|
136
|
+
return { id, title, status, detail, nextFix };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One HTTP GET that never throws. Network text is not propagated.
|
|
141
|
+
* @param {Function} fetchImpl
|
|
142
|
+
* @param {string} url
|
|
143
|
+
* @param {object} headers
|
|
144
|
+
*/
|
|
145
|
+
async function probe(fetchImpl, url, headers) {
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetchImpl(url, {
|
|
148
|
+
method: "GET",
|
|
149
|
+
headers,
|
|
150
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
151
|
+
});
|
|
152
|
+
let body = null;
|
|
153
|
+
try {
|
|
154
|
+
const text = await res.text();
|
|
155
|
+
if (text) body = JSON.parse(text);
|
|
156
|
+
} catch {
|
|
157
|
+
body = null;
|
|
158
|
+
}
|
|
159
|
+
const rawHeaders = res.headers;
|
|
160
|
+
return {
|
|
161
|
+
unreachable: false,
|
|
162
|
+
status: res.status,
|
|
163
|
+
body,
|
|
164
|
+
headers: {
|
|
165
|
+
get: (name) => (typeof rawHeaders?.get === "function" ? rawHeaders.get(name) : null),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
} catch {
|
|
169
|
+
return { unreachable: true, status: 0, body: null, headers: { get: () => null } };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a site for doctor from flags, a config file, env, or loaded config.
|
|
175
|
+
* @param {object} args
|
|
176
|
+
* @param {NodeJS.ProcessEnv} env
|
|
177
|
+
* @returns {object}
|
|
178
|
+
*/
|
|
179
|
+
export function resolveDoctorSite(args, env = process.env) {
|
|
180
|
+
if (args.host) return siteFromFlags(args, env);
|
|
181
|
+
if (args.config) {
|
|
182
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- operator --config path
|
|
183
|
+
const cfg = JSON.parse(readFileSync(String(args.config), "utf8"));
|
|
184
|
+
const name = args.site ? String(args.site) : cfg.defaultSite;
|
|
185
|
+
const raw = new Map(Object.entries(cfg.sites || {})).get(name);
|
|
186
|
+
if (!raw) return { _name: name || "default", baseUrl: "" };
|
|
187
|
+
return resolveOauth(resolveApiToken({ ...raw, _name: name }));
|
|
188
|
+
}
|
|
189
|
+
if (env.DRUPAL_BASE_URL) return siteFromFlags({ ...args, host: env.DRUPAL_BASE_URL }, env);
|
|
190
|
+
try {
|
|
191
|
+
return getSiteConfig(args.site ? String(args.site) : undefined);
|
|
192
|
+
} catch {
|
|
193
|
+
return { _name: args.site ? String(args.site) : "default", baseUrl: env.DRUPAL_BASE_URL || "" };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {object} args
|
|
199
|
+
* @param {NodeJS.ProcessEnv} env
|
|
200
|
+
* @returns {object}
|
|
201
|
+
*/
|
|
202
|
+
function siteFromFlags(args, env) {
|
|
203
|
+
const site = {
|
|
204
|
+
_name: args.site ? String(args.site) : "default",
|
|
205
|
+
baseUrl: String(args.host || env.DRUPAL_BASE_URL || "").replace(/\/$/, ""),
|
|
206
|
+
requireGovernance: args["no-require-governance"] !== true,
|
|
207
|
+
security: { preset: args.security ? String(args.security) : "production-strict" },
|
|
208
|
+
};
|
|
209
|
+
if (args["oauth-client-id"] || args.auth === "oauth") {
|
|
210
|
+
const secretEnv = String(args["oauth-secret-env"] || "MCP_CONTENT_SECRET");
|
|
211
|
+
site.oauth = {
|
|
212
|
+
clientId: String(args["oauth-client-id"] || ""),
|
|
213
|
+
clientSecretEnv: secretEnv,
|
|
214
|
+
clientSecret: env[secretEnv] || "",
|
|
215
|
+
scopes: ["mcp_read", "mcp_write"],
|
|
216
|
+
grant: "client_credentials",
|
|
217
|
+
};
|
|
218
|
+
} else {
|
|
219
|
+
const tokenEnv = String(args["token-env"] || "DRUPAL_API_TOKEN");
|
|
220
|
+
site.apiTokenEnv = tokenEnv;
|
|
221
|
+
site.apiToken = env[tokenEnv] || env.DRUPAL_API_TOKEN || "";
|
|
222
|
+
}
|
|
223
|
+
return resolveOauth(resolveApiToken(site));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {object} site
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
function hasCredential(site) {
|
|
231
|
+
return Boolean(
|
|
232
|
+
site.apiToken
|
|
233
|
+
|| (site.username && site.password)
|
|
234
|
+
|| (site.oauth?.clientId && (site.oauth.clientSecret || site.oauth.clientSecretEnv)),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Agent client identity the connector will send.
|
|
240
|
+
* @param {object} site
|
|
241
|
+
* @param {object} args
|
|
242
|
+
* @param {NodeJS.ProcessEnv} env
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
export function resolveAgentClientId(site, args = {}, env = process.env) {
|
|
246
|
+
if (args["client-id"]) return String(args["client-id"]);
|
|
247
|
+
if (env.MCP_CLIENT_ID !== undefined) return env.MCP_CLIENT_ID;
|
|
248
|
+
if (site.clientId) return String(site.clientId);
|
|
249
|
+
const headers = clientHeaders();
|
|
250
|
+
return headers["X-MCP-Client"] || "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Run the ordered doctor gates.
|
|
255
|
+
* @param {object} [options]
|
|
256
|
+
* @returns {Promise<object>}
|
|
257
|
+
*/
|
|
258
|
+
export async function runDoctor(options = {}) {
|
|
259
|
+
const args = options.args || {};
|
|
260
|
+
const env = options.env || process.env;
|
|
261
|
+
const fetchImpl = options.fetch || fetch;
|
|
262
|
+
const connectorVersion = options.connectorVersion || CLIENT_VERSION;
|
|
263
|
+
const site = options.site || resolveDoctorSite(args, env);
|
|
264
|
+
const secrets = secretValuesFromSite(site);
|
|
265
|
+
const clientId = resolveAgentClientId(site, args, env);
|
|
266
|
+
const requireGovernance = Boolean(site.requireGovernance);
|
|
267
|
+
|
|
268
|
+
const checks = [];
|
|
269
|
+
let readinessProbe = null;
|
|
270
|
+
let authHeaders = {};
|
|
271
|
+
const host = site.baseUrl || "";
|
|
272
|
+
|
|
273
|
+
if (!host) {
|
|
274
|
+
checks.push(check(
|
|
275
|
+
"reachability",
|
|
276
|
+
"Drupal host is reachable",
|
|
277
|
+
FAIL,
|
|
278
|
+
"No Drupal host configured.",
|
|
279
|
+
"Run npx drupal-mcp-connector init --host https://your-site.example (or set DRUPAL_BASE_URL / config.json).",
|
|
280
|
+
));
|
|
281
|
+
} else if (isReservedDocumentationHost(host)) {
|
|
282
|
+
checks.push(check(
|
|
283
|
+
"reachability",
|
|
284
|
+
"Drupal host is reachable",
|
|
285
|
+
FAIL,
|
|
286
|
+
`Documentation-reserved host (${host}) — not probed.`,
|
|
287
|
+
"Point --host at a real Drupal URL (Local stdio or Tailscale VPN). Public HTTPS is gated/later and is not a hosted SaaS endpoint.",
|
|
288
|
+
));
|
|
289
|
+
} else {
|
|
290
|
+
const jsonapi = await probe(fetchImpl, `${host}/jsonapi`, {
|
|
291
|
+
Accept: "application/vnd.api+json",
|
|
292
|
+
...clientHeaders(),
|
|
293
|
+
});
|
|
294
|
+
if (jsonapi.unreachable) {
|
|
295
|
+
const root = await probe(fetchImpl, host, { Accept: "text/html", ...clientHeaders() });
|
|
296
|
+
if (root.unreachable) {
|
|
297
|
+
checks.push(check(
|
|
298
|
+
"reachability",
|
|
299
|
+
"Drupal host is reachable",
|
|
300
|
+
FAIL,
|
|
301
|
+
`No TCP/HTTP response from ${host} (jsonapi or origin).`,
|
|
302
|
+
isTailscaleMagicDnsHost(host)
|
|
303
|
+
? "Join the Tailscale tailnet on this machine, then retry. Tailscale is VPN-required — this is not a public URL."
|
|
304
|
+
: "Confirm the host URL, DNS, and that this machine can route to Drupal (Local stdio or Tailscale VPN).",
|
|
305
|
+
));
|
|
306
|
+
} else {
|
|
307
|
+
checks.push(check(
|
|
308
|
+
"reachability",
|
|
309
|
+
"Drupal host is reachable",
|
|
310
|
+
PASS,
|
|
311
|
+
`Origin responded (${root.status}); /jsonapi did not. Enable JSON:API or check the path.`,
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
checks.push(check(
|
|
316
|
+
"reachability",
|
|
317
|
+
"Drupal host is reachable",
|
|
318
|
+
PASS,
|
|
319
|
+
`Host responded to /jsonapi with HTTP ${jsonapi.status}.`,
|
|
320
|
+
));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const reach = statusOf(checks, "reachability");
|
|
325
|
+
const reachOk = reach === PASS;
|
|
326
|
+
|
|
327
|
+
if (!reachOk) {
|
|
328
|
+
checks.push(check("auth", "Auth accepted (not 401)", SKIPPED, "Skipped after reachability failed."));
|
|
329
|
+
} else if (!hasCredential(site)) {
|
|
330
|
+
checks.push(check(
|
|
331
|
+
"auth",
|
|
332
|
+
"Auth accepted (not 401)",
|
|
333
|
+
FAIL,
|
|
334
|
+
"No sealed token or OAuth client credentials are configured.",
|
|
335
|
+
"Re-run init with --auth token --token-env DRUPAL_API_TOKEN (export the token) or --auth oauth. Doctor never prints the secret.",
|
|
336
|
+
));
|
|
337
|
+
} else {
|
|
338
|
+
try {
|
|
339
|
+
authHeaders = await authHeadersAsync(site);
|
|
340
|
+
} catch {
|
|
341
|
+
checks.push(check(
|
|
342
|
+
"auth",
|
|
343
|
+
"Auth accepted (not 401)",
|
|
344
|
+
FAIL,
|
|
345
|
+
"Credential acquisition failed (OAuth token endpoint or missing secret env).",
|
|
346
|
+
"Export the env var named in oauth.clientSecretEnv / apiTokenEnv. Doctor never prints the secret.",
|
|
347
|
+
));
|
|
348
|
+
}
|
|
349
|
+
if (statusOf(checks, "auth") === undefined) {
|
|
350
|
+
const authed = await probe(fetchImpl, `${host}/jsonapi`, {
|
|
351
|
+
Accept: "application/vnd.api+json",
|
|
352
|
+
...clientHeaders(),
|
|
353
|
+
...authHeaders,
|
|
354
|
+
});
|
|
355
|
+
if (authed.unreachable) {
|
|
356
|
+
checks.push(check(
|
|
357
|
+
"auth",
|
|
358
|
+
"Auth accepted (not 401)",
|
|
359
|
+
FAIL,
|
|
360
|
+
"Authenticated /jsonapi request did not complete.",
|
|
361
|
+
"The host was reachable unauthenticated but the credentialed request failed. Recheck the token endpoint and TLS.",
|
|
362
|
+
));
|
|
363
|
+
} else if (authed.status === 401) {
|
|
364
|
+
checks.push(check(
|
|
365
|
+
"auth",
|
|
366
|
+
"Auth accepted (not 401)",
|
|
367
|
+
FAIL,
|
|
368
|
+
"Drupal returned HTTP 401 for /jsonapi with the configured credential.",
|
|
369
|
+
"Rotate or re-export the sealed token / OAuth client secret. Confirm Simple OAuth is enabled and the consumer is active.",
|
|
370
|
+
));
|
|
371
|
+
} else if (authed.status === 404) {
|
|
372
|
+
checks.push(check(
|
|
373
|
+
"auth",
|
|
374
|
+
"Auth accepted (not 401)",
|
|
375
|
+
FAIL,
|
|
376
|
+
"HTTP 404 from /jsonapi — JSON:API is not enabled or the path is wrong.",
|
|
377
|
+
"Enable the core jsonapi module (drush en jsonapi -y) and confirm baseUrl has no extra path prefix.",
|
|
378
|
+
));
|
|
379
|
+
} else {
|
|
380
|
+
checks.push(check(
|
|
381
|
+
"auth",
|
|
382
|
+
"Auth accepted (not 401)",
|
|
383
|
+
PASS,
|
|
384
|
+
`Authenticated /jsonapi returned HTTP ${authed.status}.`,
|
|
385
|
+
));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const authStatus = statusOf(checks, "auth");
|
|
391
|
+
|
|
392
|
+
if (clientId === "") {
|
|
393
|
+
checks.push(check(
|
|
394
|
+
"agent_client",
|
|
395
|
+
"Agent client identity is set",
|
|
396
|
+
FAIL,
|
|
397
|
+
"MCP_CLIENT_ID is empty, so the X-MCP-Client identity header is disabled.",
|
|
398
|
+
"Unset the empty MCP_CLIENT_ID or set --client-id (for example cursor-local). The header is a log label, not an auth bypass.",
|
|
399
|
+
));
|
|
400
|
+
} else if (authStatus === FAIL || authStatus === SKIPPED) {
|
|
401
|
+
checks.push(check(
|
|
402
|
+
"agent_client",
|
|
403
|
+
"Agent client identity is set",
|
|
404
|
+
PASS,
|
|
405
|
+
`Will send X-MCP-Client: ${clientId}. Live registration is checked with readiness.`,
|
|
406
|
+
));
|
|
407
|
+
} else {
|
|
408
|
+
checks.push(check(
|
|
409
|
+
"agent_client",
|
|
410
|
+
"Agent client identity is set",
|
|
411
|
+
PASS,
|
|
412
|
+
`X-MCP-Client: ${clientId}. Live registration is confirmed on the readiness gate when Sentinel reports it.`,
|
|
413
|
+
));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const sec = host ? resolveSecurityConfig(site) : null;
|
|
417
|
+
if (!sec) {
|
|
418
|
+
checks.push(check("allowlist", "Entity allowlist permits a content read", SKIPPED, "Skipped — no site config to inspect."));
|
|
419
|
+
} else if (Array.isArray(sec.allowedEntityTypes) && sec.allowedEntityTypes.length === 0) {
|
|
420
|
+
checks.push(check(
|
|
421
|
+
"allowlist",
|
|
422
|
+
"Entity allowlist permits a content read",
|
|
423
|
+
FAIL,
|
|
424
|
+
"security.allowedEntityTypes is an empty list, so every entity tool is denied.",
|
|
425
|
+
"Set an explicit allowlist that includes node (or use a preset such as production-strict / content-editor). Doctor will not widen it for you.",
|
|
426
|
+
));
|
|
427
|
+
} else if (Array.isArray(sec.deniedEntityTypes) && sec.deniedEntityTypes.includes("node")) {
|
|
428
|
+
checks.push(check(
|
|
429
|
+
"allowlist",
|
|
430
|
+
"Entity allowlist permits a content read",
|
|
431
|
+
FAIL,
|
|
432
|
+
"node is on security.deniedEntityTypes (deny-first), so drupal_list_nodes / drupal_get_node will fail closed.",
|
|
433
|
+
"Remove node from deniedEntityTypes on a staging preset only when a human intends that. Do not auto-widen production denylists.",
|
|
434
|
+
));
|
|
435
|
+
} else if (Array.isArray(sec.allowedEntityTypes) && !sec.allowedEntityTypes.includes("node")) {
|
|
436
|
+
checks.push(check(
|
|
437
|
+
"allowlist",
|
|
438
|
+
"Entity allowlist permits a content read",
|
|
439
|
+
FAIL,
|
|
440
|
+
"security.allowedEntityTypes is set and omits node, so drupal_list_nodes / drupal_get_node will fail closed.",
|
|
441
|
+
"Add node to security.allowedEntityTypes on a staging preset. Do not auto-widen production allowlists.",
|
|
442
|
+
));
|
|
443
|
+
} else {
|
|
444
|
+
const summary = getSecuritySummary(site);
|
|
445
|
+
const allowed = summary.allowedEntityTypes === "all"
|
|
446
|
+
? "all non-denied types"
|
|
447
|
+
: `allowed: ${[].concat(summary.allowedEntityTypes).join(", ")}`;
|
|
448
|
+
checks.push(check(
|
|
449
|
+
"allowlist",
|
|
450
|
+
"Entity allowlist permits a content read",
|
|
451
|
+
PASS,
|
|
452
|
+
`Preset ${summary.preset}; ${allowed}. Sensitive types stay denied.`,
|
|
453
|
+
));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!reachOk) {
|
|
457
|
+
checks.push(check(
|
|
458
|
+
"readiness",
|
|
459
|
+
"Source governance contract_ready",
|
|
460
|
+
SKIPPED,
|
|
461
|
+
requireGovernance
|
|
462
|
+
? "Skipped after reachability failed (requireGovernance is on)."
|
|
463
|
+
: "Skipped after reachability failed.",
|
|
464
|
+
));
|
|
465
|
+
} else {
|
|
466
|
+
try {
|
|
467
|
+
if (!Object.keys(authHeaders).length && hasCredential(site)) {
|
|
468
|
+
authHeaders = await authHeadersAsync(site);
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
authHeaders = {};
|
|
472
|
+
}
|
|
473
|
+
readinessProbe = await probe(fetchImpl, `${host}/drupal-mcp/readiness`, {
|
|
474
|
+
Accept: "application/json",
|
|
475
|
+
...clientHeaders(),
|
|
476
|
+
...authHeaders,
|
|
477
|
+
});
|
|
478
|
+
if (readinessProbe.unreachable) {
|
|
479
|
+
checks.push(check(
|
|
480
|
+
"readiness",
|
|
481
|
+
"Source governance contract_ready",
|
|
482
|
+
requireGovernance ? FAIL : SKIPPED,
|
|
483
|
+
requireGovernance
|
|
484
|
+
? "GET /drupal-mcp/readiness did not complete and requireGovernance is on."
|
|
485
|
+
: "GET /drupal-mcp/readiness did not complete. requireGovernance is off, so this is skipped rather than a pass.",
|
|
486
|
+
requireGovernance
|
|
487
|
+
? "Install and enable mcp_sentinel, or pass --no-require-governance for a local ungoverned experiment. Marketplace listings keep requireGovernance on."
|
|
488
|
+
: null,
|
|
489
|
+
));
|
|
490
|
+
} else if (readinessProbe.status === 404) {
|
|
491
|
+
checks.push(check(
|
|
492
|
+
"readiness",
|
|
493
|
+
"Source governance contract_ready",
|
|
494
|
+
requireGovernance ? FAIL : SKIPPED,
|
|
495
|
+
"GET /drupal-mcp/readiness returned 404 (MCP Sentinel not installed, or the route is missing).",
|
|
496
|
+
requireGovernance
|
|
497
|
+
? "composer require drupal/mcp_sentinel && drush en mcp_sentinel -y, then retry doctor."
|
|
498
|
+
: null,
|
|
499
|
+
));
|
|
500
|
+
} else if (readinessProbe.status === 401 || readinessProbe.status === 403) {
|
|
501
|
+
checks.push(check(
|
|
502
|
+
"readiness",
|
|
503
|
+
"Source governance contract_ready",
|
|
504
|
+
FAIL,
|
|
505
|
+
`Readiness returned HTTP ${readinessProbe.status} (not_authorized_for_governance).`,
|
|
506
|
+
"Grant the consumer access to the readiness route / mcp_read scope. This is an authz miss, not a reason to disable governance.",
|
|
507
|
+
));
|
|
508
|
+
} else if (readinessProbe.status === 200 && readinessProbe.body?.contract_ready === true) {
|
|
509
|
+
checks.push(check(
|
|
510
|
+
"readiness",
|
|
511
|
+
"Source governance contract_ready",
|
|
512
|
+
PASS,
|
|
513
|
+
"contract_ready is true.",
|
|
514
|
+
));
|
|
515
|
+
} else if (readinessProbe.body?.contract_ready === false) {
|
|
516
|
+
const reason = String(readinessProbe.body.reason ?? "contract_not_ready");
|
|
517
|
+
const clientMiss = CLIENT_NOT_REGISTERED.has(reason);
|
|
518
|
+
if (clientMiss) {
|
|
519
|
+
replaceCheck(checks, "agent_client", check(
|
|
520
|
+
"agent_client",
|
|
521
|
+
"Agent client is registered",
|
|
522
|
+
FAIL,
|
|
523
|
+
`Sentinel readiness reason: ${reason}.`,
|
|
524
|
+
"Register this agent client id on the Drupal consumer / Sentinel designated-consumer list. Do not disable the identity header to skip the gate.",
|
|
525
|
+
));
|
|
526
|
+
}
|
|
527
|
+
checks.push(check(
|
|
528
|
+
"readiness",
|
|
529
|
+
"Source governance contract_ready",
|
|
530
|
+
FAIL,
|
|
531
|
+
`contract_ready is false (${reason}).`,
|
|
532
|
+
clientMiss
|
|
533
|
+
? "Register the agent client, then re-run doctor. Widening entity allowlists will not fix a missing consumer."
|
|
534
|
+
: `Fix the Sentinel condition named '${reason}', then re-run npx drupal-mcp-connector doctor.`,
|
|
535
|
+
));
|
|
536
|
+
} else {
|
|
537
|
+
checks.push(check(
|
|
538
|
+
"readiness",
|
|
539
|
+
"Source governance contract_ready",
|
|
540
|
+
requireGovernance ? FAIL : SKIPPED,
|
|
541
|
+
`Unexpected readiness response (HTTP ${readinessProbe.status}).`,
|
|
542
|
+
"Confirm mcp_sentinel serves GET /drupal-mcp/readiness as JSON { contract_ready, reason }.",
|
|
543
|
+
));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
checks.push(versionSkewCheck(detectVersionSkew(
|
|
548
|
+
extractReportedVersions(readinessProbe?.body, readinessProbe?.headers),
|
|
549
|
+
connectorVersion,
|
|
550
|
+
)));
|
|
551
|
+
|
|
552
|
+
const primaryFailure = checks.find((item) => item.status === FAIL) || null;
|
|
553
|
+
const report = {
|
|
554
|
+
tool: "drupal-mcp-connector doctor",
|
|
555
|
+
connectorVersion,
|
|
556
|
+
ok: !primaryFailure,
|
|
557
|
+
target: { name: site._name, baseUrl: host || null },
|
|
558
|
+
agentClientId: clientId || null,
|
|
559
|
+
primaryFailure: primaryFailure
|
|
560
|
+
? {
|
|
561
|
+
id: primaryFailure.id,
|
|
562
|
+
title: primaryFailure.title,
|
|
563
|
+
detail: primaryFailure.detail,
|
|
564
|
+
nextFix: primaryFailure.nextFix,
|
|
565
|
+
}
|
|
566
|
+
: null,
|
|
567
|
+
checks,
|
|
568
|
+
happyPath:
|
|
569
|
+
"After doctor is green: call drupal_mcp_whoami → drupal_list_sites → a safe unpublished read " +
|
|
570
|
+
"(drupal_list_nodes with status:false) or dryRun unpublished create. See README Two-minute happy path.",
|
|
571
|
+
};
|
|
572
|
+
return scrubSecrets(report, secrets);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @param {Array<object>} checks
|
|
577
|
+
* @param {string} id
|
|
578
|
+
* @returns {string|undefined}
|
|
579
|
+
*/
|
|
580
|
+
function statusOf(checks, id) {
|
|
581
|
+
return checks.find((item) => item.id === id)?.status;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @param {Array<object>} checks
|
|
586
|
+
* @param {string} id
|
|
587
|
+
* @param {object} next
|
|
588
|
+
*/
|
|
589
|
+
function replaceCheck(checks, id, next) {
|
|
590
|
+
const index = checks.findIndex((item) => item.id === id);
|
|
591
|
+
if (index === -1) checks.push(next);
|
|
592
|
+
else checks.splice(index, 1, next);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Human-readable doctor report. One primary failure + next fix.
|
|
597
|
+
* @param {object} report
|
|
598
|
+
* @returns {string}
|
|
599
|
+
*/
|
|
600
|
+
export function formatDoctorReport(report) {
|
|
601
|
+
const mark = { pass: "PASS", fail: "FAIL", skipped: "SKIP" };
|
|
602
|
+
const lines = [
|
|
603
|
+
`${report.tool} — connector ${report.connectorVersion}`,
|
|
604
|
+
`target: ${report.target?.name ?? "?"} @ ${report.target?.baseUrl ?? "(none)"}`,
|
|
605
|
+
"",
|
|
606
|
+
];
|
|
607
|
+
for (const item of report.checks) {
|
|
608
|
+
lines.push(` [${mark[item.status] ?? item.status}] ${item.id} — ${item.detail}`);
|
|
609
|
+
}
|
|
610
|
+
lines.push("");
|
|
611
|
+
if (report.primaryFailure) {
|
|
612
|
+
lines.push(`PRIMARY FAILURE: ${report.primaryFailure.id}`);
|
|
613
|
+
lines.push(`NEXT FIX: ${report.primaryFailure.nextFix || report.primaryFailure.detail}`);
|
|
614
|
+
} else {
|
|
615
|
+
lines.push("All gates passed (or skipped without a required fail).");
|
|
616
|
+
lines.push(report.happyPath);
|
|
617
|
+
}
|
|
618
|
+
return `${lines.join("\n")}\n`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* CLI entry for doctor.
|
|
623
|
+
* @param {string[]} argv Flags after the command name.
|
|
624
|
+
* @param {{stdout?: {write: function(string): void}, env?: NodeJS.ProcessEnv, fetch?: Function}} [io]
|
|
625
|
+
* @returns {Promise<number>}
|
|
626
|
+
*/
|
|
627
|
+
export async function runDoctorCli(argv, io = {}) {
|
|
628
|
+
const args = parseDoctorArgs(argv);
|
|
629
|
+
const stdout = io.stdout || process.stdout;
|
|
630
|
+
if (args.help) {
|
|
631
|
+
stdout.write(DOCTOR_USAGE);
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
const report = await runDoctor({
|
|
635
|
+
args,
|
|
636
|
+
env: io.env || process.env,
|
|
637
|
+
fetch: io.fetch,
|
|
638
|
+
});
|
|
639
|
+
if (args.json) {
|
|
640
|
+
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
641
|
+
} else {
|
|
642
|
+
stdout.write(formatDoctorReport(report));
|
|
643
|
+
}
|
|
644
|
+
return report.ok ? 0 : 1;
|
|
645
|
+
}
|
package/src/lib/module-tools.js
CHANGED
|
@@ -53,9 +53,24 @@ function entries(sites) {
|
|
|
53
53
|
return result;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Whether an inbound principal satisfies a module-tool policy scope.
|
|
58
|
+
* A config *read* binding historically declared `mcp_config`; the read-only
|
|
59
|
+
* `mcp_config_read` auditor scope is enough for those reads (#397).
|
|
60
|
+
* @param {object|null} identity
|
|
61
|
+
* @param {object} policy
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function principalHasPolicyScope(identity, policy) {
|
|
65
|
+
if (principalHasScope(identity, policy.scope)) return true;
|
|
66
|
+
return policy.operation === "read"
|
|
67
|
+
&& policy.scope === "mcp_config"
|
|
68
|
+
&& principalHasScope(identity, "mcp_config_read");
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
function allowed(entry, identity, sites, grants) {
|
|
57
72
|
const { site, policy } = entry;
|
|
58
|
-
if (identity && (!
|
|
73
|
+
if (identity && (!principalHasPolicyScope(identity, policy) ||
|
|
59
74
|
!resolveGrantedSites(identity, sites, grants).some((s) => s._name === site._name))) return false;
|
|
60
75
|
const sec = resolveSecurityConfig(site);
|
|
61
76
|
if (policy.operation !== "read" && sec.readOnly) return false;
|
|
@@ -155,13 +170,29 @@ export function isModuleTool(name) {
|
|
|
155
170
|
return typeof name === "string" && name.startsWith(PREFIX);
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Whether a binding policy scope satisfies a compatibility required.scope.
|
|
175
|
+
* Config reads accept either `mcp_config` or `mcp_config_read` (#397).
|
|
176
|
+
* @param {object} policy
|
|
177
|
+
* @param {object} required
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
function bindingScopeMatches(policy, required) {
|
|
181
|
+
if (policy.scope === required.scope) return true;
|
|
182
|
+
if (required.operation !== "read") return false;
|
|
183
|
+
const caps = required.capabilities ?? [];
|
|
184
|
+
if (!caps.includes("configRead")) return false;
|
|
185
|
+
const configReadScopes = new Set(["mcp_config", "mcp_config_read"]);
|
|
186
|
+
return configReadScopes.has(policy.scope) && configReadScopes.has(required.scope);
|
|
187
|
+
}
|
|
188
|
+
|
|
158
189
|
/** Resolves a local binding without granting access or contacting its provider. */
|
|
159
190
|
export function resolveModuleBinding(site, binding, required) {
|
|
160
191
|
const bindings = new Map(Object.entries(site.serverTools?.bindings ?? {}));
|
|
161
192
|
const alias = bindings.get(binding);
|
|
162
193
|
const entry = [...entries([site]).values()].find((item) => item.alias === alias);
|
|
163
194
|
if (!entry || entry.policy.operation !== required.operation ||
|
|
164
|
-
entry.policy
|
|
195
|
+
!bindingScopeMatches(entry.policy, required) ||
|
|
165
196
|
!required.capabilities.every((cap) => entry.policy.capabilities.includes(cap))) {
|
|
166
197
|
throw new SecurityError("Module binding is missing or does not satisfy the operation contract.");
|
|
167
198
|
}
|