drupal-mcp-connector 2.19.0 → 2.20.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.
Files changed (70) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +254 -0
  20. package/README.md +10 -3
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/dispatch.js +6 -6
  32. package/src/lib/drupal-fetch.js +97 -26
  33. package/src/lib/dry-run-checks.js +78 -0
  34. package/src/lib/error-body.js +448 -0
  35. package/src/lib/error-status.js +38 -0
  36. package/src/lib/errors.js +0 -11
  37. package/src/lib/evidence.js +0 -6
  38. package/src/lib/governance.js +2 -8
  39. package/src/lib/link-checker.js +3 -3
  40. package/src/lib/mcp-server.js +7 -1
  41. package/src/lib/metatag-audit.js +2 -1
  42. package/src/lib/module-tools.js +23 -2
  43. package/src/lib/operations.js +2 -2
  44. package/src/lib/patch-preflight.js +23 -5
  45. package/src/lib/policy-enforcement.js +4 -4
  46. package/src/lib/principal.js +3 -3
  47. package/src/lib/relay/edge.js +2 -2
  48. package/src/lib/reports-support.js +75 -0
  49. package/src/lib/security.js +234 -18
  50. package/src/lib/sentinel-draft.js +3 -2
  51. package/src/lib/server-tools.js +188 -27
  52. package/src/lib/tool-prompts.js +139 -8
  53. package/src/lib/usage.js +0 -9
  54. package/src/lib/verify.js +164 -61
  55. package/src/tools/config.js +70 -5
  56. package/src/tools/drush.js +191 -17
  57. package/src/tools/entities.js +18 -6
  58. package/src/tools/fields.js +40 -4
  59. package/src/tools/graphql.js +10 -5
  60. package/src/tools/nodes.js +16 -6
  61. package/src/tools/paragraphs.js +1 -1
  62. package/src/tools/reports-config.js +3 -3
  63. package/src/tools/reports-content.js +34 -26
  64. package/src/tools/reports-extra.js +46 -38
  65. package/src/tools/reports.js +50 -25
  66. package/src/tools/scheduler.js +1 -1
  67. package/src/tools/structure.js +12 -2
  68. package/src/tools/translations.js +5 -1
  69. package/src/lib/draft-write.js +0 -19
  70. package/src/lib/node-draft-inventory.js +0 -5
@@ -15,10 +15,10 @@ import { resolveSecurityConfig, assertNotReadOnly,
15
15
  import { toolError, toolResult } from "./errors.js";
16
16
  import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
17
17
  import { inferOperation } from "./operations.js";
18
- import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
18
+ import { assertSourceGovernance, GovernanceError } from "./governance.js";
19
19
  import {
20
- assertPrincipalEntitlement, callerTargetHints, getRequestIdentity,
21
- principalHasScope, resolveAuthoritativeTarget,
20
+ assertPrincipalEntitlement, callerTargetHints, DIAGNOSTIC_TOOLS,
21
+ getRequestIdentity, principalHasScope, resolveAuthoritativeTarget,
22
22
  } from "./principal.js";
23
23
  import { assertExplicitSiteForWrite, withResolvedTarget } from "./site-target.js";
24
24
  import { buildDataFlowContext, consumeBudgetIfEnforced, runWithDataFlow } from "./data-flow.js";
@@ -55,7 +55,7 @@ export function listResolvableSiteConfigs() {
55
55
  * @returns {?{site: object, source: string, name: string}}
56
56
  * @throws {SecurityError}
57
57
  */
58
- export function resolveCallTarget(toolName, rawArgs, context = {}) {
58
+ function resolveCallTarget(toolName, rawArgs, context = {}) {
59
59
  if (toolName === "drupal_list_sites") return null;
60
60
  if (toolName === "drupal_governance_status" && callerTargetHints(rawArgs).length === 0) {
61
61
  return null;
@@ -157,7 +157,7 @@ export async function securityMiddleware(toolName, args, handler, context = {})
157
157
  const assertCallAllowed = async () => {
158
158
  // Source-governance gate (#176). The diagnostic tools stay callable while
159
159
  // governance fails — they are how an operator learns which condition failed.
160
- if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
160
+ if (!DIAGNOSTIC_TOOLS.has(toolName)) {
161
161
  await assertSourceGovernance(site);
162
162
  }
163
163
 
@@ -188,7 +188,7 @@ export async function securityMiddleware(toolName, args, handler, context = {})
188
188
  await assertCallAllowed();
189
189
  // Charge only after governance and policy gates pass, so an outage or a
190
190
  // local deny cannot exhaust the window. Diagnostics do not consume.
191
- if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
191
+ if (!DIAGNOSTIC_TOOLS.has(toolName)) {
192
192
  consumeBudgetIfEnforced("chained_action");
193
193
  }
194
194
  return handler(nextArgs);
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Authenticated HTTP wrappers for Drupal JSON:API and file uploads.
3
+ * Northbound node-fetch calls share a 30s abort timeout (`DRUPAL_FETCH_TIMEOUT_MS`).
3
4
  */
4
5
 
5
6
  import fetch from "node-fetch";
@@ -19,9 +20,32 @@ import {
19
20
  sanitizeUploadFilename,
20
21
  validateMachineName,
21
22
  } from "./validate.js";
23
+ import { describeErrorBody, cleanGraphqlErrors, ERROR_DOCUMENT_MAX_CHARS } from "./error-body.js";
22
24
 
23
25
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
24
26
 
27
+ /** Default northbound Drupal HTTP timeout in milliseconds. Matches `sshDrush`. */
28
+ export const DRUPAL_FETCH_TIMEOUT_MS = 30_000;
29
+
30
+ /**
31
+ * node-fetch with the shared abort timeout. A caller `signal` wins.
32
+ * @param {string} url Fully-qualified request URL.
33
+ * @param {object} [options] node-fetch options (method, body, headers, signal).
34
+ * @returns {Promise<object>} node-fetch Response.
35
+ * @throws {Error} when the default timeout fires (`AbortError` remapped).
36
+ */
37
+ async function timedDrupalFetch(url, options = {}) {
38
+ const signal = options.signal ?? AbortSignal.timeout(DRUPAL_FETCH_TIMEOUT_MS);
39
+ try {
40
+ return await fetch(url, { ...options, signal });
41
+ } catch (err) {
42
+ if (err?.name === "AbortError" && !options.signal) {
43
+ throw new Error(`Drupal request timed out after ${DRUPAL_FETCH_TIMEOUT_MS / 1000}s`);
44
+ }
45
+ throw err;
46
+ }
47
+ }
48
+
25
49
  /**
26
50
  * Read a 2xx body once. Prefers text() so byte accounting can see the payload;
27
51
  * falls back to json() for test doubles that only implement that.
@@ -41,6 +65,45 @@ async function readOkBody(res) {
41
65
  return { json: await res.json(), text: "" };
42
66
  }
43
67
 
68
+ /**
69
+ * Build the error for a non-2xx response.
70
+ *
71
+ * The body is untrusted: it may be an HTML error page from Drupal, PHP or a
72
+ * proxy, or carry a server path, another user's filename or a backtrace. Only a
73
+ * cleaned, bounded detail is surfaced (see `describeErrorBody`); the prefix,
74
+ * which holds the HTTP status, is always kept because callers match on it
75
+ * (#343, #345).
76
+ * @param {object} res node-fetch Response with `ok === false`.
77
+ * @param {string} prefix Message start, e.g. "Drupal 404 on GET /jsonapi/x".
78
+ * @param {object} [options] Passed to `describeErrorBody`.
79
+ * @returns {Promise<Error>} A source budget denial, or the described failure
80
+ * with the HTTP status on its `status` property. Callers that branch on the
81
+ * status read it with `httpStatusOf()`, never from the message text (#355).
82
+ */
83
+ async function failedResponseError(res, prefix, options) {
84
+ let body;
85
+ try {
86
+ body = await res.text();
87
+ } catch {
88
+ return withStatus(new Error(`${prefix} (response body could not be read)`), res.status);
89
+ }
90
+ const mapped = sourceBudgetDenial(body);
91
+ if (mapped) return mapped;
92
+ const detail = describeErrorBody(body, res.headers?.get?.("content-type") ?? null, options);
93
+ return withStatus(new Error(detail ? `${prefix}: ${detail}` : `${prefix} (empty response body)`), res.status);
94
+ }
95
+
96
+ /**
97
+ * Set the HTTP status on an error.
98
+ * @param {Error} error Error to mark.
99
+ * @param {number} status HTTP status of the response.
100
+ * @returns {Error} The same error.
101
+ */
102
+ function withStatus(error, status) {
103
+ error.status = status;
104
+ return error;
105
+ }
106
+
44
107
  /**
45
108
  * Standard JSON:API request against a site.
46
109
  *
@@ -50,7 +113,10 @@ async function readOkBody(res) {
50
113
  * @param {string} path Path appended to site.baseUrl (e.g. "/jsonapi/node/article").
51
114
  * @param {object} [options] node-fetch options (method, body, extra headers).
52
115
  * @returns {Promise<object|null>} Parsed JSON body, or null for a 204 No Content.
53
- * @throws {Error} on any non-2xx response, with Drupal error detail when available.
116
+ * @throws {Error} on any non-2xx response. The message is
117
+ * `Drupal <status> on <method> <path>: <detail>`, where the detail is the
118
+ * cleaned, bounded `errors[].detail` text (see `describeErrorBody`). The raw
119
+ * body and any HTML page are never included.
54
120
  */
55
121
  export async function drupalFetch(site, path, options = {}) {
56
122
  const url = `${site.baseUrl}${path}`;
@@ -61,7 +127,7 @@ export async function drupalFetch(site, path, options = {}) {
61
127
  consumeBudgetIfEnforced("request", 1, { retry: isRetry || paid });
62
128
  if (collection) consumeBudgetIfEnforced("page", 1, { retry: isRetry || paid });
63
129
  paid = true;
64
- return fetch(url, {
130
+ return timedDrupalFetch(url, {
65
131
  ...options,
66
132
  headers: {
67
133
  "Content-Type": JSON_API_CONTENT_TYPE,
@@ -83,18 +149,11 @@ export async function drupalFetch(site, path, options = {}) {
83
149
  }
84
150
 
85
151
  if (!res.ok) {
86
- const body = await res.text();
87
- const mapped = sourceBudgetDenial(body);
88
- if (mapped) throw mapped;
89
- let detail = body;
90
- try {
91
- const parsed = JSON.parse(body);
92
- // Drupal JSON:API surfaces errors in errors[].detail
93
- if (parsed.errors?.length) {
94
- detail = parsed.errors.map((e) => e.detail || e.title).join("; ");
95
- }
96
- } catch { /* use raw body */ }
97
- throw new Error(`Drupal ${res.status} on ${options.method || "GET"} ${path}: ${detail}`);
152
+ throw await failedResponseError(
153
+ res,
154
+ `Drupal ${res.status} on ${options.method || "GET"} ${path}`,
155
+ { maxChars: ERROR_DOCUMENT_MAX_CHARS },
156
+ );
98
157
  }
99
158
 
100
159
  if (res.status === 204) return null; // No Content (e.g. DELETE success)
@@ -107,15 +166,22 @@ export async function drupalFetch(site, path, options = {}) {
107
166
  * GraphQL request — posts a JSON body to the site's GraphQL endpoint.
108
167
  * @param {object} site Resolved site config (provides baseUrl + auth).
109
168
  * @param {object} body GraphQL request body, e.g. { query, variables }.
110
- * @returns {Promise<object>} Parsed GraphQL JSON response.
169
+ * @returns {Promise<object>} Parsed GraphQL JSON response. `data` is returned
170
+ * as received. GraphQL reports a failed query as HTTP 200 with an `errors`
171
+ * array; that array is replaced by its cleaned, bounded form (see
172
+ * `cleanGraphqlErrors`): `message`, `path`, `locations` and the machine
173
+ * values of `extensions` only (#356).
111
174
  * @throws {Error} on any non-2xx response (clears the OAuth token cache on 401).
175
+ * The message is `GraphQL request failed <status>: <detail>`, where the detail
176
+ * is the cleaned, bounded `errors[].message` text (see `describeErrorBody`).
177
+ * The raw body and any HTML page are never included.
112
178
  */
113
179
  export async function drupalGraphqlFetch(site, body) {
114
180
  const endpoint = site.graphqlEndpoint || "/graphql";
115
181
  const url = `${site.baseUrl}${endpoint}`;
116
182
 
117
183
  consumeBudgetIfEnforced("request");
118
- const res = await fetch(url, {
184
+ const res = await timedDrupalFetch(url, {
119
185
  method: "POST",
120
186
  headers: {
121
187
  "Content-Type": "application/json",
@@ -131,14 +197,20 @@ export async function drupalGraphqlFetch(site, body) {
131
197
  // OAuth sites: a 401 likely means the token expired server-side. Clear the
132
198
  // cached token so the next request re-acquires, then surface the error.
133
199
  if (res.status === 401 && site.oauth) clearToken(site);
134
- const text = await res.text();
135
- const mapped = sourceBudgetDenial(text);
136
- if (mapped) throw mapped;
137
- throw new Error(`GraphQL request failed ${res.status}: ${text}`);
200
+ throw await failedResponseError(
201
+ res,
202
+ `GraphQL request failed ${res.status}`,
203
+ { maxChars: ERROR_DOCUMENT_MAX_CHARS },
204
+ );
138
205
  }
139
206
 
140
207
  const { json, text } = await readOkBody(res);
141
208
  accountNorthboundBody(json, text);
209
+ if (json && typeof json === "object" && !Array.isArray(json) && "errors" in json) {
210
+ json.errors = cleanGraphqlErrors(json.errors);
211
+ // An empty list is not an error; consumers test `json.errors` for truth.
212
+ if (!json.errors.length) delete json.errors;
213
+ }
142
214
  return json;
143
215
  }
144
216
 
@@ -159,7 +231,9 @@ export async function drupalGraphqlFetch(site, body) {
159
231
  * @param {string} fieldName File field on the bundle (e.g. "field_media_image").
160
232
  * @param {string} filePath Local path to the file to upload.
161
233
  * @returns {Promise<object>} Parsed JSON:API File entity response.
162
- * @throws {Error} on any non-2xx response.
234
+ * @throws {Error} on any non-2xx response. The message keeps the HTTP status and a
235
+ * cleaned, bounded detail from the body (see `describeErrorBody`); the raw body
236
+ * and any HTML page are never included.
163
237
  */
164
238
  export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
165
239
  // #137: machine-name segments + path allowlist before any FS or network I/O.
@@ -173,7 +247,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
173
247
  const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
174
248
 
175
249
  consumeBudgetIfEnforced("request");
176
- const res = await fetch(url, {
250
+ const res = await timedDrupalFetch(url, {
177
251
  method: "POST",
178
252
  headers: {
179
253
  "Content-Type": "application/octet-stream",
@@ -190,10 +264,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
190
264
  });
191
265
 
192
266
  if (!res.ok) {
193
- const body = await res.text();
194
- const mapped = sourceBudgetDenial(body);
195
- if (mapped) throw mapped;
196
- throw new Error(`File upload failed ${res.status}: ${body}`);
267
+ throw await failedResponseError(res, `File upload failed ${res.status}`);
197
268
  }
198
269
 
199
270
  return res.json();
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What a `dryRun` preview actually checked (#336).
3
+ *
4
+ * A preview that returns without a refusal reads as "this write will work".
5
+ * That is only true for the checks that ran, and most previews run few of
6
+ * them. Every dryRun result carries a `checks` block naming each check as
7
+ * `checked` or `not_checked`, plus a `caveat` sentence when anything was left
8
+ * out. `checked` always means "ran and passed": a failed check throws, so it
9
+ * never reaches a preview.
10
+ *
11
+ * Three server-side preflights exist:
12
+ *
13
+ * - `sentinel_draft` — Sentinel's non-saving draft endpoint. It receives the
14
+ * real attributes and relationships, applies them through core field
15
+ * access, validates the entity, and returns before saving. It predicts a
16
+ * field-access or validation refusal.
17
+ * - `core_patch_guard` — a core JSON:API PATCH with no fields and a
18
+ * non-matching `data.id`. The route checks entity update access and core
19
+ * checks the working-copy guard, then rejects the id. Core evaluates field
20
+ * access and validation only after the id check, so this probe says
21
+ * nothing about the submitted fields.
22
+ * - `none` — Drupal evaluated nothing about the write. The connector may
23
+ * still have read from Drupal (the existing entity, field definitions).
24
+ */
25
+
26
+ /** No server-side preflight ran. */
27
+ export const PREFLIGHT_NONE = "none";
28
+
29
+ /** Core's PATCH working-copy guard was probed with an empty, id-mismatched body. */
30
+ export const PREFLIGHT_CORE_GUARD = "core_patch_guard";
31
+
32
+ /** Sentinel's non-saving draft endpoint evaluated the real payload. */
33
+ export const PREFLIGHT_SENTINEL_DRAFT = "sentinel_draft";
34
+
35
+ const CHECKED = "checked";
36
+ const NOT_CHECKED = "not_checked";
37
+
38
+ const CAVEAT_CORE_GUARD =
39
+ "Field access and entity validation were NOT checked. The server-side probe carried no fields: " +
40
+ "it checked entity update access and core's working-copy guard only. " +
41
+ "The real write can still fail with a field-access 403 or a validation 422.";
42
+
43
+ const CAVEAT_NONE_WRITE =
44
+ "Drupal did not evaluate this write. Entity access, field access and entity validation were NOT checked. " +
45
+ "This preview shows the payload the connector would send after its own policy checks; " +
46
+ "the real write can still fail with a 403 or a validation 422.";
47
+
48
+ const CAVEAT_NONE_DELETE =
49
+ "Drupal did not evaluate this delete. Drupal's delete access for this entity was NOT checked; " +
50
+ "only the connector's own policy was. The real delete can still fail with a 403 or 404.";
51
+
52
+ /**
53
+ * Describe what a dryRun preview checked.
54
+ *
55
+ * Fails closed: an unknown or missing `preflight` is reported as `none`, so a
56
+ * caller that forgets to pass it cannot claim a check that did not run.
57
+ *
58
+ * @param {object} args
59
+ * @param {"create"|"update"|"delete"|string} args.operation Previewed operation.
60
+ * @param {?string} [args.preflight] One of the PREFLIGHT_* constants.
61
+ * @returns {{checks: {serverPreflight: string, connectorPolicy: string, entityAccess: string, revisionGuard: string, fieldAccess: string, entityValidation: string}, caveat?: string}}
62
+ */
63
+ export function dryRunChecks({ operation, preflight } = {}) {
64
+ const sentinel = preflight === PREFLIGHT_SENTINEL_DRAFT;
65
+ const core = preflight === PREFLIGHT_CORE_GUARD;
66
+ const contacted = sentinel || core;
67
+ const checks = {
68
+ serverPreflight: sentinel ? PREFLIGHT_SENTINEL_DRAFT : core ? PREFLIGHT_CORE_GUARD : PREFLIGHT_NONE,
69
+ connectorPolicy: CHECKED,
70
+ entityAccess: contacted ? CHECKED : NOT_CHECKED,
71
+ revisionGuard: contacted ? CHECKED : NOT_CHECKED,
72
+ fieldAccess: sentinel ? CHECKED : NOT_CHECKED,
73
+ entityValidation: sentinel ? CHECKED : NOT_CHECKED,
74
+ };
75
+ if (sentinel) return { checks };
76
+ if (core) return { checks, caveat: CAVEAT_CORE_GUARD };
77
+ return { checks, caveat: operation === "delete" ? CAVEAT_NONE_DELETE : CAVEAT_NONE_WRITE };
78
+ }