drupal-mcp-connector 2.20.0 → 2.22.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.
@@ -13,7 +13,8 @@
13
13
  * call site performs the MCP session handshake — `initialize` (read the
14
14
  * `Mcp-Session-Id` response header) → `notifications/initialized` → `tools/call`
15
15
  * carrying that session id. The session is cached per site and transparently
16
- * re-initialised when the server expires it.
16
+ * re-initialised when the server expires it. `prompts/list` and `prompts/get`
17
+ * reuse the same session.
17
18
  *
18
19
  * Config (per site):
19
20
  * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
@@ -139,6 +140,12 @@ export async function callGovernedServerTool(site, binding, args = {}) {
139
140
  /** MCP protocol version advertised on the handshake and every subsequent POST. */
140
141
  const MCP_PROTOCOL_VERSION = "2025-06-18";
141
142
 
143
+ /** MCP server-tool POST timeout. Handshake, catalog, and tools/call share this. */
144
+ export const SERVER_TOOL_TIMEOUT_MS = 15_000;
145
+
146
+ /** Default MCP response body cap (bytes). Caller `maxBytes` overrides. */
147
+ export const SERVER_TOOL_MAX_BYTES = 262_144;
148
+
142
149
  // Monotonic JSON-RPC request id. A simple counter keeps ids unique per process
143
150
  // without relying on Math.random()/Date.now().
144
151
  let rpcId = 0;
@@ -327,7 +334,7 @@ async function initializeSession(site, endpoint, key) {
327
334
  method: "initialize",
328
335
  params: {
329
336
  protocolVersion: MCP_PROTOCOL_VERSION,
330
- capabilities: {},
337
+ capabilities: { prompts: {} },
331
338
  clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
332
339
  },
333
340
  };
@@ -337,8 +344,8 @@ async function initializeSession(site, endpoint, key) {
337
344
  method: "POST",
338
345
  headers: await baseHeaders(site, null),
339
346
  body: JSON.stringify(payload),
340
- size: 262144,
341
- signal: AbortSignal.timeout(15000),
347
+ size: SERVER_TOOL_MAX_BYTES,
348
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
342
349
  });
343
350
 
344
351
  let res = await post();
@@ -374,8 +381,8 @@ async function initializeSession(site, endpoint, key) {
374
381
  method: "POST",
375
382
  headers: await baseHeaders(site, sessionId),
376
383
  body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
377
- size: 262144,
378
- signal: AbortSignal.timeout(15000),
384
+ size: SERVER_TOOL_MAX_BYTES,
385
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
379
386
  });
380
387
  } catch {
381
388
  // Notification is advisory; proceed with the established session.
@@ -432,11 +439,79 @@ export async function callServerTool(site, toolName, args = {}, options = {}) {
432
439
  /** Fetch one page of the authenticated module tool catalog. */
433
440
  export async function listServerTools(site, cursor) {
434
441
  return requestServerTool(site, "tools/list", cursor === undefined ? {} : { cursor }, {
435
- maxBytes: 262144, preserveErrors: true,
442
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
443
+ });
444
+ }
445
+
446
+ /** Fetch one page of Drupal `McpPromptConfig` prompts. */
447
+ export async function listServerPrompts(site, cursor) {
448
+ return requestServerTool(site, "prompts/list", cursor === undefined ? {} : { cursor }, {
449
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Fetch one Drupal prompt body. Pass no arguments so `{{token}}` placeholders
455
+ * stay intact for the connector's workflow renderer.
456
+ */
457
+ export async function getServerPrompt(site, name) {
458
+ return requestServerTool(site, "prompts/get", { name, arguments: {} }, {
459
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
436
460
  });
437
461
  }
438
462
 
439
- /** Shared bounded MCP request transport; retries only explicit auth/session rejection. */
463
+ /**
464
+ * Page `prompts/list` the same way as `tools/list`.
465
+ * @param {object} site Resolved site config.
466
+ * @param {Function} [list] Catalog page reader.
467
+ * @returns {Promise<object[]>} Prompt descriptors.
468
+ */
469
+ export async function advertisedServerPrompts(site, list = listServerPrompts) {
470
+ const prompts = [];
471
+ const seen = new Set();
472
+ let cursor;
473
+ for (let page = 0; page < MAX_CATALOG_PAGES; page++) {
474
+ const result = await list(site, cursor);
475
+ if (!Array.isArray(result?.prompts)) {
476
+ throw new Error(`Server-tool catalog for site "${site._name}" is malformed: prompts/list returned no prompts array.`);
477
+ }
478
+ for (const prompt of result.prompts) {
479
+ if (prompt && typeof prompt.name === "string") prompts.push(prompt);
480
+ }
481
+ if (result.nextCursor === undefined || result.nextCursor === null) return prompts;
482
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) {
483
+ throw new Error(`Server-tool catalog for site "${site._name}" returned an invalid or repeated prompts cursor.`);
484
+ }
485
+ cursor = result.nextCursor;
486
+ seen.add(cursor);
487
+ }
488
+ throw new Error(`Server-tool catalog for site "${site._name}" exceeds ${MAX_CATALOG_PAGES} prompt pages.`);
489
+ }
490
+
491
+ /**
492
+ * Load listed prompt descriptors plus `prompts/get` bodies for the named ids.
493
+ * A get failure omits that id; the caller fail-closes rather than widening.
494
+ * @param {object} site Resolved site config.
495
+ * @param {string[]} names Workflow ids to fetch.
496
+ * @param {{list?: Function, get?: Function}} [deps]
497
+ * @returns {Promise<{list: object[], bodies: Map<string, object>}>}
498
+ */
499
+ export async function fetchSiteWorkflowPrompts(site, names, { list = listServerPrompts, get = getServerPrompt } = {}) {
500
+ const listed = await advertisedServerPrompts(site, list);
501
+ const want = new Set(names ?? []);
502
+ const bodies = new Map();
503
+ for (const prompt of listed) {
504
+ if (!want.has(prompt.name)) continue;
505
+ try {
506
+ bodies.set(prompt.name, await get(site, prompt.name));
507
+ } catch {
508
+ // Fail closed for this id: no body, so the merge will not list it.
509
+ }
510
+ }
511
+ return { list: listed, bodies };
512
+ }
513
+
514
+ /** Shared bounded MCP request transport; size + abort are always attached. */
440
515
  async function requestServerTool(site, method, params, options) {
441
516
  const toolName = params.name ?? method;
442
517
  const endpoint = resolveEndpoint(site);
@@ -463,7 +538,8 @@ async function requestServerTool(site, method, params, options) {
463
538
  method: "POST",
464
539
  headers: await baseHeaders(site, sessionId),
465
540
  body: JSON.stringify(payload),
466
- ...(options.maxBytes ? { size: options.maxBytes, signal: AbortSignal.timeout(15000) } : {}),
541
+ size: options.maxBytes ?? SERVER_TOOL_MAX_BYTES,
542
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
467
543
  });
468
544
  const { body, rawText } = await readBody(res);
469
545
 
@@ -239,10 +239,15 @@ export function getToolPromptMessages(promptName, args = {}, definitionsByName)
239
239
  * @param {Set<string>} options.workflowNames - Names of the hand-authored workflow prompts.
240
240
  * @param {(name: string, args: object) => Array<object>} options.workflowMessages
241
241
  * @param {Map<string,object>} options.definitionsByName - Built-in tool name → definition.
242
+ * @param {(tools: Array<object>, taken: Set<string>) => object[]|Promise<object[]>} [options.extraWorkflows]
243
+ * Module-owned workflows visible for this request. Each item has name,
244
+ * description, arguments, and is renderable by extraWorkflowMessages.
245
+ * @param {(workflow: object, args: object) => Array<object>} [options.extraWorkflowMessages]
242
246
  * @returns {{definitions: Array<object>, list: Function, describe: Function, get: Function}}
243
247
  */
244
248
  export function createPromptSurface({
245
249
  staticPrompts, discover, filter, workflowNames, workflowMessages, definitionsByName,
250
+ extraWorkflows, extraWorkflowMessages,
246
251
  }) {
247
252
  const get = (name, args) => workflowNames.has(name)
248
253
  ? workflowMessages(name, args)
@@ -251,11 +256,22 @@ export function createPromptSurface({
251
256
  async function visible() {
252
257
  const tools = await discover();
253
258
  const taken = new Set(staticPrompts.map((prompt) => prompt.name));
259
+ const extra = extraWorkflows ? await extraWorkflows(tools, taken) : [];
260
+ for (const workflow of extra) taken.add(workflow.name);
254
261
  // A reserved module name cannot match a built-in, but never let a remote
255
262
  // catalog shadow a static prompt if that invariant is ever broken.
256
263
  const moduleDefs = tools.filter((tool) =>
257
264
  isModuleDefinition(tool) && !taken.has(toolNameToPromptName(tool.name)));
258
- return { prompts: [...filter(staticPrompts, tools), ...buildToolPrompts(moduleDefs)], moduleDefs };
265
+ const extraPrompts = extra.map((workflow) => ({
266
+ name: workflow.name,
267
+ description: workflow.description,
268
+ arguments: workflow.arguments,
269
+ }));
270
+ return {
271
+ prompts: [...filter([...staticPrompts, ...extraPrompts], tools), ...buildToolPrompts(moduleDefs)],
272
+ moduleDefs,
273
+ extra,
274
+ };
259
275
  }
260
276
 
261
277
  return {
@@ -264,14 +280,21 @@ export function createPromptSurface({
264
280
  list: async () => (await visible()).prompts,
265
281
  /** Resolve one prompt, or null when it is not visible to this request. */
266
282
  async describe(name, args = {}) {
267
- const { prompts, moduleDefs } = await visible();
283
+ const { prompts, moduleDefs, extra } = await visible();
268
284
  const known = prompts.find((prompt) => prompt.name === name);
269
285
  if (!known) return null;
270
286
  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 };
287
+ if (live) {
288
+ return {
289
+ description: known.description,
290
+ messages: getToolPromptMessages(name, args, new Map([[live.name, live]])),
291
+ };
292
+ }
293
+ const workflow = extra.find((item) => item.name === name);
294
+ if (workflow && extraWorkflowMessages) {
295
+ return { description: known.description, messages: extraWorkflowMessages(workflow, args) };
296
+ }
297
+ return { description: known.description, messages: get(name, args) };
275
298
  },
276
299
  };
277
300
  }
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Module-owned and built-in workflow prompts (#333).
3
+ *
4
+ * A workflow is a definition (id, tools, instructions) from a provider. The
5
+ * loader validates, bounds, and filters. It does not call tools. See
6
+ * docs/module-workflows.md.
7
+ */
8
+
9
+ import { sourceText, toolNameToPromptName } from "./tool-prompts.js";
10
+
11
+ const ID_RE = /^[a-z][a-z0-9_]{0,47}$/;
12
+ const ARG_RE = /^[a-z][a-z0-9_]{0,47}$/;
13
+ const MAX_DESCRIPTION = 1024;
14
+ const MAX_INSTRUCTIONS = 8192;
15
+ const MAX_ARG_VALUE = 200;
16
+ const MAX_WORKFLOWS = 64;
17
+ const MAX_TOOLS = 32;
18
+ const MAX_ARGUMENTS = 16;
19
+ const WRITE_EPILOGUE = "Module writes are not retried.";
20
+
21
+ /**
22
+ * Public MCP prompt name for a module workflow.
23
+ *
24
+ * @param {string} namespace
25
+ * @param {string} id
26
+ * @returns {string}
27
+ */
28
+ export function workflowPromptName(namespace, id) {
29
+ return `drupal-${String(namespace).replace(/_/g, "-")}-${String(id).replace(/_/g, "-")}`;
30
+ }
31
+
32
+ /**
33
+ * Instruction text safe to hand to a model: no heading, no system: prefix.
34
+ *
35
+ * @param {*} value
36
+ * @param {number} [limit]
37
+ * @returns {string}
38
+ */
39
+ export function sanitizeInstructions(value, limit = MAX_INSTRUCTIONS) {
40
+ return String(value ?? "")
41
+ .replace(/\r/g, "")
42
+ .replace(/^\s*#{1,6}\s+/gm, "")
43
+ .replace(/^\s*system\s*:/gim, "")
44
+ .trim()
45
+ .slice(0, limit);
46
+ }
47
+
48
+ function sanitizeArgValue(value) {
49
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_ARG_VALUE);
50
+ }
51
+
52
+ /**
53
+ * Validate one definition. Throws if it cannot be loaded.
54
+ *
55
+ * @param {object} raw
56
+ * @returns {object}
57
+ */
58
+ export function normalizeWorkflow(raw) {
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
60
+ throw new Error("Workflow definition must be an object.");
61
+ }
62
+ if (!ID_RE.test(raw.id ?? "")) throw new Error("Invalid workflow id.");
63
+ if (typeof raw.description !== "string" || !raw.description.trim()) {
64
+ throw new Error("Workflow description is required.");
65
+ }
66
+ if (typeof raw.readOnly !== "boolean") throw new Error("Workflow readOnly is required.");
67
+ if (!Array.isArray(raw.tools) || raw.tools.length < 1 || raw.tools.length > MAX_TOOLS) {
68
+ throw new Error("Workflow tools must be a non-empty list.");
69
+ }
70
+ if (raw.tools.some((alias) => !ID_RE.test(alias ?? ""))) {
71
+ throw new Error("Invalid workflow tool alias.");
72
+ }
73
+ if (new Set(raw.tools).size !== raw.tools.length) {
74
+ throw new Error("Workflow tools must be unique.");
75
+ }
76
+ if (typeof raw.instructions !== "string" || !raw.instructions.trim()) {
77
+ throw new Error("Workflow instructions are required.");
78
+ }
79
+ const args = Array.isArray(raw.arguments) ? raw.arguments : [];
80
+ if (args.length > MAX_ARGUMENTS) throw new Error("Too many workflow arguments.");
81
+ const arguments_ = [];
82
+ for (const arg of args) {
83
+ if (!arg || !ARG_RE.test(arg.name ?? "") || typeof arg.description !== "string") {
84
+ throw new Error("Invalid workflow argument.");
85
+ }
86
+ arguments_.push({
87
+ name: arg.name,
88
+ description: sourceText(arg.description),
89
+ required: Boolean(arg.required),
90
+ });
91
+ }
92
+ const placeholders = [...raw.instructions.matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)].map((m) => m[1]);
93
+ for (const alias of placeholders) {
94
+ if (!raw.tools.includes(alias)) {
95
+ throw new Error("Workflow instructions name a tool that is not in tools.");
96
+ }
97
+ }
98
+ return {
99
+ id: raw.id,
100
+ description: sourceText(raw.description),
101
+ readOnly: raw.readOnly,
102
+ tools: [...raw.tools],
103
+ arguments: arguments_,
104
+ instructions: sanitizeInstructions(raw.instructions),
105
+ name: typeof raw.name === "string" && raw.name ? raw.name : undefined,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @param {object} def
111
+ * @param {string} namespace
112
+ * @returns {string}
113
+ */
114
+ export function resolvedName(def, namespace) {
115
+ return def.name || workflowPromptName(namespace, def.id);
116
+ }
117
+
118
+ /**
119
+ * Map a local alias to the public module tool name using the live tool list.
120
+ *
121
+ * @param {string} namespace
122
+ * @param {string} alias
123
+ * @param {Array<{name: string}>} tools
124
+ * @returns {string|null}
125
+ */
126
+ export function publicToolName(namespace, alias, tools) {
127
+ const suffix = `_${namespace}__${alias}`;
128
+ const found = (tools ?? []).filter((tool) =>
129
+ typeof tool?.name === "string" &&
130
+ tool.name.startsWith("drupal_module_") &&
131
+ tool.name.endsWith(suffix));
132
+ return found.length === 1 ? found[0].name : null;
133
+ }
134
+
135
+ /**
136
+ * Whether every named tool is visible. Built-in public names must match
137
+ * exactly; module aliases resolve through {@link publicToolName}.
138
+ *
139
+ * @param {object} def
140
+ * @param {string} namespace
141
+ * @param {Array<{name: string}>} tools
142
+ * @param {{builtin?: boolean}} [opts]
143
+ * @returns {string[]|null} Public names, or null when any tool is missing.
144
+ */
145
+ export function resolveToolNames(def, namespace, tools, opts = {}) {
146
+ const visible = new Set((tools ?? []).map((tool) => tool.name));
147
+ const names = [];
148
+ for (const alias of def.tools) {
149
+ const publicName = opts.builtin
150
+ ? (visible.has(alias) ? alias : null)
151
+ : publicToolName(namespace, alias, tools);
152
+ if (!publicName) return null;
153
+ names.push(publicName);
154
+ }
155
+ return names;
156
+ }
157
+
158
+ /**
159
+ * Load workflows from providers. Each provider is
160
+ * `{ id, namespace, builtin?, workflows }` or a function returning that.
161
+ *
162
+ * Two unrelated providers are the intended test shape: a CRM-like catalog and
163
+ * an intake-like catalog must not leak into each other.
164
+ *
165
+ * @param {Array<object|Function>} providers
166
+ * @param {object} [context]
167
+ * @param {Array<{name: string}>} [context.tools]
168
+ * @param {Set<string>} [context.taken]
169
+ * @returns {object[]} Loaded, visible workflows.
170
+ */
171
+ export function loadWorkflows(providers, context = {}) {
172
+ const tools = context.tools ?? [];
173
+ const taken = new Set(context.taken ?? []);
174
+ const out = [];
175
+ for (const rawProvider of providers) {
176
+ const provider = typeof rawProvider === "function" ? rawProvider() : rawProvider;
177
+ if (!provider || typeof provider.namespace !== "string") continue;
178
+ if (!provider.builtin && !/^[a-z][a-z0-9_]{0,23}$/.test(provider.namespace ?? "")) {
179
+ continue;
180
+ }
181
+ const list = Array.isArray(provider.workflows) ? provider.workflows : [];
182
+ let n = 0;
183
+ for (const raw of list) {
184
+ if (n >= MAX_WORKFLOWS) break;
185
+ let def;
186
+ try {
187
+ def = normalizeWorkflow(raw);
188
+ } catch {
189
+ continue;
190
+ }
191
+ n += 1;
192
+ const name = resolvedName(def, provider.namespace);
193
+ if (taken.has(name) || (!provider.builtin && name !== workflowPromptName(provider.namespace, def.id))) {
194
+ continue;
195
+ }
196
+ const publicNames = resolveToolNames(def, provider.namespace, tools, { builtin: Boolean(provider.builtin) });
197
+ if (!publicNames) continue;
198
+ taken.add(name);
199
+ out.push({
200
+ ...def,
201
+ name,
202
+ namespace: provider.namespace,
203
+ builtin: Boolean(provider.builtin),
204
+ publicTools: publicNames,
205
+ });
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * MCP prompt descriptor (no internal fields).
213
+ *
214
+ * @param {object} workflow
215
+ * @returns {object}
216
+ */
217
+ export function toPromptDescriptor(workflow) {
218
+ return {
219
+ name: workflow.name,
220
+ description: workflow.description,
221
+ arguments: workflow.arguments.map((arg) => ({
222
+ name: arg.name,
223
+ description: arg.description,
224
+ required: arg.required,
225
+ })),
226
+ };
227
+ }
228
+
229
+ function builtinArgValues(args) {
230
+ const site = sanitizeArgValue(args?.site);
231
+ return {
232
+ site_phrase: site ? `on the "${site}" site` : "on the default site",
233
+ type: sanitizeArgValue(args?.type) || "article",
234
+ topic: sanitizeArgValue(args?.topic) || "the requested topic",
235
+ site,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Render MCP messages for a loaded workflow.
241
+ *
242
+ * @param {object} workflow
243
+ * @param {object} [args]
244
+ * @returns {Array<object>}
245
+ */
246
+ export function renderWorkflowMessages(workflow, args = {}) {
247
+ const publicTools = [...(workflow.publicTools ?? [])];
248
+ const toolMap = new Map();
249
+ workflow.tools.forEach((alias, index) => {
250
+ toolMap.set(alias, publicTools.at(index));
251
+ });
252
+ const argMap = new Map(Object.entries(builtinArgValues(args)));
253
+ for (const arg of workflow.arguments) {
254
+ if (arg.name !== "site_phrase" && Object.hasOwn(args, arg.name)) {
255
+ argMap.set(arg.name, sanitizeArgValue(args[arg.name]));
256
+ }
257
+ }
258
+ let text = workflow.instructions.replace(/\{tool:([a-z][a-z0-9_]{0,47})\}/g, (_, alias) => {
259
+ return toolMap.get(alias) || `{tool:${alias}}`;
260
+ });
261
+ text = text.replace(/\{arg:([a-z][a-z0-9_]{0,47})\}/g, (_, name) => argMap.get(name) ?? "");
262
+ if (!workflow.readOnly) {
263
+ text = `${text}\n${WRITE_EPILOGUE}`;
264
+ }
265
+ return [{ role: "user", content: { type: "text", text } }];
266
+ }
267
+
268
+ /**
269
+ * Unique `{tool:alias}` names in instruction text, first-seen order.
270
+ *
271
+ * @param {string} instructions
272
+ * @returns {string[]}
273
+ */
274
+ export function toolAliasesInInstructions(instructions) {
275
+ const aliases = [];
276
+ const seen = new Set();
277
+ for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
278
+ if (seen.has(match[1])) continue;
279
+ seen.add(match[1]);
280
+ aliases.push(match[1]);
281
+ }
282
+ return aliases;
283
+ }
284
+
285
+ /**
286
+ * Join user-role text from a Drupal `prompts/get` result.
287
+ *
288
+ * @param {object} got
289
+ * @returns {string}
290
+ */
291
+ export function instructionTextFromDrupalPrompt(got) {
292
+ const messages = Array.isArray(got?.messages) ? got.messages : [];
293
+ const parts = [];
294
+ for (const message of messages) {
295
+ if (message?.role !== "user") continue;
296
+ const content = message.content;
297
+ if (typeof content?.text === "string") {
298
+ parts.push(content.text);
299
+ continue;
300
+ }
301
+ if (Array.isArray(content)) {
302
+ for (const item of content) {
303
+ if (item?.type === "text" && typeof item.text === "string") parts.push(item.text);
304
+ }
305
+ }
306
+ }
307
+ return parts.join("\n").replace(/\{\{\s*([a-z][a-z0-9_]{0,47})\s*\}\}/g, "{arg:$1}");
308
+ }
309
+
310
+ /**
311
+ * `readOnly` is false when any named alias is a local write or delete.
312
+ *
313
+ * @param {string[]} aliases
314
+ * @param {object} toolsMap `serverTools.modules.tools`
315
+ * @returns {boolean}
316
+ */
317
+ export function inferReadOnly(aliases, toolsMap) {
318
+ for (const alias of aliases) {
319
+ const operation = toolsMap?.[alias]?.operation;
320
+ if (operation === "write" || operation === "delete") return false;
321
+ }
322
+ return true;
323
+ }
324
+
325
+ /**
326
+ * Map a Drupal prompt list item + get body onto the v1 workflow definition.
327
+ * Returns null when the prompt cannot be a workflow (no `{tool:alias}`, an
328
+ * alias missing from the local tools map, or an unusable description).
329
+ *
330
+ * @param {string} id
331
+ * @param {object} listed
332
+ * @param {object} got
333
+ * @param {object} toolsMap
334
+ * @returns {object|null}
335
+ */
336
+ export function workflowFromDrupalPrompt(id, listed, got, toolsMap) {
337
+ const instructions = instructionTextFromDrupalPrompt(got);
338
+ if (!instructions.trim()) return null;
339
+ const tools = toolAliasesInInstructions(instructions);
340
+ if (tools.length < 1) return null;
341
+ for (const alias of tools) {
342
+ if (!toolsMap?.[alias] || typeof toolsMap[alias] !== "object") return null;
343
+ }
344
+ const rawArgs = Array.isArray(listed?.arguments) ? listed.arguments : [];
345
+ const arguments_ = rawArgs.map((arg) => ({
346
+ name: arg?.name ?? arg?.machine_name,
347
+ description: arg?.description ?? "",
348
+ required: Boolean(arg?.required),
349
+ }));
350
+ const description = String(listed?.description ?? got?.description ?? "").trim() || "Module workflow.";
351
+ return {
352
+ id,
353
+ description,
354
+ readOnly: inferReadOnly(tools, toolsMap),
355
+ tools,
356
+ arguments: arguments_,
357
+ instructions,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Provider from site `serverTools.modules.workflows` maps, optionally merged
363
+ * with Drupal `prompts/list` + `prompts/get`. Local keys enable; remote bodies
364
+ * replace a local body when the catalog returns that id. A remote prompt that
365
+ * is not named locally is ignored. Fetch failures keep the v1 local bodies.
366
+ *
367
+ * @param {Array<object>} sites
368
+ * @param {{fetch?: Function}} [deps]
369
+ * @returns {Promise<object[]>}
370
+ */
371
+ export async function moduleWorkflowProvidersWithRemote(sites, { fetch } = {}) {
372
+ const { fetchSiteWorkflowPrompts } = await import("./server-tools.js");
373
+ const load = fetch ?? fetchSiteWorkflowPrompts;
374
+ const providers = [];
375
+ for (const site of sites ?? []) {
376
+ const modules = site.serverTools?.modules;
377
+ if (!modules?.namespace || !modules.workflows || typeof modules.workflows !== "object") continue;
378
+ const enabled = [];
379
+ for (const [id, body] of Object.entries(modules.workflows)) {
380
+ if (id.startsWith("_") || !body || typeof body !== "object") continue;
381
+ enabled.push({ id: body.id ?? id, body });
382
+ }
383
+ let catalog;
384
+ try {
385
+ catalog = await load(site, enabled.map((item) => item.id));
386
+ } catch {
387
+ catalog = { list: [], bodies: new Map() };
388
+ }
389
+ const listedByName = new Map((catalog.list ?? []).map((prompt) => [prompt.name, prompt]));
390
+ const workflows = [];
391
+ for (const item of enabled) {
392
+ const listed = listedByName.get(item.id);
393
+ const got = catalog.bodies?.get(item.id);
394
+ if (listed && got) {
395
+ const mapped = workflowFromDrupalPrompt(item.id, listed, got, modules.tools ?? {});
396
+ if (mapped) workflows.push(mapped);
397
+ continue;
398
+ }
399
+ workflows.push({ ...item.body, id: item.id });
400
+ }
401
+ providers.push({
402
+ id: `config:${modules.namespace}`,
403
+ namespace: modules.namespace,
404
+ workflows,
405
+ });
406
+ }
407
+ return providers;
408
+ }
409
+
410
+ /**
411
+ * Provider from site `serverTools.modules.workflows` maps.
412
+ *
413
+ * @param {Array<object>} sites
414
+ * @returns {Array<object>}
415
+ */
416
+ export function moduleWorkflowProviders(sites) {
417
+ const providers = [];
418
+ for (const site of sites ?? []) {
419
+ const modules = site.serverTools?.modules;
420
+ if (!modules?.namespace || !modules.workflows || typeof modules.workflows !== "object") continue;
421
+ const workflows = [];
422
+ for (const [id, body] of Object.entries(modules.workflows)) {
423
+ if (id.startsWith("_") || !body || typeof body !== "object") continue;
424
+ workflows.push({ ...body, id: body.id ?? id });
425
+ }
426
+ providers.push({
427
+ id: `config:${modules.namespace}`,
428
+ namespace: modules.namespace,
429
+ workflows,
430
+ });
431
+ }
432
+ return providers;
433
+ }
434
+
435
+ const INDEX = new Map();
436
+ const MODULE_KEYS = new Set();
437
+
438
+ /**
439
+ * Register built-in workflows for principal filtering.
440
+ *
441
+ * @param {object[]} workflows
442
+ */
443
+ export function registerBuiltinWorkflows(workflows) {
444
+ for (const wf of workflows ?? []) INDEX.set(wf.name, wf);
445
+ }
446
+
447
+ /**
448
+ * Replace the module-owned slice of the workflow index (per request).
449
+ *
450
+ * @param {object[]} workflows
451
+ */
452
+ export function replaceModuleWorkflows(workflows) {
453
+ for (const key of MODULE_KEYS) INDEX.delete(key);
454
+ MODULE_KEYS.clear();
455
+ for (const wf of workflows ?? []) {
456
+ INDEX.set(wf.name, wf);
457
+ MODULE_KEYS.add(wf.name);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * @param {string} name
463
+ * @returns {object|undefined}
464
+ */
465
+ export function lookupWorkflow(name) {
466
+ return INDEX.get(name);
467
+ }
468
+
469
+ /**
470
+ * Index used by tests.
471
+ *
472
+ * @param {object[]} workflows
473
+ * @returns {Map<string, {readOnly: boolean, builtin: boolean, publicTools: string[]}>}
474
+ */
475
+ export function workflowIndex(workflows) {
476
+ return new Map((workflows ?? []).map((wf) => [wf.name, {
477
+ readOnly: wf.readOnly,
478
+ builtin: wf.builtin,
479
+ publicTools: wf.publicTools,
480
+ }]));
481
+ }
482
+
483
+ export const WORKFLOW_LIMITS = {
484
+ MAX_DESCRIPTION,
485
+ MAX_INSTRUCTIONS,
486
+ MAX_WORKFLOWS,
487
+ };
488
+
489
+ // Re-export for callers that already import tool prompt hyphenation.
490
+ export { toolNameToPromptName };