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 +24 -0
- package/README.md +2 -0
- package/config/config.example.json +12 -1
- package/package.json +1 -1
- package/scripts/generate-commands.js +43 -0
- package/scripts/install-commands.js +56 -5
- package/src/index.js +26 -109
- package/src/lib/governance.js +9 -2
- package/src/lib/oauth.js +23 -9
- package/src/lib/principal.js +14 -0
- package/src/lib/server-tools.js +14 -7
- package/src/lib/tool-prompts.js +29 -6
- package/src/lib/workflow-prompts.js +348 -0
- package/src/lib/workflows/builtin.js +134 -0
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.
|
|
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
|
-
|
|
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
|
|
270
|
-
the sources in config/config.json
|
|
271
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
//
|
|
210
|
-
//
|
|
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
|
|
217
|
-
*
|
|
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
|
|
226
|
-
|
|
227
|
-
const
|
|
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
|
|
package/src/lib/governance.js
CHANGED
|
@@ -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`, {
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
package/src/lib/principal.js
CHANGED
|
@@ -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
|
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -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:
|
|
341
|
-
signal: AbortSignal.timeout(
|
|
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:
|
|
378
|
-
signal: AbortSignal.timeout(
|
|
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:
|
|
441
|
+
maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
|
|
436
442
|
});
|
|
437
443
|
}
|
|
438
444
|
|
|
439
|
-
/** Shared bounded MCP request transport;
|
|
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
|
-
|
|
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
|
|
package/src/lib/tool-prompts.js
CHANGED
|
@@ -239,10 +239,15 @@ export function getToolPromptMessages(promptName, args = {}, definitionsByName)
|
|
|
239
239
|
* @param {Set<string>} options.workflowNames - Names of the hand-authored workflow prompts.
|
|
240
240
|
* @param {(name: string, args: object) => Array<object>} options.workflowMessages
|
|
241
241
|
* @param {Map<string,object>} options.definitionsByName - Built-in tool name → definition.
|
|
242
|
+
* @param {(tools: Array<object>, taken: Set<string>) => object[]} [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
|
-
|
|
265
|
+
const extraPrompts = extra.map((workflow) => ({
|
|
266
|
+
name: workflow.name,
|
|
267
|
+
description: workflow.description,
|
|
268
|
+
arguments: workflow.arguments,
|
|
269
|
+
}));
|
|
270
|
+
return {
|
|
271
|
+
prompts: [...filter([...staticPrompts, ...extraPrompts], tools), ...buildToolPrompts(moduleDefs)],
|
|
272
|
+
moduleDefs,
|
|
273
|
+
extra,
|
|
274
|
+
};
|
|
259
275
|
}
|
|
260
276
|
|
|
261
277
|
return {
|
|
@@ -264,14 +280,21 @@ export function createPromptSurface({
|
|
|
264
280
|
list: async () => (await visible()).prompts,
|
|
265
281
|
/** Resolve one prompt, or null when it is not visible to this request. */
|
|
266
282
|
async describe(name, args = {}) {
|
|
267
|
-
const { prompts, moduleDefs } = await visible();
|
|
283
|
+
const { prompts, moduleDefs, extra } = await visible();
|
|
268
284
|
const known = prompts.find((prompt) => prompt.name === name);
|
|
269
285
|
if (!known) return null;
|
|
270
286
|
const live = moduleDefs.find((def) => toolNameToPromptName(def.name) === name);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
287
|
+
if (live) {
|
|
288
|
+
return {
|
|
289
|
+
description: known.description,
|
|
290
|
+
messages: getToolPromptMessages(name, args, new Map([[live.name, live]])),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const workflow = extra.find((item) => item.name === name);
|
|
294
|
+
if (workflow && extraWorkflowMessages) {
|
|
295
|
+
return { description: known.description, messages: extraWorkflowMessages(workflow, args) };
|
|
296
|
+
}
|
|
297
|
+
return { description: known.description, messages: get(name, args) };
|
|
275
298
|
},
|
|
276
299
|
};
|
|
277
300
|
}
|
|
@@ -0,0 +1,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
|
+
};
|