@procedures/mcp-server 0.2.3 → 0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/mcp-server",
4
- "version": "0.2.3",
4
+ "version": "0.3.0",
5
5
  "description": "Model Context Protocol server transport: serve typed procedures as MCP tools to Claude and other MCP clients, with streaming progress, list_changed and an expose policy.",
6
6
  "keywords": [
7
7
  "mcp",
@@ -60,17 +60,17 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@modelcontextprotocol/sdk": "^1.0.0",
63
- "@procedures/mcp": "0.2.3"
63
+ "@procedures/mcp": "0.3.0"
64
64
  },
65
65
  "peerDependencies": {
66
- "@procedures/client": "^0.2.3"
66
+ "@procedures/client": "^0.3.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "typescript": "^7.0.2",
70
70
  "@types/node": "^22.0.0",
71
71
  "@mark1russell7/cue": "^1.0.0",
72
72
  "vitest": "^5.0.3",
73
- "@procedures/client": "0.2.3",
73
+ "@procedures/client": "0.3.0",
74
74
  "zod": "^4.6.5"
75
75
  },
76
76
  "scripts": {
@@ -67,7 +67,8 @@ export const mcpListToolsProcedure: AnyProcedure = defineProcedure({
67
67
  }
68
68
 
69
69
  // The registry of the caller: a host with its own registry lists its own tools
70
- const procedures = (ctx?.registry ?? PROCEDURE_REGISTRY).getAll();
70
+ // Only the procedures that the host exposes (roadmap R17)
71
+ const procedures = (ctx?.registry ?? PROCEDURE_REGISTRY).getAll().filter((procedure) => !ctx?.expose || ctx.expose(procedure.path));
71
72
  const mcpTools = proceduresToMcpTools(procedures, filter);
72
73
 
73
74
  const tools: McpToolInfo[] = mcpTools.map((tool) => ({
@@ -25,6 +25,7 @@ import {
25
25
  import { McpServerTransport } from "../../transport/mcp-transport.js";
26
26
  import type { McpServerTransportOptions } from "../../types.js";
27
27
  import { exposeRule, importBundles, mergeSurfaces } from "../../surface.js";
28
+ import { ms } from "@procedures/client";
28
29
 
29
30
  /** The default bundle of the tool surface. */
30
31
  export const DEFAULT_SERVE_BUNDLE = "@procedures/bundle-mcp";
@@ -129,7 +130,7 @@ export async function prepareServe(
129
130
  registry,
130
131
  expose,
131
132
  strictCalls: input.strictCalls ?? true,
132
- interceptors: [timeoutInterceptor({ ms: input.toolTimeoutMs ?? 600_000 })],
133
+ interceptors: [timeoutInterceptor({ limit: ms(input.toolTimeoutMs ?? 600_000) })],
133
134
  });
134
135
  return warning === undefined ? { server, expose } : { server, expose, warning };
135
136
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The MCP transport over Streamable HTTP (roadmap R26), the HTTP transport of the current MCP
3
+ * specification. Before, this package had only stdio, and its SSE transport only threw.
4
+ *
5
+ * Each client gets a session: its `initialize` request makes a transport and an MCP server of its
6
+ * own, and the later requests of the client carry the `mcp-session-id` header of that session.
7
+ * A session gets the progress of a tool call and the `list_changed` notifications. The checks of
8
+ * a local server apply: the server listens on loopback by default, it refuses a web page of
9
+ * another host or origin (DNS rebinding, cross-site requests), and with a token it refuses a
10
+ * request without the token.
11
+ */
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
15
+ import type { AddressInfo } from "node:net";
16
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
+ import { bearerOf, checkBrowserRequest, tokenMatches } from "@procedures/client";
18
+ import type { HttpTransportOptions } from "../types.js";
19
+
20
+ /** The HTTP endpoint of an MCP server, with a session for each client. */
21
+ export interface McpHttpTransport {
22
+ /** This function starts to listen, when the endpoint has a server of its own. It gives the URL. */
23
+ listen(): Promise<string>;
24
+ /** The number of open sessions. */
25
+ readonly sessions: number;
26
+ /** This function closes each session, and the server when the endpoint made one. */
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * This function makes the Streamable HTTP endpoint of an MCP server. `connect` connects the
32
+ * transport of a new session to an MCP server of its own.
33
+ */
34
+ export function createHttpTransport(
35
+ options: HttpTransportOptions,
36
+ connect: (transport: StreamableHTTPServerTransport) => Promise<void>,
37
+ ): McpHttpTransport {
38
+ const path = options.path ?? "/mcp";
39
+ const host = options.host ?? "127.0.0.1";
40
+ const owned = options.server === undefined;
41
+ const server: HttpServer = options.server ?? createServer();
42
+ const sessions = new Map<string, StreamableHTTPServerTransport>();
43
+
44
+ const refuse = (res: ServerResponse, status: number, code: string, message: string): void => {
45
+ res.statusCode = status;
46
+ res.setHeader("content-type", "application/json");
47
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message, data: { code } }, id: null }));
48
+ };
49
+
50
+ /** The transport of a request: the one of its session, or a new one for an `initialize`. */
51
+ const transportOf = async (req: IncomingMessage): Promise<StreamableHTTPServerTransport | undefined> => {
52
+ const sessionId = req.headers["mcp-session-id"];
53
+ if (typeof sessionId === "string") return sessions.get(sessionId);
54
+ if (req.method !== "POST") return undefined;
55
+ const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
56
+ sessionIdGenerator: () => randomUUID(),
57
+ onsessioninitialized: (id) => {
58
+ sessions.set(id, transport);
59
+ },
60
+ });
61
+ transport.onclose = () => {
62
+ if (transport.sessionId) sessions.delete(transport.sessionId);
63
+ };
64
+ await connect(transport);
65
+ return transport;
66
+ };
67
+
68
+ const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
69
+ const url = new URL(req.url ?? "/", "http://localhost");
70
+ if (url.pathname !== path) {
71
+ if (owned) refuse(res, 404, "NOT_FOUND", `No MCP endpoint at ${url.pathname}`);
72
+ return;
73
+ }
74
+ // A web page must not reach the tools: the host and the origin of the request
75
+ const guard = checkBrowserRequest(req, { ...(options.allowedOrigins ? { allowedOrigins: options.allowedOrigins } : {}) });
76
+ if (!guard.ok) {
77
+ refuse(res, guard.status, guard.code, guard.message);
78
+ return;
79
+ }
80
+ if (options.token !== undefined && !tokenMatches(options.token, bearerOf(req.headers["authorization"]))) {
81
+ refuse(res, 401, "UNAUTHORIZED", "Unauthorized: this server requires its token");
82
+ return;
83
+ }
84
+ void transportOf(req)
85
+ .then((transport) => {
86
+ if (!transport) {
87
+ refuse(res, 404, "SESSION_NOT_FOUND", "No session: start one with an initialize request");
88
+ return;
89
+ }
90
+ return transport.handleRequest(req, res);
91
+ })
92
+ .catch((error: unknown) => {
93
+ if (!res.headersSent) refuse(res, 500, "INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
94
+ });
95
+ };
96
+ server.on("request", onRequest);
97
+
98
+ return {
99
+ listen: async () => {
100
+ if (owned && !server.listening) {
101
+ await new Promise<void>((resolve, reject) => {
102
+ server.once("error", reject);
103
+ server.listen(options.port ?? 3003, host, () => {
104
+ server.off("error", reject);
105
+ resolve();
106
+ });
107
+ });
108
+ }
109
+ const address = server.address() as AddressInfo | null;
110
+ const shown = host === "0.0.0.0" ? "localhost" : host;
111
+ return `http://${shown}:${address?.port ?? options.port ?? 3003}${path}`;
112
+ },
113
+ get sessions() {
114
+ return sessions.size;
115
+ },
116
+ close: async () => {
117
+ server.off("request", onRequest);
118
+ const open = [...sessions.values()];
119
+ sessions.clear();
120
+ await Promise.all(open.map((transport) => transport.close().catch(() => undefined)));
121
+ if (owned && server.listening) {
122
+ await new Promise<void>((resolve) => {
123
+ server.close(() => resolve());
124
+ server.closeAllConnections();
125
+ });
126
+ }
127
+ },
128
+ };
129
+ }
@@ -23,6 +23,8 @@ import {
23
23
  import type { McpServerTransportOptions, McpServerTransportState } from "../types.js";
24
24
  import { createStdioTransport } from "./stdio.js";
25
25
  import { createSseTransport } from "./sse.js";
26
+ import { createHttpTransport, type McpHttpTransport } from "./http.js";
27
+ import { optionalMs } from "@procedures/client";
26
28
 
27
29
  /**
28
30
  * Generate a unique request ID.
@@ -84,8 +86,15 @@ export class McpServerTransport implements ServerTransport {
84
86
  private tools: Map<string, McpTool> = new Map();
85
87
  private registerListener: ((procedure: unknown) => void) | undefined;
86
88
  private unregisterListener: ((procedure: unknown) => void) | undefined;
89
+ /** The Streamable HTTP transport and its URL, when the transport is "http" (R26) */
90
+ private http: McpHttpTransport | undefined;
91
+ /** The MCP servers of the sessions of the Streamable HTTP transport */
92
+ private readonly sessionServers = new Set<McpServer>();
93
+ private httpUrl: string | undefined;
87
94
  /** A pending list_changed notification: a burst of registrations sends one */
88
95
  private listChangedTimer: ReturnType<typeof setTimeout> | undefined;
96
+ /** The limits of a stream that can run without an end, the time in milliseconds (R15) */
97
+ private readonly streamLimits: { items: number; timeMs: number };
89
98
 
90
99
  constructor(server: Server, options: McpServerTransportOptions = {}) {
91
100
  this.server = server;
@@ -97,6 +106,10 @@ export class McpServerTransport implements ServerTransport {
97
106
  },
98
107
  ...options,
99
108
  };
109
+ this.streamLimits = {
110
+ items: Math.max(1, options.streamLimits?.items ?? 100),
111
+ timeMs: optionalMs(options.streamLimits?.time, "streamLimits.time") ?? 30_000,
112
+ };
100
113
  // The registry of the server, when it has one (a ProcedureServer with an explicit registry):
101
114
  // before, the tools came from the global registry, also when the server served another one
102
115
  this.registry =
@@ -106,25 +119,32 @@ export class McpServerTransport implements ServerTransport {
106
119
 
107
120
  // Create MCP server with capabilities. The tool list follows the registry: when it changes,
108
121
  // the server sends notifications/tools/list_changed (deep dive roadmap 0.4)
109
- this.mcpServer = new McpServer(this.options.serverInfo, {
122
+ this.mcpServer = this.createSdkServer();
123
+
124
+ // The registry listeners are attached in start() and removed in stop(): attaching them here
125
+ // leaked them for a transport that never started or failed to start (BUGS-2026-07 L28)
126
+ }
127
+
128
+ /**
129
+ * An MCP server of the SDK with the handlers of this transport. The stdio transport has one, and
130
+ * each session of the Streamable HTTP transport has one of its own (roadmap R26).
131
+ */
132
+ private createSdkServer(): McpServer {
133
+ const server = new McpServer(this.options.serverInfo, {
110
134
  capabilities: {
111
135
  tools: { listChanged: true },
112
136
  },
113
137
  });
114
-
115
- // Setup request handlers
116
- this.setupHandlers();
117
-
118
- // The registry listeners are attached in start() and removed in stop(): attaching them here
119
- // leaked them for a transport that never started or failed to start (BUGS-2026-07 L28)
138
+ this.setupHandlers(server);
139
+ return server;
120
140
  }
121
141
 
122
142
  /**
123
143
  * Setup MCP request handlers.
124
144
  */
125
- private setupHandlers(): void {
145
+ private setupHandlers(server: McpServer): void {
126
146
  // ListToolsRequestSchema -> List all procedures as tools
127
- this.mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
147
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
128
148
  this.log("ListToolsRequest received");
129
149
 
130
150
  const tools = Array.from(this.tools.values()).map(toToolDefinition);
@@ -135,7 +155,7 @@ export class McpServerTransport implements ServerTransport {
135
155
  });
136
156
 
137
157
  // CallToolRequestSchema -> Execute procedure
138
- this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
158
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
139
159
  const { name, arguments: args } = request.params;
140
160
 
141
161
  this.log(`CallToolRequest: ${name}`);
@@ -175,7 +195,8 @@ export class McpServerTransport implements ServerTransport {
175
195
 
176
196
  // Convert to MCP result
177
197
  if (response.status.type === "success" && response.stream) {
178
- return await this.streamResult(response.stream, extra);
198
+ const unbounded = response.metadata?.["stream"] === "unbounded";
199
+ return await this.streamResult(response.stream, extra, unbounded);
179
200
  }
180
201
  if (response.status.type === "success") {
181
202
  const structured = tool.outputSchema ? structuredContentOf(response.payload) : undefined;
@@ -186,14 +207,17 @@ export class McpServerTransport implements ServerTransport {
186
207
  ...(structured ? { structuredContent: structured } : {}),
187
208
  };
188
209
  } else {
210
+ // The code and the reason of the error travel on each face (roadmap R25)
211
+ const reason = typeof response.metadata?.["reason"] === "string" ? response.metadata["reason"] : undefined;
189
212
  return {
190
213
  content: [
191
214
  {
192
215
  type: "text" as const,
193
- text: response.status.message ?? "Unknown error",
216
+ text: `${response.status.message ?? "Unknown error"}${reason ? ` (reason: ${reason})` : ""}`,
194
217
  },
195
218
  ],
196
219
  isError: true,
220
+ _meta: { code: response.status.code, ...(reason ? { reason } : {}) },
197
221
  };
198
222
  }
199
223
  } catch (error) {
@@ -222,14 +246,25 @@ export class McpServerTransport implements ServerTransport {
222
246
  extra: {
223
247
  _meta?: { progressToken?: string | number | undefined } | undefined;
224
248
  sendNotification: (notification: ServerNotification) => Promise<void>;
225
- }
249
+ },
250
+ unbounded = false
226
251
  ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
227
252
  const progressToken = extra._meta?.progressToken;
228
253
  const blocks: Array<{ type: "text"; text: string }> = [];
229
254
  let count = 0;
255
+ // A stream that can run without an end stops at the first limit (roadmap R15: before, a
256
+ // docker.logs with follow ended only at the time limit of the tool call)
257
+ const limits = this.streamLimits;
258
+ const started = Date.now();
259
+ let stoppedBy: string | undefined;
230
260
  try {
231
261
  for await (const item of stream) {
232
262
  count++;
263
+ if (unbounded && (count > limits.items || Date.now() - started > limits.timeMs)) {
264
+ stoppedBy = count > limits.items ? `${limits.items} items` : `${Math.round(limits.timeMs / 1000)} seconds`;
265
+ count--;
266
+ break;
267
+ }
233
268
  const text = toText(item);
234
269
  blocks.push({ type: "text", text });
235
270
  if (blocks.length > MAX_STREAM_BLOCKS) blocks.shift();
@@ -249,6 +284,9 @@ export class McpServerTransport implements ServerTransport {
249
284
  if (count > MAX_STREAM_BLOCKS) {
250
285
  blocks.unshift({ type: "text", text: `(${count} items: the first ${count - MAX_STREAM_BLOCKS} are not shown)` });
251
286
  }
287
+ if (stoppedBy) {
288
+ blocks.push({ type: "text", text: `(The stream can run without an end, so the tool stopped it after ${stoppedBy}.)` });
289
+ }
252
290
  if (count === 0) blocks.push({ type: "text", text: "(no items)" });
253
291
  return { content: blocks };
254
292
  }
@@ -313,6 +351,7 @@ export class McpServerTransport implements ServerTransport {
313
351
  this.listChangedTimer = setTimeout(() => {
314
352
  this.listChangedTimer = undefined;
315
353
  if (!this.running) return;
354
+ for (const sessionServer of this.sessionServers) sessionServer.sendToolListChanged().catch(() => undefined);
316
355
  this.mcpServer.sendToolListChanged().catch((error: unknown) => {
317
356
  this.log(`list_changed failed: ${error instanceof Error ? error.message : String(error)}`);
318
357
  });
@@ -346,13 +385,22 @@ export class McpServerTransport implements ServerTransport {
346
385
 
347
386
  try {
348
387
  // Create appropriate transport
349
- let transport: SdkTransport;
388
+ let transport: SdkTransport | undefined;
350
389
  if (this.options.sdkTransport) {
351
390
  transport = this.options.sdkTransport;
352
391
  this.log("Using the given transport");
353
392
  } else if (this.options.transport === "stdio") {
354
393
  transport = createStdioTransport();
355
394
  this.log("Using stdio transport");
395
+ } else if (this.options.transport === "http") {
396
+ // Streamable HTTP (roadmap R26): an MCP server of its own for each session
397
+ this.http = createHttpTransport(this.options.httpOptions ?? {}, async (sessionTransport) => {
398
+ const sessionServer = this.createSdkServer();
399
+ this.sessionServers.add(sessionServer);
400
+ sessionServer.onclose = () => this.sessionServers.delete(sessionServer);
401
+ await sessionServer.connect(sessionTransport as unknown as SdkTransport);
402
+ });
403
+ transport = undefined;
356
404
  } else if (this.options.transport === "sse") {
357
405
  transport = createSseTransport(this.options.sseOptions);
358
406
  this.log(`Using SSE transport on ${this.options.sseOptions?.path ?? "/mcp/sse"}`);
@@ -361,9 +409,15 @@ export class McpServerTransport implements ServerTransport {
361
409
  }
362
410
 
363
411
  // Connect MCP server to transport
364
- await this.mcpServer.connect(transport);
412
+ if (transport) await this.mcpServer.connect(transport);
413
+ if (this.http) {
414
+ this.httpUrl = await this.http.listen();
415
+ this.log(`Using Streamable HTTP at ${this.httpUrl}`);
416
+ }
365
417
  } catch (error) {
366
418
  this.removeRegistryListeners();
419
+ await this.http?.close().catch(() => undefined);
420
+ this.http = undefined;
367
421
  throw error;
368
422
  }
369
423
 
@@ -387,6 +441,11 @@ export class McpServerTransport implements ServerTransport {
387
441
  }
388
442
 
389
443
  await this.mcpServer.close();
444
+ await this.http?.close();
445
+ for (const sessionServer of this.sessionServers) await sessionServer.close().catch(() => undefined);
446
+ this.sessionServers.clear();
447
+ this.http = undefined;
448
+ this.httpUrl = undefined;
390
449
  this.running = false;
391
450
  this.log("MCP server stopped");
392
451
  }
@@ -406,6 +465,7 @@ export class McpServerTransport implements ServerTransport {
406
465
  running: this.running,
407
466
  toolCount: this.tools.size,
408
467
  transport: this.options.transport,
468
+ ...(this.httpUrl ? { url: this.httpUrl } : {}),
409
469
  };
410
470
  }
411
471
 
package/src/types.ts CHANGED
@@ -7,10 +7,27 @@
7
7
  import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
8
8
  import type { ProcedureRegistry } from "@procedures/client";
9
9
  import type { McpServerInfo, McpToolFilter } from "@procedures/mcp";
10
+ import type { Duration } from "@procedures/client";
10
11
 
11
12
  /**
12
13
  * SSE transport configuration options.
13
14
  */
15
+ /** The options of the Streamable HTTP transport (roadmap R26). */
16
+ export interface HttpTransportOptions {
17
+ /** Port to listen on (default: 3003) */
18
+ port?: number | undefined;
19
+ /** Host to bind to (default: "127.0.0.1": only this computer can reach the tools) */
20
+ host?: string | undefined;
21
+ /** The path of the MCP endpoint (default: "/mcp") */
22
+ path?: string | undefined;
23
+ /** A secret that each request must send as `Authorization: Bearer <token>` */
24
+ token?: string | undefined;
25
+ /** Browser origins that can call the server, besides loopback origins */
26
+ allowedOrigins?: string[] | undefined;
27
+ /** An HTTP server to answer on, in place of a server of the transport (it does not listen then) */
28
+ server?: import("node:http").Server | undefined;
29
+ }
30
+
14
31
  export interface SseTransportOptions {
15
32
  /** Port to listen on (default: 3002) */
16
33
  port?: number;
@@ -26,8 +43,14 @@ export interface SseTransportOptions {
26
43
  * MCP Server Transport configuration options.
27
44
  */
28
45
  export interface McpServerTransportOptions {
29
- /** Transport type: stdio or sse (default: "stdio") */
30
- transport?: "stdio" | "sse";
46
+ /**
47
+ * Transport type (default: "stdio"). "http" is Streamable HTTP, the HTTP transport of the
48
+ * current MCP specification (roadmap R26). "sse" is not implemented.
49
+ */
50
+ transport?: "stdio" | "sse" | "http";
51
+
52
+ /** The options of the Streamable HTTP transport. */
53
+ httpOptions?: HttpTransportOptions | undefined;
31
54
 
32
55
  /** Procedure registry to use (defaults to PROCEDURE_REGISTRY) */
33
56
  registry?: ProcedureRegistry;
@@ -49,6 +72,13 @@ export interface McpServerTransportOptions {
49
72
 
50
73
  /** Enable debug logging */
51
74
  debug?: boolean;
75
+
76
+ /**
77
+ * The limits of a stream that can run without an end (`stream: { unbounded: true }` of its
78
+ * procedure): the tool stops it at the first one and gives the items that it has (roadmap R15).
79
+ * @default { items: 100, time: seconds(30) }
80
+ */
81
+ streamLimits?: { items?: number; time?: Duration } | undefined;
52
82
  }
53
83
 
54
84
  /**
@@ -60,5 +90,7 @@ export interface McpServerTransportState {
60
90
  /** Number of registered tools */
61
91
  toolCount: number;
62
92
  /** Transport type in use */
63
- transport: "stdio" | "sse";
93
+ transport: "stdio" | "sse" | "http";
94
+ /** The URL of the Streamable HTTP endpoint, when the transport is "http" */
95
+ url?: string;
64
96
  }