drupal-mcp-connector 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +6 -1
- package/package.json +4 -2
- package/src/index.js +40 -71
- package/src/lib/http-handler.js +172 -20
- package/src/lib/mcp-server.js +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-08-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **MCP 2026-07-28 transport support (#172).** HTTP and stdio now serve the
|
|
15
|
+
current request-scoped protocol through the stable 2.0.0 server, Node, and
|
|
16
|
+
client packages. Modern HTTP requests use a fresh server instance, expose
|
|
17
|
+
`server/discover`, carry client metadata/capabilities in the request envelope,
|
|
18
|
+
and do not create `Mcp-Session-Id` state.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **One `/mcp` URL now has explicit dual-era routing.** Auth and rate limiting
|
|
23
|
+
run before a POST body is read once and bounded; the SDK classifier then sends
|
|
24
|
+
that parsed body to exactly one arm. Current requests use the strict modern
|
|
25
|
+
handler. 2025-era clients keep the existing sessionful handler by default and
|
|
26
|
+
can be disabled with `MCP_LEGACY_TRANSPORT=reject`. stdio uses the same server
|
|
27
|
+
factory and preserves both eras.
|
|
28
|
+
- **MCP SDK packages are migrated to stable v2.** Runtime dependencies are now
|
|
29
|
+
`@modelcontextprotocol/server` and `@modelcontextprotocol/node` 2.0.0; the
|
|
30
|
+
matching client package supplies integration evidence. Node.js 20+ remains
|
|
31
|
+
the runtime floor.
|
|
32
|
+
|
|
33
|
+
### Security
|
|
34
|
+
|
|
35
|
+
- Unexpected request conversion, era classification, and handler failures no
|
|
36
|
+
longer expose internal error messages. Before response headers they return a
|
|
37
|
+
generic 500; after headers they terminate the response without a second write.
|
|
38
|
+
Protocol/header/body disagreements continue to return the SDK's typed 400
|
|
39
|
+
errors, and the outbound private-Drupal bridge remains pinned to its
|
|
40
|
+
sessionful 2025-06-18 contract.
|
|
41
|
+
|
|
42
|
+
## [2.2.2] - 2026-08-12
|
|
43
|
+
|
|
44
|
+
### Security
|
|
45
|
+
- Bump `hono` to 4.13.1, `ip-address` to 10.5.0, and `fast-uri` to 3.1.5,
|
|
46
|
+
closing eight npm advisories (GHSA-mwp4-54f8-5fhr, GHSA-4xrf-jv44-h6hh,
|
|
47
|
+
GHSA-22jq-vg5j-6vgg, GHSA-7p8r-x3mc-p8w7, GHSA-8j4g-w8fx-2239,
|
|
48
|
+
GHSA-f23p-vx2j-j53r, GHSA-79qm-7rj5-m7r9, GHSA-54fx-42gc-7vw4). Lockfile-only
|
|
49
|
+
bumps within existing ranges; `npm audit` reports 0 vulnerabilities (#173).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **CI: the attribution check is now the shared workflow.**
|
|
54
|
+
`.github/workflows/attribution.yml` becomes a thin caller pinned to
|
|
55
|
+
`Wilkes-Liberty/shared-ci@v1`, and the vendored `.github/scripts/` copies are
|
|
56
|
+
removed. One implementation for every repository makes copy drift structurally
|
|
57
|
+
impossible instead of merely detectable.
|
|
58
|
+
- **CI: the changelog gate is required-by-default again.** The brief conversion
|
|
59
|
+
to the org's opt-in model is reverted: this package's CHANGELOG ships as a
|
|
60
|
+
published artifact, so an entry is the default and trivial PRs opt out with
|
|
61
|
+
the `no-changelog` label. The conversion's hardening is kept — fixed-string
|
|
62
|
+
match, checkout skipped on bypassed PRs, Dependabot exempt.
|
|
63
|
+
|
|
10
64
|
## [2.2.1] - 2026-08-02
|
|
11
65
|
|
|
12
66
|
### Fixed
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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,25 +20,21 @@
|
|
|
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
39
|
import { resolveSecurityConfig, assertNotReadOnly,
|
|
44
40
|
assertDestructiveAllowed, assertGraphqlMutationAllowed,
|
|
@@ -290,19 +286,10 @@ function getPromptMessages(name, args) {
|
|
|
290
286
|
}
|
|
291
287
|
|
|
292
288
|
// ---------------------------------------------------------------------------
|
|
293
|
-
// MCP Server
|
|
289
|
+
// MCP Server surface
|
|
294
290
|
// ---------------------------------------------------------------------------
|
|
295
291
|
|
|
296
|
-
|
|
297
|
-
{ name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
298
|
-
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
// Tools
|
|
302
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allDefinitions }));
|
|
303
|
-
|
|
304
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
305
|
-
const { name, arguments: args } = request.params;
|
|
292
|
+
async function callTool(name, args) {
|
|
306
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
|
|
307
294
|
const handler = allHandlers[name];
|
|
308
295
|
|
|
@@ -329,32 +316,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
329
316
|
}
|
|
330
317
|
return toolError(err);
|
|
331
318
|
}
|
|
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
|
-
});
|
|
319
|
+
}
|
|
346
320
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
return { description: known.description, messages };
|
|
321
|
+
const buildConnectorServer = createConnectorServerFactory({
|
|
322
|
+
serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
323
|
+
tools: { definitions: allDefinitions, call: callTool },
|
|
324
|
+
resources: { definitions: RESOURCES, read: readResource },
|
|
325
|
+
prompts: {
|
|
326
|
+
definitions: ALL_PROMPTS,
|
|
327
|
+
get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
|
|
328
|
+
? getPromptMessages(name, args)
|
|
329
|
+
: getToolPromptMessages(name, args, definitionsByName),
|
|
330
|
+
},
|
|
358
331
|
});
|
|
359
332
|
|
|
360
333
|
// ---------------------------------------------------------------------------
|
|
@@ -362,22 +335,21 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
|
362
335
|
// ---------------------------------------------------------------------------
|
|
363
336
|
|
|
364
337
|
const transport = process.env.MCP_TRANSPORT || "stdio";
|
|
338
|
+
const reportMcpTransportStage = (stage) => {
|
|
339
|
+
console.error(`[drupal-mcp-connector] MCP ${stage} failed.`);
|
|
340
|
+
};
|
|
365
341
|
|
|
366
342
|
if (transport === "stdio") {
|
|
367
|
-
|
|
368
|
-
|
|
343
|
+
serveStdio(buildConnectorServer, {
|
|
344
|
+
legacy: "serve",
|
|
345
|
+
onerror: () => reportMcpTransportStage("stdio-dispatch"),
|
|
346
|
+
});
|
|
369
347
|
console.error(
|
|
370
348
|
`[drupal-mcp-connector v${CLIENT_VERSION}] stdio transport active. ` +
|
|
371
349
|
`${allDefinitions.length} tools · ${RESOURCES.length} resources · ${ALL_PROMPTS.length} prompts`
|
|
372
350
|
);
|
|
373
351
|
|
|
374
352
|
} 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
353
|
const tlsCfg = getTlsConfig();
|
|
382
354
|
const port = tlsCfg.port;
|
|
383
355
|
const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
|
|
@@ -436,21 +408,6 @@ if (transport === "stdio") {
|
|
|
436
408
|
});
|
|
437
409
|
}
|
|
438
410
|
|
|
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
411
|
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
455
412
|
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
456
413
|
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
@@ -487,10 +444,22 @@ if (transport === "stdio") {
|
|
|
487
444
|
);
|
|
488
445
|
}
|
|
489
446
|
|
|
447
|
+
const legacyMode = process.env.MCP_LEGACY_TRANSPORT || "serve";
|
|
448
|
+
const modernMcpHandler = createMcpHandler(buildConnectorServer, {
|
|
449
|
+
legacy: "reject",
|
|
450
|
+
onerror: () => reportMcpTransportStage("modern-protocol"),
|
|
451
|
+
});
|
|
452
|
+
const modernHandler = toNodeHandler(modernMcpHandler, {
|
|
453
|
+
onerror: () => reportMcpTransportStage("modern-adapter"),
|
|
454
|
+
});
|
|
455
|
+
const legacyHandler = createLegacySessionHandler({
|
|
456
|
+
buildServer: buildConnectorServer,
|
|
457
|
+
mode: legacyMode,
|
|
458
|
+
});
|
|
490
459
|
const requestHandler = createMcpRequestHandler({
|
|
491
460
|
checkAuth,
|
|
492
|
-
|
|
493
|
-
|
|
461
|
+
modernHandler,
|
|
462
|
+
legacyHandler,
|
|
494
463
|
toolCount: allDefinitions.length,
|
|
495
464
|
rateLimiter,
|
|
496
465
|
});
|
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,59 @@
|
|
|
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>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
|
|
16
|
+
* @param {{definitions: Array<object>, read: (uri: string) => Promise<object>}} surface.resources
|
|
17
|
+
* @param {{definitions: Array<object>, get: (name: string, args: object) => Array<object>}} surface.prompts
|
|
18
|
+
* @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
|
|
19
|
+
*/
|
|
20
|
+
export function createConnectorServerFactory({ serverInfo, tools, resources, prompts }) {
|
|
21
|
+
const toolDefinitions = new Map(tools.definitions.map((definition) => [definition.name, definition]));
|
|
22
|
+
|
|
23
|
+
return function buildConnectorServer(_context) {
|
|
24
|
+
const server = new Server(
|
|
25
|
+
serverInfo,
|
|
26
|
+
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
server.setRequestHandler("tools/list", async () => ({ tools: tools.definitions }));
|
|
30
|
+
server.setRequestHandler("tools/call", async (request, context) => {
|
|
31
|
+
const { name, arguments: args } = request.params;
|
|
32
|
+
const result = await tools.call(name, args ?? {}, context);
|
|
33
|
+
return server.projectCallToolResult(result, toolDefinitions.get(name)?.outputSchema);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
server.setRequestHandler("resources/list", async () => ({ resources: resources.definitions }));
|
|
37
|
+
server.setRequestHandler("resources/read", async (request) => {
|
|
38
|
+
const { uri } = request.params;
|
|
39
|
+
try {
|
|
40
|
+
const data = await resources.read(uri);
|
|
41
|
+
return {
|
|
42
|
+
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2) }],
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Resource read failed (${uri})`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
server.setRequestHandler("prompts/list", async () => ({ prompts: prompts.definitions }));
|
|
50
|
+
server.setRequestHandler("prompts/get", async (request) => {
|
|
51
|
+
const { name, arguments: args } = request.params;
|
|
52
|
+
const known = prompts.definitions.find((prompt) => prompt.name === name);
|
|
53
|
+
if (!known) throw new Error(`Unknown prompt: "${name}"`);
|
|
54
|
+
return { description: known.description, messages: prompts.get(name, args ?? {}) };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return server;
|
|
58
|
+
};
|
|
59
|
+
}
|