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.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -0
- package/config/config.example.json +12 -1
- package/package.json +1 -1
- package/scripts/generate-commands.js +43 -0
- package/scripts/install-commands.js +56 -5
- package/src/index.js +29 -109
- package/src/lib/governance.js +9 -2
- package/src/lib/oauth.js +23 -9
- package/src/lib/principal.js +14 -0
- package/src/lib/server-tools.js +85 -9
- package/src/lib/tool-prompts.js +29 -6
- package/src/lib/workflow-prompts.js +490 -0
- package/src/lib/workflows/builtin.js +134 -0
package/src/lib/server-tools.js
CHANGED
|
@@ -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:
|
|
341
|
-
signal: AbortSignal.timeout(
|
|
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:
|
|
378
|
-
signal: AbortSignal.timeout(
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 };
|