drupal-mcp-connector 2.2.2 → 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 CHANGED
@@ -7,6 +7,38 @@ 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
+
10
42
  ## [2.2.2] - 2026-08-12
11
43
 
12
44
  ### Security
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org)
7
7
  [![Drupal](https://img.shields.io/badge/drupal-10%20%7C%2011-blue)](https://drupal.org)
8
- [![MCP](https://img.shields.io/badge/MCP-2025--11--25-purple)](https://modelcontextprotocol.io)
8
+ [![MCP](https://img.shields.io/badge/MCP-2026--07--28-purple)](https://modelcontextprotocol.io)
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
@@ -221,6 +221,11 @@ or remote use, run the HTTPS transport and register the endpoint instead — see
221
221
  **[docs/getting-started.md](docs/getting-started.md)** and
222
222
  **[docs/mcp-clients.md](docs/mcp-clients.md)**.
223
223
 
224
+ The same entry point serves current MCP 2026-07-28 clients and 2025-era clients.
225
+ Current HTTP requests are stateless and never receive `Mcp-Session-Id`; legacy
226
+ HTTP clients retain their existing session. Set `MCP_LEGACY_TRANSPORT=reject`
227
+ to end the compatibility window deliberately. The `/mcp` URL does not change.
228
+
224
229
  ---
225
230
 
226
231
  ## Companion Drupal Module — MCP Sentinel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.2.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/sdk": "^1.30.0",
61
+ "@modelcontextprotocol/node": "^2.0.0",
62
+ "@modelcontextprotocol/server": "^2.0.0",
62
63
  "graphql": "^17.0.0",
63
64
  "node-fetch": "^3.3.2",
64
65
  "ssh2": "^1.16.0"
65
66
  },
66
67
  "devDependencies": {
68
+ "@modelcontextprotocol/client": "^2.0.0",
67
69
  "eslint": "^10.4.1",
68
70
  "eslint-plugin-n": "^18.1.0",
69
71
  "eslint-plugin-security": "^4.0.0",
package/src/index.js CHANGED
@@ -20,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 { Server } from "@modelcontextprotocol/sdk/server/index.js";
31
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
- import { CallToolRequestSchema,
33
- ListToolsRequestSchema,
34
- ListResourcesRequestSchema,
35
- ReadResourceRequestSchema,
36
- ListPromptsRequestSchema,
37
- GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
30
+ import { createMcpHandler } from "@modelcontextprotocol/server";
31
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
32
+ import { toNodeHandler } from "@modelcontextprotocol/node";
38
33
 
39
34
  import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
40
35
  import { makeBearerCheck } from "./lib/http-auth.js";
41
- import { createMcpRequestHandler } from "./lib/http-handler.js";
36
+ import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
37
+ import { createConnectorServerFactory } from "./lib/mcp-server.js";
42
38
  import { createRateLimiter } from "./lib/rate-limit.js";
43
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 construction
289
+ // MCP Server surface
294
290
  // ---------------------------------------------------------------------------
295
291
 
296
- const server = new Server(
297
- { name: "drupal-mcp-connector", version: CLIENT_VERSION },
298
- { capabilities: { tools: {}, resources: {}, prompts: {} } }
299
- );
300
-
301
- // Tools
302
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allDefinitions }));
303
-
304
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
305
- const { name, arguments: args } = request.params;
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
- // 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 };
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
- const stdioTransport = new StdioServerTransport();
368
- await server.connect(stdioTransport);
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
- sessions,
493
- openSession,
461
+ modernHandler,
462
+ legacyHandler,
494
463
  toolCount: allDefinitions.length,
495
464
  rateLimiter,
496
465
  });
@@ -2,39 +2,178 @@
2
2
  * HTTP request handler for the Streamable-HTTP MCP transport.
3
3
  *
4
4
  * Extracted from index.js so the routing/auth/health/404 behavior is unit
5
- * testable without standing up a real server or the MCP SDK. The entry point
6
- * wires the concrete dependencies (bearer check, session map, session factory);
7
- * tests inject stubs.
5
+ * testable without standing up a real server. The entry point wires the bearer
6
+ * check plus strict modern and sessionful legacy handler arms; tests inject
7
+ * stubs around that same required pair.
8
8
  */
9
9
 
10
+ import { randomUUID } from "node:crypto";
11
+ import { isInitializeRequest, isJsonContentType, isLegacyRequest } from "@modelcontextprotocol/server";
12
+ import { NodeStreamableHTTPServerTransport, toWebRequest } from "@modelcontextprotocol/node";
13
+
14
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
15
+
16
+ class McpHttpClientError extends Error {
17
+ constructor(statusCode, publicMessage) {
18
+ super(publicMessage);
19
+ this.name = "McpHttpClientError";
20
+ this.statusCode = statusCode;
21
+ this.publicMessage = publicMessage;
22
+ }
23
+ }
24
+
25
+ function defaultOnError({ stage }) {
26
+ console.error(`[drupal-mcp-connector] MCP ${stage} failed.`);
27
+ }
28
+
29
+ function reportUnexpected(onError, stage) {
30
+ try {
31
+ onError({ stage });
32
+ } catch {
33
+ // Diagnostics must never replace the controlled transport response.
34
+ }
35
+ }
36
+
37
+ function respondAfterFailure(res, error, onError, stage) {
38
+ if (error instanceof McpHttpClientError && !res.headersSent) {
39
+ res.writeHead(error.statusCode).end(error.publicMessage);
40
+ return;
41
+ }
42
+
43
+ reportUnexpected(onError, stage);
44
+ if (res.headersSent) {
45
+ res.destroy();
46
+ return;
47
+ }
48
+ res.writeHead(500).end("Internal Server Error");
49
+ }
50
+
51
+ async function readJsonBody(req, maxBodyBytes) {
52
+ const declared = Number(req.headers["content-length"] || 0);
53
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
54
+ throw new McpHttpClientError(413, "Request body exceeds the configured limit");
55
+ }
56
+
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
61
+ size += bytes.length;
62
+ if (size > maxBodyBytes) {
63
+ throw new McpHttpClientError(413, "Request body exceeds the configured limit");
64
+ }
65
+ chunks.push(bytes);
66
+ }
67
+
68
+ try {
69
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
70
+ } catch {
71
+ throw new McpHttpClientError(400, "Malformed JSON request body");
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Build the bounded 2025-era sessionful transport arm.
77
+ *
78
+ * @param {object} deps
79
+ * @param {(ctx: {era: "legacy"}) => object|Promise<object>} deps.buildServer
80
+ * @param {Map<string, object>} [deps.sessions]
81
+ * @param {"serve"|"reject"} [deps.mode]
82
+ * @param {(options: object) => object} [deps.transportFactory]
83
+ * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse, body?: unknown) => Promise<void>}
84
+ */
85
+ export function createLegacySessionHandler({
86
+ buildServer,
87
+ sessions = new Map(),
88
+ mode = "serve",
89
+ transportFactory = (options) => new NodeStreamableHTTPServerTransport(options),
90
+ }) {
91
+ if (mode !== "serve" && mode !== "reject") {
92
+ throw new Error(`Invalid legacy MCP transport mode: "${mode}". Use "serve" or "reject".`);
93
+ }
94
+
95
+ async function openSession() {
96
+ const transport = transportFactory({
97
+ sessionIdGenerator: randomUUID,
98
+ onsessioninitialized: (id) => sessions.set(id, transport),
99
+ });
100
+ transport.onclose = () => {
101
+ if (transport.sessionId) sessions.delete(transport.sessionId);
102
+ };
103
+ const server = await buildServer({ era: "legacy" });
104
+ await server.connect(transport);
105
+ return transport;
106
+ }
107
+
108
+ return async function handleLegacy(req, res, body) {
109
+ if (mode === "reject") {
110
+ res.writeHead(400).end("Legacy MCP transport is disabled");
111
+ return;
112
+ }
113
+
114
+ const sessionId = req.headers["mcp-session-id"];
115
+ if (sessionId) {
116
+ const transport = sessions.get(sessionId);
117
+ if (!transport) {
118
+ res.writeHead(404).end("Unknown MCP-Session-Id");
119
+ return;
120
+ }
121
+ await transport.handleRequest(req, res, body);
122
+ return;
123
+ }
124
+
125
+ if (req.method !== "POST" || !isInitializeRequest(body)) {
126
+ res.writeHead(400).end("MCP-Session-Id is required for legacy session requests");
127
+ return;
128
+ }
129
+
130
+ const transport = await openSession();
131
+ await transport.handleRequest(req, res, body);
132
+ };
133
+ }
134
+
10
135
  /**
11
136
  * Build the `(req, res)` handler for the MCP HTTP endpoint.
12
137
  *
13
138
  * Routes:
14
- * - `POST /mcp` — bearer-gated; reuses a session by `Mcp-Session-Id` or opens
15
- * a new one, then delegates to the transport's `handleRequest`.
16
- * - `GET /mcp` — bearer-gated; requires a known session, else 400.
17
- * - `GET /health`— unauthenticated liveness probe (`{status, tools}`).
139
+ * - `POST /mcp` rate- and bearer-gated, bounded/body-parsed once, then
140
+ * classified into exactly one modern-stateless or legacy-sessionful arm.
141
+ * - `GET|DELETE /mcp` rate- and bearer-gated legacy session operations.
142
+ * - `GET /health` unauthenticated liveness probe (`{status, tools}`).
18
143
  * - everything else — 404.
19
144
  *
20
145
  * @param {object} deps
21
146
  * @param {(authHeader: any) => boolean} deps.checkAuth Bearer predicate (see http-auth.js).
22
- * @param {Map<string, {handleRequest: Function, sessionId?: string}>} deps.sessions Session id → transport.
23
- * @param {() => Promise<{handleRequest: Function}>} deps.openSession Create+connect a new transport.
24
147
  * @param {number} deps.toolCount Tool count reported by /health.
148
+ * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.modernHandler
149
+ * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.legacyHandler
25
150
  * @param {?{check: (key: string) => {allowed: boolean, retryAfterSec: number}}} [deps.rateLimiter]
26
151
  * Optional rate limiter (see rate-limit.js). Omit/null to disable.
27
152
  * @param {(req: import("http").IncomingMessage) => string} [deps.clientKey]
28
153
  * Maps a request to a rate-limit key (default: client IP).
154
+ * @param {number} [deps.maxBodyBytes] Maximum accepted POST body size.
155
+ * @param {typeof toWebRequest} [deps.toWebRequestFn] Injectable Node-to-Web adapter.
156
+ * @param {typeof isLegacyRequest} [deps.isLegacyRequestFn] Injectable SDK era classifier.
157
+ * @param {(event: {stage: string}) => void} [deps.onError] Sanitized diagnostics sink.
29
158
  * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
30
159
  */
31
160
  export function createMcpRequestHandler({
32
- checkAuth, sessions, openSession, toolCount,
161
+ checkAuth, toolCount,
162
+ modernHandler = null,
163
+ legacyHandler = null,
33
164
  rateLimiter = null,
34
165
  clientKey = (req) => req.socket?.remoteAddress || "unknown",
166
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
167
+ toWebRequestFn = toWebRequest,
168
+ isLegacyRequestFn = isLegacyRequest,
169
+ onError = defaultOnError,
35
170
  }) {
171
+ if (!modernHandler || !legacyHandler) {
172
+ throw new Error("modernHandler and legacyHandler must be configured together for dual-era routing");
173
+ }
174
+
36
175
  return async function handle(req, res) {
37
- if (req.url === "/mcp" && (req.method === "POST" || req.method === "GET")) {
176
+ if (req.url === "/mcp" && ["POST", "GET", "DELETE"].includes(req.method)) {
38
177
  // Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
39
178
  if (rateLimiter) {
40
179
  const verdict = rateLimiter.check(clientKey(req));
@@ -50,20 +189,33 @@ export function createMcpRequestHandler({
50
189
  }
51
190
  }
52
191
 
53
- if (req.method === "POST" && req.url === "/mcp") {
54
- const sessionId = req.headers["mcp-session-id"];
55
- const transport = (sessionId && sessions.get(sessionId)) || (await openSession());
56
- await transport.handleRequest(req, res);
192
+ if (req.url === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
193
+ try {
194
+ await legacyHandler(req, res);
195
+ } catch (error) {
196
+ respondAfterFailure(res, error, onError, "legacy-dispatch");
197
+ }
57
198
  return;
58
199
  }
59
200
 
60
- if (req.method === "GET" && req.url === "/mcp") {
61
- const sessionId = req.headers["mcp-session-id"];
62
- if (!sessionId || !sessions.has(sessionId)) {
63
- res.writeHead(400).end("Missing or unknown MCP-Session-Id");
201
+ if (req.method === "POST" && req.url === "/mcp") {
202
+ if (!isJsonContentType(req.headers["content-type"])) {
203
+ res.writeHead(415).end("Content-Type must be application/json");
64
204
  return;
65
205
  }
66
- await sessions.get(sessionId).handleRequest(req, res);
206
+ let stage = "body-read";
207
+ try {
208
+ const body = await readJsonBody(req, maxBodyBytes);
209
+ stage = "request-conversion";
210
+ const request = await toWebRequestFn(req, body);
211
+ stage = "classification";
212
+ const legacy = await isLegacyRequestFn(request, body);
213
+ const selectedHandler = legacy ? legacyHandler : modernHandler;
214
+ stage = legacy ? "legacy-dispatch" : "modern-dispatch";
215
+ await selectedHandler(req, res, body);
216
+ } catch (error) {
217
+ respondAfterFailure(res, error, onError, stage);
218
+ }
67
219
  return;
68
220
  }
69
221
 
@@ -0,0 +1,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
+ }