drupal-mcp-connector 2.20.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -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 +29 -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 +85 -9
- package/src/lib/tool-prompts.js +29 -6
- package/src/lib/workflow-prompts.js +490 -0
- package/src/lib/workflows/builtin.js +134 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.22.0] - 2026-09-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Remote Drupal workflow prompts (#371).** The module-workflow loader fetches
|
|
14
|
+
`prompts/list` and `prompts/get` from `serverTools.url` (contrib `mcp_server`
|
|
15
|
+
`McpPromptConfig` entities) and merges them into the v1 path. A remote
|
|
16
|
+
workflow is listed only when local `serverTools.modules.workflows` names it
|
|
17
|
+
and every `{tool:alias}` is already in `serverTools.modules.tools`. Local
|
|
18
|
+
bodies remain the fallback when the catalog omits an enabled id. Write
|
|
19
|
+
workflows still require `mcp_write` and append that module writes are not
|
|
20
|
+
retried. See `docs/module-workflows.md`.
|
|
21
|
+
|
|
22
|
+
## [2.21.0] - 2026-09-19
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Module-owned workflow prompts (#333).** Workflows are definitions the
|
|
26
|
+
connector loads, filters and relays. v1 sources them from built-in
|
|
27
|
+
definitions (the five connector-authored workflows, now the same format)
|
|
28
|
+
and from `serverTools.modules.workflows` next to approved module tools.
|
|
29
|
+
A workflow names tools by local alias (`{tool:alias}`), is listed only
|
|
30
|
+
when every named tool is visible, and cannot invent a tool the site did
|
|
31
|
+
not already approve. Write workflows require `mcp_write`, tell the model
|
|
32
|
+
to confirm, and append that module writes are not retried. Design:
|
|
33
|
+
`docs/module-workflows.md`. The example config ships `review_and_log` on
|
|
34
|
+
`example_site`. Drupal `prompts/list` is specified there and not
|
|
35
|
+
implemented in this change.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Remaining northbound HTTP timeouts.** Server-tool `tools/call` (including
|
|
39
|
+
governed config), the OAuth token POST, and the Sentinel readiness GET now
|
|
40
|
+
abort with `AbortSignal.timeout` so a hung Drupal/Sentinel host cannot stall
|
|
41
|
+
the MCP process. `requestServerTool` always attaches the 256 KiB body cap and
|
|
42
|
+
a 15s abort; those were previously gated on `maxBytes`, so `callGovernedServerTool`
|
|
43
|
+
could hang unbounded. Token acquisition fails as `OAuthError` after 30s.
|
|
44
|
+
A hung readiness probe stays `sentinel_unreachable`.
|
|
45
|
+
|
|
10
46
|
## [2.20.0] - 2026-09-19
|
|
11
47
|
|
|
12
48
|
### Added
|
package/README.md
CHANGED
|
@@ -118,6 +118,8 @@ and other MCP-aware agents). Two kinds ship with the connector:
|
|
|
118
118
|
- `drupal-create-article` — guided article creation with all fields
|
|
119
119
|
- `drupal-seo-fix` — find and fix SEO gaps
|
|
120
120
|
- `drupal-user-cleanup` — identify and handle inactive accounts
|
|
121
|
+
- Module-owned workflows (`drupal-<namespace>-<workflow>`) when
|
|
122
|
+
`serverTools.modules.workflows` is enabled — see `docs/module-workflows.md`
|
|
121
123
|
|
|
122
124
|
**One prompt per tool** — every `drupal_*` tool is also exposed as a
|
|
123
125
|
`drupal-<tool>` prompt (e.g. `drupal-create-node`, `drupal-list-nodes`,
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
|
|
156
156
|
"_server_tools": {
|
|
157
157
|
"_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer.",
|
|
158
|
-
"_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. See docs/module-tools.md.",
|
|
158
|
+
"_modules_comment": "Optional. serverTools.modules exposes Drupal module-owned tools through the generic registry. It is opt-in: a tool installed on the source, or listed in its catalog, is not exposed until it is named here. namespace is lowercase letters, digits and underscores, up to 24 characters. An alias follows the same rule, up to 48 characters. The namespace and alias together must be unique across configured sites. Each entry under tools is an alias with four required keys. name is the exact wire name from the source's tools/list; copy it, because bridge versions join the parts differently and the connector does not rewrite it. scope is the inbound scope a caller needs. operation is read, write or delete; it sets the public tool name (drupal_module_<operation>_<namespace>__<alias>) and the connector gate, whatever the source's own annotation says. capabilities lists extra connector gates (publish, configRead, configWrite, graphql, rawSql) and may be empty. These keys only narrow access; Drupal stays authoritative. To get slash-command stubs for the approved module tools, run `npm run install:commands -- --modules` from the directory that holds config/config.json. Optional serverTools.modules.workflows enables module-owned workflow prompts (drupal-<namespace>-<workflow>); a workflow may only name aliases already under tools. See docs/module-tools.md and docs/module-workflows.md.",
|
|
159
159
|
"_bindings_comment": "Optional. serverTools.bindings keeps a built-in command's public name while its implementation lives in a Drupal module. Each binding names an alias under serverTools.modules.tools on the same site. configGet, configList, configSet serve drupal_config_get/list/set and the config reports; codegenInspect, codegenDiff, codegenPreview serve drupal_codegen_inspect/diff/generate; sqlQuery serves drupal_drush_sql_query. Required alias policy: config and codegen aliases use scope mcp_config; get, list and the three codegen aliases are operation read with capability configRead; configSet is operation write with capability configWrite; sqlQuery is scope mcp_admin, operation read, capability rawSql, and still needs drushSsh.rawSql: \"governed\" on the site. Once a bindings object exists, every one of these commands resolves only through its binding: a missing or mismatched mapping refuses the command, with no fallback to another tool or to SSH. Configure the full set for the commands the site uses. Without a bindings object these commands keep their built-in transport. See docs/module-tools.md.",
|
|
160
160
|
"example": {
|
|
161
161
|
"serverTools": {
|
|
@@ -182,6 +182,17 @@
|
|
|
182
182
|
"codegen_diff": { "name": "tool_api__graphql_compose_codegen_diff", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
183
183
|
"codegen_preview": { "name": "tool_api__graphql_compose_codegen_preview", "scope": "mcp_config", "operation": "read", "capabilities": ["configRead"] },
|
|
184
184
|
"sql_query": { "name": "tool_api__mcp_sentinel_sql_query", "scope": "mcp_admin", "operation": "read", "capabilities": ["rawSql"] }
|
|
185
|
+
},
|
|
186
|
+
"workflows": {
|
|
187
|
+
"review_and_log": {
|
|
188
|
+
"description": "Review recent activities and, after confirmation, log a follow-up. Write is opt-in and is not retried.",
|
|
189
|
+
"readOnly": false,
|
|
190
|
+
"tools": ["list_activities", "record_activity"],
|
|
191
|
+
"arguments": [
|
|
192
|
+
{ "name": "site", "description": "Target site", "required": false }
|
|
193
|
+
],
|
|
194
|
+
"instructions": "1. Call {tool:list_activities} for recent rows.\n2. Summarize what needs a follow-up.\n3. Ask the person before calling {tool:record_activity}. Do not retry a write."
|
|
195
|
+
}
|
|
185
196
|
}
|
|
186
197
|
},
|
|
187
198
|
"bindings": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -45,6 +45,20 @@ export function commandFileName(def) {
|
|
|
45
45
|
/** Trailing marker that tells the installer a stub belongs to a module-owned tool. */
|
|
46
46
|
export const MODULE_STUB_MARKER = "<!-- drupal-mcp-connector:module-tool -->";
|
|
47
47
|
|
|
48
|
+
/** Trailing marker for a module-owned workflow stub. */
|
|
49
|
+
export const WORKFLOW_STUB_MARKER = "<!-- drupal-mcp-connector:module-workflow -->";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Command filename for a module-owned workflow prompt.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} namespace
|
|
55
|
+
* @param {string} id
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function workflowCommandFileName(namespace, id) {
|
|
59
|
+
return `drupal-${namespace}-${id}.md`.replace(/_/g, "-");
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
const MODULE_TOOL_NAME = /^drupal_module_(?:read|write|delete)_([a-z][a-z0-9_]*?)__([a-z][a-z0-9_]*)$/;
|
|
49
63
|
|
|
50
64
|
/**
|
|
@@ -144,6 +158,35 @@ export function renderCommandMarkdown(def, options = {}) {
|
|
|
144
158
|
* @param {object} def - The tool definition.
|
|
145
159
|
* @returns {string} File contents.
|
|
146
160
|
*/
|
|
161
|
+
/**
|
|
162
|
+
* Filesystem stub for a module-owned workflow prompt.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} workflow - A loaded workflow from the #333 loader.
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function renderWorkflowCommandMarkdown(workflow) {
|
|
168
|
+
const params = (workflow.arguments ?? []).map((arg) => ({
|
|
169
|
+
name: arg.name,
|
|
170
|
+
required: Boolean(arg.required),
|
|
171
|
+
}));
|
|
172
|
+
const hint = argumentHint(params);
|
|
173
|
+
const tools = (workflow.publicTools ?? []).map((name) => `\`${name}\``).join(", ");
|
|
174
|
+
const body = [
|
|
175
|
+
`# ${workflow.name}`,
|
|
176
|
+
"",
|
|
177
|
+
workflow.description,
|
|
178
|
+
"",
|
|
179
|
+
`This is a workflow prompt. Ask the MCP client for prompt \`${workflow.name}\`.`,
|
|
180
|
+
tools ? `It names these tools: ${tools}.` : "",
|
|
181
|
+
workflow.readOnly
|
|
182
|
+
? "Read-only: do not write."
|
|
183
|
+
: "Confirm with the person before any write. Module writes are not retried.",
|
|
184
|
+
"",
|
|
185
|
+
WORKFLOW_STUB_MARKER,
|
|
186
|
+
].filter((line, i, arr) => line !== "" || arr[i - 1] !== "");
|
|
187
|
+
return `---\ndescription: ${yamlString(workflow.description)}\nargument-hint: "${hint}"\n---\n\n${body.join("\n")}\n`;
|
|
188
|
+
}
|
|
189
|
+
|
|
147
190
|
export function renderClaudeCommandMarkdown(def) {
|
|
148
191
|
return renderCommandMarkdown(def, {
|
|
149
192
|
allowedTools: `mcp__drupal__${def.name}`,
|
|
@@ -32,10 +32,13 @@ import {
|
|
|
32
32
|
commandFileName,
|
|
33
33
|
moduleCommandFileName,
|
|
34
34
|
MODULE_STUB_MARKER,
|
|
35
|
+
WORKFLOW_STUB_MARKER,
|
|
35
36
|
renderCommandMarkdown,
|
|
36
37
|
renderClaudeCommandMarkdown,
|
|
37
38
|
renderCodexSkillMarkdown,
|
|
38
39
|
renderCodexToolsReference,
|
|
40
|
+
renderWorkflowCommandMarkdown,
|
|
41
|
+
workflowCommandFileName,
|
|
39
42
|
CODEX_SKILL_NAME,
|
|
40
43
|
} from "./generate-commands.js";
|
|
41
44
|
|
|
@@ -154,15 +157,43 @@ export function missingModuleTools(configured, discovered) {
|
|
|
154
157
|
return configured.filter((name) => !found.has(name));
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
/** Whether an installed stub was written for a module-owned tool. */
|
|
160
|
+
/** Whether an installed stub was written for a module-owned tool or workflow. */
|
|
158
161
|
function isModuleStub(path) {
|
|
159
162
|
try {
|
|
160
|
-
|
|
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
|
+
moduleWorkflowProvidersWithRemote,
|
|
68
|
+
registerBuiltinWorkflows,
|
|
69
|
+
replaceModuleWorkflows,
|
|
70
|
+
} from "./lib/workflow-prompts.js";
|
|
71
|
+
import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
|
|
63
72
|
|
|
64
73
|
// Apply config/secrets.map (or the shipped example table) before any site
|
|
65
74
|
// resolution. MCP clients spawn this file directly; the shell launcher is
|
|
@@ -167,129 +176,31 @@ async function readResource(uri) {
|
|
|
167
176
|
// MCP Prompts — common Drupal workflow templates
|
|
168
177
|
// ---------------------------------------------------------------------------
|
|
169
178
|
|
|
170
|
-
const
|
|
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,15 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
320
231
|
workflowNames: WORKFLOW_PROMPT_NAMES,
|
|
321
232
|
workflowMessages: getPromptMessages,
|
|
322
233
|
definitionsByName,
|
|
234
|
+
extraWorkflows: async (tools, taken) => {
|
|
235
|
+
const loaded = loadWorkflows(
|
|
236
|
+
await moduleWorkflowProvidersWithRemote(listResolvableSiteConfigs()),
|
|
237
|
+
{ tools, taken },
|
|
238
|
+
);
|
|
239
|
+
replaceModuleWorkflows(loaded);
|
|
240
|
+
return loaded;
|
|
241
|
+
},
|
|
242
|
+
extraWorkflowMessages: renderWorkflowMessages,
|
|
323
243
|
}),
|
|
324
244
|
});
|
|
325
245
|
|
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
|
}
|