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 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.21.0",
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
- moduleWorkflowProviders,
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(moduleWorkflowProviders(listResolvableSiteConfigs()), { tools, taken });
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
  },
@@ -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;
@@ -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
  *