drupal-mcp-connector 2.3.0 → 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.
- package/.claude/commands/drupal-governance-status.md +16 -0
- package/CHANGELOG.md +23 -0
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/src/index.js +8 -90
- package/src/lib/dispatch.js +113 -0
- package/src/lib/governance.js +204 -0
- package/src/lib/mcp-server.js +6 -2
- package/src/tools/site.js +23 -0
|
@@ -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,29 @@ 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
|
+
|
|
10
33
|
## [2.3.0] - 2026-08-13
|
|
11
34
|
|
|
12
35
|
### Added
|
|
@@ -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
package/src/index.js
CHANGED
|
@@ -36,69 +36,12 @@ import { makeBearerCheck } from "./lib/http-auth.js";
|
|
|
36
36
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
37
37
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
38
38
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
SecurityError } from "./lib/security.js";
|
|
42
|
-
import { toolError, toolResult } from "./lib/errors.js";
|
|
43
|
-
import { BackendCapabilityError, BackendResolutionError } from "./lib/backends/errors.js";
|
|
39
|
+
import { callTool } from "./lib/dispatch.js";
|
|
40
|
+
import { filterDiscoverableTools } from "./lib/governance.js";
|
|
44
41
|
|
|
45
42
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
46
43
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
47
44
|
import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
|
|
48
|
-
import { inferOperation } from "./lib/operations.js";
|
|
49
|
-
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
// Security middleware — runs BEFORE every tool handler
|
|
52
|
-
//
|
|
53
|
-
// Operation intent (read/write/delete/graphql) is inferred from the tool name
|
|
54
|
-
// prefix rather than trusting per-tool metadata, so a new tool that follows the
|
|
55
|
-
// naming convention is gated automatically. The matched operation drives which
|
|
56
|
-
// assertions from lib/security.js run against the resolved per-site policy.
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Derive the entity type a tool acts on, for destructive-allow assertions.
|
|
61
|
-
*
|
|
62
|
-
* @param {string} toolName - The MCP tool name.
|
|
63
|
-
* @param {object} args - The tool arguments.
|
|
64
|
-
* @returns {string} Explicit args.entityType when present, else the suffix
|
|
65
|
-
* parsed from the tool name (e.g. "node" from "drupal_delete_node"),
|
|
66
|
-
* falling back to "entity".
|
|
67
|
-
*/
|
|
68
|
-
function extractEntityType(toolName, args) {
|
|
69
|
-
if (args?.entityType) return args.entityType;
|
|
70
|
-
const m = toolName.match(/^drupal_(?:delete|create|update|get|list)_(.+)$/);
|
|
71
|
-
return m ? m[1] : "entity";
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Apply per-site security assertions before dispatching to a tool handler.
|
|
76
|
-
*
|
|
77
|
-
* @param {string} toolName - The MCP tool name.
|
|
78
|
-
* @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
|
|
79
|
-
* @param {Function} handler - The resolved tool handler.
|
|
80
|
-
* @returns {Promise<*>} The handler's result.
|
|
81
|
-
* @throws {SecurityError} If the resolved policy forbids the inferred operation.
|
|
82
|
-
*/
|
|
83
|
-
async function securityMiddleware(toolName, args, handler) {
|
|
84
|
-
// Tools with no site context skip per-site checks
|
|
85
|
-
if (toolName === "drupal_list_sites") return handler(args);
|
|
86
|
-
|
|
87
|
-
const site = getSiteConfig(args?.site);
|
|
88
|
-
const sec = resolveSecurityConfig(site);
|
|
89
|
-
const op = inferOperation(toolName);
|
|
90
|
-
|
|
91
|
-
if (op === "delete") {
|
|
92
|
-
assertDestructiveAllowed(sec, extractEntityType(toolName, args), args?.id ?? "?");
|
|
93
|
-
assertNotReadOnly(sec, toolName);
|
|
94
|
-
} else if (op === "write") {
|
|
95
|
-
assertNotReadOnly(sec, toolName);
|
|
96
|
-
} else if (op === "graphql" && args?.query) {
|
|
97
|
-
assertGraphqlMutationAllowed(sec, args.query);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return handler(args);
|
|
101
|
-
}
|
|
102
45
|
|
|
103
46
|
// ---------------------------------------------------------------------------
|
|
104
47
|
// MCP Resources — browsable, always-fresh site context
|
|
@@ -286,41 +229,16 @@ function getPromptMessages(name, args) {
|
|
|
286
229
|
}
|
|
287
230
|
|
|
288
231
|
// ---------------------------------------------------------------------------
|
|
289
|
-
// MCP Server surface
|
|
232
|
+
// MCP Server surface — dispatch (middleware + callTool) lives in lib/dispatch.js
|
|
290
233
|
// ---------------------------------------------------------------------------
|
|
291
234
|
|
|
292
|
-
async function callTool(name, args) {
|
|
293
|
-
// 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
|
|
294
|
-
const handler = allHandlers[name];
|
|
295
|
-
|
|
296
|
-
if (!handler) {
|
|
297
|
-
return toolError(new Error(
|
|
298
|
-
`Unknown tool "${name}". Call drupal_list_entity_types to discover available resources.`
|
|
299
|
-
));
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
const result = await securityMiddleware(name, args ?? {}, handler);
|
|
304
|
-
return toolResult(result);
|
|
305
|
-
} catch (err) {
|
|
306
|
-
// Translate known error classes into clear, non-leaky isError responses;
|
|
307
|
-
// anything else falls through to toolError for a generic envelope.
|
|
308
|
-
if (err instanceof SecurityError) {
|
|
309
|
-
return { content: [{ type: "text", text: `Access denied: ${err.message}` }], isError: true };
|
|
310
|
-
}
|
|
311
|
-
if (err instanceof BackendCapabilityError) {
|
|
312
|
-
return { content: [{ type: "text", text: `Not supported by this site's backend: ${err.message}` }], isError: true };
|
|
313
|
-
}
|
|
314
|
-
if (err instanceof BackendResolutionError) {
|
|
315
|
-
return { content: [{ type: "text", text: `Backend resolution failed: ${err.message}` }], isError: true };
|
|
316
|
-
}
|
|
317
|
-
return toolError(err);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
235
|
const buildConnectorServer = createConnectorServerFactory({
|
|
322
236
|
serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
323
|
-
tools: {
|
|
237
|
+
tools: {
|
|
238
|
+
definitions: allDefinitions,
|
|
239
|
+
list: () => filterDiscoverableTools(allDefinitions, listSiteNames().map((n) => getSiteConfig(n))),
|
|
240
|
+
call: callTool,
|
|
241
|
+
},
|
|
324
242
|
resources: { definitions: RESOURCES, read: readResource },
|
|
325
243
|
prompts: {
|
|
326
244
|
definitions: ALL_PROMPTS,
|
|
@@ -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
|
+
}
|
package/src/lib/mcp-server.js
CHANGED
|
@@ -12,7 +12,9 @@ import { Server } from "@modelcontextprotocol/server";
|
|
|
12
12
|
*
|
|
13
13
|
* @param {object} surface
|
|
14
14
|
* @param {{name: string, version: string}} surface.serverInfo
|
|
15
|
-
* @param {{definitions: Array<object>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
|
|
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).
|
|
16
18
|
* @param {{definitions: Array<object>, read: (uri: string) => Promise<object>}} surface.resources
|
|
17
19
|
* @param {{definitions: Array<object>, get: (name: string, args: object) => Array<object>}} surface.prompts
|
|
18
20
|
* @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
|
|
@@ -26,7 +28,9 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
|
|
|
26
28
|
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
27
29
|
);
|
|
28
30
|
|
|
29
|
-
server.setRequestHandler("tools/list", async () => ({
|
|
31
|
+
server.setRequestHandler("tools/list", async () => ({
|
|
32
|
+
tools: tools.list ? await tools.list() : tools.definitions,
|
|
33
|
+
}));
|
|
30
34
|
server.setRequestHandler("tools/call", async (request, context) => {
|
|
31
35
|
const { name, arguments: args } = request.params;
|
|
32
36
|
const result = await tools.call(name, args ?? {}, context);
|
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
|
};
|