@procedures/mcp-server 0.7.0 → 0.8.1
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 +36 -0
- package/README.md +211 -292
- package/dist/.tsbuildinfo +1 -1
- package/dist/host.d.ts +168 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +182 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/procedures/mcp/serve.d.ts +18 -0
- package/dist/procedures/mcp/serve.d.ts.map +1 -1
- package/dist/procedures/mcp/serve.js +36 -31
- package/dist/procedures/mcp/serve.js.map +1 -1
- package/dist/transport/http.d.ts +4 -0
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +9 -3
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/mcp-transport.d.ts +26 -0
- package/dist/transport/mcp-transport.d.ts.map +1 -1
- package/dist/transport/mcp-transport.js +184 -19
- package/dist/transport/mcp-transport.js.map +1 -1
- package/dist/transport/stdio.d.ts +4 -2
- package/dist/transport/stdio.d.ts.map +1 -1
- package/dist/transport/stdio.js +4 -3
- package/dist/transport/stdio.js.map +1 -1
- package/dist/types.d.ts +70 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/untrusted.d.ts +56 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +68 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +4 -4
- package/src/host.ts +346 -0
- package/src/index.ts +15 -0
- package/src/procedures/mcp/serve.ts +54 -36
- package/src/transport/http.ts +9 -3
- package/src/transport/mcp-transport.ts +206 -21
- package/src/transport/stdio.ts +5 -3
- package/src/types.ts +82 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
if (
|
|
117
|
-
|
|
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
|
|
129
|
-
autoRegister: input.autoRegister ?? true,
|
|
149
|
+
const host = createMcpHost({
|
|
130
150
|
registry,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
197
|
+
toolCount,
|
|
180
198
|
serverName,
|
|
181
199
|
...(warning ? { warning } : {}),
|
|
182
200
|
};
|
package/src/transport/http.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
174
|
-
// architecture review 3.3)
|
|
175
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/transport/stdio.ts
CHANGED
|
@@ -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
|
}
|