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
@@ -18,8 +18,8 @@
18
18
  * Config (per site):
19
19
  * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
20
20
  *
21
- * Tools are NOT functional until the Drupal-side governed config tools ship;
22
- * until then the server returns a tool-not-found error, surfaced verbatim.
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
- * Canonical server-side tool names for governed config operations.
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
- * Drupal's mcp_server_tool_bridge exposes every Tool-API tool through the MCP
43
- * protocol under the derivative name `tool_api.<mcp_tool_config id>`, so the
44
- * governed config tools registered against mcp_sentinel's McpConfigGet/List/Set
45
- * plugins surface as `tool_api.mcp_sentinel_config_*`. Keep the mapping here so
46
- * a server-side rename is a one-line change.
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 const SERVER_TOOLS = {
49
- configGet: "tool_api.mcp_sentinel_config_get",
50
- configList: "tool_api.mcp_sentinel_config_list",
51
- configSet: "tool_api.mcp_sentinel_config_set",
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 new Error(`Server-tool session initialize failed ${res.status}: ${rawText}`);
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
- const { code, message } = body.error;
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 new Error(
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 tool name (see SERVER_TOOLS).
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 new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
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
- const { code, message } = body.error;
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
- const detail = extractTextContent(result) || "tool reported an error";
347
- const mapped = sourceBudgetDenial(detail);
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
- throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
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
  }
@@ -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
- export function typeHint(spec) {
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.description}`.slice(0, 300),
72
- arguments: paramList(def.inputSchema).map((p) => ({
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
- export function renderToolInstruction(def, args = {}) {
88
- const params = paramList(def.inputSchema);
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.description];
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);