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 CHANGED
@@ -7,6 +7,42 @@ 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
+
22
+ ## [2.21.0] - 2026-09-19
23
+
24
+ ### Added
25
+ - **Module-owned workflow prompts (#333).** Workflows are definitions the
26
+ connector loads, filters and relays. v1 sources them from built-in
27
+ definitions (the five connector-authored workflows, now the same format)
28
+ and from `serverTools.modules.workflows` next to approved module tools.
29
+ A workflow names tools by local alias (`{tool:alias}`), is listed only
30
+ when every named tool is visible, and cannot invent a tool the site did
31
+ not already approve. Write workflows require `mcp_write`, tell the model
32
+ to confirm, and append that module writes are not retried. Design:
33
+ `docs/module-workflows.md`. The example config ships `review_and_log` on
34
+ `example_site`. Drupal `prompts/list` is specified there and not
35
+ implemented in this change.
36
+
37
+ ### Fixed
38
+ - **Remaining northbound HTTP timeouts.** Server-tool `tools/call` (including
39
+ governed config), the OAuth token POST, and the Sentinel readiness GET now
40
+ abort with `AbortSignal.timeout` so a hung Drupal/Sentinel host cannot stall
41
+ the MCP process. `requestServerTool` always attaches the 256 KiB body cap and
42
+ a 15s abort; those were previously gated on `maxBytes`, so `callGovernedServerTool`
43
+ could hang unbounded. Token acquisition fails as `OAuthError` after 30s.
44
+ A hung readiness probe stays `sentinel_unreachable`.
45
+
10
46
  ## [2.20.0] - 2026-09-19
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -118,6 +118,8 @@ and other MCP-aware agents). Two kinds ship with the connector:
118
118
  - `drupal-create-article` — guided article creation with all fields
119
119
  - `drupal-seo-fix` — find and fix SEO gaps
120
120
  - `drupal-user-cleanup` — identify and handle inactive accounts
121
+ - Module-owned workflows (`drupal-<namespace>-<workflow>`) when
122
+ `serverTools.modules.workflows` is enabled — see `docs/module-workflows.md`
121
123
 
122
124
  **One prompt per tool** — every `drupal_*` tool is also exposed as a
123
125
  `drupal-<tool>` prompt (e.g. `drupal-create-node`, `drupal-list-nodes`,
@@ -155,7 +155,7 @@
155
155
 
156
156
  "_server_tools": {
157
157
  "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer.",
158
- "_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. See docs/module-tools.md.",
158
+ "_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. Optional serverTools.modules.workflows enables module-owned workflow prompts (drupal-<namespace>-<workflow>); a workflow may only name aliases already under tools. See docs/module-tools.md and docs/module-workflows.md.",
159
159
  "_bindings_comment": "Optional. serverTools.bindings keeps a built-in command's public name while its implementation lives in a Drupal module. Each binding names an alias under serverTools.modules.tools on the same site. configGet, configList, configSet serve drupal_config_get/list/set and the config reports; codegenInspect, codegenDiff, codegenPreview serve drupal_codegen_inspect/diff/generate; sqlQuery serves drupal_drush_sql_query. Required alias policy: config and codegen aliases use scope mcp_config; get, list and the three codegen aliases are operation read with capability configRead; configSet is operation write with capability configWrite; sqlQuery is scope mcp_admin, operation read, capability rawSql, and still needs drushSsh.rawSql: \"governed\" on the site. Once a bindings object exists, every one of these commands resolves only through its binding: a missing or mismatched mapping refuses the command, with no fallback to another tool or to SSH. Configure the full set for the commands the site uses. Without a bindings object these commands keep their built-in transport. See docs/module-tools.md.",
160
160
  "example": {
161
161
  "serverTools": {
@@ -182,6 +182,17 @@
182
182
  "codegen_diff": { "name": "tool_api__graphql_compose_codegen_diff", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
183
183
  "codegen_preview": { "name": "tool_api__graphql_compose_codegen_preview", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
184
184
  "sql_query": { "name": "tool_api__mcp_sentinel_sql_query", "scope": "mcp_admin", "operation": "read", "capabilities": ["rawSql"] }
185
+ },
186
+ "workflows": {
187
+ "review_and_log": {
188
+ "description": "Review recent activities and, after confirmation, log a follow-up. Write is opt-in and is not retried.",
189
+ "readOnly": false,
190
+ "tools": ["list_activities", "record_activity"],
191
+ "arguments": [
192
+ { "name": "site", "description": "Target site", "required": false }
193
+ ],
194
+ "instructions": "1. Call {tool:list_activities} for recent rows.\n2. Summarize what needs a follow-up.\n3. Ask the person before calling {tool:record_activity}. Do not retry a write."
195
+ }
185
196
  }
186
197
  },
187
198
  "bindings": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.20.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",
@@ -45,6 +45,20 @@ export function commandFileName(def) {
45
45
  /** Trailing marker that tells the installer a stub belongs to a module-owned tool. */
46
46
  export const MODULE_STUB_MARKER = "<!-- drupal-mcp-connector:module-tool -->";
47
47
 
48
+ /** Trailing marker for a module-owned workflow stub. */
49
+ export const WORKFLOW_STUB_MARKER = "<!-- drupal-mcp-connector:module-workflow -->";
50
+
51
+ /**
52
+ * Command filename for a module-owned workflow prompt.
53
+ *
54
+ * @param {string} namespace
55
+ * @param {string} id
56
+ * @returns {string}
57
+ */
58
+ export function workflowCommandFileName(namespace, id) {
59
+ return `drupal-${namespace}-${id}.md`.replace(/_/g, "-");
60
+ }
61
+
48
62
  const MODULE_TOOL_NAME = /^drupal_module_(?:read|write|delete)_([a-z][a-z0-9_]*?)__([a-z][a-z0-9_]*)$/;
49
63
 
50
64
  /**
@@ -144,6 +158,35 @@ export function renderCommandMarkdown(def, options = {}) {
144
158
  * @param {object} def - The tool definition.
145
159
  * @returns {string} File contents.
146
160
  */
161
+ /**
162
+ * Filesystem stub for a module-owned workflow prompt.
163
+ *
164
+ * @param {object} workflow - A loaded workflow from the #333 loader.
165
+ * @returns {string}
166
+ */
167
+ export function renderWorkflowCommandMarkdown(workflow) {
168
+ const params = (workflow.arguments ?? []).map((arg) => ({
169
+ name: arg.name,
170
+ required: Boolean(arg.required),
171
+ }));
172
+ const hint = argumentHint(params);
173
+ const tools = (workflow.publicTools ?? []).map((name) => `\`${name}\``).join(", ");
174
+ const body = [
175
+ `# ${workflow.name}`,
176
+ "",
177
+ workflow.description,
178
+ "",
179
+ `This is a workflow prompt. Ask the MCP client for prompt \`${workflow.name}\`.`,
180
+ tools ? `It names these tools: ${tools}.` : "",
181
+ workflow.readOnly
182
+ ? "Read-only: do not write."
183
+ : "Confirm with the person before any write. Module writes are not retried.",
184
+ "",
185
+ WORKFLOW_STUB_MARKER,
186
+ ].filter((line, i, arr) => line !== "" || arr[i - 1] !== "");
187
+ return `---\ndescription: ${yamlString(workflow.description)}\nargument-hint: "${hint}"\n---\n\n${body.join("\n")}\n`;
188
+ }
189
+
147
190
  export function renderClaudeCommandMarkdown(def) {
148
191
  return renderCommandMarkdown(def, {
149
192
  allowedTools: `mcp__drupal__${def.name}`,
@@ -32,10 +32,13 @@ import {
32
32
  commandFileName,
33
33
  moduleCommandFileName,
34
34
  MODULE_STUB_MARKER,
35
+ WORKFLOW_STUB_MARKER,
35
36
  renderCommandMarkdown,
36
37
  renderClaudeCommandMarkdown,
37
38
  renderCodexSkillMarkdown,
38
39
  renderCodexToolsReference,
40
+ renderWorkflowCommandMarkdown,
41
+ workflowCommandFileName,
39
42
  CODEX_SKILL_NAME,
40
43
  } from "./generate-commands.js";
41
44
 
@@ -154,15 +157,43 @@ export function missingModuleTools(configured, discovered) {
154
157
  return configured.filter((name) => !found.has(name));
155
158
  }
156
159
 
157
- /** Whether an installed stub was written for a module-owned tool. */
160
+ /** Whether an installed stub was written for a module-owned tool or workflow. */
158
161
  function isModuleStub(path) {
159
162
  try {
160
- return readFileSync(path, "utf8").includes(MODULE_STUB_MARKER);
163
+ const text = readFileSync(path, "utf8");
164
+ return text.includes(MODULE_STUB_MARKER) || text.includes(WORKFLOW_STUB_MARKER);
161
165
  } catch {
162
166
  return false;
163
167
  }
164
168
  }
165
169
 
170
+ /**
171
+ * Workflow stubs for enabled local definitions whose named tools were discovered.
172
+ *
173
+ * @param {object[]} workflows
174
+ * @param {Set<string>} builtInFiles
175
+ * @param {Set<string>} moduleFiles
176
+ * @returns {{stubs: Array<{file: string, workflow: object}>, refused: Array<object>}}
177
+ */
178
+ export function planWorkflowStubs(workflows, builtInFiles, moduleFiles) {
179
+ const stubs = [];
180
+ const refused = [];
181
+ const byFile = new Map();
182
+ for (const workflow of workflows ?? []) {
183
+ const file = workflowCommandFileName(workflow.namespace, workflow.id);
184
+ if (builtInFiles.has(file) || moduleFiles.has(file)) {
185
+ refused.push({ name: workflow.name, file, reason: "matches a built-in or module-tool command" });
186
+ continue;
187
+ }
188
+ byFile.set(file, [...(byFile.get(file) ?? []), workflow]);
189
+ }
190
+ for (const [file, defs] of byFile) {
191
+ if (defs.length === 1) stubs.push({ file, workflow: defs[0] });
192
+ else defs.forEach((workflow) => refused.push({ name: workflow.name, file, reason: "shared by more than one workflow" }));
193
+ }
194
+ return { stubs, refused };
195
+ }
196
+
166
197
  /**
167
198
  * Write one `drupal-*.md` per tool into each requested client directory,
168
199
  * pruning stale stubs first. Unknown client names fail closed.
@@ -229,6 +260,11 @@ export function install(options = {}) {
229
260
  writeFileSync(join(dir, file), client.render(def));
230
261
  moduleWritten.push(file);
231
262
  }
263
+ const workflowStubs = options.workflowStubs ?? [];
264
+ for (const { file, workflow } of workflowStubs) {
265
+ writeFileSync(join(dir, file), renderWorkflowCommandMarkdown(workflow));
266
+ moduleWritten.push(file);
267
+ }
232
268
  results.push({ client: name, dir, written, moduleWritten });
233
269
  }
234
270
  return results;
@@ -266,9 +302,11 @@ deprecated Codex custom prompts (~/.codex/prompts).
266
302
  --home DIR Install root (default: the current user's home)
267
303
  --clients LIST Comma-separated subset of: claude, grok, codex, agents
268
304
  (default: claude,grok,codex)
269
- --modules Also write stubs for module-owned tools. Discovers them from
270
- the sources in config/config.json (run from that directory).
271
- Without this flag, installed module stubs are left alone.
305
+ --modules Also write stubs for module-owned tools and workflows.
306
+ Discovers tools from the sources in config/config.json
307
+ (run from that directory). Workflows come from
308
+ serverTools.modules.workflows. Without this flag, installed
309
+ module stubs are left alone.
272
310
  `;
273
311
 
274
312
  const invokedDirectly =
@@ -302,6 +340,19 @@ if (invokedDirectly) {
302
340
  opts.moduleParts = parts;
303
341
  // An incomplete listing must not delete stubs for tools that may only be unreachable.
304
342
  opts.pruneModules = missing.length === 0;
343
+ const { loadWorkflows, moduleWorkflowProviders } = await import("../src/lib/workflow-prompts.js");
344
+ const { listResolvableSiteConfigs } = await import("../src/lib/dispatch.js");
345
+ const workflows = loadWorkflows(moduleWorkflowProviders(listResolvableSiteConfigs()), {
346
+ tools: definitions,
347
+ taken: new Set(allDefinitions.map((def) => def.name.replace(/_/g, "-"))),
348
+ });
349
+ const builtInFiles = new Set(allDefinitions.map(commandFileName));
350
+ const moduleFiles = new Set(planModuleStubs(definitions, allDefinitions, parts).stubs.map((s) => s.file));
351
+ const planned = planWorkflowStubs(workflows, builtInFiles, moduleFiles);
352
+ for (const r of planned.refused) {
353
+ console.error(`[install-commands] WARNING: no workflow stub for ${r.name}: ${r.reason}${r.file ? ` (${r.file})` : ""}`);
354
+ }
355
+ opts.workflowStubs = planned.stubs;
305
356
  }
306
357
  const results = install(opts);
307
358
  for (const r of results) {
package/src/index.js CHANGED
@@ -60,6 +60,15 @@ import {
60
60
  import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
61
61
  import { createModuleToolRegistry, isModuleTool } from "./lib/module-tools.js";
62
62
  import { buildToolPrompts, createPromptSurface } from "./lib/tool-prompts.js";
63
+ import {
64
+ loadWorkflows,
65
+ toPromptDescriptor,
66
+ renderWorkflowMessages,
67
+ moduleWorkflowProvidersWithRemote,
68
+ registerBuiltinWorkflows,
69
+ replaceModuleWorkflows,
70
+ } from "./lib/workflow-prompts.js";
71
+ import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
63
72
 
64
73
  // Apply config/secrets.map (or the shipped example table) before any site
65
74
  // resolution. MCP clients spawn this file directly; the shell launcher is
@@ -167,129 +176,31 @@ async function readResource(uri) {
167
176
  // MCP Prompts — common Drupal workflow templates
168
177
  // ---------------------------------------------------------------------------
169
178
 
170
- const PROMPTS = [
171
- {
172
- name: "drupal-content-audit",
173
- description: "Walk through a full content audit: inventory, staleness, SEO gaps, accessibility issues, and recommendations.",
174
- arguments: [{ name: "site", description: "Named site to audit (omit for default)", required: false }],
175
- },
176
- {
177
- name: "drupal-create-article",
178
- description: "Guided workflow to research, draft, and publish an article node with all fields, tags, and metadata.",
179
- arguments: [
180
- { name: "site", description: "Target site", required: false },
181
- { name: "topic", description: "Article topic/brief", required: true },
182
- ],
183
- },
184
- {
185
- name: "drupal-seo-fix",
186
- description: "Find SEO gaps in content (missing meta descriptions, thin content, title issues) and fix them interactively.",
187
- arguments: [
188
- { name: "site", description: "Target site", required: false },
189
- { name: "type", description: "Content type to scan", required: false },
190
- ],
191
- },
192
- {
193
- name: "drupal-user-cleanup",
194
- description: "Identify inactive, never-logged-in, or overly permissioned user accounts and take action.",
195
- arguments: [{ name: "site", description: "Target site", required: false }],
196
- },
197
- {
198
- name: "drupal-full-audit",
199
- description: "Run a full site-health audit — content, link/404 integrity, and configuration posture — and turn the scored dashboard into a prioritized action plan.",
200
- arguments: [
201
- { name: "site", description: "Named site to audit (omit for default)", required: false },
202
- { name: "type", description: "Primary content type to audit", required: false },
203
- ],
204
- },
205
- ];
179
+ const builtinWorkflows = loadWorkflows([builtinWorkflowProvider], { tools: allDefinitions });
180
+ registerBuiltinWorkflows(builtinWorkflows);
181
+ const PROMPTS = builtinWorkflows.map(toPromptDescriptor);
206
182
 
207
183
  // Per-tool prompts: one slash-command prompt for every Drupal tool, derived from
208
184
  // the tool definitions so the set always matches the tools. Merged after the
209
- // hand-authored workflow prompts above (names never collide — workflow prompts use
210
- // composite verbs, tool prompts mirror the `drupal_*` tool names).
185
+ // workflow prompts (names never collide — workflow prompts use composite verbs,
186
+ // tool prompts mirror the `drupal_*` tool names).
211
187
  const WORKFLOW_PROMPT_NAMES = new Set(PROMPTS.map((p) => p.name));
212
188
  const TOOL_PROMPTS = buildToolPrompts(allDefinitions);
213
189
  const ALL_PROMPTS = [...PROMPTS, ...TOOL_PROMPTS];
214
190
 
215
191
  /**
216
- * Build the message list for a named prompt, interpolating site/type/topic
217
- * args into a pre-authored multi-step workflow. Unknown prompt names fall back
218
- * to a generic one-line instruction so the call never fails.
192
+ * Build the message list for a named built-in workflow. Unknown names fall
193
+ * back to a generic one-line instruction so the call never fails.
219
194
  *
220
195
  * @param {string} name - The prompt name.
221
196
  * @param {object} args - Prompt arguments (site, type, topic — all optional).
222
197
  * @returns {Array<object>} MCP prompt messages.
223
198
  */
224
199
  function getPromptMessages(name, args) {
225
- const site = args?.site ? `on the "${args.site}" site` : "on the default site";
226
- const type = args?.type || "article";
227
- const topic = args?.topic || "the requested topic";
228
-
229
- const prompts = {
230
- "drupal-content-audit": [
231
- { role: "user", content: { type: "text", text:
232
- `Please run a comprehensive content audit ${site}. Do not assume any particular content type exists — every site has a different model, so discover it first and audit the types this site actually has.\n` +
233
- "1. Call drupal_report_content_summary for the full inventory. Its byContentType list is the set of content types to audit — derive the types from it; never assume a fixed type such as \"article\".\n" +
234
- "2. For each content type that has nodes, call drupal_report_stale_content (days: 180).\n" +
235
- "3. For each content type that has nodes, call drupal_report_field_completeness.\n" +
236
- "4. For each content type with published nodes, check SEO: prefer drupal_report_seo_meta_coverage (it reads the site's actual meta field rather than assuming one) and use drupal_report_seo_audit for title-length and thin-content checks.\n" +
237
- "5. For each content type with published nodes, call drupal_report_accessibility_audit.\n" +
238
- "6. For any content type reporting zero nodes, skip its per-type scans and record it as empty — an empty type is not a clean one.\n" +
239
- "7. Synthesize findings into: (a) immediate actions, (b) medium-term improvements, (c) process recommendations.\n" +
240
- "Present results as a structured report with counts, severity, and specific node links where possible. State which content types were scanned so an empty or unexpected model cannot be mistaken for a clean audit."
241
- }},
242
- ],
243
- "drupal-create-article": [
244
- { role: "user", content: { type: "text", text:
245
- `I need to create a new article ${site} about: ${topic}\n\n` +
246
- "Please:\n" +
247
- "1. Call drupal_list_content_types to confirm \"article\" exists and check its fields.\n" +
248
- "2. Call drupal_get_entity_schema for node/article to see all available fields.\n" +
249
- "3. Call drupal_list_vocabularies and drupal_get_taxonomy_terms for relevant vocabularies.\n" +
250
- "4. Draft the article — title, body (well-structured HTML), summary, and meta description.\n" +
251
- "5. Suggest appropriate taxonomy tags.\n" +
252
- "6. Call drupal_create_node with status: false (draft) and show me the result.\n" +
253
- "7. Ask me to review before publishing."
254
- }},
255
- ],
256
- "drupal-seo-fix": [
257
- { role: "user", content: { type: "text", text:
258
- `Please find and fix SEO issues in "${type}" content ${site}.\n\n` +
259
- "1. Call drupal_report_seo_audit to identify all issues.\n" +
260
- "2. For nodes missing meta descriptions: generate appropriate descriptions (max 160 chars) and update them.\n" +
261
- "3. For thin content (under 300 words): flag for editorial review — do not auto-expand.\n" +
262
- "4. For title length issues: suggest better titles but ask before updating.\n" +
263
- "5. Report what was fixed, what needs human review, and any patterns you noticed."
264
- }},
265
- ],
266
- "drupal-user-cleanup": [
267
- { role: "user", content: { type: "text", text:
268
- `Please audit user accounts ${site} and recommend cleanup actions.\n\n` +
269
- "1. Call drupal_report_user_activity to identify inactive and never-logged-in accounts.\n" +
270
- "2. Call drupal_list_users with no filter to get the full list.\n" +
271
- "3. Call drupal_list_roles to see all available roles.\n" +
272
- "4. Identify: (a) accounts inactive 90+ days, (b) never-logged-in accounts, (c) accounts with admin roles that look like test/temp accounts.\n" +
273
- "5. For each category, recommend action (block, delete, or keep) with reasoning.\n" +
274
- "6. Ask for approval before making any changes."
275
- }},
276
- ],
277
- "drupal-full-audit": [
278
- { role: "user", content: { type: "text", text:
279
- `Please run a full site-health audit ${site} and turn it into a prioritized action plan.\n\n` +
280
- `1. Call drupal_audit_site_health (type: "${type}") for the scored dashboard and overall grade.\n` +
281
- "2. For any section reporting high-severity findings, drill in with the matching tool for detail:\n" +
282
- " - links/404: drupal_report_404_log, drupal_report_redirect_health, drupal_report_broken_links (checkLive only with approval).\n" +
283
- " - config: drupal_audit_config_best_practices, drupal_report_module_audit, drupal_report_permission_audit.\n" +
284
- " - content: drupal_report_pii_exposure, drupal_report_duplicate_content, drupal_report_readability.\n" +
285
- "3. For sections reported 'unavailable', note what (server-tool bridge or drush) would enable them — do not treat unavailable as 'passing'.\n" +
286
- "4. Synthesize a prioritized plan: (a) high-severity/security fixes first, (b) content-quality improvements, (c) process recommendations.\n" +
287
- "5. Present counts, severity, and specific node/config references; propose redirects for the top 404s. Ask before making any changes."
288
- }},
289
- ],
290
- };
291
-
292
- return new Map(Object.entries(prompts)).get(name) ?? [{ role: "user", content: { type: "text", text: `Run the ${name} workflow ${site}.` } }];
200
+ const workflow = builtinWorkflows.find((item) => item.name === name);
201
+ if (workflow) return renderWorkflowMessages(workflow, args);
202
+ const site = args?.site ? `on the "${args.site}" site` : "on the default site";
203
+ return [{ role: "user", content: { type: "text", text: `Run the ${name} workflow ${site}.` } }];
293
204
  }
294
205
 
295
206
  // ---------------------------------------------------------------------------
@@ -320,6 +231,15 @@ const buildConnectorServer = createConnectorServerFactory({
320
231
  workflowNames: WORKFLOW_PROMPT_NAMES,
321
232
  workflowMessages: getPromptMessages,
322
233
  definitionsByName,
234
+ extraWorkflows: async (tools, taken) => {
235
+ const loaded = loadWorkflows(
236
+ await moduleWorkflowProvidersWithRemote(listResolvableSiteConfigs()),
237
+ { tools, taken },
238
+ );
239
+ replaceModuleWorkflows(loaded);
240
+ return loaded;
241
+ },
242
+ extraWorkflowMessages: renderWorkflowMessages,
323
243
  }),
324
244
  });
325
245
 
@@ -23,6 +23,9 @@ import { DIAGNOSTIC_TOOLS } from "./principal.js";
23
23
  /** How long a passing verification stays fresh before it must be re-proven. */
24
24
  export const OK_TTL_MS = 60_000;
25
25
 
26
+ /** Readiness GET abort timeout. Matches Drupal/Drush outbound HTTP. */
27
+ export const READINESS_TIMEOUT_MS = 30_000;
28
+
26
29
  /** How long a failed verification is held before the next attempt re-checks. */
27
30
  export const FAIL_TTL_MS = 5_000;
28
31
 
@@ -81,7 +84,7 @@ export async function verifySourceGovernance(site, { force = false } = {}) {
81
84
  }
82
85
 
83
86
  /**
84
- * One authenticated readiness probe; maps every outcome to {ok, reason}.
87
+ * One authenticated readiness probe; maps every outcome (including abort) to {ok, reason}.
85
88
  * @param {object} site Resolved site config.
86
89
  * @returns {Promise<{ok: boolean, reason: string|null, checkedAt: number}>}
87
90
  */
@@ -104,7 +107,11 @@ async function probeReadiness(site) {
104
107
 
105
108
  let res;
106
109
  try {
107
- res = await fetch(`${site.baseUrl}/drupal-mcp/readiness`, { method: "GET", headers });
110
+ res = await fetch(`${site.baseUrl}/drupal-mcp/readiness`, {
111
+ method: "GET",
112
+ headers,
113
+ signal: AbortSignal.timeout(READINESS_TIMEOUT_MS),
114
+ });
108
115
  } catch {
109
116
  // Network detail (addresses, DNS text) is deliberately not propagated.
110
117
  return { ok: false, reason: "sentinel_unreachable", checkedAt };
package/src/lib/oauth.js CHANGED
@@ -15,6 +15,9 @@ import fetch from "node-fetch";
15
15
  /** Re-acquire this many ms before the stated expiry to absorb clock skew. */
16
16
  const EXPIRY_SKEW_MS = 60_000;
17
17
 
18
+ /** Token-endpoint abort timeout. Matches Drupal/Drush outbound HTTP. */
19
+ export const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
20
+
18
21
  /**
19
22
  * Per-site token cache, keyed by site._name. A value is either a resolved
20
23
  * { token, expiresAt, refreshToken } entry or an in-flight Promise of one
@@ -67,20 +70,31 @@ function buildBody(oauth, useRefresh, refreshToken) {
67
70
  * @param {boolean} useRefresh Whether to use the refresh_token grant.
68
71
  * @param {?string} refreshToken Refresh token for the refresh grant.
69
72
  * @returns {Promise<{token: string, expiresAt: number, refreshToken: ?string}>}
70
- * @throws {OAuthError} on a non-2xx response or a missing access_token.
73
+ * @throws {OAuthError} on a non-2xx response, a missing access_token, or a timeout.
71
74
  */
72
75
  async function requestToken(site, useRefresh, refreshToken) {
73
76
  const { oauth } = site;
74
77
  const url = `${site.baseUrl}${oauth.tokenUrl || "/oauth/token"}`;
75
78
 
76
- const res = await fetch(url, {
77
- method: "POST",
78
- headers: {
79
- "Content-Type": "application/x-www-form-urlencoded",
80
- Accept: "application/json",
81
- },
82
- body: buildBody(oauth, useRefresh, refreshToken),
83
- });
79
+ let res;
80
+ try {
81
+ res = await fetch(url, {
82
+ method: "POST",
83
+ headers: {
84
+ "Content-Type": "application/x-www-form-urlencoded",
85
+ Accept: "application/json",
86
+ },
87
+ body: buildBody(oauth, useRefresh, refreshToken),
88
+ signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
89
+ });
90
+ } catch (err) {
91
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") {
92
+ throw new OAuthError(
93
+ `OAuth token request to ${site._name} timed out after ${OAUTH_TOKEN_TIMEOUT_MS / 1000}s.`
94
+ );
95
+ }
96
+ throw err;
97
+ }
84
98
 
85
99
  if (!res.ok) {
86
100
  throw new OAuthError(
@@ -17,6 +17,7 @@ import { getDefaultSiteName, getInboundGrants } from "./config.js";
17
17
  import { inferOperation } from "./operations.js";
18
18
  import { POLICY_DIGEST } from "./policy-promotion.js";
19
19
  import { resolveSecurityConfig, SecurityError } from "./security.js";
20
+ import { lookupWorkflow } from "./workflow-prompts.js";
20
21
 
21
22
  const identityStore = new AsyncLocalStorage();
22
23
 
@@ -515,6 +516,19 @@ export function filterPromptsByPrincipal(prompts, identity, visibleTools) {
515
516
  if (!identity) return prompts;
516
517
  const visible = new Set((visibleTools ?? []).map((tool) => tool.name));
517
518
  return prompts.filter((prompt) => {
519
+ const wf = lookupWorkflow(prompt.name);
520
+ if (wf) {
521
+ if (wf.readOnly) {
522
+ if (!principalHasScope(identity, "mcp_read")) return false;
523
+ } else if (!principalHasScope(identity, "mcp_write")) {
524
+ return false;
525
+ }
526
+ if (!wf.builtin && Array.isArray(wf.publicTools) &&
527
+ !wf.publicTools.every((name) => visible.has(name))) {
528
+ return false;
529
+ }
530
+ return true;
531
+ }
518
532
  if (WRITE_WORKFLOW_PROMPTS.has(prompt.name)) {
519
533
  return principalHasScope(identity, "mcp_write");
520
534
  }