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.
- package/.claude/commands/drupal-governance-status.md +16 -0
- package/CHANGELOG.md +55 -0
- package/README.md +6 -1
- package/config/config.example.json +2 -1
- package/package.json +4 -2
- package/src/index.js +44 -157
- package/src/lib/dispatch.js +113 -0
- package/src/lib/governance.js +204 -0
- package/src/lib/http-handler.js +172 -20
- package/src/lib/mcp-server.js +63 -0
- 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,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
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
[](https://drupal.org)
|
|
8
|
-
[](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.
|
|
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/
|
|
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 {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
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 {
|
|
44
|
-
|
|
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
|
|
232
|
+
// MCP Server surface — dispatch (middleware + callTool) lives in lib/dispatch.js
|
|
294
233
|
// ---------------------------------------------------------------------------
|
|
295
234
|
|
|
296
|
-
const
|
|
297
|
-
{ name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
+
}
|
package/src/lib/http-handler.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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`
|
|
15
|
-
*
|
|
16
|
-
* - `GET /mcp`
|
|
17
|
-
* - `GET /health
|
|
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,
|
|
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" &&
|
|
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.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 === "
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
};
|