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.
- package/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +254 -0
- package/README.md +10 -3
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/dispatch.js +6 -6
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/errors.js +0 -11
- package/src/lib/evidence.js +0 -6
- package/src/lib/governance.js +2 -8
- package/src/lib/link-checker.js +3 -3
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/operations.js +2 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/policy-enforcement.js +4 -4
- package/src/lib/principal.js +3 -3
- package/src/lib/relay/edge.js +2 -2
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +234 -18
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +139 -8
- package/src/lib/usage.js +0 -9
- package/src/lib/verify.js +164 -61
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/nodes.js +16 -6
- package/src/tools/paragraphs.js +1 -1
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- package/src/tools/translations.js +5 -1
- package/src/lib/draft-write.js +0 -19
- package/src/lib/node-draft-inventory.js +0 -5
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a Drupal error response body into a short, safe detail string.
|
|
3
|
+
*
|
|
4
|
+
* An error body is untrusted text. It can be a JSON:API error document, an
|
|
5
|
+
* HTML error page from Drupal, PHP or a proxy, or plain text of any length. It
|
|
6
|
+
* can carry a server file path, a filename another user supplied, or a stack
|
|
7
|
+
* fragment. Only the part meant for the caller is surfaced (#343, #345).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Longest single detail string surfaced to the caller, in characters. */
|
|
11
|
+
export const ERROR_DETAIL_MAX_CHARS = 400;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Longest joined detail for an error document that lists several errors, in
|
|
15
|
+
* characters. A JSON:API 422 names one violation per error, and a caller needs
|
|
16
|
+
* the whole list to correct its payload, so the request paths allow more than
|
|
17
|
+
* one detail's worth. Each detail is still cut to {@link ERROR_DETAIL_MAX_CHARS}.
|
|
18
|
+
*/
|
|
19
|
+
export const ERROR_DOCUMENT_MAX_CHARS = 1200;
|
|
20
|
+
|
|
21
|
+
/** Longest HTML `<title>` surfaced to the caller, in characters. */
|
|
22
|
+
const HTML_TITLE_MAX_CHARS = 80;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Text is cut to this many characters before any pattern runs over it. The
|
|
26
|
+
* tag pattern is quadratic on a run of `<` with no `>`, so the bound also caps
|
|
27
|
+
* the work a hostile body can cause.
|
|
28
|
+
*/
|
|
29
|
+
const BODY_SCAN_MAX_CHARS = 16384;
|
|
30
|
+
|
|
31
|
+
const TRUNCATED_SUFFIX = "… [truncated]";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove markup. Tags are removed until none is left, so a tag cannot be
|
|
35
|
+
* rebuilt from the pieces around a removed one (`<scr<b>ipt>`), and any angle
|
|
36
|
+
* bracket that remains is dropped.
|
|
37
|
+
* @param {string} text Untrusted text.
|
|
38
|
+
* @returns {string} Text with no `<` or `>`.
|
|
39
|
+
*/
|
|
40
|
+
function stripTags(text) {
|
|
41
|
+
let out = text;
|
|
42
|
+
let previous;
|
|
43
|
+
do {
|
|
44
|
+
previous = out;
|
|
45
|
+
out = out.replace(/<[^>]*>/g, "");
|
|
46
|
+
} while (out !== previous);
|
|
47
|
+
return out.replace(/[<>]/g, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* First segments that mark a filesystem path. Several are also plausible URL
|
|
52
|
+
* prefixes (`/home`, `/data`, `/web`); a match is redacted either way, so the
|
|
53
|
+
* rule errs towards hiding a path.
|
|
54
|
+
*/
|
|
55
|
+
const FILESYSTEM_ROOTS = new Set([
|
|
56
|
+
"var", "home", "srv", "usr", "opt", "tmp", "etc", "app", "mnt", "private", "users", "data", "code",
|
|
57
|
+
"workspace", "builds", "run", "proc", "sys", "lib", "bin", "root", "www", "sites", "vendor", "web",
|
|
58
|
+
"docroot", "html", "dev", "sbin", "boot", "lib64", "snap", "nix", "volumes",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Segments, at any depth, that mark a code tree, a web root or a file directory. */
|
|
62
|
+
const SERVER_TREE_SEGMENTS = new Set([
|
|
63
|
+
"vendor", "node_modules", "core", "modules", "themes", "profiles", "sites", "src", "lib", "docroot",
|
|
64
|
+
"public_html", "htdocs", "files", "private", "tmp",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** File extensions that mark a server-side file rather than a page. */
|
|
68
|
+
const SERVER_FILE_EXTENSIONS = new Set([
|
|
69
|
+
"php", "inc", "module", "install", "theme", "engine", "yml", "yaml", "twig", "log", "sql", "sh", "env",
|
|
70
|
+
"ini", "conf", "json", "lock", "phar",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether a slash-led path names something on the server's filesystem rather
|
|
75
|
+
* than a site-relative URL. A path is a filesystem path when it has two or
|
|
76
|
+
* more segments and
|
|
77
|
+
* - its first segment is a known filesystem root (`/var/...`, `/tmp/...`), or
|
|
78
|
+
* - any segment marks a code or server tree (`vendor`, `modules`, `files`), or
|
|
79
|
+
* - any segment carries a server-side file extension (`settings.php`,
|
|
80
|
+
* `.env.local`, `dump.sql.gz`).
|
|
81
|
+
* Segments compare case-insensitively. Everything else (`/about/team`,
|
|
82
|
+
* `/node/12/edit`) is a URL path.
|
|
83
|
+
* @param {string} path Slash-led path, as matched in an error text.
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isFilesystemPath(path) {
|
|
87
|
+
const segments = path.toLowerCase().split("/").filter(Boolean);
|
|
88
|
+
if (segments.length < 2) return false;
|
|
89
|
+
if (FILESYSTEM_ROOTS.has(segments[0])) return true;
|
|
90
|
+
return segments.some((segment) => SERVER_TREE_SEGMENTS.has(segment)
|
|
91
|
+
|| segment.split(".").slice(1).some((part) => SERVER_FILE_EXTENSIONS.has(part)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Strip markup and control characters, cut a backtrace, redact server paths,
|
|
96
|
+
* collapse whitespace, and bound the length.
|
|
97
|
+
* @param {*} text Untrusted text.
|
|
98
|
+
* @param {number} [max] Character bound.
|
|
99
|
+
* @returns {string} Cleaned text, or "" when nothing is left.
|
|
100
|
+
*/
|
|
101
|
+
export function cleanErrorText(text, max = ERROR_DETAIL_MAX_CHARS) {
|
|
102
|
+
if (typeof text !== "string") return "";
|
|
103
|
+
const cleaned = stripTags(text.slice(0, BODY_SCAN_MAX_CHARS))
|
|
104
|
+
// A PHP or Drupal backtrace is never for the caller: cut from its marker on.
|
|
105
|
+
.replace(/(?:stack trace|backtrace|call stack):[\s\S]*$/i, "[stack trace removed]")
|
|
106
|
+
// ANSI colour sequences.
|
|
107
|
+
.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
|
|
108
|
+
// Drupal stream-wrapper URIs name files other users uploaded.
|
|
109
|
+
.replace(/\b(public|private|temporary|s3|assets):\/\/[^\s"'),;]+/gi, "$1://[path]")
|
|
110
|
+
// Local-file URIs.
|
|
111
|
+
.replace(/\b(file|phar):\/\/[^\s"'),;]+/gi, "$1://[path]")
|
|
112
|
+
// Windows drive paths (`C:\dir`, `C:/dir`) and UNC paths (`\\host\share`).
|
|
113
|
+
.replace(/(?<!\w)[A-Za-z]:(?:\\+|\/(?!\/))[^\s"'),;|*?]*/g, "[path]")
|
|
114
|
+
.replace(/(?<![\w\\])\\\\[\w.$-]+\\[^\s"'),;|*?]*/g, "[path]")
|
|
115
|
+
// Slash-led paths. A filesystem path is redacted; a site-relative URL path
|
|
116
|
+
// is kept, because the caller sent it and has to read it back (#357). The
|
|
117
|
+
// path part of an absolute URL never matches: its slash follows a word
|
|
118
|
+
// character, another slash, or the colon of `scheme://`. A path straight
|
|
119
|
+
// after any other colon (`include_path=.:/usr/share/php`,
|
|
120
|
+
// `internal:/about/team`) is judged like the rest.
|
|
121
|
+
.replace(/(?:(?<![\w:/.\]])|(?<=:)(?!\/\/))\/[\w.@%+~/-]+/g, (match) => (isFilesystemPath(match) ? "[path]" : match))
|
|
122
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
123
|
+
.replace(/\s+/g, " ")
|
|
124
|
+
.trim();
|
|
125
|
+
return boundText(cleaned, max);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Cut already-cleaned text to a length, marking the cut.
|
|
130
|
+
* @param {string} text Cleaned text.
|
|
131
|
+
* @param {number} max Character bound.
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function boundText(text, max) {
|
|
135
|
+
if (text.length <= max) return text;
|
|
136
|
+
if (text.endsWith(TRUNCATED_SUFFIX) && text.length <= max + TRUNCATED_SUFFIX.length) return text;
|
|
137
|
+
return text.slice(0, max).trimEnd() + TRUNCATED_SUFFIX;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a body is an HTML (or XML) page rather than a message.
|
|
142
|
+
* @param {string} body Response body.
|
|
143
|
+
* @param {?string} contentType Response Content-Type header.
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function looksLikeMarkupPage(body, contentType) {
|
|
147
|
+
if (typeof contentType === "string" && /\b(html|xml)\b/i.test(contentType) && !/json/i.test(contentType)) return true;
|
|
148
|
+
const head = body.slice(0, 1024).trimStart().toLowerCase();
|
|
149
|
+
// Any body that opens with a tag is a page or a PHP html_errors dump
|
|
150
|
+
// (`<br />\n<b>Fatal error</b>: …`), never a message meant for the caller.
|
|
151
|
+
return head.startsWith("<");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read the detail strings of a parsed JSON error body.
|
|
156
|
+
* @param {*} parsed Parsed JSON.
|
|
157
|
+
* @returns {?string[]} Detail strings, or null when the JSON is not an error document.
|
|
158
|
+
*/
|
|
159
|
+
function jsonErrorDetails(parsed) {
|
|
160
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
161
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length) {
|
|
162
|
+
// Drupal JSON:API surfaces errors in errors[].detail, GraphQL in
|
|
163
|
+
// errors[].message. `source`, `meta`, `links`, `locations` and `extensions`
|
|
164
|
+
// are never read: with verbose errors on they hold a backtrace.
|
|
165
|
+
return parsed.errors
|
|
166
|
+
.map((e) => [e?.detail, e?.title, e?.message].find((value) => typeof value === "string" && value) || "")
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
const message = typeof parsed.message === "string" && parsed.message ? parsed.message : "";
|
|
170
|
+
// OAuth 2.0 error document (RFC 6749 §5.2): { "error": "<code>", "error_description": "…" }.
|
|
171
|
+
// `hint` is never read: it can name a key file on the server.
|
|
172
|
+
if (typeof parsed.error === "string" && parsed.error) {
|
|
173
|
+
const description = typeof parsed.error_description === "string" && parsed.error_description
|
|
174
|
+
? parsed.error_description
|
|
175
|
+
: message;
|
|
176
|
+
return [description ? `${parsed.error}: ${description}` : parsed.error];
|
|
177
|
+
}
|
|
178
|
+
// Drupal's non-JSON:API JSON errors: { "message": "…" }.
|
|
179
|
+
if (message) return [message];
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Most details of one error document that are cleaned, whatever the bound. */
|
|
184
|
+
const ERROR_DETAILS_MAX_COUNT = 50;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clean each detail on its own and join them, so a backtrace or an oversized
|
|
188
|
+
* string in one error does not remove the errors after it. Cleaning stops once
|
|
189
|
+
* the bound is reached or {@link ERROR_DETAILS_MAX_COUNT} details were read, so
|
|
190
|
+
* a document with a very long `errors` array costs a bounded amount of work.
|
|
191
|
+
* @param {string[]} details Untrusted detail strings.
|
|
192
|
+
* @param {number} maxChars Bound for the joined text.
|
|
193
|
+
* @returns {string} Joined, bounded text, or "" when nothing is left.
|
|
194
|
+
*/
|
|
195
|
+
function joinCleanDetails(details, maxChars) {
|
|
196
|
+
const cleaned = [];
|
|
197
|
+
let length = 0;
|
|
198
|
+
for (const detail of details.slice(0, ERROR_DETAILS_MAX_COUNT)) {
|
|
199
|
+
const text = cleanErrorText(detail);
|
|
200
|
+
if (!text) continue;
|
|
201
|
+
cleaned.push(text);
|
|
202
|
+
length += text.length + 2;
|
|
203
|
+
if (length > maxChars) break;
|
|
204
|
+
}
|
|
205
|
+
if (!cleaned.length) return "";
|
|
206
|
+
const joined = boundText(cleaned.join("; "), maxChars);
|
|
207
|
+
const dropped = details.length > ERROR_DETAILS_MAX_COUNT && !joined.endsWith(TRUNCATED_SUFFIX);
|
|
208
|
+
return dropped ? joined + TRUNCATED_SUFFIX : joined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Describe an error response body for the caller.
|
|
213
|
+
*
|
|
214
|
+
* - JSON:API or GraphQL error document: `errors[].detail` (or `title`, or
|
|
215
|
+
* `message`), each cleaned on its own, joined with "; ".
|
|
216
|
+
* - OAuth error document: `error` and `error_description`.
|
|
217
|
+
* - `{ message }` JSON: the message.
|
|
218
|
+
* - Other JSON: a fixed sentence. The body is not shown.
|
|
219
|
+
* - HTML or XML page: a fixed sentence plus the page `<title>`. The body is
|
|
220
|
+
* never shown.
|
|
221
|
+
* - Plain text: the text.
|
|
222
|
+
*
|
|
223
|
+
* Every surfaced string has markup and control characters stripped, server
|
|
224
|
+
* paths redacted, and is cut to {@link ERROR_DETAIL_MAX_CHARS}. The joined
|
|
225
|
+
* details of an error document are cut to `options.maxChars`.
|
|
226
|
+
*
|
|
227
|
+
* @param {*} body Response body text.
|
|
228
|
+
* @param {?string} [contentType] Response Content-Type header, when known.
|
|
229
|
+
* @param {object} [options]
|
|
230
|
+
* @param {number} [options.maxChars] Bound for the joined details of an error
|
|
231
|
+
* document. Defaults to {@link ERROR_DETAIL_MAX_CHARS}; never below it.
|
|
232
|
+
* @returns {string} Detail for the caller, or "" for an empty body.
|
|
233
|
+
*/
|
|
234
|
+
export function describeErrorBody(body, contentType = null, options = {}) {
|
|
235
|
+
if (typeof body !== "string" || !body.trim()) return "";
|
|
236
|
+
|
|
237
|
+
let parsed;
|
|
238
|
+
let isJson = false;
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(body);
|
|
241
|
+
isJson = parsed !== null && typeof parsed === "object";
|
|
242
|
+
} catch { /* not JSON */ }
|
|
243
|
+
|
|
244
|
+
if (isJson) {
|
|
245
|
+
const details = jsonErrorDetails(parsed);
|
|
246
|
+
const maxChars = Number.isFinite(options.maxChars)
|
|
247
|
+
? Math.max(ERROR_DETAIL_MAX_CHARS, Math.floor(options.maxChars))
|
|
248
|
+
: ERROR_DETAIL_MAX_CHARS;
|
|
249
|
+
const text = details ? joinCleanDetails(details, maxChars) : "";
|
|
250
|
+
return text || "the server returned JSON with no error detail, not shown";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (looksLikeMarkupPage(body, contentType)) {
|
|
254
|
+
const scan = body.slice(0, BODY_SCAN_MAX_CHARS);
|
|
255
|
+
const open = scan.search(/<title[^>]*>/i);
|
|
256
|
+
const close = open === -1 ? -1 : scan.toLowerCase().indexOf("</title", open);
|
|
257
|
+
const title = close === -1 ? "" : cleanErrorText(scan.slice(open, close), HTML_TITLE_MAX_CHARS);
|
|
258
|
+
return "the server returned an HTML page, not shown" + (title ? ` (title: ${title})` : "");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return cleanErrorText(body) || "the server returned a body with no readable text, not shown";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Bound for the summed string length of one cleaned failure payload, in characters. */
|
|
265
|
+
export const ERROR_DATA_MAX_CHARS = 4000;
|
|
266
|
+
|
|
267
|
+
/** Most entries of one array or object kept in a cleaned failure payload. */
|
|
268
|
+
export const ERROR_DATA_MAX_ENTRIES = 50;
|
|
269
|
+
|
|
270
|
+
/** Deepest nesting kept in a cleaned failure payload. */
|
|
271
|
+
export const ERROR_DATA_MAX_DEPTH = 6;
|
|
272
|
+
|
|
273
|
+
/** Bound for one key of a cleaned failure payload, in characters. */
|
|
274
|
+
const ERROR_DATA_KEY_MAX_CHARS = 100;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Clean a structured failure payload a tool returned.
|
|
278
|
+
*
|
|
279
|
+
* A module tool's failure is application data the caller needs: a message, a
|
|
280
|
+
* code, the fields that failed. Its shape is kept. Every string in it is
|
|
281
|
+
* untrusted text, so each one goes through {@link cleanErrorText}. Keys are
|
|
282
|
+
* cleaned too. Numbers, booleans and null pass through. The summed string
|
|
283
|
+
* length, the entries per level and the depth are bounded; what is dropped is
|
|
284
|
+
* marked, never silently lost.
|
|
285
|
+
* @param {*} value Parsed failure payload, or its raw text.
|
|
286
|
+
* @returns {*} Cleaned payload with the same shape.
|
|
287
|
+
*/
|
|
288
|
+
export function cleanErrorData(value) {
|
|
289
|
+
return cleanErrorNode(value, 0, { chars: ERROR_DATA_MAX_CHARS });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Clean one node of a failure payload.
|
|
294
|
+
* @param {*} value Node.
|
|
295
|
+
* @param {number} depth Nesting depth of the node.
|
|
296
|
+
* @param {{chars: number}} budget Characters left for strings, shared by the walk.
|
|
297
|
+
* @returns {*} Cleaned node.
|
|
298
|
+
*/
|
|
299
|
+
function cleanErrorNode(value, depth, budget) {
|
|
300
|
+
if (typeof value === "string") {
|
|
301
|
+
if (budget.chars <= 0) return value ? TRUNCATED_SUFFIX.trim() : "";
|
|
302
|
+
const text = cleanErrorText(value, Math.min(ERROR_DETAIL_MAX_CHARS, budget.chars));
|
|
303
|
+
budget.chars -= text.length;
|
|
304
|
+
return text;
|
|
305
|
+
}
|
|
306
|
+
if (value === null || typeof value === "boolean") return value;
|
|
307
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
308
|
+
if (typeof value !== "object") return null;
|
|
309
|
+
if (depth >= ERROR_DATA_MAX_DEPTH) return TRUNCATED_SUFFIX.trim();
|
|
310
|
+
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
const items = value.slice(0, ERROR_DATA_MAX_ENTRIES).map((item) => cleanErrorNode(item, depth + 1, budget));
|
|
313
|
+
if (value.length > ERROR_DATA_MAX_ENTRIES) items.push(TRUNCATED_SUFFIX.trim());
|
|
314
|
+
return items;
|
|
315
|
+
}
|
|
316
|
+
const cleaned = new Map();
|
|
317
|
+
const entries = Object.entries(value);
|
|
318
|
+
for (const [key, item] of entries.slice(0, ERROR_DATA_MAX_ENTRIES)) {
|
|
319
|
+
const name = cleanErrorText(key, ERROR_DATA_KEY_MAX_CHARS);
|
|
320
|
+
if (!name || cleaned.has(name)) continue;
|
|
321
|
+
cleaned.set(name, cleanErrorNode(item, depth + 1, budget));
|
|
322
|
+
}
|
|
323
|
+
if (entries.length > ERROR_DATA_MAX_ENTRIES) cleaned.set("_truncated", true);
|
|
324
|
+
return Object.fromEntries(cleaned);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Most GraphQL errors of one 200 response that are kept. */
|
|
328
|
+
export const GRAPHQL_ERRORS_MAX_COUNT = 50;
|
|
329
|
+
|
|
330
|
+
/** Bound for the summed message length of the kept GraphQL errors, in characters. */
|
|
331
|
+
export const GRAPHQL_ERRORS_MAX_CHARS = 4000;
|
|
332
|
+
|
|
333
|
+
/** Most `path` segments and `locations` entries kept on one GraphQL error. */
|
|
334
|
+
const GRAPHQL_PATH_MAX_SEGMENTS = 32;
|
|
335
|
+
const GRAPHQL_LOCATIONS_MAX_COUNT = 10;
|
|
336
|
+
|
|
337
|
+
/** Longest `path` segment or `extensions` value, in characters. */
|
|
338
|
+
const GRAPHQL_FIELD_MAX_CHARS = 100;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Clean one short machine value of `extensions`.
|
|
342
|
+
* @param {*} value Untrusted value.
|
|
343
|
+
* @returns {string|number|undefined} The value, or undefined when it is not a
|
|
344
|
+
* non-empty string or a finite number.
|
|
345
|
+
*/
|
|
346
|
+
function graphqlMachineValue(value) {
|
|
347
|
+
if (typeof value === "string") return cleanErrorText(value, GRAPHQL_FIELD_MAX_CHARS) || undefined;
|
|
348
|
+
return Number.isFinite(value) ? value : undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const GRAPHQL_NO_MESSAGE = "GraphQL error with no message";
|
|
352
|
+
const GRAPHQL_DROPPED_RE = /^\d+ more errors not shown$/;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Clean one GraphQL error. Only `message`, `path`, `locations` and the machine
|
|
356
|
+
* values of `extensions` are read; nothing else is copied.
|
|
357
|
+
* @param {*} error One entry of a GraphQL `errors` array.
|
|
358
|
+
* @returns {{message: string, path?: Array<string|number>, locations?: Array<{line: number, column: number}>, extensions?: object}}
|
|
359
|
+
*/
|
|
360
|
+
function cleanGraphqlError(error) {
|
|
361
|
+
if (typeof error === "string") return { message: cleanErrorText(error) || GRAPHQL_NO_MESSAGE };
|
|
362
|
+
const out = { message: cleanErrorText(error?.message) || GRAPHQL_NO_MESSAGE };
|
|
363
|
+
|
|
364
|
+
if (Array.isArray(error?.path)) {
|
|
365
|
+
// A path segment is a response key (an alias the caller chose) or an index.
|
|
366
|
+
const path = error.path
|
|
367
|
+
.slice(0, GRAPHQL_PATH_MAX_SEGMENTS * 2)
|
|
368
|
+
.map((segment) => (Number.isInteger(segment) ? segment : cleanErrorText(segment, GRAPHQL_FIELD_MAX_CHARS)))
|
|
369
|
+
.filter((segment) => segment !== "")
|
|
370
|
+
.slice(0, GRAPHQL_PATH_MAX_SEGMENTS);
|
|
371
|
+
if (path.length) out.path = path;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (Array.isArray(error?.locations)) {
|
|
375
|
+
// A location points into the caller's query document, not into the server.
|
|
376
|
+
const locations = error.locations
|
|
377
|
+
.slice(0, GRAPHQL_LOCATIONS_MAX_COUNT)
|
|
378
|
+
.filter((l) => Number.isInteger(l?.line) && Number.isInteger(l?.column))
|
|
379
|
+
.map((l) => ({ line: l.line, column: l.column }));
|
|
380
|
+
if (locations.length) out.locations = locations;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (error?.extensions && typeof error.extensions === "object") {
|
|
384
|
+
// Only the short machine values a caller matches on. Everything else under
|
|
385
|
+
// `extensions` (`trace`, `debugMessage`, `file`, `line`, `exception`,
|
|
386
|
+
// `stacktrace`) is dropped.
|
|
387
|
+
const { code, category, classification } = error.extensions;
|
|
388
|
+
const extensions = Object.fromEntries(Object.entries({
|
|
389
|
+
code: graphqlMachineValue(code),
|
|
390
|
+
category: graphqlMachineValue(category),
|
|
391
|
+
classification: graphqlMachineValue(classification),
|
|
392
|
+
}).filter(([, value]) => value !== undefined));
|
|
393
|
+
if (Object.keys(extensions).length) out.extensions = extensions;
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Clean the `errors` of a GraphQL response that arrived with a 2xx status.
|
|
400
|
+
*
|
|
401
|
+
* GraphQL reports a failed query as HTTP 200 with an `errors` array, so these
|
|
402
|
+
* errors never pass through {@link describeErrorBody}. With verbose errors on,
|
|
403
|
+
* a message can carry markup or a server path, and `extensions` can carry a
|
|
404
|
+
* backtrace (#356). Each message gets the {@link cleanErrorText} treatment. At
|
|
405
|
+
* most {@link GRAPHQL_ERRORS_MAX_COUNT} errors and
|
|
406
|
+
* {@link GRAPHQL_ERRORS_MAX_CHARS} characters of message are kept; a last entry
|
|
407
|
+
* says how many were dropped. The result is stable when cleaned again.
|
|
408
|
+
* @param {*} errors The `errors` value of a GraphQL response.
|
|
409
|
+
* @returns {Array<object>} Cleaned errors; empty when there is none.
|
|
410
|
+
*/
|
|
411
|
+
export function cleanGraphqlErrors(errors) {
|
|
412
|
+
// Any falsy value (`null`, `""`, `0`) means the response reports no error.
|
|
413
|
+
if (!errors) return [];
|
|
414
|
+
const list = Array.isArray(errors) ? errors : [errors];
|
|
415
|
+
if (!list.length) return [];
|
|
416
|
+
|
|
417
|
+
// A list that was cleaned before ends with the dropped-count entry. Count
|
|
418
|
+
// what it stands for, so cleaning twice does not lose the number.
|
|
419
|
+
const last = list.at(-1);
|
|
420
|
+
const lastMessage = typeof last?.message === "string" ? last.message : "";
|
|
421
|
+
const carried = GRAPHQL_DROPPED_RE.test(lastMessage) ? Number.parseInt(lastMessage, 10) : 0;
|
|
422
|
+
const source = carried ? list.slice(0, -1) : list;
|
|
423
|
+
|
|
424
|
+
const cleaned = [];
|
|
425
|
+
let chars = 0;
|
|
426
|
+
for (const error of source.slice(0, GRAPHQL_ERRORS_MAX_COUNT)) {
|
|
427
|
+
const entry = cleanGraphqlError(error);
|
|
428
|
+
if (cleaned.length && chars + entry.message.length > GRAPHQL_ERRORS_MAX_CHARS) break;
|
|
429
|
+
cleaned.push(entry);
|
|
430
|
+
chars += entry.message.length;
|
|
431
|
+
}
|
|
432
|
+
const dropped = source.length - cleaned.length + carried;
|
|
433
|
+
if (dropped > 0) cleaned.push({ message: `${dropped} more errors not shown` });
|
|
434
|
+
return cleaned;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Join the messages of a GraphQL `errors` value into one bounded string, for
|
|
439
|
+
* an error message or a report reason.
|
|
440
|
+
* @param {*} errors The `errors` value of a GraphQL response, cleaned or not.
|
|
441
|
+
* @param {number} [maxChars] Bound for the joined text.
|
|
442
|
+
* @returns {string} Joined text, or "" when there is no error.
|
|
443
|
+
*/
|
|
444
|
+
export function describeGraphqlErrors(errors, maxChars = ERROR_DOCUMENT_MAX_CHARS) {
|
|
445
|
+
const cleaned = cleanGraphqlErrors(errors);
|
|
446
|
+
if (!cleaned.length) return "";
|
|
447
|
+
return boundText(cleaned.map((e) => e.message).join("; "), maxChars);
|
|
448
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the HTTP status of a failed Drupal request from its error.
|
|
3
|
+
*
|
|
4
|
+
* `drupalFetch`, `drupalGraphqlFetch`, the upload helper and the server-tool
|
|
5
|
+
* bridge set `status` on the error they throw. Code that branches on the status reads it here instead of
|
|
6
|
+
* testing the message for a number: the message also holds the request path and
|
|
7
|
+
* Drupal's detail text, and either can contain "404" or "401" (#355).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The documented message shapes, anchored at the start:
|
|
12
|
+
* `Drupal <status> on <method> <path>…`, `GraphQL request failed <status>…`,
|
|
13
|
+
* `File upload failed <status>…`, `Server-tool call <tool> failed <status>…` and
|
|
14
|
+
* `Server-tool session initialize failed <status>…`. A tool name holds no
|
|
15
|
+
* whitespace, so the token after it is the status and nothing in the body that
|
|
16
|
+
* follows can stand in for it (#361).
|
|
17
|
+
*/
|
|
18
|
+
const STATUS_PREFIX_RE =
|
|
19
|
+
/^(?:Drupal|GraphQL request failed|File upload failed|Server-tool call \S+ failed|Server-tool session initialize failed) (\d{3})(?!\w)/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* HTTP status of a failed request.
|
|
23
|
+
*
|
|
24
|
+
* The `status` property decides when it is an integer from 100 to 599. An error
|
|
25
|
+
* with no such property (one built from a message alone, or a plain string) is
|
|
26
|
+
* read from the status token at the start of a documented message. A number
|
|
27
|
+
* anywhere else in the text is never a status.
|
|
28
|
+
* @param {unknown} err Error, or message string.
|
|
29
|
+
* @returns {?number} The status, or null when the error carries none.
|
|
30
|
+
*/
|
|
31
|
+
export function httpStatusOf(err) {
|
|
32
|
+
const status = err?.status;
|
|
33
|
+
if (Number.isInteger(status) && status >= 100 && status <= 599) return status;
|
|
34
|
+
const message = typeof err === "string" ? err : err?.message;
|
|
35
|
+
if (typeof message !== "string") return null;
|
|
36
|
+
const match = STATUS_PREFIX_RE.exec(message);
|
|
37
|
+
return match ? Number(match[1]) : null;
|
|
38
|
+
}
|
package/src/lib/errors.js
CHANGED
|
@@ -25,14 +25,3 @@ export function toolResult(data) {
|
|
|
25
25
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Wrap a plain string message (for confirmations, warnings, etc.).
|
|
31
|
-
* @param {string} text Message text.
|
|
32
|
-
* @returns {{content: Array<{type: string, text: string}>}}
|
|
33
|
-
*/
|
|
34
|
-
export function toolMessage(text) {
|
|
35
|
-
return {
|
|
36
|
-
content: [{ type: "text", text }],
|
|
37
|
-
};
|
|
38
|
-
}
|
package/src/lib/evidence.js
CHANGED
|
@@ -36,12 +36,6 @@ export const EXECUTION_IDS = Object.freeze([
|
|
|
36
36
|
"receiptId",
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
|
-
export const RECONCILE_STATES = Object.freeze([
|
|
40
|
-
"settled",
|
|
41
|
-
"incomplete",
|
|
42
|
-
"mismatched",
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
39
|
const DEFAULT_MAX_RECORDS = 10_000;
|
|
46
40
|
|
|
47
41
|
const FORBIDDEN_EXPORT_KEYS = new Set([
|
package/src/lib/governance.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import fetch from "node-fetch";
|
|
20
20
|
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
21
|
+
import { DIAGNOSTIC_TOOLS } from "./principal.js";
|
|
21
22
|
|
|
22
23
|
/** How long a passing verification stays fresh before it must be re-proven. */
|
|
23
24
|
export const OK_TTL_MS = 60_000;
|
|
@@ -25,13 +26,6 @@ export const OK_TTL_MS = 60_000;
|
|
|
25
26
|
/** How long a failed verification is held before the next attempt re-checks. */
|
|
26
27
|
export const FAIL_TTL_MS = 5_000;
|
|
27
28
|
|
|
28
|
-
/** Tools that stay discoverable and callable while governance is failing —
|
|
29
|
-
* the diagnostic surface an operator needs to see WHY it is failing. */
|
|
30
|
-
export const GOVERNANCE_DIAGNOSTIC_TOOLS = new Set([
|
|
31
|
-
"drupal_list_sites",
|
|
32
|
-
"drupal_governance_status",
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
29
|
/** Denial for a governed path whose source-governance contract is not verified. */
|
|
36
30
|
export class GovernanceError extends Error {
|
|
37
31
|
/**
|
|
@@ -203,5 +197,5 @@ export async function filterDiscoverableTools(definitions, sites) {
|
|
|
203
197
|
const verdicts = await Promise.all(sites.map(async (site) =>
|
|
204
198
|
!requiresGovernance(site) || (await verifySourceGovernance(site)).ok));
|
|
205
199
|
if (verdicts.some(Boolean)) return definitions;
|
|
206
|
-
return definitions.filter((d) =>
|
|
200
|
+
return definitions.filter((d) => DIAGNOSTIC_TOOLS.has(d.name));
|
|
207
201
|
}
|
package/src/lib/link-checker.js
CHANGED
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
import nodeFetch from "node-fetch";
|
|
19
19
|
|
|
20
20
|
/** Default per-request timeout (ms). */
|
|
21
|
-
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
22
22
|
/** Default number of concurrent in-flight checks. */
|
|
23
|
-
|
|
23
|
+
const DEFAULT_CONCURRENCY = 5;
|
|
24
24
|
/** Default hard ceiling on URLs checked in one call. */
|
|
25
|
-
|
|
25
|
+
const DEFAULT_MAX_LINKS = 200;
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Hostnames that must never be probed regardless of allowlist — loopback and
|
package/src/lib/mcp-server.js
CHANGED
|
@@ -34,7 +34,7 @@ function resourceUriIsListed(listed, requested) {
|
|
|
34
34
|
* `definitions` is the full static surface (schema projection); the optional
|
|
35
35
|
* `list` hook decides what is DISCOVERABLE per request (governance + entitlement).
|
|
36
36
|
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, read: (uri: string) => Promise<object>}} surface.resources
|
|
37
|
-
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, get: (name: string, args: object) => Array<object>}} surface.prompts
|
|
37
|
+
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, get: (name: string, args: object) => Array<object>, describe?: (name: string, args: object) => Promise<?{description: string, messages: Array<object>}>}} surface.prompts
|
|
38
38
|
* @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
|
|
39
39
|
*/
|
|
40
40
|
export function createConnectorServerFactory({ serverInfo, tools, resources, prompts }) {
|
|
@@ -78,6 +78,12 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
|
|
|
78
78
|
}));
|
|
79
79
|
server.setRequestHandler("prompts/get", async (request) => {
|
|
80
80
|
const { name, arguments: args } = request.params;
|
|
81
|
+
// A surface with live prompts resolves listing and messages in one pass.
|
|
82
|
+
if (prompts.describe) {
|
|
83
|
+
const found = await prompts.describe(name, args ?? {});
|
|
84
|
+
if (!found) throw new Error(`Unknown prompt: "${name}"`);
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
81
87
|
const visible = prompts.list ? await prompts.list() : prompts.definitions;
|
|
82
88
|
const known = visible.find((prompt) => prompt.name === name);
|
|
83
89
|
if (!known) throw new Error(`Unknown prompt: "${name}"`);
|
package/src/lib/metatag-audit.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { drupalGraphqlFetch } from "./drupal-fetch.js";
|
|
19
|
+
import { describeGraphqlErrors, ERROR_DETAIL_MAX_CHARS } from "./error-body.js";
|
|
19
20
|
|
|
20
21
|
// Node paths are batched into aliased `route()` selections per request. Kept
|
|
21
22
|
// modest so a single document stays small and one bad path can't sink a large
|
|
@@ -101,7 +102,7 @@ export async function fetchRenderedMetaDescriptions(site, entities) {
|
|
|
101
102
|
// A schema-level error (no `route`, no `metatag` field, graphql_compose_metatags
|
|
102
103
|
// absent) means we cannot determine descriptions — fail closed to
|
|
103
104
|
// "unavailable" rather than reporting every node as missing.
|
|
104
|
-
return { source: "unavailable", reason: json.errors[0]
|
|
105
|
+
return { source: "unavailable", reason: describeGraphqlErrors(json.errors[0], ERROR_DETAIL_MAX_CHARS), byId };
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
const data = json.data || {};
|
package/src/lib/module-tools.js
CHANGED
|
@@ -10,6 +10,7 @@ import { assertSourceGovernance, GovernanceError } from "./governance.js";
|
|
|
10
10
|
import { DataFlowBudgetError } from "./data-flow.js";
|
|
11
11
|
import { toolError } from "./errors.js";
|
|
12
12
|
import { withResolvedTarget } from "./site-target.js";
|
|
13
|
+
import { cleanErrorData } from "./error-body.js";
|
|
13
14
|
|
|
14
15
|
const PREFIX = "drupal_module_";
|
|
15
16
|
const OPERATIONS = new Set(["read", "write", "delete"]);
|
|
@@ -46,7 +47,7 @@ function entries(sites) {
|
|
|
46
47
|
if (result.has(name) || result.size >= MAX_TOOLS) {
|
|
47
48
|
throw new SecurityError("Duplicate module namespace or excessive tool policy entries.");
|
|
48
49
|
}
|
|
49
|
-
result.set(name, { name, alias, site, policy });
|
|
50
|
+
result.set(name, { name, alias, namespace: config.namespace, site, policy });
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
return result;
|
|
@@ -133,6 +134,22 @@ function describe(entry, remote) {
|
|
|
133
134
|
} };
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Tools the local policy configures, before any discovery or caller check, with
|
|
139
|
+
* the namespace and alias each name was built from. Tooling compares this with
|
|
140
|
+
* a live listing to tell "nothing configured" from "configured but not returned
|
|
141
|
+
* by the source". A tool name alone cannot be split back reliably, because
|
|
142
|
+
* either part may contain the `__` separator.
|
|
143
|
+
*
|
|
144
|
+
* @param {Array<object>} sites - Resolved site configs.
|
|
145
|
+
* @returns {Array<{name: string, namespace: string, alias: string}>} Sorted by name.
|
|
146
|
+
*/
|
|
147
|
+
export function configuredModuleTools(sites) {
|
|
148
|
+
return [...entries(sites).values()]
|
|
149
|
+
.map(({ name, namespace, alias }) => ({ name, namespace, alias }))
|
|
150
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
/** Reserved module names never fall back to built-in handlers. */
|
|
137
154
|
export function isModuleTool(name) {
|
|
138
155
|
return typeof name === "string" && name.startsWith(PREFIX);
|
|
@@ -228,7 +245,11 @@ export function createModuleToolRegistry({ list = listServerTools, call = callSe
|
|
|
228
245
|
const data = toolResultData(result);
|
|
229
246
|
const failed = result.isError === true || data?.success === false;
|
|
230
247
|
if (!failed && spec.output && !spec.output(data)) throw new Error("Invalid module output schema.");
|
|
231
|
-
|
|
248
|
+
// A failure is relayed because the module's message is what the
|
|
249
|
+
// caller needs. It matches no schema and is untrusted text, so it is
|
|
250
|
+
// cleaned and bounded first. A successful result is left as it is.
|
|
251
|
+
const relayed = failed ? cleanErrorData(data) : data;
|
|
252
|
+
const structuredContent = withResolvedTarget({ result: relayed }, invokeContext.resolvedTarget);
|
|
232
253
|
return { content: [{ type: "text", text: JSON.stringify(structuredContent) }], structuredContent, isError: failed };
|
|
233
254
|
}, invokeContext);
|
|
234
255
|
} catch (error) {
|
package/src/lib/operations.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { graphqlHasMutation } from "./security.js";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
|
|
16
16
|
"drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
|
|
17
17
|
"drupal_drush_config_export", "drupal_drush_config_import",
|
|
18
18
|
"drupal_drush_updatedb", "drupal_drush_module_enable",
|
|
@@ -22,7 +22,7 @@ export const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_uploa
|
|
|
22
22
|
// Governed config write (also gated inside the handler by the config-write cap):
|
|
23
23
|
"drupal_config_set"];
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
const DESTRUCTIVE_PREFIXES = ["drupal_delete_", "drupal_drush_module_disable"];
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Classify a tool's operation intent from its name prefix.
|