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
package/src/lib/server-tools.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* Config (per site):
|
|
19
19
|
* "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* The governed config tools work only when the source advertises them to this
|
|
22
|
+
* account in `tools/list`; otherwise the call fails closed before it is sent.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
@@ -27,6 +27,7 @@ import { createHmac, randomBytes } from "node:crypto";
|
|
|
27
27
|
import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
|
|
28
28
|
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial, getDataFlowContext } from "./data-flow.js";
|
|
29
29
|
import { clearToken } from "./oauth.js";
|
|
30
|
+
import { cleanErrorText, describeErrorBody } from "./error-body.js";
|
|
30
31
|
|
|
31
32
|
/** Calls a configured module binding through the registry, without fallback. */
|
|
32
33
|
export async function callBoundModuleTool(site, binding, args, required) {
|
|
@@ -37,19 +38,103 @@ export async function callBoundModuleTool(site, binding, args, required) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
+
* Tool API ids of the governed config tools (mcp_sentinel's McpConfigGet/List/Set
|
|
42
|
+
* plugins). These are ids, not wire names: the bridge decides the wire name, so
|
|
43
|
+
* it is resolved from the source's `tools/list` (see resolveServerToolName).
|
|
44
|
+
*/
|
|
45
|
+
export const SERVER_TOOL_IDS = Object.freeze({
|
|
46
|
+
configGet: "mcp_sentinel_config_get",
|
|
47
|
+
configList: "mcp_sentinel_config_list",
|
|
48
|
+
configSet: "mcp_sentinel_config_set",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wire-name prefixes the Drupal tool bridge has used for a Tool API tool, in
|
|
53
|
+
* order of preference. Current mcp_server releases join the `tool_api` base id
|
|
54
|
+
* and the tool id with `__`; older ones used a dot.
|
|
55
|
+
*/
|
|
56
|
+
const WIRE_PREFIXES = ["tool_api__", "tool_api."];
|
|
57
|
+
|
|
58
|
+
/** Upper bound on `tools/list` pages read while resolving a name. */
|
|
59
|
+
const MAX_CATALOG_PAGES = 16;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wire names a bridge may advertise for a Tool API id, preferred first.
|
|
63
|
+
* @param {string} id Tool API id, e.g. `mcp_sentinel_config_set`.
|
|
64
|
+
* @returns {string[]} Candidate wire names.
|
|
65
|
+
*/
|
|
66
|
+
export function serverToolCandidates(id) {
|
|
67
|
+
return WIRE_PREFIXES.map((prefix) => `${prefix}${id}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read every tool name the source advertises to this caller.
|
|
72
|
+
* @param {object} site Resolved site config.
|
|
73
|
+
* @param {Function} [list] Catalog page reader `(site, cursor) => {tools, nextCursor}`.
|
|
74
|
+
* @returns {Promise<Set<string>>} Advertised tool names.
|
|
75
|
+
* @throws {Error} on a malformed page, a repeated cursor, or too many pages.
|
|
76
|
+
*/
|
|
77
|
+
export async function advertisedServerToolNames(site, list = listServerTools) {
|
|
78
|
+
const names = new Set();
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
let cursor;
|
|
81
|
+
for (let page = 0; page < MAX_CATALOG_PAGES; page++) {
|
|
82
|
+
const result = await list(site, cursor);
|
|
83
|
+
if (!Array.isArray(result?.tools)) {
|
|
84
|
+
throw new Error(`Server-tool catalog for site "${site._name}" is malformed: tools/list returned no tools array.`);
|
|
85
|
+
}
|
|
86
|
+
for (const tool of result.tools) {
|
|
87
|
+
if (typeof tool?.name === "string") names.add(tool.name);
|
|
88
|
+
}
|
|
89
|
+
if (result.nextCursor === undefined || result.nextCursor === null) return names;
|
|
90
|
+
if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) {
|
|
91
|
+
throw new Error(`Server-tool catalog for site "${site._name}" returned an invalid or repeated cursor.`);
|
|
92
|
+
}
|
|
93
|
+
cursor = result.nextCursor;
|
|
94
|
+
seen.add(cursor);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Server-tool catalog for site "${site._name}" exceeds ${MAX_CATALOG_PAGES} pages.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the wire name the source advertises for a governed config tool.
|
|
41
101
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
102
|
+
* Fails closed: when the catalog lists none of the candidate names, no name is
|
|
103
|
+
* guessed and nothing is called.
|
|
104
|
+
* @param {object} site Resolved site config.
|
|
105
|
+
* @param {string} binding Key of SERVER_TOOL_IDS (`configGet` | `configList` | `configSet`).
|
|
106
|
+
* @param {{list?: Function}} [deps] Injectable catalog page reader.
|
|
107
|
+
* @returns {Promise<string>} The advertised wire name.
|
|
108
|
+
* @throws {Error} if the binding is unknown or the tool is not advertised.
|
|
47
109
|
*/
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
110
|
+
export async function resolveServerToolName(site, binding, { list = listServerTools } = {}) {
|
|
111
|
+
const id = new Map(Object.entries(SERVER_TOOL_IDS)).get(binding);
|
|
112
|
+
if (!id) throw new Error(`Unknown server tool binding "${binding}".`);
|
|
113
|
+
const candidates = serverToolCandidates(id);
|
|
114
|
+
const advertised = await advertisedServerToolNames(site, list);
|
|
115
|
+
const name = candidates.find((candidate) => advertised.has(candidate));
|
|
116
|
+
if (!name) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Server tool "${id}" is not advertised by the source for site "${site._name}" ` +
|
|
119
|
+
`(looked for ${candidates.join(" and ")} in tools/list). No call was made. ` +
|
|
120
|
+
"Register and enable it as an mcp_tool_config entity, check that this account holds the scope the tool requires, " +
|
|
121
|
+
"or map it under serverTools.bindings. See docs/integration-contract.md."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Call a governed config tool by the wire name the source advertises for it.
|
|
129
|
+
* @param {object} site Resolved site config.
|
|
130
|
+
* @param {string} binding Key of SERVER_TOOL_IDS.
|
|
131
|
+
* @param {object} [args] Tool arguments.
|
|
132
|
+
* @returns {Promise<*>} The tool's structured result.
|
|
133
|
+
* @throws {Error} if the tool is not advertised, or as callServerTool.
|
|
134
|
+
*/
|
|
135
|
+
export async function callGovernedServerTool(site, binding, args = {}) {
|
|
136
|
+
return callServerTool(site, await resolveServerToolName(site, binding), args);
|
|
137
|
+
}
|
|
53
138
|
|
|
54
139
|
/** MCP protocol version advertised on the handshake and every subsequent POST. */
|
|
55
140
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -158,6 +243,73 @@ function parseSse(text) {
|
|
|
158
243
|
return found;
|
|
159
244
|
}
|
|
160
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Message for a non-2xx bridge response.
|
|
248
|
+
*
|
|
249
|
+
* The body is untrusted: an HTML error page from Drupal, PHP or a proxy, a
|
|
250
|
+
* server path or a backtrace. Only a cleaned, bounded detail is shown (see
|
|
251
|
+
* `describeErrorBody`). The prefix holds the HTTP status and always ends with a
|
|
252
|
+
* colon — the documented message shape (`httpStatusOf`, and
|
|
253
|
+
* `classifyBridgeError` when the error has no marker).
|
|
254
|
+
*
|
|
255
|
+
* A body that is a JSON-RPC error keeps its integer code and its cleaned
|
|
256
|
+
* message, so a refusal sent with a 401 or 403 stays readable.
|
|
257
|
+
* @param {string} prefix Message start, e.g. "Server-tool call x failed 502".
|
|
258
|
+
* @param {object} res node-fetch Response with `ok === false`.
|
|
259
|
+
* @param {string} rawText Response body.
|
|
260
|
+
* @param {?object} body Parsed JSON-RPC body, if any.
|
|
261
|
+
* @returns {string} Bounded message.
|
|
262
|
+
*/
|
|
263
|
+
function failedResponseMessage(prefix, res, rawText, body) {
|
|
264
|
+
const rpcError = body?.error;
|
|
265
|
+
if (rpcError && typeof rpcError === "object") {
|
|
266
|
+
const code = Number.isInteger(rpcError.code) ? ` ${rpcError.code}` : "";
|
|
267
|
+
const message = cleanErrorText(rpcError.message) || "the server returned no error message";
|
|
268
|
+
return `${prefix}: JSON-RPC error${code}: ${message}`;
|
|
269
|
+
}
|
|
270
|
+
const detail = describeErrorBody(rawText, res.headers?.get?.("content-type") ?? null);
|
|
271
|
+
return `${prefix}: ${detail || "the server returned an empty body"}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Message for a JSON-RPC `error` object.
|
|
276
|
+
*
|
|
277
|
+
* `error.code` is kept when it is an integer, because callers read it.
|
|
278
|
+
* `error.message` is cleaned and bounded. `error.data` is never read: with
|
|
279
|
+
* verbose errors on it holds a backtrace.
|
|
280
|
+
* @param {string} subject Message start, e.g. "Server-tool x".
|
|
281
|
+
* @param {*} rpcError JSON-RPC error object from the response.
|
|
282
|
+
* @returns {string} Bounded message.
|
|
283
|
+
*/
|
|
284
|
+
function rpcErrorMessage(subject, rpcError) {
|
|
285
|
+
const code = Number.isInteger(rpcError?.code) ? ` (${rpcError.code})` : "";
|
|
286
|
+
const detail = cleanErrorText(rpcError?.message) || "the server returned no error message";
|
|
287
|
+
return `${subject} error${code}: ${detail}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build a bridge error that says what failed.
|
|
292
|
+
*
|
|
293
|
+
* The message ends with a response body or tool text, so code that branches on
|
|
294
|
+
* the failure reads these properties, never the message (#361):
|
|
295
|
+
* - `bridgeFailure`: `"session"` (the handshake failed; no tool was reached),
|
|
296
|
+
* `"http"` (non-2xx on the request), `"rpc"` (JSON-RPC error object) or
|
|
297
|
+
* `"tool"` (the tool ran and its result has `isError`);
|
|
298
|
+
* - `status`: the HTTP status, on a non-2xx response only (see `httpStatusOf`);
|
|
299
|
+
* - `rpcCode`: the JSON-RPC `error.code`, when it is an integer.
|
|
300
|
+
* @param {string} message Error message.
|
|
301
|
+
* @param {"session"|"http"|"rpc"|"tool"} failure What failed.
|
|
302
|
+
* @param {{status?: number, rpcCode?: *}} [facts] Response status or JSON-RPC code.
|
|
303
|
+
* @returns {Error} The marked error.
|
|
304
|
+
*/
|
|
305
|
+
function bridgeError(message, failure, { status, rpcCode } = {}) {
|
|
306
|
+
const error = new Error(message);
|
|
307
|
+
error.bridgeFailure = failure;
|
|
308
|
+
if (Number.isInteger(status)) error.status = status;
|
|
309
|
+
if (Number.isInteger(rpcCode)) error.rpcCode = rpcCode;
|
|
310
|
+
return error;
|
|
311
|
+
}
|
|
312
|
+
|
|
161
313
|
/**
|
|
162
314
|
* Perform the MCP session handshake against the server and cache the resulting
|
|
163
315
|
* session id: `initialize` (read the `Mcp-Session-Id` response header) followed
|
|
@@ -197,18 +349,21 @@ async function initializeSession(site, endpoint, key) {
|
|
|
197
349
|
|
|
198
350
|
const { body, rawText } = await readBody(res);
|
|
199
351
|
if (!res.ok) {
|
|
200
|
-
throw
|
|
352
|
+
throw bridgeError(
|
|
353
|
+
failedResponseMessage(`Server-tool session initialize failed ${res.status}`, res, rawText, body),
|
|
354
|
+
"session",
|
|
355
|
+
{ status: res.status },
|
|
356
|
+
);
|
|
201
357
|
}
|
|
202
358
|
if (body?.error) {
|
|
203
|
-
|
|
204
|
-
const hasCode = code !== undefined && code !== null;
|
|
205
|
-
throw new Error(`Server-tool session initialize error${hasCode ? ` (${code})` : ""}: ${message}`);
|
|
359
|
+
throw bridgeError(rpcErrorMessage("Server-tool session initialize", body.error), "session", { rpcCode: body.error?.code });
|
|
206
360
|
}
|
|
207
361
|
|
|
208
362
|
const sessionId = res.headers.get("mcp-session-id");
|
|
209
363
|
if (!sessionId) {
|
|
210
|
-
throw
|
|
211
|
-
`Server-tool session initialize for site "${site._name}" returned no Mcp-Session-Id header
|
|
364
|
+
throw bridgeError(
|
|
365
|
+
`Server-tool session initialize for site "${site._name}" returned no Mcp-Session-Id header.`,
|
|
366
|
+
"session",
|
|
212
367
|
);
|
|
213
368
|
}
|
|
214
369
|
|
|
@@ -265,7 +420,7 @@ function isSessionError(res, body) {
|
|
|
265
420
|
* OAuth sites clears and re-acquires the token then replays (same session); a
|
|
266
421
|
* server-side session expiry re-initialises the session then replays.
|
|
267
422
|
* @param {object} site Resolved site config (provides baseUrl + auth).
|
|
268
|
-
* @param {string} toolName Server-side MCP
|
|
423
|
+
* @param {string} toolName Server-side MCP wire name (see resolveServerToolName).
|
|
269
424
|
* @param {object} [args] Tool arguments object.
|
|
270
425
|
* @returns {Promise<*>} The tool's structured result.
|
|
271
426
|
* @throws {Error} on transport failure, JSON-RPC error, or tool error.
|
|
@@ -330,23 +485,29 @@ async function requestServerTool(site, method, params, options) {
|
|
|
330
485
|
if (!res.ok) {
|
|
331
486
|
const mapped = sourceBudgetDenial(rawText);
|
|
332
487
|
if (mapped) throw mapped;
|
|
333
|
-
throw
|
|
488
|
+
throw bridgeError(
|
|
489
|
+
failedResponseMessage(`Server-tool call ${toolName} failed ${res.status}`, res, rawText, body),
|
|
490
|
+
"http",
|
|
491
|
+
{ status: res.status },
|
|
492
|
+
);
|
|
334
493
|
}
|
|
335
494
|
|
|
336
495
|
// JSON-RPC transport-level error.
|
|
337
496
|
if (body?.error) {
|
|
338
|
-
|
|
339
|
-
const hasCode = code !== undefined && code !== null;
|
|
340
|
-
throw new Error(`Server-tool ${toolName} error${hasCode ? ` (${code})` : ""}: ${message}`);
|
|
497
|
+
throw bridgeError(rpcErrorMessage(`Server-tool ${toolName}`, body.error), "rpc", { rpcCode: body.error?.code });
|
|
341
498
|
}
|
|
342
499
|
|
|
343
500
|
// MCP tools/call result: { content: [...], isError?: boolean }.
|
|
344
501
|
const result = body?.result;
|
|
345
502
|
if (result?.isError && !options.preserveErrors) {
|
|
346
|
-
|
|
347
|
-
const
|
|
503
|
+
// The budget code is looked for in the whole text, before it is cut.
|
|
504
|
+
const text = extractTextContent(result);
|
|
505
|
+
const mapped = sourceBudgetDenial(text);
|
|
348
506
|
if (mapped) throw mapped;
|
|
349
|
-
|
|
507
|
+
// The tool's own words are for the caller (a governed refusal explains
|
|
508
|
+
// itself), so they are kept: cleaned and bounded, never relayed raw.
|
|
509
|
+
const detail = cleanErrorText(text) || "tool reported an error";
|
|
510
|
+
throw bridgeError(`Server-tool ${toolName} reported an error: ${detail}`, "tool");
|
|
350
511
|
}
|
|
351
512
|
return result;
|
|
352
513
|
}
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* JSON type.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { isDestructiveTool } from "./operations.js";
|
|
17
|
+
import { inferOperation, isDestructiveTool } from "./operations.js";
|
|
18
18
|
import { SITE_PARAM } from "./site-target.js";
|
|
19
19
|
|
|
20
20
|
/** Convert a tool name to its prompt/command name: `drupal_create_node` → `drupal-create-node`. */
|
|
@@ -29,8 +29,13 @@ export const promptNameToToolName = (name) => name.replace(/-/g, "_");
|
|
|
29
29
|
* @param {object} spec - A JSON-Schema property spec.
|
|
30
30
|
* @returns {string} e.g. "string", "boolean (true/false)", "object (pass as JSON)".
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
function typeHint(spec) {
|
|
33
33
|
const t = Array.isArray(spec?.type) ? spec.type[0] : spec?.type;
|
|
34
|
+
// A short closed list is the most useful thing to show for a string choice.
|
|
35
|
+
if (Array.isArray(spec?.enum) && spec.enum.length > 0 && spec.enum.length <= 12 &&
|
|
36
|
+
spec.enum.every((v) => typeof v === "string" && v.length <= 40)) {
|
|
37
|
+
return `one of: ${spec.enum.join(", ")}`;
|
|
38
|
+
}
|
|
34
39
|
switch (t) {
|
|
35
40
|
case "boolean": return "boolean (true/false)";
|
|
36
41
|
case "number":
|
|
@@ -59,6 +64,79 @@ export function paramList(inputSchema) {
|
|
|
59
64
|
}));
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether a definition came from the module registry (src/lib/module-tools.js).
|
|
69
|
+
* Those names are reserved, so the prefix alone identifies them.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} def - A tool definition.
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export const isModuleDefinition = (def) =>
|
|
75
|
+
typeof def?.name === "string" && def.name.startsWith("drupal_module_");
|
|
76
|
+
|
|
77
|
+
/** Longest source-supplied description kept in a prompt or stub. */
|
|
78
|
+
const SOURCE_TEXT_LIMIT = 1024;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Flatten and bound text that a Drupal source supplied. It ends up in
|
|
82
|
+
* instruction text and in files on the operator's disk, so it stays on one
|
|
83
|
+
* line where it cannot start a heading, a rule or a block of its own.
|
|
84
|
+
*
|
|
85
|
+
* @param {*} value - Source-supplied text.
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function sourceText(value) {
|
|
89
|
+
return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, SOURCE_TEXT_LIMIT);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A tool's description as prompts and stubs should show it. Built-in text is
|
|
94
|
+
* authored in this repo and is returned as written.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} def - A tool definition.
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function toolDescription(def) {
|
|
100
|
+
return isModuleDefinition(def) ? sourceText(def.description) : def.description;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parameter catalog a person fills in for a tool. A module-owned tool wraps the
|
|
105
|
+
* module's schema in a `{ catalogRevision, arguments }` envelope, so its
|
|
106
|
+
* parameters are the properties of `arguments`, not of the envelope.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} def - A tool definition.
|
|
109
|
+
* @returns {Array<{name:string, required:boolean, hint:string, description:string}>}
|
|
110
|
+
*/
|
|
111
|
+
export function toolParams(def) {
|
|
112
|
+
if (!isModuleDefinition(def)) return paramList(def.inputSchema);
|
|
113
|
+
return paramList(def.inputSchema?.properties?.arguments)
|
|
114
|
+
.map((param) => ({ ...param, description: sourceText(param.description) }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Call-shape guidance shared by module tool prompts and command stubs. The
|
|
119
|
+
* revision changes with the module's schema, so text never embeds its value.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} def - A module tool definition.
|
|
122
|
+
* @param {boolean} hasParams - Whether the module declares any parameters.
|
|
123
|
+
* @returns {string[]} Lines to append to the instruction.
|
|
124
|
+
*/
|
|
125
|
+
export function moduleCallNotes(def, hasParams) {
|
|
126
|
+
const notes = [
|
|
127
|
+
"This is a module-owned tool. Its input has exactly two properties: `catalogRevision` and `arguments`.",
|
|
128
|
+
hasParams
|
|
129
|
+
? "Put the parameters above inside `arguments`."
|
|
130
|
+
: "Pass an empty `arguments` object.",
|
|
131
|
+
"Copy `catalogRevision` from the constant in this tool's current input schema. " +
|
|
132
|
+
"If the call reports a changed schema, refresh the tool list and use the new value.",
|
|
133
|
+
];
|
|
134
|
+
if (inferOperation(def.name) !== "read") {
|
|
135
|
+
notes.push("Do not retry a write after an uncertain result. Report it so the outcome can be checked first.");
|
|
136
|
+
}
|
|
137
|
+
return notes;
|
|
138
|
+
}
|
|
139
|
+
|
|
62
140
|
/**
|
|
63
141
|
* Build one MCP prompt descriptor per tool definition.
|
|
64
142
|
*
|
|
@@ -68,8 +146,8 @@ export function paramList(inputSchema) {
|
|
|
68
146
|
export function buildToolPrompts(definitions) {
|
|
69
147
|
return definitions.map((def) => ({
|
|
70
148
|
name: toolNameToPromptName(def.name),
|
|
71
|
-
description: `Invoke the ${def.name} tool. ${def
|
|
72
|
-
arguments:
|
|
149
|
+
description: `Invoke the ${def.name} tool. ${toolDescription(def)}`.slice(0, 300),
|
|
150
|
+
arguments: toolParams(def).map((p) => ({
|
|
73
151
|
name: p.name,
|
|
74
152
|
description: p.description ? `${p.hint} — ${p.description}` : p.hint,
|
|
75
153
|
required: p.required,
|
|
@@ -84,13 +162,14 @@ export function buildToolPrompts(definitions) {
|
|
|
84
162
|
* @param {object} args - Arguments supplied to the prompt (all strings per MCP).
|
|
85
163
|
* @returns {string} A user-role instruction message body.
|
|
86
164
|
*/
|
|
87
|
-
|
|
88
|
-
const params =
|
|
165
|
+
function renderToolInstruction(def, args = {}) {
|
|
166
|
+
const params = toolParams(def);
|
|
89
167
|
const required = params.filter((p) => p.required);
|
|
90
168
|
const optional = params.filter((p) => !p.required);
|
|
91
169
|
const line = (p) => `- ${p.name} (${p.hint})${p.description ? `: ${p.description}` : ""}`;
|
|
170
|
+
const isModule = isModuleDefinition(def);
|
|
92
171
|
|
|
93
|
-
const out = [`Call the MCP tool \`${def.name}\`.`, "", def
|
|
172
|
+
const out = [`Call the MCP tool \`${def.name}\`.`, "", toolDescription(def)];
|
|
94
173
|
|
|
95
174
|
if (isDestructiveTool(def.name)) {
|
|
96
175
|
out.push("", "⚠ Destructive: this permanently changes or deletes data. Confirm with the user before calling.");
|
|
@@ -98,7 +177,7 @@ export function renderToolInstruction(def, args = {}) {
|
|
|
98
177
|
|
|
99
178
|
out.push("");
|
|
100
179
|
if (params.length === 0) {
|
|
101
|
-
out.push("This tool takes no arguments — call it directly.");
|
|
180
|
+
out.push(isModule ? "This tool takes no parameters." : "This tool takes no arguments — call it directly.");
|
|
102
181
|
} else {
|
|
103
182
|
if (required.length) {
|
|
104
183
|
out.push("Required parameters (ask me for any that are missing — do not invent values):");
|
|
@@ -112,6 +191,8 @@ export function renderToolInstruction(def, args = {}) {
|
|
|
112
191
|
}
|
|
113
192
|
}
|
|
114
193
|
|
|
194
|
+
if (isModule) out.push("", ...moduleCallNotes(def, params.length > 0));
|
|
195
|
+
|
|
115
196
|
const supplied = Object.entries(args ?? {}).filter(([, v]) => v !== undefined && v !== "");
|
|
116
197
|
if (supplied.length) {
|
|
117
198
|
out.push("", "Values I supplied:");
|
|
@@ -144,3 +225,53 @@ export function getToolPromptMessages(promptName, args = {}, definitionsByName)
|
|
|
144
225
|
}
|
|
145
226
|
return [{ role: "user", content: { type: "text", text: renderToolInstruction(def, args) } }];
|
|
146
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compose the server's prompt surface from the static prompts and the module
|
|
231
|
+
* tools that live discovery returns for this request. Module prompts inherit
|
|
232
|
+
* discovery's caller, site and source checks; nothing is cached across requests.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} options
|
|
235
|
+
* @param {Array<object>} options.staticPrompts - Workflow prompts plus built-in tool prompts.
|
|
236
|
+
* @param {() => Promise<Array<object>>} options.discover - Tools visible to the current request.
|
|
237
|
+
* @param {(prompts: Array<object>, tools: Array<object>) => Array<object>} options.filter
|
|
238
|
+
* Principal filter for the static prompts.
|
|
239
|
+
* @param {Set<string>} options.workflowNames - Names of the hand-authored workflow prompts.
|
|
240
|
+
* @param {(name: string, args: object) => Array<object>} options.workflowMessages
|
|
241
|
+
* @param {Map<string,object>} options.definitionsByName - Built-in tool name → definition.
|
|
242
|
+
* @returns {{definitions: Array<object>, list: Function, describe: Function, get: Function}}
|
|
243
|
+
*/
|
|
244
|
+
export function createPromptSurface({
|
|
245
|
+
staticPrompts, discover, filter, workflowNames, workflowMessages, definitionsByName,
|
|
246
|
+
}) {
|
|
247
|
+
const get = (name, args) => workflowNames.has(name)
|
|
248
|
+
? workflowMessages(name, args)
|
|
249
|
+
: getToolPromptMessages(name, args, definitionsByName);
|
|
250
|
+
|
|
251
|
+
async function visible() {
|
|
252
|
+
const tools = await discover();
|
|
253
|
+
const taken = new Set(staticPrompts.map((prompt) => prompt.name));
|
|
254
|
+
// A reserved module name cannot match a built-in, but never let a remote
|
|
255
|
+
// catalog shadow a static prompt if that invariant is ever broken.
|
|
256
|
+
const moduleDefs = tools.filter((tool) =>
|
|
257
|
+
isModuleDefinition(tool) && !taken.has(toolNameToPromptName(tool.name)));
|
|
258
|
+
return { prompts: [...filter(staticPrompts, tools), ...buildToolPrompts(moduleDefs)], moduleDefs };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
definitions: staticPrompts,
|
|
263
|
+
get,
|
|
264
|
+
list: async () => (await visible()).prompts,
|
|
265
|
+
/** Resolve one prompt, or null when it is not visible to this request. */
|
|
266
|
+
async describe(name, args = {}) {
|
|
267
|
+
const { prompts, moduleDefs } = await visible();
|
|
268
|
+
const known = prompts.find((prompt) => prompt.name === name);
|
|
269
|
+
if (!known) return null;
|
|
270
|
+
const live = moduleDefs.find((def) => toolNameToPromptName(def.name) === name);
|
|
271
|
+
const messages = live
|
|
272
|
+
? getToolPromptMessages(name, args, new Map([[live.name, live]]))
|
|
273
|
+
: get(name, args);
|
|
274
|
+
return { description: known.description, messages };
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
package/src/lib/usage.js
CHANGED
|
@@ -31,15 +31,6 @@ export const USAGE_DECISIONS = Object.freeze(["allow", "deny"]);
|
|
|
31
31
|
*/
|
|
32
32
|
export const RECEIPT_OUTCOMES = Object.freeze(["ok", "failed", "unknown"]);
|
|
33
33
|
|
|
34
|
-
/** Reconciliation states over one request / decision / receipt chain. */
|
|
35
|
-
export const RECONCILE_STATES = Object.freeze([
|
|
36
|
-
"settled",
|
|
37
|
-
"denied",
|
|
38
|
-
"missing",
|
|
39
|
-
"duplicate",
|
|
40
|
-
"uncertain",
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
34
|
const PHASE_SET = new Set(USAGE_PHASES);
|
|
44
35
|
const DECISION_SET = new Set(USAGE_DECISIONS);
|
|
45
36
|
const OUTCOME_SET = new Set(RECEIPT_OUTCOMES);
|