drupal-mcp-connector 2.3.0 → 2.4.1

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,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.1] - 2026-08-14
11
+
12
+ ### Fixed
13
+ - **One unresolvable site no longer kills tool discovery (#187).** 2.4.0's
14
+ discovery gate resolved every configured site eagerly, so a deliberately
15
+ credential-less site — the inert break-glass tier keeps its Keychain item
16
+ absent by design — threw `requireSecureAuth` during `tools/list` and took
17
+ the whole tool surface down. Discovery now skips sites whose resolution
18
+ throws; execution against such a site still surfaces its own descriptive
19
+ error at call time, exactly as in 2.3.0.
20
+
21
+ ## [2.4.0] - 2026-08-14
22
+
23
+ ### Added
24
+
25
+ - **Source governance is now enforceable on every governed product path
26
+ (#176).** A site with `requireGovernance: true` requires the Drupal
27
+ source's governance contract to verify before any tool call runs against
28
+ it: the connector probes `GET /drupal-mcp/readiness` as its own principal
29
+ (mcp_sentinel ≥ 2.4.0), caches a passing verdict for 60 seconds, and
30
+ re-proves it after that. A failed, stale, or unreachable verification
31
+ denies tool discovery and execution with the source's own stable reason —
32
+ it never falls back to a plain JSON:API or GraphQL path, on any backend or
33
+ bridge. The new `drupal_governance_status` tool stays callable while
34
+ governance is failing and reports which required condition failed, without
35
+ credentials. Ungoverned sites are untouched.
36
+
37
+ ### Changed
38
+
39
+ - The security middleware and tools/call dispatch moved from the entry point
40
+ into `src/lib/dispatch.js` (side-effect-free, testable per backend); the
41
+ entry point now only boots transports. Tool discovery accepts a per-request
42
+ `list` hook so governance can gate what is discoverable.
43
+
10
44
  ## [2.3.0] - 2026-08-13
11
45
 
12
46
  ### 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
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",
package/src/index.js CHANGED
@@ -31,74 +31,17 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
31
31
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
32
32
  import { toNodeHandler } from "@modelcontextprotocol/node";
33
33
 
34
- import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
34
+ import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
35
35
  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 { resolveSecurityConfig, assertNotReadOnly,
40
- assertDestructiveAllowed, assertGraphqlMutationAllowed,
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, listResolvableSiteConfigs } 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: { definitions: allDefinitions, call: callTool },
237
+ tools: {
238
+ definitions: allDefinitions,
239
+ list: () => filterDiscoverableTools(allDefinitions, listResolvableSiteConfigs()),
240
+ call: callTool,
241
+ },
324
242
  resources: { definitions: RESOURCES, read: readResource },
325
243
  prompts: {
326
244
  definitions: ALL_PROMPTS,
@@ -0,0 +1,133 @@
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, listSiteNames } 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
+ * Resolve every configured site that CAN resolve, skipping the ones that
23
+ * throw. A site can legitimately be unresolvable on purpose — the break-glass
24
+ * tier keeps its credential absent to stay inert — and discovery must treat
25
+ * such a site as simply unable to serve tools, never let it kill tools/list
26
+ * for everyone (the 2.4.0 regression). Execution against an unresolvable
27
+ * site still surfaces its own descriptive error at call time.
28
+ *
29
+ * @returns {Array<object>} Resolved site configs.
30
+ */
31
+ export function listResolvableSiteConfigs() {
32
+ return listSiteNames().flatMap((name) => {
33
+ try {
34
+ return [getSiteConfig(name)];
35
+ } catch {
36
+ return [];
37
+ }
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Derive the entity type a tool acts on, for destructive-allow assertions.
43
+ *
44
+ * @param {string} toolName - The MCP tool name.
45
+ * @param {object} args - The tool arguments.
46
+ * @returns {string} Explicit args.entityType when present, else the suffix
47
+ * parsed from the tool name (e.g. "node" from "drupal_delete_node"),
48
+ * falling back to "entity".
49
+ */
50
+ function extractEntityType(toolName, args) {
51
+ if (args?.entityType) return args.entityType;
52
+ const m = toolName.match(/^drupal_(?:delete|create|update|get|list)_(.+)$/);
53
+ return m ? m[1] : "entity";
54
+ }
55
+
56
+ /**
57
+ * Apply per-site governance and security assertions before dispatching.
58
+ *
59
+ * @param {string} toolName - The MCP tool name.
60
+ * @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
61
+ * @param {Function} handler - The resolved tool handler.
62
+ * @returns {Promise<*>} The handler's result.
63
+ * @throws {GovernanceError} If the site requires source governance and the
64
+ * contract is not verified — checked FIRST, so no assertion below can be
65
+ * read as an ungoverned fallback verdict.
66
+ * @throws {SecurityError} If the resolved policy forbids the inferred operation.
67
+ */
68
+ export async function securityMiddleware(toolName, args, handler) {
69
+ // Tools with no site context skip per-site checks
70
+ if (toolName === "drupal_list_sites") return handler(args);
71
+
72
+ const site = getSiteConfig(args?.site);
73
+
74
+ // Source-governance gate (#176). The diagnostic tools stay callable while
75
+ // governance fails — they are how an operator learns which condition failed.
76
+ if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
77
+ await assertSourceGovernance(site);
78
+ }
79
+
80
+ const sec = resolveSecurityConfig(site);
81
+ const op = inferOperation(toolName);
82
+
83
+ if (op === "delete") {
84
+ assertDestructiveAllowed(sec, extractEntityType(toolName, args), args?.id ?? "?");
85
+ assertNotReadOnly(sec, toolName);
86
+ } else if (op === "write") {
87
+ assertNotReadOnly(sec, toolName);
88
+ } else if (op === "graphql" && args?.query) {
89
+ assertGraphqlMutationAllowed(sec, args.query);
90
+ }
91
+
92
+ return handler(args);
93
+ }
94
+
95
+ /**
96
+ * Serve one MCP tools/call request: resolve the handler, run the middleware,
97
+ * translate known error classes into clear, non-leaky isError envelopes.
98
+ *
99
+ * @param {string} name - The MCP tool name.
100
+ * @param {object} args - The tool arguments.
101
+ * @returns {Promise<object>} An MCP tool result payload.
102
+ */
103
+ export async function callTool(name, args) {
104
+ // 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
105
+ const handler = allHandlers[name];
106
+
107
+ if (!handler) {
108
+ return toolError(new Error(
109
+ `Unknown tool "${name}". Call drupal_list_entity_types to discover available resources.`
110
+ ));
111
+ }
112
+
113
+ try {
114
+ const result = await securityMiddleware(name, args ?? {}, handler);
115
+ return toolResult(result);
116
+ } catch (err) {
117
+ // Translate known error classes into clear, non-leaky isError responses;
118
+ // anything else falls through to toolError for a generic envelope.
119
+ if (err instanceof GovernanceError) {
120
+ return { content: [{ type: "text", text: `Source governance unavailable: ${err.message}` }], isError: true };
121
+ }
122
+ if (err instanceof SecurityError) {
123
+ return { content: [{ type: "text", text: `Access denied: ${err.message}` }], isError: true };
124
+ }
125
+ if (err instanceof BackendCapabilityError) {
126
+ return { content: [{ type: "text", text: `Not supported by this site's backend: ${err.message}` }], isError: true };
127
+ }
128
+ if (err instanceof BackendResolutionError) {
129
+ return { content: [{ type: "text", text: `Backend resolution failed: ${err.message}` }], isError: true };
130
+ }
131
+ return toolError(err);
132
+ }
133
+ }
@@ -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
+ }
@@ -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 () => ({ tools: tools.definitions }));
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
  };