@procedures/mcp-server 0.8.0 → 0.9.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +211 -292
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/host.d.ts +168 -0
  5. package/dist/host.d.ts.map +1 -0
  6. package/dist/host.js +182 -0
  7. package/dist/host.js.map +1 -0
  8. package/dist/index.d.ts +6 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/procedures/mcp/serve.d.ts +18 -0
  13. package/dist/procedures/mcp/serve.d.ts.map +1 -1
  14. package/dist/procedures/mcp/serve.js +36 -31
  15. package/dist/procedures/mcp/serve.js.map +1 -1
  16. package/dist/transport/http.d.ts +4 -0
  17. package/dist/transport/http.d.ts.map +1 -1
  18. package/dist/transport/http.js +9 -3
  19. package/dist/transport/http.js.map +1 -1
  20. package/dist/transport/mcp-transport.d.ts +26 -0
  21. package/dist/transport/mcp-transport.d.ts.map +1 -1
  22. package/dist/transport/mcp-transport.js +184 -19
  23. package/dist/transport/mcp-transport.js.map +1 -1
  24. package/dist/transport/stdio.d.ts +4 -2
  25. package/dist/transport/stdio.d.ts.map +1 -1
  26. package/dist/transport/stdio.js +4 -3
  27. package/dist/transport/stdio.js.map +1 -1
  28. package/dist/types.d.ts +70 -2
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/untrusted.d.ts +56 -0
  31. package/dist/untrusted.d.ts.map +1 -0
  32. package/dist/untrusted.js +68 -0
  33. package/dist/untrusted.js.map +1 -0
  34. package/package.json +4 -4
  35. package/src/host.ts +346 -0
  36. package/src/index.ts +15 -0
  37. package/src/procedures/mcp/serve.ts +54 -36
  38. package/src/transport/http.ts +9 -3
  39. package/src/transport/mcp-transport.ts +206 -21
  40. package/src/transport/stdio.ts +5 -3
  41. package/src/types.ts +82 -2
  42. package/src/untrusted.ts +109 -0
@@ -9,6 +9,9 @@
9
9
  * (default: `@procedures/bundle-mcp`, resolved from the current folder). Without a list,
10
10
  * nothing is exposed. The nested calls follow the call policy (`strictCalls`), and each tool call
11
11
  * has a time limit.
12
+ *
13
+ * The server comes from `createMcpHost()` (CLI-10), so its options come from `hostPolicy()` of the
14
+ * core, as for the dev-tools server. Before, `mcp.serve` built them by hand.
12
15
  */
13
16
 
14
17
  import { z } from "zod";
@@ -17,14 +20,13 @@ import {
17
20
  ProcedureServer,
18
21
  PROCEDURE_REGISTRY,
19
22
  ProcedureRegistry,
20
- timeoutInterceptor,
21
23
  type AnyProcedure,
22
24
  type ProcedureContext,
23
25
  type ProcedurePath,
26
+ type ToolSurface,
24
27
  } from "@procedures/client";
25
- import { McpServerTransport } from "../../transport/mcp-transport.js";
26
- import type { McpServerTransportOptions } from "../../types.js";
27
28
  import { exposeRule, importBundles, mergeSurfaces } from "../../surface.js";
29
+ import { createMcpHost, type McpHost, type McpHostFaces } from "../../host.js";
28
30
  import { ms } from "@procedures/client";
29
31
 
30
32
  /** The default bundle of the tool surface. */
@@ -54,6 +56,10 @@ const McpServeInputSchema = z.object({
54
56
  strictCalls: z.boolean().default(true).describe("True (the default): a procedure of code without metadata.calls can call only tools."),
55
57
  /** The time limit of each tool call, in milliseconds (default: 600000). 0: no limit. */
56
58
  toolTimeoutMs: z.number().int().min(0).default(600_000).describe("The time limit of each tool call, in milliseconds (default: 600000). 0: no limit."),
59
+ /** True: the tools are only the procedures that change nothing, and each call has that capability. */
60
+ readOnly: z.boolean().default(false).describe("True: the tools are only the procedures that change nothing, and each call has that capability."),
61
+ /** The instructions of the server: the MCP client gives them to the model. */
62
+ instructions: z.string().optional().describe("The instructions of the server: the MCP client gives them to the model."),
57
63
  });
58
64
 
59
65
  type McpServeInput = z.infer<typeof McpServeInputSchema>;
@@ -83,12 +89,26 @@ export interface ServeOptions {
83
89
  bundles?: readonly string[] | undefined;
84
90
  strictCalls?: boolean | undefined;
85
91
  toolTimeoutMs?: number | undefined;
92
+ /** True: the tools are only the procedures that change nothing. */
93
+ readOnly?: boolean | undefined;
94
+ /** The name of the server for the MCP client. */
95
+ name?: string | undefined;
96
+ /** The version of the server for the MCP client. */
97
+ version?: string | undefined;
98
+ /** The instructions of the server. */
99
+ instructions?: string | undefined;
100
+ /** True: debug lines go to stderr. */
101
+ debug?: boolean | undefined;
102
+ /** The faces of the host. The default is stdio. */
103
+ faces?: McpHostFaces | undefined;
86
104
  }
87
105
 
88
106
  /** The server of `mcp.serve`, before its transport starts. */
89
107
  export interface ServeSetup {
90
108
  server: ProcedureServer;
91
109
  expose: (path: ProcedurePath) => boolean;
110
+ /** The MCP host of the server: `host.start()` starts its faces. */
111
+ host: McpHost;
92
112
  /** Why the server has no tools, when it has none. */
93
113
  warning?: string;
94
114
  }
@@ -105,16 +125,17 @@ export async function prepareServe(
105
125
  from: string = process.cwd(),
106
126
  registry: ProcedureRegistry = PROCEDURE_REGISTRY
107
127
  ): Promise<ServeSetup> {
108
- let expose: (path: ProcedurePath) => boolean = () => false;
128
+ // Without a surface, nothing is a tool
129
+ let surface: ToolSurface | ((path: ProcedurePath) => boolean) = () => false;
109
130
  let warning: string | undefined;
110
131
  if (input.namespaces) {
111
- expose = exposeRule({ namespaces: input.namespaces, exclude: input.exclude });
132
+ surface = { namespaces: input.namespaces, exclude: input.exclude };
112
133
  } else {
113
134
  const bundles = input.bundles ?? [DEFAULT_SERVE_BUNDLE];
114
135
  try {
115
- const surface = mergeSurfaces(await importBundles(bundles, from));
116
- if (surface) {
117
- expose = exposeRule({ namespaces: surface.namespaces, exclude: [...(surface.exclude ?? []), ...(input.exclude ?? [])] });
136
+ const merged = mergeSurfaces(await importBundles(bundles, from));
137
+ if (merged) {
138
+ surface = { namespaces: merged.namespaces, exclude: [...(merged.exclude ?? []), ...(input.exclude ?? [])] };
118
139
  } else {
119
140
  warning = `The bundles ${bundles.join(", ")} give no mcpNamespaces, so no procedure is a tool. Give namespaces.`;
120
141
  }
@@ -125,14 +146,23 @@ export async function prepareServe(
125
146
  }
126
147
  }
127
148
 
128
- const server = new ProcedureServer({
129
- autoRegister: input.autoRegister ?? true,
149
+ const host = createMcpHost({
130
150
  registry,
131
- expose,
132
- strictCalls: input.strictCalls ?? true,
133
- interceptors: [timeoutInterceptor({ limit: ms(input.toolTimeoutMs ?? 600_000) })],
151
+ surface,
152
+ readOnly: input.readOnly === true,
153
+ policy: { strictCalls: input.strictCalls ?? true, timeout: ms(input.toolTimeoutMs ?? 600_000) },
154
+ serverOptions: { autoRegister: input.autoRegister ?? true },
155
+ ...(input.name !== undefined ? { name: input.name } : {}),
156
+ ...(input.version !== undefined ? { version: input.version } : {}),
157
+ ...(input.instructions !== undefined ? { instructions: input.instructions } : {}),
158
+ ...(input.debug !== undefined ? { debug: input.debug } : {}),
159
+ faces: input.faces ?? { stdio: true },
134
160
  });
135
- return warning === undefined ? { server, expose } : { server, expose, warning };
161
+ // A surface always gives a rule, so the host has one
162
+ const expose = host.expose ?? (typeof surface === "function" ? surface : exposeRule(surface));
163
+ const setup: ServeSetup = { server: host.server, expose, host };
164
+ if (warning !== undefined) setup.warning = warning;
165
+ return setup;
136
166
  }
137
167
 
138
168
  export const mcpServeProcedure: AnyProcedure = defineProcedure({
@@ -150,33 +180,21 @@ export const mcpServeProcedure: AnyProcedure = defineProcedure({
150
180
  const serverVersion = input.version ?? "1.0.0";
151
181
  // The registry of the caller: a host with its own registry serves its own procedures
152
182
  const registry = ctx?.registry instanceof ProcedureRegistry ? ctx.registry : PROCEDURE_REGISTRY;
153
- const { server, expose, warning } = await prepareServe(input, process.cwd(), registry);
154
-
155
- // Build transport options
156
- const options: McpServerTransportOptions = {
157
- transport: input.transport,
158
- serverInfo: {
159
- name: serverName,
160
- version: serverVersion,
161
- },
162
- debug: input.debug,
163
- toolFilter: { include: expose },
164
- };
165
-
166
- // Create MCP transport
167
- const mcpTransport = new McpServerTransport(server, options);
168
-
169
- // Add transport and start
170
- server.addTransport(mcpTransport);
171
- await server.start();
172
-
173
- const state = mcpTransport.getState();
183
+ const { host, warning } = await prepareServe(
184
+ { ...input, name: serverName, version: serverVersion, faces: { stdio: true } },
185
+ process.cwd(),
186
+ registry,
187
+ );
188
+ // The face of stdio: it stops the host when the client closes the input
189
+ await host.start();
190
+
191
+ const toolCount = host.state().faces[0]?.toolCount ?? host.tools().length;
174
192
  if (warning) console.error(`[mcp.serve] ${warning}`);
175
193
 
176
194
  return {
177
195
  success: true,
178
196
  transport: input.transport,
179
- toolCount: state.toolCount,
197
+ toolCount,
180
198
  serverName,
181
199
  ...(warning ? { warning } : {}),
182
200
  };
@@ -8,13 +8,17 @@
8
8
  * a local server apply: the server listens on loopback by default, it refuses a web page of
9
9
  * another host or origin (DNS rebinding, cross-site requests), and with a token it refuses a
10
10
  * request without the token.
11
+ *
12
+ * Each tool call carries the credentials of its HTTP request (CLI-10), so the request hook of the
13
+ * server (`principalOf`) and its token check apply. A server that reads cookies trusts a request
14
+ * with a cookie only from a named origin (CSRF).
11
15
  */
12
16
 
13
17
  import { randomUUID } from "node:crypto";
14
18
  import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
15
19
  import type { AddressInfo } from "node:net";
16
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
- import { bearerOf, checkBrowserRequest, tokenMatches } from "@procedures/client";
21
+ import { bearerOf, checkBrowserRequest, hasAmbientCredentials, tokenMatches } from "@procedures/client";
18
22
  import type { HttpTransportOptions } from "../types.js";
19
23
 
20
24
  /** The HTTP endpoint of an MCP server, with a session for each client. */
@@ -71,8 +75,10 @@ export function createHttpTransport(
71
75
  if (owned) refuse(res, 404, "NOT_FOUND", `No MCP endpoint at ${url.pathname}`);
72
76
  return;
73
77
  }
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 } : {}) });
78
+ // A web page must not reach the tools: the host and the origin of the request. A server that
79
+ // reads cookies trusts a request with ambient credentials only from a named origin (CSRF)
80
+ const ambient = options.ambientCredentials === "always" || hasAmbientCredentials(req.headers);
81
+ const guard = checkBrowserRequest(req, { ...(options.allowedOrigins ? { allowedOrigins: options.allowedOrigins } : {}) }, false, ambient);
76
82
  if (!guard.ok) {
77
83
  refuse(res, guard.status, guard.code, guard.message);
78
84
  return;
@@ -12,19 +12,63 @@ import {
12
12
  type ServerNotification,
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
  import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
15
- import type { ServerTransport, Server, ProcedureRegistry } from "@procedures/client";
16
- import { PROCEDURE_REGISTRY, pathToMethod } from "@procedures/client";
15
+ import type {
16
+ ServerTransport,
17
+ Server,
18
+ ProcedureRegistry,
19
+ RequestHandler,
20
+ Credentials,
21
+ Elicit,
22
+ ElicitMode,
23
+ ElicitRequest,
24
+ ElicitResult,
25
+ ElicitOptions,
26
+ ReportProgress,
27
+ } from "@procedures/client";
28
+ import { PROCEDURE_REGISTRY, pathToMethod, createElicit, elicitWithPolicy, requestCredentials, untrustedTextsOf } from "@procedures/client";
17
29
  import {
18
30
  proceduresToMcpTools,
19
31
  toToolDefinition,
20
- decodePath,
21
32
  type McpTool,
22
33
  } from "@procedures/mcp";
23
- import type { McpServerTransportOptions, McpServerTransportState } from "../types.js";
34
+ import type { McpServerTransportOptions, McpServerTransportState, McpTextBlock, McpToolContent } from "../types.js";
24
35
  import { createStdioTransport } from "./stdio.js";
25
36
  import { createSseTransport } from "./sse.js";
26
37
  import { createHttpTransport, type McpHttpTransport } from "./http.js";
27
38
  import { optionalMs } from "@procedures/client";
39
+ import { createUntrustedEnvelopes } from "../untrusted.js";
40
+
41
+ /**
42
+ * The tools with the names of a `toolName` function. Without the function, the tools keep the
43
+ * default names.
44
+ */
45
+ export function namedTools(tools: readonly McpTool[], toolName?: ((path: readonly string[]) => string) | undefined): McpTool[] {
46
+ return toolName ? tools.map((tool) => ({ ...tool, name: toolName(tool.procedurePath) })) : [...tools];
47
+ }
48
+
49
+ /** The time that the face waits for the answer of the person, when the question gives no time. */
50
+ export const DEFAULT_ELICIT_TIMEOUT_MS = 300_000;
51
+
52
+ /** The parts of the request handler context of the SDK that a tool call uses. */
53
+ interface CallExtra {
54
+ signal: AbortSignal;
55
+ requestId: string | number;
56
+ _meta?: { progressToken?: string | number | undefined } | undefined;
57
+ sendNotification: (notification: ServerNotification) => Promise<void>;
58
+ /** The HTTP request of the message: present only for a network transport (Streamable HTTP) */
59
+ requestInfo?: { headers: Record<string, string | string[] | undefined>; url?: URL | undefined } | undefined;
60
+ }
61
+
62
+ /**
63
+ * The credentials of a tool call that came over the network (CLI-10): the bearer token, the
64
+ * cookies, the headers and the query of its HTTP request. A call over stdio or an in-memory
65
+ * transport has none: it comes from this process.
66
+ */
67
+ function callCredentials(extra: CallExtra): Credentials | undefined {
68
+ const info = extra.requestInfo;
69
+ if (!info) return undefined;
70
+ return requestCredentials({ headers: info.headers, url: info.url ? `${info.url.pathname}${info.url.search}` : undefined });
71
+ }
28
72
 
29
73
  /**
30
74
  * Generate a unique request ID.
@@ -32,8 +76,10 @@ import { optionalMs } from "@procedures/client";
32
76
  /** The most text blocks of a stream result: the last items of a long stream. */
33
77
  const MAX_STREAM_BLOCKS = 100;
34
78
 
35
- function toText(value: unknown): string {
36
- return typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
79
+ /** The text of a value: a string as it is, another value as JSON (pretty, or compact on one line). */
80
+ function toText(value: unknown, json: "pretty" | "compact" = "pretty"): string {
81
+ if (typeof value === "string") return value;
82
+ return (json === "compact" ? JSON.stringify(value) : JSON.stringify(value, null, 2)) ?? String(value);
37
83
  }
38
84
 
39
85
  /**
@@ -95,6 +141,8 @@ export class McpServerTransport implements ServerTransport {
95
141
  private listChangedTimer: ReturnType<typeof setTimeout> | undefined;
96
142
  /** The limits of a stream that can run without an end, the time in milliseconds (R15) */
97
143
  private readonly streamLimits: { items: number; timeMs: number };
144
+ /** This function removes the listener of the end of the stdio input */
145
+ private stdinEnd: (() => void) | undefined;
98
146
 
99
147
  constructor(server: Server, options: McpServerTransportOptions = {}) {
100
148
  this.server = server;
@@ -134,6 +182,8 @@ export class McpServerTransport implements ServerTransport {
134
182
  capabilities: {
135
183
  tools: { listChanged: true },
136
184
  },
185
+ // The instructions of the server: the client gives them to the model (CLI-10)
186
+ ...(this.options.instructions !== undefined ? { instructions: this.options.instructions } : {}),
137
187
  });
138
188
  this.setupHandlers(server);
139
189
  return server;
@@ -170,11 +220,13 @@ export class McpServerTransport implements ServerTransport {
170
220
  };
171
221
  }
172
222
 
173
- // Decode path from tool name. The method is the core mapping of every host (deep dive
174
- // architecture review 3.3)
175
- const path = decodePath(name);
223
+ // The path of the procedure of the tool. The method is the core mapping of every host
224
+ // (deep dive architecture review 3.3). A tool name of the toolName option does not decode,
225
+ // and a sanitized segment does not decode to the segment of the procedure.
226
+ const path = tool.procedurePath;
176
227
 
177
228
  // Build ServerRequest
229
+ const credentials = callCredentials(extra as CallExtra);
178
230
  const serverRequest = {
179
231
  id: generateRequestId(),
180
232
  method: pathToMethod(path),
@@ -185,26 +237,41 @@ export class McpServerTransport implements ServerTransport {
185
237
  },
186
238
  // A cancelled tool call (notifications/cancelled) aborts the procedure
187
239
  signal: extra.signal,
240
+ // The credentials of a call over the network: the request hook (principalOf) and the
241
+ // token check of the server apply to it (CLI-10)
242
+ ...(credentials ? { credentials } : {}),
243
+ };
244
+ // What this face offers the procedure (CLI-12): the questions to the person, and the
245
+ // progress for a caller that sent a progress token
246
+ const elicit = elicitWithPolicy(this.faceElicit(server, extra), this.options.elicitation);
247
+ const progress = this.callProgress(extra);
248
+ const callRequest = {
249
+ ...serverRequest,
250
+ ...(elicit ? { elicit } : {}),
251
+ ...(progress ? { progress: progress.report } : {}),
188
252
  };
189
253
 
190
254
  try {
191
255
  // Process through universal server
192
- const response = await this.server.handle(serverRequest);
256
+ const response = await this.server.handle(callRequest);
193
257
 
194
258
  this.log(`Tool ${name} completed: ${response.status.type}`);
195
259
 
196
260
  // Convert to MCP result
197
261
  if (response.status.type === "success" && response.stream) {
198
262
  const unbounded = response.metadata?.["stream"] === "unbounded";
199
- return await this.streamResult(response.stream, extra, unbounded);
263
+ // The items are the progress of a stream, unless the procedure reports its own
264
+ return await this.streamResult(response.stream, extra, unbounded, tool, () => progress?.used() === true);
200
265
  }
201
266
  if (response.status.type === "success") {
202
- const structured = tool.outputSchema ? structuredContentOf(response.payload) : undefined;
267
+ const rendered = this.render(response.payload, tool);
268
+ const hooked = this.options.content?.({ ...rendered, tool, value: response.payload });
269
+ const result = hooked ?? rendered;
203
270
  return {
204
- content: [{ type: "text" as const, text: toText(response.payload) }],
271
+ content: result.content,
205
272
  // MCP structured output: the result as an object, for a tool with an output schema.
206
273
  // The text block stays, for clients that do not read structured content.
207
- ...(structured ? { structuredContent: structured } : {}),
274
+ ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}),
208
275
  };
209
276
  } else {
210
277
  // The code and the reason of the error travel on each face (roadmap R25)
@@ -247,7 +314,9 @@ export class McpServerTransport implements ServerTransport {
247
314
  _meta?: { progressToken?: string | number | undefined } | undefined;
248
315
  sendNotification: (notification: ServerNotification) => Promise<void>;
249
316
  },
250
- unbounded = false
317
+ unbounded = false,
318
+ tool?: McpTool,
319
+ ownProgress: () => boolean = () => false
251
320
  ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
252
321
  const progressToken = extra._meta?.progressToken;
253
322
  const blocks: Array<{ type: "text"; text: string }> = [];
@@ -265,14 +334,17 @@ export class McpServerTransport implements ServerTransport {
265
334
  count--;
266
335
  break;
267
336
  }
268
- const text = toText(item);
337
+ // An item with untrusted fields gives its data, then its envelopes (CLI-10)
338
+ const rendered = tool?.untrusted ? this.render(item, tool).content : undefined;
339
+ const text = rendered ? rendered.map((block) => block.text).join("\n") : toText(item, this.options.json);
269
340
  blocks.push({ type: "text", text });
270
341
  if (blocks.length > MAX_STREAM_BLOCKS) blocks.shift();
271
- if (progressToken !== undefined) {
342
+ if (progressToken !== undefined && !ownProgress()) {
343
+ const message = rendered ? rendered[0]!.text : text;
272
344
  await extra
273
345
  .sendNotification({
274
346
  method: "notifications/progress",
275
- params: { progressToken, progress: count, message: text.slice(0, 1000) },
347
+ params: { progressToken, progress: count, message: message.slice(0, 1000) },
276
348
  })
277
349
  .catch(() => undefined);
278
350
  }
@@ -291,6 +363,96 @@ export class McpServerTransport implements ServerTransport {
291
363
  return { content: blocks };
292
364
  }
293
365
 
366
+ /**
367
+ * The content of a successful value. A tool with untrusted fields (`metadata.untrusted`) gives
368
+ * the data as JSON in the first block. The second block has the texts of those fields in
369
+ * envelopes. In the structured content, each untrusted field is the text of its envelopes.
370
+ */
371
+ private render(value: unknown, tool: McpTool): McpToolContent {
372
+ const fields = tool.untrusted ?? [];
373
+ if (fields.length === 0 || value === null || typeof value !== "object" || Array.isArray(value)) {
374
+ const structured = tool.outputSchema ? structuredContentOf(value) : undefined;
375
+ return { content: [{ type: "text", text: toText(value, this.options.json) }], ...(structured ? { structuredContent: structured } : {}) };
376
+ }
377
+ const data: Record<string, unknown> = { ...(value as Record<string, unknown>) };
378
+ const envelopes = createUntrustedEnvelopes(this.options.untrusted ?? {});
379
+ const wrap = this.options.untrusted?.wrap;
380
+ const texts: Record<string, string> = {};
381
+ const blocks: string[] = [];
382
+ for (const field of fields) {
383
+ if (!(field in data)) continue;
384
+ const items = untrustedTextsOf(data[field]);
385
+ delete data[field];
386
+ const text = wrap ? wrap(items, { tool: tool.name, field }) : envelopes.text(items);
387
+ texts[field] = text;
388
+ if (text !== "") blocks.push(text);
389
+ }
390
+ const content: McpTextBlock[] = [{ type: "text", text: toText(data, this.options.json) }];
391
+ if (blocks.length > 0) content.push({ type: "text", text: blocks.join("\n") });
392
+ const structured = tool.outputSchema ? structuredContentOf(data) : undefined;
393
+ return { content, ...(structured ? { structuredContent: { ...structured, ...texts } } : {}) };
394
+ }
395
+
396
+ /**
397
+ * The function of this face that asks the person, or undefined when the client cannot ask
398
+ * (it declares no elicitation). Each question goes with the tool call (`relatedRequestId`), so
399
+ * the Streamable HTTP transport sends it on the stream of the call.
400
+ */
401
+ private faceElicit(server: McpServer, extra: CallExtra): Elicit | undefined {
402
+ const capability = server.getClientCapabilities()?.elicitation as { form?: unknown; url?: unknown } | undefined;
403
+ if (!capability) return undefined;
404
+ const modes: ElicitMode[] = [];
405
+ if (capability.form) modes.push("form");
406
+ if (capability.url) modes.push("url");
407
+ if (modes.length === 0) return undefined;
408
+ return createElicit(modes, async (request: ElicitRequest, options: ElicitOptions): Promise<ElicitResult> => {
409
+ const timeout = optionalMs(options.timeout, "timeout") ?? DEFAULT_ELICIT_TIMEOUT_MS;
410
+ const signal = options.signal && options.signal !== extra.signal ? AbortSignal.any([extra.signal, options.signal]) : extra.signal;
411
+ const params =
412
+ request.mode === "url"
413
+ ? { mode: "url" as const, message: request.message, url: request.url, elicitationId: request.id ?? globalThis.crypto.randomUUID() }
414
+ : {
415
+ mode: "form" as const,
416
+ message: request.message,
417
+ requestedSchema: {
418
+ type: "object" as const,
419
+ properties: request.fields,
420
+ ...(request.required ? { required: [...request.required] } : {}),
421
+ },
422
+ };
423
+ // The SDK types do not agree with exactOptionalPropertyTypes and readonly arrays
424
+ const reply = await server.elicitInput(params as never, { relatedRequestId: extra.requestId, signal, timeout });
425
+ return { action: reply.action, ...(reply.content ? { content: reply.content } : {}), by: "person" };
426
+ });
427
+ }
428
+
429
+ /**
430
+ * The progress function of a call, when the caller sent a progress token. `used()` tells if
431
+ * the procedure reported its own progress.
432
+ */
433
+ private callProgress(extra: CallExtra): { report: ReportProgress; used: () => boolean } | undefined {
434
+ const progressToken = extra._meta?.progressToken;
435
+ if (progressToken === undefined) return undefined;
436
+ let used = false;
437
+ return {
438
+ used: () => used,
439
+ report: async (update) => {
440
+ used = true;
441
+ await extra
442
+ .sendNotification({
443
+ method: "notifications/progress",
444
+ params: {
445
+ progressToken,
446
+ progress: update.progress,
447
+ ...(update.total !== undefined ? { total: update.total } : {}),
448
+ ...(update.message !== undefined ? { message: update.message.slice(0, 1000) } : {}),
449
+ },
450
+ })
451
+ .catch(() => undefined);
452
+ },
453
+ };
454
+ }
455
+
294
456
  private setupRegistryListeners(): void {
295
457
  // Create listener functions that we can later remove
296
458
  this.registerListener = () => {
@@ -331,7 +493,11 @@ export class McpServerTransport implements ServerTransport {
331
493
  const before = this.toolSignature();
332
494
 
333
495
  this.tools.clear();
334
- for (const tool of mcpTools) {
496
+ for (const tool of namedTools(mcpTools, this.options.toolName)) {
497
+ if (this.tools.has(tool.name)) {
498
+ // MUST be stderr: stdout carries the protocol of stdio
499
+ console.error(`[mcp] Two procedures have the tool name "${tool.name}": the transport keeps ${tool.procedurePath.join(".")}`);
500
+ }
335
501
  this.tools.set(tool.name, tool);
336
502
  }
337
503
 
@@ -390,11 +556,26 @@ export class McpServerTransport implements ServerTransport {
390
556
  transport = this.options.sdkTransport;
391
557
  this.log("Using the given transport");
392
558
  } else if (this.options.transport === "stdio") {
393
- transport = createStdioTransport();
559
+ const stdio = this.options.stdio;
560
+ const stdin = stdio?.stdin ?? process.stdin;
561
+ transport = createStdioTransport(stdin, stdio?.stdout);
562
+ // The client closed the input: the host stops (CLI-10). The SDK does not watch the end.
563
+ if (stdio?.onEnd) {
564
+ const onEnd = stdio.onEnd;
565
+ const listener = (): void => {
566
+ this.log("The input of stdio ended");
567
+ onEnd();
568
+ };
569
+ stdin.once("end", listener);
570
+ this.stdinEnd = () => stdin.off("end", listener);
571
+ }
394
572
  this.log("Using stdio transport");
395
573
  } else if (this.options.transport === "http") {
396
574
  // Streamable HTTP (roadmap R26): an MCP server of its own for each session
397
- this.http = createHttpTransport(this.options.httpOptions ?? {}, async (sessionTransport) => {
575
+ // The face trusts cookies only as the server reads them (CSRF)
576
+ const ambientCredentials = (this.server as Partial<RequestHandler>).ambientCredentials;
577
+ const httpOptions = { ...this.options.httpOptions, ...(ambientCredentials ? { ambientCredentials } : {}) };
578
+ this.http = createHttpTransport(httpOptions, async (sessionTransport) => {
398
579
  const sessionServer = this.createSdkServer();
399
580
  this.sessionServers.add(sessionServer);
400
581
  sessionServer.onclose = () => this.sessionServers.delete(sessionServer);
@@ -416,6 +597,8 @@ export class McpServerTransport implements ServerTransport {
416
597
  }
417
598
  } catch (error) {
418
599
  this.removeRegistryListeners();
600
+ this.stdinEnd?.();
601
+ this.stdinEnd = undefined;
419
602
  await this.http?.close().catch(() => undefined);
420
603
  this.http = undefined;
421
604
  throw error;
@@ -435,6 +618,8 @@ export class McpServerTransport implements ServerTransport {
435
618
 
436
619
  // Cleanup registry listeners
437
620
  this.removeRegistryListeners();
621
+ this.stdinEnd?.();
622
+ this.stdinEnd = undefined;
438
623
  if (this.listChangedTimer) {
439
624
  clearTimeout(this.listChangedTimer);
440
625
  this.listChangedTimer = undefined;
@@ -4,16 +4,18 @@
4
4
  * Creates an MCP stdio transport for CLI usage.
5
5
  */
6
6
 
7
+ import type { Readable, Writable } from "node:stream";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
9
 
9
10
  /**
10
11
  * Create a stdio transport for MCP server.
11
12
  *
12
13
  * Stdio transport uses standard input/output for communication,
13
- * suitable for CLI tools and desktop app integrations.
14
+ * suitable for CLI tools and desktop app integrations. A test or an embedding gives other
15
+ * streams: the defaults are `process.stdin` and `process.stdout`.
14
16
  *
15
17
  * @returns MCP stdio transport instance
16
18
  */
17
- export function createStdioTransport(): StdioServerTransport {
18
- return new StdioServerTransport();
19
+ export function createStdioTransport(stdin?: Readable, stdout?: Writable): StdioServerTransport {
20
+ return new StdioServerTransport(stdin, stdout);
19
21
  }