drupal-mcp-connector 2.20.0 → 2.21.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,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.21.0] - 2026-09-19
11
+
12
+ ### Added
13
+ - **Module-owned workflow prompts (#333).** Workflows are definitions the
14
+ connector loads, filters and relays. v1 sources them from built-in
15
+ definitions (the five connector-authored workflows, now the same format)
16
+ and from `serverTools.modules.workflows` next to approved module tools.
17
+ A workflow names tools by local alias (`{tool:alias}`), is listed only
18
+ when every named tool is visible, and cannot invent a tool the site did
19
+ not already approve. Write workflows require `mcp_write`, tell the model
20
+ to confirm, and append that module writes are not retried. Design:
21
+ `docs/module-workflows.md`. The example config ships `review_and_log` on
22
+ `example_site`. Drupal `prompts/list` is specified there and not
23
+ implemented in this change.
24
+
25
+ ### Fixed
26
+ - **Remaining northbound HTTP timeouts.** Server-tool `tools/call` (including
27
+ governed config), the OAuth token POST, and the Sentinel readiness GET now
28
+ abort with `AbortSignal.timeout` so a hung Drupal/Sentinel host cannot stall
29
+ the MCP process. `requestServerTool` always attaches the 256 KiB body cap and
30
+ a 15s abort; those were previously gated on `maxBytes`, so `callGovernedServerTool`
31
+ could hang unbounded. Token acquisition fails as `OAuthError` after 30s.
32
+ A hung readiness probe stays `sentinel_unreachable`.
33
+
10
34
  ## [2.20.0] - 2026-09-19
11
35
 
12
36
  ### 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.21.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
+ moduleWorkflowProviders,
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,12 @@ const buildConnectorServer = createConnectorServerFactory({
320
231
  workflowNames: WORKFLOW_PROMPT_NAMES,
321
232
  workflowMessages: getPromptMessages,
322
233
  definitionsByName,
234
+ extraWorkflows: (tools, taken) => {
235
+ const loaded = loadWorkflows(moduleWorkflowProviders(listResolvableSiteConfigs()), { tools, taken });
236
+ replaceModuleWorkflows(loaded);
237
+ return loaded;
238
+ },
239
+ extraWorkflowMessages: renderWorkflowMessages,
323
240
  }),
324
241
  });
325
242
 
@@ -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
  }
@@ -139,6 +139,12 @@ export async function callGovernedServerTool(site, binding, args = {}) {
139
139
  /** MCP protocol version advertised on the handshake and every subsequent POST. */
140
140
  const MCP_PROTOCOL_VERSION = "2025-06-18";
141
141
 
142
+ /** MCP server-tool POST timeout. Handshake, catalog, and tools/call share this. */
143
+ export const SERVER_TOOL_TIMEOUT_MS = 15_000;
144
+
145
+ /** Default MCP response body cap (bytes). Caller `maxBytes` overrides. */
146
+ export const SERVER_TOOL_MAX_BYTES = 262_144;
147
+
142
148
  // Monotonic JSON-RPC request id. A simple counter keeps ids unique per process
143
149
  // without relying on Math.random()/Date.now().
144
150
  let rpcId = 0;
@@ -337,8 +343,8 @@ async function initializeSession(site, endpoint, key) {
337
343
  method: "POST",
338
344
  headers: await baseHeaders(site, null),
339
345
  body: JSON.stringify(payload),
340
- size: 262144,
341
- signal: AbortSignal.timeout(15000),
346
+ size: SERVER_TOOL_MAX_BYTES,
347
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
342
348
  });
343
349
 
344
350
  let res = await post();
@@ -374,8 +380,8 @@ async function initializeSession(site, endpoint, key) {
374
380
  method: "POST",
375
381
  headers: await baseHeaders(site, sessionId),
376
382
  body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
377
- size: 262144,
378
- signal: AbortSignal.timeout(15000),
383
+ size: SERVER_TOOL_MAX_BYTES,
384
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
379
385
  });
380
386
  } catch {
381
387
  // Notification is advisory; proceed with the established session.
@@ -432,11 +438,11 @@ export async function callServerTool(site, toolName, args = {}, options = {}) {
432
438
  /** Fetch one page of the authenticated module tool catalog. */
433
439
  export async function listServerTools(site, cursor) {
434
440
  return requestServerTool(site, "tools/list", cursor === undefined ? {} : { cursor }, {
435
- maxBytes: 262144, preserveErrors: true,
441
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
436
442
  });
437
443
  }
438
444
 
439
- /** Shared bounded MCP request transport; retries only explicit auth/session rejection. */
445
+ /** Shared bounded MCP request transport; size + abort are always attached. */
440
446
  async function requestServerTool(site, method, params, options) {
441
447
  const toolName = params.name ?? method;
442
448
  const endpoint = resolveEndpoint(site);
@@ -463,7 +469,8 @@ async function requestServerTool(site, method, params, options) {
463
469
  method: "POST",
464
470
  headers: await baseHeaders(site, sessionId),
465
471
  body: JSON.stringify(payload),
466
- ...(options.maxBytes ? { size: options.maxBytes, signal: AbortSignal.timeout(15000) } : {}),
472
+ size: options.maxBytes ?? SERVER_TOOL_MAX_BYTES,
473
+ signal: AbortSignal.timeout(SERVER_TOOL_TIMEOUT_MS),
467
474
  });
468
475
  const { body, rawText } = await readBody(res);
469
476
 
@@ -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[]} [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 ? extraWorkflows(tools, taken) : [];
260
+ for (const workflow of extra) taken.add(workflow.name);
254
261
  // A reserved module name cannot match a built-in, but never let a remote
255
262
  // catalog shadow a static prompt if that invariant is ever broken.
256
263
  const moduleDefs = tools.filter((tool) =>
257
264
  isModuleDefinition(tool) && !taken.has(toolNameToPromptName(tool.name)));
258
- return { prompts: [...filter(staticPrompts, tools), ...buildToolPrompts(moduleDefs)], moduleDefs };
265
+ const extraPrompts = extra.map((workflow) => ({
266
+ name: workflow.name,
267
+ description: workflow.description,
268
+ arguments: workflow.arguments,
269
+ }));
270
+ return {
271
+ prompts: [...filter([...staticPrompts, ...extraPrompts], tools), ...buildToolPrompts(moduleDefs)],
272
+ moduleDefs,
273
+ extra,
274
+ };
259
275
  }
260
276
 
261
277
  return {
@@ -264,14 +280,21 @@ export function createPromptSurface({
264
280
  list: async () => (await visible()).prompts,
265
281
  /** Resolve one prompt, or null when it is not visible to this request. */
266
282
  async describe(name, args = {}) {
267
- const { prompts, moduleDefs } = await visible();
283
+ const { prompts, moduleDefs, extra } = await visible();
268
284
  const known = prompts.find((prompt) => prompt.name === name);
269
285
  if (!known) return null;
270
286
  const live = moduleDefs.find((def) => toolNameToPromptName(def.name) === name);
271
- const messages = live
272
- ? getToolPromptMessages(name, args, new Map([[live.name, live]]))
273
- : get(name, args);
274
- return { description: known.description, messages };
287
+ if (live) {
288
+ return {
289
+ description: known.description,
290
+ messages: getToolPromptMessages(name, args, new Map([[live.name, live]])),
291
+ };
292
+ }
293
+ const workflow = extra.find((item) => item.name === name);
294
+ if (workflow && extraWorkflowMessages) {
295
+ return { description: known.description, messages: extraWorkflowMessages(workflow, args) };
296
+ }
297
+ return { description: known.description, messages: get(name, args) };
275
298
  },
276
299
  };
277
300
  }
@@ -0,0 +1,348 @@
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
+ * Provider from site `serverTools.modules.workflows` maps.
270
+ *
271
+ * @param {Array<object>} sites
272
+ * @returns {Array<object>}
273
+ */
274
+ export function moduleWorkflowProviders(sites) {
275
+ const providers = [];
276
+ for (const site of sites ?? []) {
277
+ const modules = site.serverTools?.modules;
278
+ if (!modules?.namespace || !modules.workflows || typeof modules.workflows !== "object") continue;
279
+ const workflows = [];
280
+ for (const [id, body] of Object.entries(modules.workflows)) {
281
+ if (id.startsWith("_") || !body || typeof body !== "object") continue;
282
+ workflows.push({ ...body, id: body.id ?? id });
283
+ }
284
+ providers.push({
285
+ id: `config:${modules.namespace}`,
286
+ namespace: modules.namespace,
287
+ workflows,
288
+ });
289
+ }
290
+ return providers;
291
+ }
292
+
293
+ const INDEX = new Map();
294
+ const MODULE_KEYS = new Set();
295
+
296
+ /**
297
+ * Register built-in workflows for principal filtering.
298
+ *
299
+ * @param {object[]} workflows
300
+ */
301
+ export function registerBuiltinWorkflows(workflows) {
302
+ for (const wf of workflows ?? []) INDEX.set(wf.name, wf);
303
+ }
304
+
305
+ /**
306
+ * Replace the module-owned slice of the workflow index (per request).
307
+ *
308
+ * @param {object[]} workflows
309
+ */
310
+ export function replaceModuleWorkflows(workflows) {
311
+ for (const key of MODULE_KEYS) INDEX.delete(key);
312
+ MODULE_KEYS.clear();
313
+ for (const wf of workflows ?? []) {
314
+ INDEX.set(wf.name, wf);
315
+ MODULE_KEYS.add(wf.name);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * @param {string} name
321
+ * @returns {object|undefined}
322
+ */
323
+ export function lookupWorkflow(name) {
324
+ return INDEX.get(name);
325
+ }
326
+
327
+ /**
328
+ * Index used by tests.
329
+ *
330
+ * @param {object[]} workflows
331
+ * @returns {Map<string, {readOnly: boolean, builtin: boolean, publicTools: string[]}>}
332
+ */
333
+ export function workflowIndex(workflows) {
334
+ return new Map((workflows ?? []).map((wf) => [wf.name, {
335
+ readOnly: wf.readOnly,
336
+ builtin: wf.builtin,
337
+ publicTools: wf.publicTools,
338
+ }]));
339
+ }
340
+
341
+ export const WORKFLOW_LIMITS = {
342
+ MAX_DESCRIPTION,
343
+ MAX_INSTRUCTIONS,
344
+ MAX_WORKFLOWS,
345
+ };
346
+
347
+ // Re-export for callers that already import tool prompt hyphenation.
348
+ export { toolNameToPromptName };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Connector-authored workflow definitions (#333).
3
+ *
4
+ * Public names and steps match the previous hand-written prompts in
5
+ * src/index.js. They go through the same loader as module workflows.
6
+ */
7
+
8
+ const SITE = { name: "site", description: "Named site (omit for default)", required: false };
9
+
10
+ export const builtinWorkflowProvider = {
11
+ id: "builtin",
12
+ namespace: "builtin",
13
+ builtin: true,
14
+ workflows: [
15
+ {
16
+ id: "content_audit",
17
+ name: "drupal-content-audit",
18
+ description: "Walk through a full content audit: inventory, staleness, SEO gaps, accessibility issues, and recommendations.",
19
+ readOnly: true,
20
+ tools: [
21
+ "drupal_report_content_summary",
22
+ "drupal_report_stale_content",
23
+ "drupal_report_field_completeness",
24
+ "drupal_report_seo_meta_coverage",
25
+ "drupal_report_seo_audit",
26
+ "drupal_report_accessibility_audit",
27
+ ],
28
+ arguments: [SITE],
29
+ instructions:
30
+ "Please run a comprehensive content audit {arg:site_phrase}. 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" +
31
+ "1. Call {tool: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" +
32
+ "2. For each content type that has nodes, call {tool:drupal_report_stale_content} (days: 180).\n" +
33
+ "3. For each content type that has nodes, call {tool:drupal_report_field_completeness}.\n" +
34
+ "4. For each content type with published nodes, check SEO: prefer {tool:drupal_report_seo_meta_coverage} (it reads the site's actual meta field rather than assuming one) and use {tool:drupal_report_seo_audit} for title-length and thin-content checks.\n" +
35
+ "5. For each content type with published nodes, call {tool:drupal_report_accessibility_audit}.\n" +
36
+ "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" +
37
+ "7. Synthesize findings into: (a) immediate actions, (b) medium-term improvements, (c) process recommendations.\n" +
38
+ "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.",
39
+ },
40
+ {
41
+ id: "create_article",
42
+ name: "drupal-create-article",
43
+ description: "Guided workflow to research, draft, and publish an article node with all fields, tags, and metadata.",
44
+ readOnly: false,
45
+ tools: [
46
+ "drupal_list_content_types",
47
+ "drupal_get_entity_schema",
48
+ "drupal_list_vocabularies",
49
+ "drupal_get_taxonomy_terms",
50
+ "drupal_create_node",
51
+ ],
52
+ arguments: [
53
+ SITE,
54
+ { name: "topic", description: "Article topic/brief", required: true },
55
+ ],
56
+ instructions:
57
+ "I need to create a new article {arg:site_phrase} about: {arg:topic}\n\n" +
58
+ "Please:\n" +
59
+ "1. Call {tool:drupal_list_content_types} to confirm \"article\" exists and check its fields.\n" +
60
+ "2. Call {tool:drupal_get_entity_schema} for node/article to see all available fields.\n" +
61
+ "3. Call {tool:drupal_list_vocabularies} and {tool:drupal_get_taxonomy_terms} for relevant vocabularies.\n" +
62
+ "4. Draft the article — title, body (well-structured HTML), summary, and meta description.\n" +
63
+ "5. Suggest appropriate taxonomy tags.\n" +
64
+ "6. Call {tool:drupal_create_node} with status: false (draft) and show me the result.\n" +
65
+ "7. Ask me to review before publishing.",
66
+ },
67
+ {
68
+ id: "seo_fix",
69
+ name: "drupal-seo-fix",
70
+ description: "Find SEO gaps in content (missing meta descriptions, thin content, title issues) and fix them interactively.",
71
+ readOnly: false,
72
+ tools: ["drupal_report_seo_audit"],
73
+ arguments: [
74
+ SITE,
75
+ { name: "type", description: "Content type to scan", required: false },
76
+ ],
77
+ instructions:
78
+ "Please find and fix SEO issues in \"{arg:type}\" content {arg:site_phrase}.\n\n" +
79
+ "1. Call {tool:drupal_report_seo_audit} to identify all issues.\n" +
80
+ "2. For nodes missing meta descriptions: generate appropriate descriptions (max 160 chars) and update them.\n" +
81
+ "3. For thin content (under 300 words): flag for editorial review — do not auto-expand.\n" +
82
+ "4. For title length issues: suggest better titles but ask before updating.\n" +
83
+ "5. Report what was fixed, what needs human review, and any patterns you noticed.",
84
+ },
85
+ {
86
+ id: "user_cleanup",
87
+ name: "drupal-user-cleanup",
88
+ description: "Identify inactive, never-logged-in, or overly permissioned user accounts and take action.",
89
+ readOnly: false,
90
+ tools: ["drupal_report_user_activity", "drupal_list_users", "drupal_list_roles"],
91
+ arguments: [SITE],
92
+ instructions:
93
+ "Please audit user accounts {arg:site_phrase} and recommend cleanup actions.\n\n" +
94
+ "1. Call {tool:drupal_report_user_activity} to identify inactive and never-logged-in accounts.\n" +
95
+ "2. Call {tool:drupal_list_users} with no filter to get the full list.\n" +
96
+ "3. Call {tool:drupal_list_roles} to see all available roles.\n" +
97
+ "4. Identify: (a) accounts inactive 90+ days, (b) never-logged-in accounts, (c) accounts with admin roles that look like test/temp accounts.\n" +
98
+ "5. For each category, recommend action (block, delete, or keep) with reasoning.\n" +
99
+ "6. Ask for approval before making any changes.",
100
+ },
101
+ {
102
+ id: "full_audit",
103
+ name: "drupal-full-audit",
104
+ description: "Run a full site-health audit — content, link/404 integrity, and configuration posture — and turn the scored dashboard into a prioritized action plan.",
105
+ readOnly: true,
106
+ tools: [
107
+ "drupal_audit_site_health",
108
+ "drupal_report_404_log",
109
+ "drupal_report_redirect_health",
110
+ "drupal_report_broken_links",
111
+ "drupal_audit_config_best_practices",
112
+ "drupal_report_module_audit",
113
+ "drupal_report_permission_audit",
114
+ "drupal_report_pii_exposure",
115
+ "drupal_report_duplicate_content",
116
+ "drupal_report_readability",
117
+ ],
118
+ arguments: [
119
+ SITE,
120
+ { name: "type", description: "Primary content type to audit", required: false },
121
+ ],
122
+ instructions:
123
+ "Please run a full site-health audit {arg:site_phrase} and turn it into a prioritized action plan.\n\n" +
124
+ "1. Call {tool:drupal_audit_site_health} (type: \"{arg:type}\") for the scored dashboard and overall grade.\n" +
125
+ "2. For any section reporting high-severity findings, drill in with the matching tool for detail:\n" +
126
+ " - links/404: {tool:drupal_report_404_log}, {tool:drupal_report_redirect_health}, {tool:drupal_report_broken_links} (checkLive only with approval).\n" +
127
+ " - config: {tool:drupal_audit_config_best_practices}, {tool:drupal_report_module_audit}, {tool:drupal_report_permission_audit}.\n" +
128
+ " - content: {tool:drupal_report_pii_exposure}, {tool:drupal_report_duplicate_content}, {tool:drupal_report_readability}.\n" +
129
+ "3. For sections reported 'unavailable', note what (server-tool bridge or drush) would enable them — do not treat unavailable as 'passing'.\n" +
130
+ "4. Synthesize a prioritized plan: (a) high-severity/security fixes first, (b) content-quality improvements, (c) process recommendations.\n" +
131
+ "5. Present counts, severity, and specific node/config references; propose redirects for the top 404s. Ask before making any changes.",
132
+ },
133
+ ],
134
+ };