drupal-mcp-connector 2.2.2 → 2.4.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.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: "Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial."
3
+ argument-hint: "[site]"
4
+ allowed-tools: mcp__drupal__drupal_governance_status
5
+ ---
6
+
7
+ Call the `mcp__drupal__drupal_governance_status` MCP tool.
8
+
9
+ Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial.
10
+
11
+ Parse the request in `$ARGUMENTS` into this tool's parameters:
12
+
13
+ **Optional:**
14
+ - `site` (string): omit for the default site
15
+
16
+ If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-08-14
11
+
12
+ ### Added
13
+
14
+ - **Source governance is now enforceable on every governed product path
15
+ (#176).** A site with `requireGovernance: true` requires the Drupal
16
+ source's governance contract to verify before any tool call runs against
17
+ it: the connector probes `GET /drupal-mcp/readiness` as its own principal
18
+ (mcp_sentinel ≥ 2.4.0), caches a passing verdict for 60 seconds, and
19
+ re-proves it after that. A failed, stale, or unreachable verification
20
+ denies tool discovery and execution with the source's own stable reason —
21
+ it never falls back to a plain JSON:API or GraphQL path, on any backend or
22
+ bridge. The new `drupal_governance_status` tool stays callable while
23
+ governance is failing and reports which required condition failed, without
24
+ credentials. Ungoverned sites are untouched.
25
+
26
+ ### Changed
27
+
28
+ - The security middleware and tools/call dispatch moved from the entry point
29
+ into `src/lib/dispatch.js` (side-effect-free, testable per backend); the
30
+ entry point now only boots transports. Tool discovery accepts a per-request
31
+ `list` hook so governance can gate what is discoverable.
32
+
33
+ ## [2.3.0] - 2026-08-13
34
+
35
+ ### Added
36
+
37
+ - **MCP 2026-07-28 transport support (#172).** HTTP and stdio now serve the
38
+ current request-scoped protocol through the stable 2.0.0 server, Node, and
39
+ client packages. Modern HTTP requests use a fresh server instance, expose
40
+ `server/discover`, carry client metadata/capabilities in the request envelope,
41
+ and do not create `Mcp-Session-Id` state.
42
+
43
+ ### Changed
44
+
45
+ - **One `/mcp` URL now has explicit dual-era routing.** Auth and rate limiting
46
+ run before a POST body is read once and bounded; the SDK classifier then sends
47
+ that parsed body to exactly one arm. Current requests use the strict modern
48
+ handler. 2025-era clients keep the existing sessionful handler by default and
49
+ can be disabled with `MCP_LEGACY_TRANSPORT=reject`. stdio uses the same server
50
+ factory and preserves both eras.
51
+ - **MCP SDK packages are migrated to stable v2.** Runtime dependencies are now
52
+ `@modelcontextprotocol/server` and `@modelcontextprotocol/node` 2.0.0; the
53
+ matching client package supplies integration evidence. Node.js 20+ remains
54
+ the runtime floor.
55
+
56
+ ### Security
57
+
58
+ - Unexpected request conversion, era classification, and handler failures no
59
+ longer expose internal error messages. Before response headers they return a
60
+ generic 500; after headers they terminate the response without a second write.
61
+ Protocol/header/body disagreements continue to return the SDK's typed 400
62
+ errors, and the outbound private-Drupal bridge remains pinned to its
63
+ sessionful 2025-06-18 contract.
64
+
10
65
  ## [2.2.2] - 2026-08-12
11
66
 
12
67
  ### Security
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org)
7
7
  [![Drupal](https://img.shields.io/badge/drupal-10%20%7C%2011-blue)](https://drupal.org)
8
- [![MCP](https://img.shields.io/badge/MCP-2025--11--25-purple)](https://modelcontextprotocol.io)
8
+ [![MCP](https://img.shields.io/badge/MCP-2026--07--28-purple)](https://modelcontextprotocol.io)
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
@@ -221,6 +221,11 @@ or remote use, run the HTTPS transport and register the endpoint instead — see
221
221
  **[docs/getting-started.md](docs/getting-started.md)** and
222
222
  **[docs/mcp-clients.md](docs/mcp-clients.md)**.
223
223
 
224
+ The same entry point serves current MCP 2026-07-28 clients and 2025-era clients.
225
+ Current HTTP requests are stateless and never receive `Mcp-Session-Id`; legacy
226
+ HTTP clients retain their existing session. Set `MCP_LEGACY_TRANSPORT=reject`
227
+ to end the compatibility window deliberately. The `/mcp` URL does not change.
228
+
224
229
  ---
225
230
 
226
231
  ## Companion Drupal Module — MCP Sentinel
@@ -7,7 +7,7 @@
7
7
  },
8
8
 
9
9
  "_security_options": {
10
- "_comment": "All optional. apiTokenEnv: read the Bearer token from this env var instead of apiToken (keeps secrets out of the config file). requireSecureAuth: reject anon/basic, require HTTPS+Bearer (recommended for production). Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_AUTH_TOKEN requires bearer auth on the HTTPS /mcp endpoint; MCP_BIND_HOST restricts the listen interface (with TLS). See docs/security-hardening.md."
10
+ "_comment": "All optional. apiTokenEnv: read the Bearer token from this env var instead of apiToken (keeps secrets out of the config file). requireSecureAuth: reject anon/basic, require HTTPS+Bearer (recommended for production). requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel >= 2.4.0) verifies — no ungoverned JSON:API/GraphQL fallback; recommended wherever mcp_sentinel governs the site. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_AUTH_TOKEN requires bearer auth on the HTTPS /mcp endpoint; MCP_BIND_HOST restricts the listen interface (with TLS). See docs/security-hardening.md."
11
11
  },
12
12
 
13
13
  "_governance_tiers": {
@@ -28,6 +28,7 @@
28
28
  "_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (server-side editorial gate). No drushSsh.",
29
29
  "baseUrl": "https://api.int.wilkesliberty.com",
30
30
  "requireSecureAuth": true,
31
+ "requireGovernance": true,
31
32
  "api": "jsonapi",
32
33
  "oauth": {
33
34
  "tokenUrl": "/oauth/token",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.2.2",
3
+ "version": "2.4.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -58,12 +58,14 @@
58
58
  "syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f ✓\"; done"
59
59
  },
60
60
  "dependencies": {
61
- "@modelcontextprotocol/sdk": "^1.30.0",
61
+ "@modelcontextprotocol/node": "^2.0.0",
62
+ "@modelcontextprotocol/server": "^2.0.0",
62
63
  "graphql": "^17.0.0",
63
64
  "node-fetch": "^3.3.2",
64
65
  "ssh2": "^1.16.0"
65
66
  },
66
67
  "devDependencies": {
68
+ "@modelcontextprotocol/client": "^2.0.0",
67
69
  "eslint": "^10.4.1",
68
70
  "eslint-plugin-n": "^18.1.0",
69
71
  "eslint-plugin-security": "^4.0.0",
package/src/index.js CHANGED
@@ -20,89 +20,28 @@
20
20
  * (default: "0.0.0.0"; ignored without TLS, which forces loopback)
21
21
  * MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
22
22
  * MCP_RATE_WINDOW_SEC Rate-limit window in seconds (default: 60)
23
+ * MCP_LEGACY_TRANSPORT "serve" (default) | "reject" for 2025-era clients
23
24
  */
24
25
 
25
26
  import { createServer as createHttpsServer } from "https";
26
27
  import { createServer as createHttpServer } from "http";
27
28
  import { readFileSync } from "fs";
28
- import { randomUUID } from "node:crypto";
29
29
 
30
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
31
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
- import { CallToolRequestSchema,
33
- ListToolsRequestSchema,
34
- ListResourcesRequestSchema,
35
- ReadResourceRequestSchema,
36
- ListPromptsRequestSchema,
37
- GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
30
+ import { createMcpHandler } from "@modelcontextprotocol/server";
31
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
32
+ import { toNodeHandler } from "@modelcontextprotocol/node";
38
33
 
39
34
  import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
40
35
  import { makeBearerCheck } from "./lib/http-auth.js";
41
- import { createMcpRequestHandler } from "./lib/http-handler.js";
36
+ import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
37
+ import { createConnectorServerFactory } from "./lib/mcp-server.js";
42
38
  import { createRateLimiter } from "./lib/rate-limit.js";
43
- import { resolveSecurityConfig, assertNotReadOnly,
44
- assertDestructiveAllowed, assertGraphqlMutationAllowed,
45
- SecurityError } from "./lib/security.js";
46
- import { toolError, toolResult } from "./lib/errors.js";
47
- import { BackendCapabilityError, BackendResolutionError } from "./lib/backends/errors.js";
39
+ import { callTool } from "./lib/dispatch.js";
40
+ import { filterDiscoverableTools } from "./lib/governance.js";
48
41
 
49
42
  // Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
50
43
  import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
51
44
  import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
52
- import { inferOperation } from "./lib/operations.js";
53
-
54
- // ---------------------------------------------------------------------------
55
- // Security middleware — runs BEFORE every tool handler
56
- //
57
- // Operation intent (read/write/delete/graphql) is inferred from the tool name
58
- // prefix rather than trusting per-tool metadata, so a new tool that follows the
59
- // naming convention is gated automatically. The matched operation drives which
60
- // assertions from lib/security.js run against the resolved per-site policy.
61
- // ---------------------------------------------------------------------------
62
-
63
- /**
64
- * Derive the entity type a tool acts on, for destructive-allow assertions.
65
- *
66
- * @param {string} toolName - The MCP tool name.
67
- * @param {object} args - The tool arguments.
68
- * @returns {string} Explicit args.entityType when present, else the suffix
69
- * parsed from the tool name (e.g. "node" from "drupal_delete_node"),
70
- * falling back to "entity".
71
- */
72
- function extractEntityType(toolName, args) {
73
- if (args?.entityType) return args.entityType;
74
- const m = toolName.match(/^drupal_(?:delete|create|update|get|list)_(.+)$/);
75
- return m ? m[1] : "entity";
76
- }
77
-
78
- /**
79
- * Apply per-site security assertions before dispatching to a tool handler.
80
- *
81
- * @param {string} toolName - The MCP tool name.
82
- * @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
83
- * @param {Function} handler - The resolved tool handler.
84
- * @returns {Promise<*>} The handler's result.
85
- * @throws {SecurityError} If the resolved policy forbids the inferred operation.
86
- */
87
- async function securityMiddleware(toolName, args, handler) {
88
- // Tools with no site context skip per-site checks
89
- if (toolName === "drupal_list_sites") return handler(args);
90
-
91
- const site = getSiteConfig(args?.site);
92
- const sec = resolveSecurityConfig(site);
93
- const op = inferOperation(toolName);
94
-
95
- if (op === "delete") {
96
- assertDestructiveAllowed(sec, extractEntityType(toolName, args), args?.id ?? "?");
97
- assertNotReadOnly(sec, toolName);
98
- } else if (op === "write") {
99
- assertNotReadOnly(sec, toolName);
100
- } else if (op === "graphql" && args?.query) {
101
- assertGraphqlMutationAllowed(sec, args.query);
102
- }
103
-
104
- return handler(args);
105
- }
106
45
 
107
46
  // ---------------------------------------------------------------------------
108
47
  // MCP Resources — browsable, always-fresh site context
@@ -290,71 +229,23 @@ function getPromptMessages(name, args) {
290
229
  }
291
230
 
292
231
  // ---------------------------------------------------------------------------
293
- // MCP Server construction
232
+ // MCP Server surface — dispatch (middleware + callTool) lives in lib/dispatch.js
294
233
  // ---------------------------------------------------------------------------
295
234
 
296
- const server = new Server(
297
- { name: "drupal-mcp-connector", version: CLIENT_VERSION },
298
- { capabilities: { tools: {}, resources: {}, prompts: {} } }
299
- );
300
-
301
- // Tools
302
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allDefinitions }));
303
-
304
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
305
- const { name, arguments: args } = request.params;
306
- // eslint-disable-next-line security/detect-object-injection -- name is an MCP tool name from validated schema; allHandlers is a closed dispatch table built at startup
307
- const handler = allHandlers[name];
308
-
309
- if (!handler) {
310
- return toolError(new Error(
311
- `Unknown tool "${name}". Call drupal_list_entity_types to discover available resources.`
312
- ));
313
- }
314
-
315
- try {
316
- const result = await securityMiddleware(name, args ?? {}, handler);
317
- return toolResult(result);
318
- } catch (err) {
319
- // Translate known error classes into clear, non-leaky isError responses;
320
- // anything else falls through to toolError for a generic envelope.
321
- if (err instanceof SecurityError) {
322
- return { content: [{ type: "text", text: `Access denied: ${err.message}` }], isError: true };
323
- }
324
- if (err instanceof BackendCapabilityError) {
325
- return { content: [{ type: "text", text: `Not supported by this site's backend: ${err.message}` }], isError: true };
326
- }
327
- if (err instanceof BackendResolutionError) {
328
- return { content: [{ type: "text", text: `Backend resolution failed: ${err.message}` }], isError: true };
329
- }
330
- return toolError(err);
331
- }
332
- });
333
-
334
- // Resources
335
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCES }));
336
-
337
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
338
- const { uri } = request.params;
339
- try {
340
- const data = await readResource(uri);
341
- return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
342
- } catch (err) {
343
- throw new Error(`Resource read failed (${uri}): ${err.message}`);
344
- }
345
- });
346
-
347
- // Prompts — hand-authored workflows + one generated prompt per tool
348
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: ALL_PROMPTS }));
349
-
350
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
351
- const { name, arguments: args } = request.params;
352
- const known = ALL_PROMPTS.find((p) => p.name === name);
353
- if (!known) throw new Error(`Unknown prompt: "${name}"`);
354
- const messages = WORKFLOW_PROMPT_NAMES.has(name)
355
- ? getPromptMessages(name, args)
356
- : getToolPromptMessages(name, args, definitionsByName);
357
- return { description: known.description, messages };
235
+ const buildConnectorServer = createConnectorServerFactory({
236
+ serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
237
+ tools: {
238
+ definitions: allDefinitions,
239
+ list: () => filterDiscoverableTools(allDefinitions, listSiteNames().map((n) => getSiteConfig(n))),
240
+ call: callTool,
241
+ },
242
+ resources: { definitions: RESOURCES, read: readResource },
243
+ prompts: {
244
+ definitions: ALL_PROMPTS,
245
+ get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
246
+ ? getPromptMessages(name, args)
247
+ : getToolPromptMessages(name, args, definitionsByName),
248
+ },
358
249
  });
359
250
 
360
251
  // ---------------------------------------------------------------------------
@@ -362,22 +253,21 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
362
253
  // ---------------------------------------------------------------------------
363
254
 
364
255
  const transport = process.env.MCP_TRANSPORT || "stdio";
256
+ const reportMcpTransportStage = (stage) => {
257
+ console.error(`[drupal-mcp-connector] MCP ${stage} failed.`);
258
+ };
365
259
 
366
260
  if (transport === "stdio") {
367
- const stdioTransport = new StdioServerTransport();
368
- await server.connect(stdioTransport);
261
+ serveStdio(buildConnectorServer, {
262
+ legacy: "serve",
263
+ onerror: () => reportMcpTransportStage("stdio-dispatch"),
264
+ });
369
265
  console.error(
370
266
  `[drupal-mcp-connector v${CLIENT_VERSION}] stdio transport active. ` +
371
267
  `${allDefinitions.length} tools · ${RESOURCES.length} resources · ${ALL_PROMPTS.length} prompts`
372
268
  );
373
269
 
374
270
  } else if (transport === "https" || transport === "http") {
375
-
376
- // Dynamically import the HTTP transport — only needed in server mode
377
- const { StreamableHTTPServerTransport } = await import(
378
- "@modelcontextprotocol/sdk/server/streamableHttp.js"
379
- );
380
-
381
271
  const tlsCfg = getTlsConfig();
382
272
  const port = tlsCfg.port;
383
273
  const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
@@ -436,21 +326,6 @@ if (transport === "stdio") {
436
326
  });
437
327
  }
438
328
 
439
- // Map of sessionId → transport for multi-client support
440
- const sessions = new Map();
441
-
442
- // Create + connect a new Streamable-HTTP transport, registering it in the
443
- // session map on initialize and pruning it on close.
444
- async function openSession() {
445
- const mcpTransport = new StreamableHTTPServerTransport({
446
- sessionIdGenerator: () => randomUUID(),
447
- onsessioninitialized: (id) => sessions.set(id, mcpTransport),
448
- });
449
- mcpTransport.onclose = () => sessions.delete(mcpTransport.sessionId);
450
- await server.connect(mcpTransport);
451
- return mcpTransport;
452
- }
453
-
454
329
  const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
455
330
  // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
456
331
  // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
@@ -487,10 +362,22 @@ if (transport === "stdio") {
487
362
  );
488
363
  }
489
364
 
365
+ const legacyMode = process.env.MCP_LEGACY_TRANSPORT || "serve";
366
+ const modernMcpHandler = createMcpHandler(buildConnectorServer, {
367
+ legacy: "reject",
368
+ onerror: () => reportMcpTransportStage("modern-protocol"),
369
+ });
370
+ const modernHandler = toNodeHandler(modernMcpHandler, {
371
+ onerror: () => reportMcpTransportStage("modern-adapter"),
372
+ });
373
+ const legacyHandler = createLegacySessionHandler({
374
+ buildServer: buildConnectorServer,
375
+ mode: legacyMode,
376
+ });
490
377
  const requestHandler = createMcpRequestHandler({
491
378
  checkAuth,
492
- sessions,
493
- openSession,
379
+ modernHandler,
380
+ legacyHandler,
494
381
  toolCount: allDefinitions.length,
495
382
  rateLimiter,
496
383
  });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tool dispatch — the security middleware and the tools/call entry point.
3
+ *
4
+ * Lives outside src/index.js (which boots a transport on import) so the
5
+ * gate order — source governance first, then per-site security assertions,
6
+ * then the handler — is testable per tool and per backend. Every tool call,
7
+ * whichever backend or bridge it ends up on, flows through here: denial in
8
+ * this module is denial on every path, with no ungoverned fallback below it.
9
+ */
10
+
11
+ import { getSiteConfig } from "./config.js";
12
+ import { resolveSecurityConfig, assertNotReadOnly,
13
+ assertDestructiveAllowed, assertGraphqlMutationAllowed,
14
+ SecurityError } from "./security.js";
15
+ import { toolError, toolResult } from "./errors.js";
16
+ import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
17
+ import { inferOperation } from "./operations.js";
18
+ import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
19
+ import { allHandlers } from "../tools/index.js";
20
+
21
+ /**
22
+ * Derive the entity type a tool acts on, for destructive-allow assertions.
23
+ *
24
+ * @param {string} toolName - The MCP tool name.
25
+ * @param {object} args - The tool arguments.
26
+ * @returns {string} Explicit args.entityType when present, else the suffix
27
+ * parsed from the tool name (e.g. "node" from "drupal_delete_node"),
28
+ * falling back to "entity".
29
+ */
30
+ function extractEntityType(toolName, args) {
31
+ if (args?.entityType) return args.entityType;
32
+ const m = toolName.match(/^drupal_(?:delete|create|update|get|list)_(.+)$/);
33
+ return m ? m[1] : "entity";
34
+ }
35
+
36
+ /**
37
+ * Apply per-site governance and security assertions before dispatching.
38
+ *
39
+ * @param {string} toolName - The MCP tool name.
40
+ * @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
41
+ * @param {Function} handler - The resolved tool handler.
42
+ * @returns {Promise<*>} The handler's result.
43
+ * @throws {GovernanceError} If the site requires source governance and the
44
+ * contract is not verified — checked FIRST, so no assertion below can be
45
+ * read as an ungoverned fallback verdict.
46
+ * @throws {SecurityError} If the resolved policy forbids the inferred operation.
47
+ */
48
+ export async function securityMiddleware(toolName, args, handler) {
49
+ // Tools with no site context skip per-site checks
50
+ if (toolName === "drupal_list_sites") return handler(args);
51
+
52
+ const site = getSiteConfig(args?.site);
53
+
54
+ // Source-governance gate (#176). The diagnostic tools stay callable while
55
+ // governance fails — they are how an operator learns which condition failed.
56
+ if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
57
+ await assertSourceGovernance(site);
58
+ }
59
+
60
+ const sec = resolveSecurityConfig(site);
61
+ const op = inferOperation(toolName);
62
+
63
+ if (op === "delete") {
64
+ assertDestructiveAllowed(sec, extractEntityType(toolName, args), args?.id ?? "?");
65
+ assertNotReadOnly(sec, toolName);
66
+ } else if (op === "write") {
67
+ assertNotReadOnly(sec, toolName);
68
+ } else if (op === "graphql" && args?.query) {
69
+ assertGraphqlMutationAllowed(sec, args.query);
70
+ }
71
+
72
+ return handler(args);
73
+ }
74
+
75
+ /**
76
+ * Serve one MCP tools/call request: resolve the handler, run the middleware,
77
+ * translate known error classes into clear, non-leaky isError envelopes.
78
+ *
79
+ * @param {string} name - The MCP tool name.
80
+ * @param {object} args - The tool arguments.
81
+ * @returns {Promise<object>} An MCP tool result payload.
82
+ */
83
+ export async function callTool(name, args) {
84
+ // eslint-disable-next-line security/detect-object-injection -- name is an MCP tool name from validated schema; allHandlers is a closed dispatch table built at startup
85
+ const handler = allHandlers[name];
86
+
87
+ if (!handler) {
88
+ return toolError(new Error(
89
+ `Unknown tool "${name}". Call drupal_list_entity_types to discover available resources.`
90
+ ));
91
+ }
92
+
93
+ try {
94
+ const result = await securityMiddleware(name, args ?? {}, handler);
95
+ return toolResult(result);
96
+ } catch (err) {
97
+ // Translate known error classes into clear, non-leaky isError responses;
98
+ // anything else falls through to toolError for a generic envelope.
99
+ if (err instanceof GovernanceError) {
100
+ return { content: [{ type: "text", text: `Source governance unavailable: ${err.message}` }], isError: true };
101
+ }
102
+ if (err instanceof SecurityError) {
103
+ return { content: [{ type: "text", text: `Access denied: ${err.message}` }], isError: true };
104
+ }
105
+ if (err instanceof BackendCapabilityError) {
106
+ return { content: [{ type: "text", text: `Not supported by this site's backend: ${err.message}` }], isError: true };
107
+ }
108
+ if (err instanceof BackendResolutionError) {
109
+ return { content: [{ type: "text", text: `Backend resolution failed: ${err.message}` }], isError: true };
110
+ }
111
+ return toolError(err);
112
+ }
113
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Source-governance verification for governed sites (#176).
3
+ *
4
+ * A site with `requireGovernance: true` declares that every product path —
5
+ * tool discovery and execution, on every backend — depends on the Drupal
6
+ * source's governance layer (MCP Sentinel) being present, applicable, and
7
+ * enforcing. The connector verifies that claim against the source's own
8
+ * readiness contract (`GET /drupal-mcp/readiness`, authenticated as the
9
+ * connector's principal) and DENIES instead of falling back to a plain
10
+ * JSON:API or GraphQL path when the contract is not ready, cannot be
11
+ * reached, or the verification has gone stale and cannot be refreshed.
12
+ *
13
+ * The readiness endpoint answers for the whole contract: module present,
14
+ * an applicable active policy/profile for the requesting principal, and the
15
+ * enforcement wiring active. Its `reason` values are stable, non-secret
16
+ * diagnostics designed to be surfaced to operators verbatim.
17
+ */
18
+
19
+ import fetch from "node-fetch";
20
+ import { authHeadersAsync, clientHeaders } from "./config.js";
21
+
22
+ /** How long a passing verification stays fresh before it must be re-proven. */
23
+ export const OK_TTL_MS = 60_000;
24
+
25
+ /** How long a failed verification is held before the next attempt re-checks. */
26
+ export const FAIL_TTL_MS = 5_000;
27
+
28
+ /** Tools that stay discoverable and callable while governance is failing —
29
+ * the diagnostic surface an operator needs to see WHY it is failing. */
30
+ export const GOVERNANCE_DIAGNOSTIC_TOOLS = new Set([
31
+ "drupal_list_sites",
32
+ "drupal_governance_status",
33
+ ]);
34
+
35
+ /** Denial for a governed path whose source-governance contract is not verified. */
36
+ export class GovernanceError extends Error {
37
+ /**
38
+ * @param {string} message Operator-facing description (no secrets).
39
+ * @param {string} reason Stable machine reason (e.g. "sentinel_unreachable").
40
+ */
41
+ constructor(message, reason) {
42
+ super(message);
43
+ this.name = "GovernanceError";
44
+ this.reason = reason;
45
+ }
46
+ }
47
+
48
+ /** Per-site verification cache: name → {ok, reason, checkedAt}. */
49
+ const cache = new Map();
50
+
51
+ /** Drop all cached verifications (tests, config reloads). */
52
+ export function clearGovernanceCache() {
53
+ cache.clear();
54
+ }
55
+
56
+ /**
57
+ * Whether a site declares the source-governance requirement.
58
+ * @param {object} site Resolved site config.
59
+ * @returns {boolean}
60
+ */
61
+ export function requiresGovernance(site) {
62
+ return site?.requireGovernance === true;
63
+ }
64
+
65
+ /**
66
+ * Verify the site's source-governance contract, with a short-lived cache.
67
+ *
68
+ * Never throws: the result carries `ok` plus a stable `reason` on failure.
69
+ * A stale cache entry is re-verified; if the re-check cannot happen the
70
+ * result is a failure — staleness never extends trust.
71
+ *
72
+ * @param {object} site Resolved site config.
73
+ * @param {{force?: boolean}} [options] `force` bypasses the cache.
74
+ * @returns {Promise<{ok: boolean, reason: string|null, checkedAt: number}>}
75
+ */
76
+ export async function verifySourceGovernance(site, { force = false } = {}) {
77
+ const key = site._name ?? site.baseUrl;
78
+ const cached = cache.get(key);
79
+ if (!force && cached) {
80
+ const age = Date.now() - cached.checkedAt;
81
+ if (age <= (cached.ok ? OK_TTL_MS : FAIL_TTL_MS)) return cached;
82
+ }
83
+
84
+ const result = await probeReadiness(site);
85
+ cache.set(key, result);
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * One authenticated readiness probe; maps every outcome to {ok, reason}.
91
+ * @param {object} site Resolved site config.
92
+ * @returns {Promise<{ok: boolean, reason: string|null, checkedAt: number}>}
93
+ */
94
+ async function probeReadiness(site) {
95
+ const checkedAt = Date.now();
96
+
97
+ // Credential construction is its own failure class: an OAuth token the
98
+ // connector cannot acquire is the connector's principal failing, and must
99
+ // not be misreported as the source being unreachable.
100
+ let headers;
101
+ try {
102
+ headers = {
103
+ Accept: "application/json",
104
+ ...clientHeaders(),
105
+ ...(await authHeadersAsync(site)),
106
+ };
107
+ } catch {
108
+ return { ok: false, reason: "credential_acquisition_failed", checkedAt };
109
+ }
110
+
111
+ let res;
112
+ try {
113
+ res = await fetch(`${site.baseUrl}/drupal-mcp/readiness`, { method: "GET", headers });
114
+ } catch {
115
+ // Network detail (addresses, DNS text) is deliberately not propagated.
116
+ return { ok: false, reason: "sentinel_unreachable", checkedAt };
117
+ }
118
+
119
+ if (res.status === 404) {
120
+ return { ok: false, reason: "sentinel_unavailable", checkedAt };
121
+ }
122
+ if (res.status === 401 || res.status === 403) {
123
+ return { ok: false, reason: "not_authorized_for_governance", checkedAt };
124
+ }
125
+
126
+ let body = null;
127
+ try {
128
+ body = await res.json();
129
+ } catch {
130
+ return { ok: false, reason: "unexpected_response", checkedAt };
131
+ }
132
+
133
+ if (res.status === 200 && body?.contract_ready === true) {
134
+ return { ok: true, reason: null, checkedAt };
135
+ }
136
+ if (body?.contract_ready === false) {
137
+ // The server's own stable, non-secret readiness reason.
138
+ return { ok: false, reason: String(body.reason ?? "contract_not_ready"), checkedAt };
139
+ }
140
+ return { ok: false, reason: "unexpected_response", checkedAt };
141
+ }
142
+
143
+ /**
144
+ * Gate a governed product path: no-op for ungoverned sites, throws otherwise
145
+ * unless the source contract verifies.
146
+ *
147
+ * @param {object} site Resolved site config.
148
+ * @returns {Promise<void>}
149
+ * @throws {GovernanceError} naming the failed condition (never secrets).
150
+ */
151
+ export async function assertSourceGovernance(site) {
152
+ if (!requiresGovernance(site)) return;
153
+ const result = await verifySourceGovernance(site);
154
+ if (result.ok) return;
155
+ throw new GovernanceError(
156
+ `Site "${site._name}" requires source governance and the contract is not verified ` +
157
+ `(${result.reason}). Governed paths are denied — there is no ungoverned fallback. ` +
158
+ "Run drupal_governance_status for per-site diagnostics.",
159
+ result.reason,
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Per-site governance condition for operator diagnostics. No secrets: only
165
+ * the site name, whether governance is required, the verdict, and the reason.
166
+ *
167
+ * @param {Array<object>} sites Resolved site configs.
168
+ * @returns {Promise<Array<{site: string, required: boolean, ok: boolean, reason: string|null, checkedAt: number|null}>>}
169
+ */
170
+ export async function governanceStatus(sites) {
171
+ return Promise.all(sites.map(async (site) => {
172
+ if (!requiresGovernance(site)) {
173
+ return { site: site._name, required: false, ok: true, reason: null, checkedAt: null };
174
+ }
175
+ const result = await verifySourceGovernance(site);
176
+ return {
177
+ site: site._name,
178
+ required: true,
179
+ ok: result.ok,
180
+ reason: result.reason,
181
+ checkedAt: result.checkedAt,
182
+ };
183
+ }));
184
+ }
185
+
186
+ /**
187
+ * Discovery gate: hide governed tools when NO configured site can serve them.
188
+ *
189
+ * A site can serve governed tools when it is either ungoverned or its source
190
+ * contract verifies. While at least one site qualifies the full surface stays
191
+ * discoverable (execution remains per-site gated); when none does, only the
192
+ * diagnostic tools remain, so a client sees the denial instead of a surface
193
+ * it cannot use.
194
+ *
195
+ * @param {Array<object>} definitions Tool definitions ({name, ...}).
196
+ * @param {Array<object>} sites Resolved site configs.
197
+ * @returns {Promise<Array<object>>} The discoverable definitions.
198
+ */
199
+ export async function filterDiscoverableTools(definitions, sites) {
200
+ const verdicts = await Promise.all(sites.map(async (site) =>
201
+ !requiresGovernance(site) || (await verifySourceGovernance(site)).ok));
202
+ if (verdicts.some(Boolean)) return definitions;
203
+ return definitions.filter((d) => GOVERNANCE_DIAGNOSTIC_TOOLS.has(d.name));
204
+ }
@@ -2,39 +2,178 @@
2
2
  * HTTP request handler for the Streamable-HTTP MCP transport.
3
3
  *
4
4
  * Extracted from index.js so the routing/auth/health/404 behavior is unit
5
- * testable without standing up a real server or the MCP SDK. The entry point
6
- * wires the concrete dependencies (bearer check, session map, session factory);
7
- * tests inject stubs.
5
+ * testable without standing up a real server. The entry point wires the bearer
6
+ * check plus strict modern and sessionful legacy handler arms; tests inject
7
+ * stubs around that same required pair.
8
8
  */
9
9
 
10
+ import { randomUUID } from "node:crypto";
11
+ import { isInitializeRequest, isJsonContentType, isLegacyRequest } from "@modelcontextprotocol/server";
12
+ import { NodeStreamableHTTPServerTransport, toWebRequest } from "@modelcontextprotocol/node";
13
+
14
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
15
+
16
+ class McpHttpClientError extends Error {
17
+ constructor(statusCode, publicMessage) {
18
+ super(publicMessage);
19
+ this.name = "McpHttpClientError";
20
+ this.statusCode = statusCode;
21
+ this.publicMessage = publicMessage;
22
+ }
23
+ }
24
+
25
+ function defaultOnError({ stage }) {
26
+ console.error(`[drupal-mcp-connector] MCP ${stage} failed.`);
27
+ }
28
+
29
+ function reportUnexpected(onError, stage) {
30
+ try {
31
+ onError({ stage });
32
+ } catch {
33
+ // Diagnostics must never replace the controlled transport response.
34
+ }
35
+ }
36
+
37
+ function respondAfterFailure(res, error, onError, stage) {
38
+ if (error instanceof McpHttpClientError && !res.headersSent) {
39
+ res.writeHead(error.statusCode).end(error.publicMessage);
40
+ return;
41
+ }
42
+
43
+ reportUnexpected(onError, stage);
44
+ if (res.headersSent) {
45
+ res.destroy();
46
+ return;
47
+ }
48
+ res.writeHead(500).end("Internal Server Error");
49
+ }
50
+
51
+ async function readJsonBody(req, maxBodyBytes) {
52
+ const declared = Number(req.headers["content-length"] || 0);
53
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
54
+ throw new McpHttpClientError(413, "Request body exceeds the configured limit");
55
+ }
56
+
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
61
+ size += bytes.length;
62
+ if (size > maxBodyBytes) {
63
+ throw new McpHttpClientError(413, "Request body exceeds the configured limit");
64
+ }
65
+ chunks.push(bytes);
66
+ }
67
+
68
+ try {
69
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
70
+ } catch {
71
+ throw new McpHttpClientError(400, "Malformed JSON request body");
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Build the bounded 2025-era sessionful transport arm.
77
+ *
78
+ * @param {object} deps
79
+ * @param {(ctx: {era: "legacy"}) => object|Promise<object>} deps.buildServer
80
+ * @param {Map<string, object>} [deps.sessions]
81
+ * @param {"serve"|"reject"} [deps.mode]
82
+ * @param {(options: object) => object} [deps.transportFactory]
83
+ * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse, body?: unknown) => Promise<void>}
84
+ */
85
+ export function createLegacySessionHandler({
86
+ buildServer,
87
+ sessions = new Map(),
88
+ mode = "serve",
89
+ transportFactory = (options) => new NodeStreamableHTTPServerTransport(options),
90
+ }) {
91
+ if (mode !== "serve" && mode !== "reject") {
92
+ throw new Error(`Invalid legacy MCP transport mode: "${mode}". Use "serve" or "reject".`);
93
+ }
94
+
95
+ async function openSession() {
96
+ const transport = transportFactory({
97
+ sessionIdGenerator: randomUUID,
98
+ onsessioninitialized: (id) => sessions.set(id, transport),
99
+ });
100
+ transport.onclose = () => {
101
+ if (transport.sessionId) sessions.delete(transport.sessionId);
102
+ };
103
+ const server = await buildServer({ era: "legacy" });
104
+ await server.connect(transport);
105
+ return transport;
106
+ }
107
+
108
+ return async function handleLegacy(req, res, body) {
109
+ if (mode === "reject") {
110
+ res.writeHead(400).end("Legacy MCP transport is disabled");
111
+ return;
112
+ }
113
+
114
+ const sessionId = req.headers["mcp-session-id"];
115
+ if (sessionId) {
116
+ const transport = sessions.get(sessionId);
117
+ if (!transport) {
118
+ res.writeHead(404).end("Unknown MCP-Session-Id");
119
+ return;
120
+ }
121
+ await transport.handleRequest(req, res, body);
122
+ return;
123
+ }
124
+
125
+ if (req.method !== "POST" || !isInitializeRequest(body)) {
126
+ res.writeHead(400).end("MCP-Session-Id is required for legacy session requests");
127
+ return;
128
+ }
129
+
130
+ const transport = await openSession();
131
+ await transport.handleRequest(req, res, body);
132
+ };
133
+ }
134
+
10
135
  /**
11
136
  * Build the `(req, res)` handler for the MCP HTTP endpoint.
12
137
  *
13
138
  * Routes:
14
- * - `POST /mcp` — bearer-gated; reuses a session by `Mcp-Session-Id` or opens
15
- * a new one, then delegates to the transport's `handleRequest`.
16
- * - `GET /mcp` — bearer-gated; requires a known session, else 400.
17
- * - `GET /health`— unauthenticated liveness probe (`{status, tools}`).
139
+ * - `POST /mcp` rate- and bearer-gated, bounded/body-parsed once, then
140
+ * classified into exactly one modern-stateless or legacy-sessionful arm.
141
+ * - `GET|DELETE /mcp` rate- and bearer-gated legacy session operations.
142
+ * - `GET /health` unauthenticated liveness probe (`{status, tools}`).
18
143
  * - everything else — 404.
19
144
  *
20
145
  * @param {object} deps
21
146
  * @param {(authHeader: any) => boolean} deps.checkAuth Bearer predicate (see http-auth.js).
22
- * @param {Map<string, {handleRequest: Function, sessionId?: string}>} deps.sessions Session id → transport.
23
- * @param {() => Promise<{handleRequest: Function}>} deps.openSession Create+connect a new transport.
24
147
  * @param {number} deps.toolCount Tool count reported by /health.
148
+ * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.modernHandler
149
+ * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.legacyHandler
25
150
  * @param {?{check: (key: string) => {allowed: boolean, retryAfterSec: number}}} [deps.rateLimiter]
26
151
  * Optional rate limiter (see rate-limit.js). Omit/null to disable.
27
152
  * @param {(req: import("http").IncomingMessage) => string} [deps.clientKey]
28
153
  * Maps a request to a rate-limit key (default: client IP).
154
+ * @param {number} [deps.maxBodyBytes] Maximum accepted POST body size.
155
+ * @param {typeof toWebRequest} [deps.toWebRequestFn] Injectable Node-to-Web adapter.
156
+ * @param {typeof isLegacyRequest} [deps.isLegacyRequestFn] Injectable SDK era classifier.
157
+ * @param {(event: {stage: string}) => void} [deps.onError] Sanitized diagnostics sink.
29
158
  * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
30
159
  */
31
160
  export function createMcpRequestHandler({
32
- checkAuth, sessions, openSession, toolCount,
161
+ checkAuth, toolCount,
162
+ modernHandler = null,
163
+ legacyHandler = null,
33
164
  rateLimiter = null,
34
165
  clientKey = (req) => req.socket?.remoteAddress || "unknown",
166
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
167
+ toWebRequestFn = toWebRequest,
168
+ isLegacyRequestFn = isLegacyRequest,
169
+ onError = defaultOnError,
35
170
  }) {
171
+ if (!modernHandler || !legacyHandler) {
172
+ throw new Error("modernHandler and legacyHandler must be configured together for dual-era routing");
173
+ }
174
+
36
175
  return async function handle(req, res) {
37
- if (req.url === "/mcp" && (req.method === "POST" || req.method === "GET")) {
176
+ if (req.url === "/mcp" && ["POST", "GET", "DELETE"].includes(req.method)) {
38
177
  // Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
39
178
  if (rateLimiter) {
40
179
  const verdict = rateLimiter.check(clientKey(req));
@@ -50,20 +189,33 @@ export function createMcpRequestHandler({
50
189
  }
51
190
  }
52
191
 
53
- if (req.method === "POST" && req.url === "/mcp") {
54
- const sessionId = req.headers["mcp-session-id"];
55
- const transport = (sessionId && sessions.get(sessionId)) || (await openSession());
56
- await transport.handleRequest(req, res);
192
+ if (req.url === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
193
+ try {
194
+ await legacyHandler(req, res);
195
+ } catch (error) {
196
+ respondAfterFailure(res, error, onError, "legacy-dispatch");
197
+ }
57
198
  return;
58
199
  }
59
200
 
60
- if (req.method === "GET" && req.url === "/mcp") {
61
- const sessionId = req.headers["mcp-session-id"];
62
- if (!sessionId || !sessions.has(sessionId)) {
63
- res.writeHead(400).end("Missing or unknown MCP-Session-Id");
201
+ if (req.method === "POST" && req.url === "/mcp") {
202
+ if (!isJsonContentType(req.headers["content-type"])) {
203
+ res.writeHead(415).end("Content-Type must be application/json");
64
204
  return;
65
205
  }
66
- await sessions.get(sessionId).handleRequest(req, res);
206
+ let stage = "body-read";
207
+ try {
208
+ const body = await readJsonBody(req, maxBodyBytes);
209
+ stage = "request-conversion";
210
+ const request = await toWebRequestFn(req, body);
211
+ stage = "classification";
212
+ const legacy = await isLegacyRequestFn(request, body);
213
+ const selectedHandler = legacy ? legacyHandler : modernHandler;
214
+ stage = legacy ? "legacy-dispatch" : "modern-dispatch";
215
+ await selectedHandler(req, res, body);
216
+ } catch (error) {
217
+ respondAfterFailure(res, error, onError, stage);
218
+ }
67
219
  return;
68
220
  }
69
221
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Transport-neutral MCP server construction.
3
+ *
4
+ * The returned factory creates one low-level SDK server for each serving unit:
5
+ * one request on modern HTTP and one connection on legacy HTTP or stdio.
6
+ */
7
+
8
+ import { Server } from "@modelcontextprotocol/server";
9
+
10
+ /**
11
+ * Create the server factory shared by HTTP and stdio transports.
12
+ *
13
+ * @param {object} surface
14
+ * @param {{name: string, version: string}} surface.serverInfo
15
+ * @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
16
+ * `definitions` is the full static surface (schema projection); the optional
17
+ * `list` hook decides what is DISCOVERABLE per request (governance gating).
18
+ * @param {{definitions: Array<object>, read: (uri: string) => Promise<object>}} surface.resources
19
+ * @param {{definitions: Array<object>, get: (name: string, args: object) => Array<object>}} surface.prompts
20
+ * @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
21
+ */
22
+ export function createConnectorServerFactory({ serverInfo, tools, resources, prompts }) {
23
+ const toolDefinitions = new Map(tools.definitions.map((definition) => [definition.name, definition]));
24
+
25
+ return function buildConnectorServer(_context) {
26
+ const server = new Server(
27
+ serverInfo,
28
+ { capabilities: { tools: {}, resources: {}, prompts: {} } }
29
+ );
30
+
31
+ server.setRequestHandler("tools/list", async () => ({
32
+ tools: tools.list ? await tools.list() : tools.definitions,
33
+ }));
34
+ server.setRequestHandler("tools/call", async (request, context) => {
35
+ const { name, arguments: args } = request.params;
36
+ const result = await tools.call(name, args ?? {}, context);
37
+ return server.projectCallToolResult(result, toolDefinitions.get(name)?.outputSchema);
38
+ });
39
+
40
+ server.setRequestHandler("resources/list", async () => ({ resources: resources.definitions }));
41
+ server.setRequestHandler("resources/read", async (request) => {
42
+ const { uri } = request.params;
43
+ try {
44
+ const data = await resources.read(uri);
45
+ return {
46
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2) }],
47
+ };
48
+ } catch {
49
+ throw new Error(`Resource read failed (${uri})`);
50
+ }
51
+ });
52
+
53
+ server.setRequestHandler("prompts/list", async () => ({ prompts: prompts.definitions }));
54
+ server.setRequestHandler("prompts/get", async (request) => {
55
+ const { name, arguments: args } = request.params;
56
+ const known = prompts.definitions.find((prompt) => prompt.name === name);
57
+ if (!known) throw new Error(`Unknown prompt: "${name}"`);
58
+ return { description: known.description, messages: prompts.get(name, args ?? {}) };
59
+ });
60
+
61
+ return server;
62
+ };
63
+ }
package/src/tools/site.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { getSiteConfig, listSiteNames } from "../lib/config.js";
10
+ import { governanceStatus } from "../lib/governance.js";
10
11
  import { resolveBackend } from "../lib/backends/index.js";
11
12
 
12
13
  // ---------------------------------------------------------------------------
@@ -49,6 +50,19 @@ async function listConfiguredSites() {
49
50
  return { sites: listSiteNames() };
50
51
  }
51
52
 
53
+ /**
54
+ * Per-site source-governance condition (#176). The one governed-path
55
+ * diagnostic that stays callable while governance is failing, so an operator
56
+ * can see WHICH required condition failed. Never includes credentials.
57
+ *
58
+ * @param {object} args - { site? } (a named site narrows the report).
59
+ * @returns {Promise<{sites: object[]}>} required/ok/reason per site.
60
+ */
61
+ async function getGovernanceStatus({ site: siteName } = {}) {
62
+ const names = siteName ? [siteName] : listSiteNames();
63
+ return { sites: await governanceStatus(names.map((n) => getSiteConfig(n))) };
64
+ }
65
+
52
66
  // ---------------------------------------------------------------------------
53
67
  // Definitions
54
68
  // ---------------------------------------------------------------------------
@@ -70,6 +84,14 @@ export const definitions = [
70
84
  properties: { site: { type: "string" } },
71
85
  },
72
86
  },
87
+ {
88
+ name: "drupal_governance_status",
89
+ description: "Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: { site: { type: "string" } },
93
+ },
94
+ },
73
95
  {
74
96
  name: "drupal_list_sites",
75
97
  description: "List all named Drupal sites configured in config.json. Useful for multi-site setups.",
@@ -84,4 +106,5 @@ export const handlers = {
84
106
  drupal_site_info: getSiteInfo,
85
107
  drupal_list_content_types: listContentTypes,
86
108
  drupal_list_sites: listConfiguredSites,
109
+ drupal_governance_status: getGovernanceStatus,
87
110
  };