drupal-mcp-connector 2.21.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 +12 -0
- package/package.json +1 -1
- package/src/index.js +6 -3
- package/src/lib/server-tools.js +71 -2
- package/src/lib/tool-prompts.js +2 -2
- package/src/lib/workflow-prompts.js +142 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.22.0] - 2026-09-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Remote Drupal workflow prompts (#371).** The module-workflow loader fetches
|
|
14
|
+
`prompts/list` and `prompts/get` from `serverTools.url` (contrib `mcp_server`
|
|
15
|
+
`McpPromptConfig` entities) and merges them into the v1 path. A remote
|
|
16
|
+
workflow is listed only when local `serverTools.modules.workflows` names it
|
|
17
|
+
and every `{tool:alias}` is already in `serverTools.modules.tools`. Local
|
|
18
|
+
bodies remain the fallback when the catalog omits an enabled id. Write
|
|
19
|
+
workflows still require `mcp_write` and append that module writes are not
|
|
20
|
+
retried. See `docs/module-workflows.md`.
|
|
21
|
+
|
|
10
22
|
## [2.21.0] - 2026-09-19
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/index.js
CHANGED
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
loadWorkflows,
|
|
65
65
|
toPromptDescriptor,
|
|
66
66
|
renderWorkflowMessages,
|
|
67
|
-
|
|
67
|
+
moduleWorkflowProvidersWithRemote,
|
|
68
68
|
registerBuiltinWorkflows,
|
|
69
69
|
replaceModuleWorkflows,
|
|
70
70
|
} from "./lib/workflow-prompts.js";
|
|
@@ -231,8 +231,11 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
231
231
|
workflowNames: WORKFLOW_PROMPT_NAMES,
|
|
232
232
|
workflowMessages: getPromptMessages,
|
|
233
233
|
definitionsByName,
|
|
234
|
-
extraWorkflows: (tools, taken) => {
|
|
235
|
-
const loaded = loadWorkflows(
|
|
234
|
+
extraWorkflows: async (tools, taken) => {
|
|
235
|
+
const loaded = loadWorkflows(
|
|
236
|
+
await moduleWorkflowProvidersWithRemote(listResolvableSiteConfigs()),
|
|
237
|
+
{ tools, taken },
|
|
238
|
+
);
|
|
236
239
|
replaceModuleWorkflows(loaded);
|
|
237
240
|
return loaded;
|
|
238
241
|
},
|
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
|
|
@@ -333,7 +334,7 @@ async function initializeSession(site, endpoint, key) {
|
|
|
333
334
|
method: "initialize",
|
|
334
335
|
params: {
|
|
335
336
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
336
|
-
capabilities: {},
|
|
337
|
+
capabilities: { prompts: {} },
|
|
337
338
|
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
|
|
338
339
|
},
|
|
339
340
|
};
|
|
@@ -442,6 +443,74 @@ export async function listServerTools(site, cursor) {
|
|
|
442
443
|
});
|
|
443
444
|
}
|
|
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,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
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
|
+
|
|
445
514
|
/** Shared bounded MCP request transport; size + abort are always attached. */
|
|
446
515
|
async function requestServerTool(site, method, params, options) {
|
|
447
516
|
const toolName = params.name ?? method;
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -239,7 +239,7 @@ 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[]} [options.extraWorkflows]
|
|
242
|
+
* @param {(tools: Array<object>, taken: Set<string>) => object[]|Promise<object[]>} [options.extraWorkflows]
|
|
243
243
|
* Module-owned workflows visible for this request. Each item has name,
|
|
244
244
|
* description, arguments, and is renderable by extraWorkflowMessages.
|
|
245
245
|
* @param {(workflow: object, args: object) => Array<object>} [options.extraWorkflowMessages]
|
|
@@ -256,7 +256,7 @@ export function createPromptSurface({
|
|
|
256
256
|
async function visible() {
|
|
257
257
|
const tools = await discover();
|
|
258
258
|
const taken = new Set(staticPrompts.map((prompt) => prompt.name));
|
|
259
|
-
const extra = extraWorkflows ? extraWorkflows(tools, taken) : [];
|
|
259
|
+
const extra = extraWorkflows ? await extraWorkflows(tools, taken) : [];
|
|
260
260
|
for (const workflow of extra) taken.add(workflow.name);
|
|
261
261
|
// A reserved module name cannot match a built-in, but never let a remote
|
|
262
262
|
// catalog shadow a static prompt if that invariant is ever broken.
|
|
@@ -265,6 +265,148 @@ export function renderWorkflowMessages(workflow, args = {}) {
|
|
|
265
265
|
return [{ role: "user", content: { type: "text", text } }];
|
|
266
266
|
}
|
|
267
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
|
+
|
|
268
410
|
/**
|
|
269
411
|
* Provider from site `serverTools.modules.workflows` maps.
|
|
270
412
|
*
|