@jini-ai/mcp 0.3.3 → 0.4.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 +151 -0
- package/README.md +97 -22
- package/dist/agent-install/install.d.ts +20 -4
- package/dist/agent-install/install.js +11 -11
- package/dist/bin/serve.d.ts +25 -13
- package/dist/bin/serve.js +42 -21
- package/dist/client/client.d.ts +28 -8
- package/dist/client/client.js +25 -21
- package/dist/core/config.d.ts +57 -18
- package/dist/core/config.js +76 -48
- package/dist/core/index.d.ts +1 -2
- package/dist/core/index.js +1 -2
- package/dist/core/install-info.d.ts +4 -1
- package/dist/core/install-info.js +5 -5
- package/dist/core/tokens.d.ts +39 -13
- package/dist/core/tokens.js +54 -24
- package/dist/federation/adapter.http.d.ts +67 -0
- package/dist/federation/adapter.http.js +414 -0
- package/dist/federation/approvals/call-confirmation.d.ts +23 -0
- package/dist/federation/approvals/call-confirmation.js +124 -0
- package/dist/federation/approvals/index.d.ts +4 -0
- package/dist/federation/approvals/index.js +4 -0
- package/dist/federation/approvals/ports.d.ts +107 -0
- package/dist/federation/approvals/ports.js +2 -0
- package/dist/federation/approvals/revocation.d.ts +61 -0
- package/dist/federation/approvals/revocation.js +52 -0
- package/dist/federation/bootstrap.d.ts +100 -0
- package/dist/federation/bootstrap.js +182 -0
- package/dist/federation/config.d.ts +68 -0
- package/dist/federation/config.js +40 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.js +14 -0
- package/dist/federation/mcp-protocol.d.ts +115 -0
- package/dist/federation/mcp-protocol.js +151 -0
- package/dist/federation/messages.d.ts +54 -0
- package/dist/federation/messages.js +35 -0
- package/dist/federation/ports.d.ts +287 -0
- package/dist/federation/ports.js +5 -0
- package/dist/federation/presets.d.ts +66 -0
- package/dist/federation/presets.js +30 -0
- package/dist/federation/refusal-notice.d.ts +198 -0
- package/dist/federation/refusal-notice.js +193 -0
- package/dist/federation/registrations.d.ts +171 -0
- package/dist/federation/registrations.js +181 -0
- package/dist/federation/reload.d.ts +112 -0
- package/dist/federation/reload.js +110 -0
- package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
- package/dist/federation/stdio/adapter.stdio.js +526 -0
- package/dist/federation/stdio/default-connect.d.ts +29 -0
- package/dist/federation/stdio/default-connect.js +92 -0
- package/dist/federation/stdio/index.d.ts +4 -0
- package/dist/federation/stdio/index.js +4 -0
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
- package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
- package/dist/federation/testing/adapter.memory.d.ts +177 -0
- package/dist/federation/testing/adapter.memory.js +189 -0
- package/dist/federation/testing/index.d.ts +3 -0
- package/dist/federation/testing/index.js +3 -0
- package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
- package/dist/federation/testing/tool-approvals.memory.js +38 -0
- package/dist/federation/tool-approvals.d.ts +97 -0
- package/dist/federation/tool-approvals.js +42 -0
- package/dist/federation/trust.d.ts +471 -0
- package/dist/federation/trust.js +707 -0
- package/dist/index.d.ts +175 -2
- package/dist/index.js +172 -2
- package/dist/server/daemon-client.d.ts +16 -4
- package/dist/server/daemon-client.js +41 -19
- package/dist/server/ports.d.ts +9 -0
- package/dist/server/ports.js +2 -0
- package/dist/server/resource-protocol.d.ts +14 -4
- package/dist/server/resource-protocol.js +6 -6
- package/dist/server/resources/active-resource.js +3 -3
- package/dist/server/sdk-transport.d.ts +11 -0
- package/dist/server/sdk-transport.js +31 -0
- package/dist/server/tool-protocol.d.ts +31 -9
- package/dist/server/tool-protocol.js +20 -18
- package/dist/server/tool-server.d.ts +60 -43
- package/dist/server/tool-server.js +51 -27
- package/dist/server/tools/component-catalog-tools.js +7 -7
- package/dist/server/tools/delegated-tool.d.ts +7 -4
- package/dist/server/tools/delegated-tool.js +14 -15
- package/dist/server/tools/run-tools.js +15 -15
- package/dist/server/tools/tool-catalog-tools.js +7 -7
- package/dist/tools/ask-choice/index.d.ts +123 -0
- package/dist/tools/ask-choice/index.js +98 -0
- package/dist/tools/ask-choice/pending.d.ts +11 -0
- package/dist/tools/ask-choice/pending.js +34 -0
- package/dist/tools/ask-choice/question.d.ts +16 -0
- package/dist/tools/ask-choice/question.js +60 -0
- package/dist/tools/ask-choice/schema.d.ts +91 -0
- package/dist/tools/ask-choice/schema.js +27 -0
- package/dist/tools/ask-choice/types.d.ts +106 -0
- package/dist/tools/ask-choice/types.js +2 -0
- package/package.json +43 -5
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { buildInitializeParams, drainToolsList, McpProtocolError, parseCallToolResult, parseInitializeResult, } from "../mcp-protocol.js";
|
|
5
|
+
/**
|
|
6
|
+
* @file The real `McpSessionPort` adapter: a minimal MCP client speaking newline-delimited JSON-RPC
|
|
7
|
+
* 2.0 over a child process's stdio, which is the transport every locally-launched MCP server
|
|
8
|
+
* (Supabase's `mcp-server-supabase` bin included) exposes.
|
|
9
|
+
*
|
|
10
|
+
* Purpose:
|
|
11
|
+
* Turns "there is a program on disk that speaks MCP" into the three-method
|
|
12
|
+
* `listTools`/`callTool`/`close` port the federation layer depends on. It performs the handshake
|
|
13
|
+
* (`initialize` -> `notifications/initialized`), drains `tools/list` across its cursor pagination,
|
|
14
|
+
* correlates `tools/call` responses by JSON-RPC id, and enforces a timeout on every request.
|
|
15
|
+
*
|
|
16
|
+
* Why hand-rolled rather than `@modelcontextprotocol/sdk`:
|
|
17
|
+
* see `ports.ts`'s header. Short version — the SDK is not a dependency here, the wire format needed
|
|
18
|
+
* is four methods of JSON-RPC, and this codebase's convention is a declared port with a real
|
|
19
|
+
* adapter and a double rather than a vendored client.
|
|
20
|
+
*
|
|
21
|
+
* Why the {@link McpStdioChannel} seam:
|
|
22
|
+
* this adapter takes a channel, not a `ChildProcess`. All the logic worth testing — that
|
|
23
|
+
* `initialize` precedes everything, that `notifications/initialized` is sent and carries no id,
|
|
24
|
+
* that a paginated `tools/list` is drained to completion and bounded, that a late reply to a
|
|
25
|
+
* timed-out id is discarded rather than resolving the wrong call, that a channel closing mid-flight
|
|
26
|
+
* rejects every in-flight request instead of hanging forever — is exercisable against a scripted
|
|
27
|
+
* channel with no process at all. `spawnMcpStdioChannel` is the thin, deliberately logic-free
|
|
28
|
+
* production implementation of that seam.
|
|
29
|
+
*
|
|
30
|
+
* Hostile-server posture (this file's share of it; `trust.ts` owns the rest):
|
|
31
|
+
* a remote is assumed to be able to stall, flood, or lie. So: every request is timeout-bounded,
|
|
32
|
+
* pagination is loop-bounded, a single inbound line is length-bounded, unparseable lines are
|
|
33
|
+
* dropped rather than thrown on, and unsolicited server-to-client requests (including
|
|
34
|
+
* `sampling/createMessage` — a remote asking host app to run inference on its behalf) are answered with
|
|
35
|
+
* a JSON-RPC "method not found" rather than being honoured. Notifications, `tools/list_changed`
|
|
36
|
+
* among them, are ignored by design: `trust.ts` R5 freezes the admitted set at connect.
|
|
37
|
+
*
|
|
38
|
+
* Architectural role:
|
|
39
|
+
* Infrastructure adapter, co-located with its port under `assistant/mcp-federation` — mirroring
|
|
40
|
+
* `features/database/adapter.sqlite.ts`'s co-location with the `DatabaseIntrospectionPort` it
|
|
41
|
+
* implements, rather than `db/`'s older per-port-file convention, because this port is this
|
|
42
|
+
* adapter's own invention rather than a cross-domain-shared one.
|
|
43
|
+
*/
|
|
44
|
+
/** Bound on a single inbound line. A remote that streams one unterminated multi-gigabyte "message"
|
|
45
|
+
* would otherwise grow the reassembly buffer without limit. 4 MiB is far above any legitimate MCP
|
|
46
|
+
* message and far below anything that threatens the daemon. */
|
|
47
|
+
const MAX_INBOUND_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
/** How much of a child's stderr survives into its exit reason — enough for a startup error message,
|
|
49
|
+
* not enough to turn the admissions report into a log viewer. */
|
|
50
|
+
const STDERR_TAIL_CHARS = 600;
|
|
51
|
+
/** How long an exit waits for the child's stderr to drain before the close reason is emitted. */
|
|
52
|
+
const STDERR_DRAIN_MS = 250;
|
|
53
|
+
/** Secret values shorter than this are not redacted: replacing every "1" or "on" in a message would
|
|
54
|
+
* destroy it, and a value that short is not a credential. */
|
|
55
|
+
const MIN_REDACTED_VALUE_LENGTH = 4;
|
|
56
|
+
/** Commands that fetch a package by name and run it — `npx -y some-mcp@latest`, the shape most
|
|
57
|
+
* published MCP servers document. Matched on the basename, without a Windows `.cmd`/`.exe`. */
|
|
58
|
+
const PACKAGE_RUNNER_COMMANDS = new Set(["npx"]);
|
|
59
|
+
/**
|
|
60
|
+
* The working directory one stdio child is spawned in.
|
|
61
|
+
*
|
|
62
|
+
* An explicit `cwd` always wins. Otherwise a package runner gets a neutral directory instead of the
|
|
63
|
+
* daemon's own cwd, because npm reads the cwd's project tree BEFORE it runs anything: in this dev
|
|
64
|
+
* checkout that tree links `@jini-ai/*` to a pnpm monorepo whose `workspace:*` ranges npm cannot
|
|
65
|
+
* parse, so `npx -y namecom-mcp@latest` died with `EUNSUPPORTEDPROTOCOL` on every daemon boot
|
|
66
|
+
* (2026-09-29) while the same command worked from any other directory. A package fetched by name
|
|
67
|
+
* never needs host app's project directory; a host's own `package.json` is the wrong input for it.
|
|
68
|
+
*
|
|
69
|
+
* Anything else keeps inheriting the daemon's cwd exactly as before — a relative `command` (a
|
|
70
|
+
* package runner given as `./node_modules/.bin/npx` included) or a server that reads files relative
|
|
71
|
+
* to where it was started depends on it.
|
|
72
|
+
*
|
|
73
|
+
* @complexity O(1).
|
|
74
|
+
*/
|
|
75
|
+
export function resolveStdioChildCwd({ resolved }, { neutralDir = os.tmpdir() } = {}) {
|
|
76
|
+
if (resolved.cwd !== undefined)
|
|
77
|
+
return resolved.cwd;
|
|
78
|
+
// The command as configured, not as run: desktop's resolver rewrites `npx` to Electron running
|
|
79
|
+
// `npx-cli.js`, whose basename says nothing about being a package runner.
|
|
80
|
+
const command = resolved.requestedCommand ?? resolved.command;
|
|
81
|
+
// A relative path (`./node_modules/.bin/npx`) is resolved against the child's cwd at exec time,
|
|
82
|
+
// so moving the cwd would make the executable itself vanish.
|
|
83
|
+
const pathModule = /[\\]/.test(command) || /^[a-z]:/i.test(command) ? path.win32 : path.posix;
|
|
84
|
+
if (!pathModule.isAbsolute(command) && /[\\/]/.test(command))
|
|
85
|
+
return undefined;
|
|
86
|
+
const name = pathModule.basename(command).replace(/\.(cmd|exe)$/i, "").toLowerCase();
|
|
87
|
+
return PACKAGE_RUNNER_COMMANDS.has(name) ? neutralDir : undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Appends one stderr chunk and keeps only the last {@link STDERR_TAIL_CHARS} characters.
|
|
91
|
+
* @complexity O(n) in the chunk length.
|
|
92
|
+
*/
|
|
93
|
+
export function keepStderrTail({ tail, chunk }) {
|
|
94
|
+
return (tail + chunk).slice(-STDERR_TAIL_CHARS);
|
|
95
|
+
}
|
|
96
|
+
/** Intentionally retains the stderr channel's lowercase marker and minimum secret length;
|
|
97
|
+
* the core PII scrubber would change both output and which values get masked.
|
|
98
|
+
* Replaces every redactable `secretValues` entry in `text` with `[redacted]`. @complexity O(s · v). */
|
|
99
|
+
function redactSecrets(text, secretValues) {
|
|
100
|
+
let said = text;
|
|
101
|
+
for (const value of secretValues) {
|
|
102
|
+
if (value.length >= MIN_REDACTED_VALUE_LENGTH)
|
|
103
|
+
said = said.split(value).join("[redacted]");
|
|
104
|
+
}
|
|
105
|
+
return said;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A child's stderr tail that is redacted BEFORE it is truncated.
|
|
109
|
+
*
|
|
110
|
+
* Truncating first cut a token printed just before ~600 characters of diagnostics in half, leaving a
|
|
111
|
+
* fragment no exact-value replacement can match (codex review 2026-09-29). So each chunk is redacted
|
|
112
|
+
* as it arrives, and the last `longest secret - 1` characters are held back from the kept tail until
|
|
113
|
+
* the next chunk — they may be the start of a secret split across chunks. Only redacted text is
|
|
114
|
+
* ever truncated, so the kept tail can begin mid-`[redacted]` but never mid-secret.
|
|
115
|
+
*
|
|
116
|
+
* @param secretValues - The child's own `env` values, as spawned.
|
|
117
|
+
* @complexity O(c · v) per chunk of length c.
|
|
118
|
+
*/
|
|
119
|
+
export function createStderrTail({ secretValues }) {
|
|
120
|
+
const redactable = secretValues.filter((value) => value.length >= MIN_REDACTED_VALUE_LENGTH);
|
|
121
|
+
const holdBack = Math.max(0, ...redactable.map((value) => value.length - 1));
|
|
122
|
+
let kept = "";
|
|
123
|
+
let pending = "";
|
|
124
|
+
return {
|
|
125
|
+
append({ chunk }) {
|
|
126
|
+
const redacted = redactSecrets(pending + chunk, redactable);
|
|
127
|
+
const cut = Math.max(0, redacted.length - holdBack);
|
|
128
|
+
kept = keepStderrTail({ tail: kept, chunk: redacted.slice(0, cut) });
|
|
129
|
+
pending = redacted.slice(cut);
|
|
130
|
+
},
|
|
131
|
+
text: () => keepStderrTail({ tail: kept, chunk: redactSecrets(pending, redactable) }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The close reason for a child that exited: code and signal, plus what it last printed to stderr.
|
|
136
|
+
*
|
|
137
|
+
* Every value of the connection's own `env` is replaced with `[redacted]` first. That env holds the
|
|
138
|
+
* row's unsealed credentials, and a server that echoes its config on failure would otherwise carry a
|
|
139
|
+
* token into `connectFailures` — which the admissions route serves to the admin UI and whose contract
|
|
140
|
+
* (`bootstrap.ts`) is "never a raw env value or secret".
|
|
141
|
+
*
|
|
142
|
+
* @param secretValues - The child's own `env` values, as spawned.
|
|
143
|
+
* @complexity O(s · v) in stderr length and env value count.
|
|
144
|
+
*/
|
|
145
|
+
export function describeChildExit({ code, signal, stderrTail, secretValues }) {
|
|
146
|
+
const base = `child process exited (code=${String(code)}, signal=${String(signal)})`;
|
|
147
|
+
const said = redactSecrets(stderrTail, secretValues).replace(/\s+/g, " ").trim();
|
|
148
|
+
return said ? `${base}: ${said}` : base;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A connected MCP client session over one {@link McpStdioChannel}.
|
|
152
|
+
*
|
|
153
|
+
* Constructed by {@link connectMcpStdioSession} rather than directly, so a session that exists is
|
|
154
|
+
* always a session whose handshake has completed — there is no half-initialized state a caller
|
|
155
|
+
* could accidentally use.
|
|
156
|
+
*/
|
|
157
|
+
class McpStdioSession {
|
|
158
|
+
channel;
|
|
159
|
+
requestTimeoutMs;
|
|
160
|
+
pending = new Map();
|
|
161
|
+
clientInfo;
|
|
162
|
+
messages;
|
|
163
|
+
nextId = 1;
|
|
164
|
+
closedReason = null;
|
|
165
|
+
/** The protocol version the SERVER said it would speak, for diagnostics. */
|
|
166
|
+
serverProtocolVersion = "";
|
|
167
|
+
/** The server's self-reported identity, for diagnostics and audit. Untrusted, like everything
|
|
168
|
+
* else it sends — recorded, never acted on. */
|
|
169
|
+
serverInfo = {};
|
|
170
|
+
constructor(deps) {
|
|
171
|
+
this.channel = deps.channel;
|
|
172
|
+
this.clientInfo = deps.clientInfo;
|
|
173
|
+
this.messages = deps.messages;
|
|
174
|
+
this.requestTimeoutMs = deps.requestTimeoutMs;
|
|
175
|
+
this.channel.onMessage({ listener: ({ message: line }) => this.handleMessage(line) });
|
|
176
|
+
this.channel.onClose({ listener: ({ reason }) => this.handleClose(reason) });
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* @complexity O(p) in the number of `tools/list` pages, bounded by {@link MAX_LIST_TOOLS_PAGES}.
|
|
180
|
+
*/
|
|
181
|
+
async listTools() {
|
|
182
|
+
return drainToolsList({ requestPage: ({ params }) => this.request("tools/list", params) });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* @complexity O(1) beyond the remote's own round-trip.
|
|
186
|
+
*/
|
|
187
|
+
async callTool(request, options = {}) {
|
|
188
|
+
return parseCallToolResult({ result: await this.request("tools/call", { name: request.name, arguments: request.arguments }, options.signal) });
|
|
189
|
+
}
|
|
190
|
+
async close(_required) {
|
|
191
|
+
this.handleClose(this.messages.closedByHost);
|
|
192
|
+
this.channel.close({});
|
|
193
|
+
}
|
|
194
|
+
/** Sends the handshake. Separate from the constructor because it is the one part that can fail,
|
|
195
|
+
* and a session object that exists must be one that completed it. */
|
|
196
|
+
async initialize() {
|
|
197
|
+
const identity = parseInitializeResult({ result: await this.request("initialize", buildInitializeParams({ clientInfo: this.clientInfo })) });
|
|
198
|
+
this.serverProtocolVersion = identity.protocolVersion;
|
|
199
|
+
this.serverInfo = identity.info;
|
|
200
|
+
// MCP requires this notification after a successful initialize, and requires that a
|
|
201
|
+
// notification carry no `id` — a server is entitled to reject the session otherwise.
|
|
202
|
+
this.channel.send({ message: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) });
|
|
203
|
+
}
|
|
204
|
+
request(method, params, signal) {
|
|
205
|
+
if (this.closedReason !== null) {
|
|
206
|
+
return Promise.reject(new McpProtocolError({ message: `mcp-federation: session is closed (${this.closedReason})` }));
|
|
207
|
+
}
|
|
208
|
+
const id = this.nextId++;
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
// Deliberately NOT `unref()`ed. An unref'd timer lets the event loop drain while a request is
|
|
211
|
+
// still in flight, which means the request's promise is abandoned unsettled rather than
|
|
212
|
+
// rejected — a caller awaiting it simply never continues. It bought nothing anyway: shutdown
|
|
213
|
+
// is `close()`, and `handleClose` already rejects every pending request and clears every
|
|
214
|
+
// timer, so a session being torn down never waits on this timer regardless.
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
// Settle AND forget. Deleting the entry is what makes a late reply to this id a no-op
|
|
217
|
+
// rather than a resolution of whatever call happens to be waiting later.
|
|
218
|
+
this.pending.delete(id);
|
|
219
|
+
reject(new McpProtocolError({ message: `mcp-federation: '${method}' timed out after ${this.requestTimeoutMs}ms` }));
|
|
220
|
+
}, this.requestTimeoutMs);
|
|
221
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
222
|
+
if (signal) {
|
|
223
|
+
if (signal.aborted) {
|
|
224
|
+
this.settleRejection(id, new McpProtocolError({ message: `mcp-federation: '${method}' was aborted before it was sent` }));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
signal.addEventListener("abort", () => this.settleRejection(id, new McpProtocolError({ message: `mcp-federation: '${method}' was aborted` })), {
|
|
228
|
+
once: true,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
this.channel.send({ message: JSON.stringify({ jsonrpc: "2.0", id, method, params }) });
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
this.settleRejection(id, error instanceof Error ? error : new Error(String(error)));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
settleRejection(id, error) {
|
|
240
|
+
const entry = this.pending.get(id);
|
|
241
|
+
if (!entry)
|
|
242
|
+
return;
|
|
243
|
+
this.pending.delete(id);
|
|
244
|
+
clearTimeout(entry.timer);
|
|
245
|
+
entry.reject(error);
|
|
246
|
+
}
|
|
247
|
+
handleMessage(line) {
|
|
248
|
+
if (line.length > MAX_INBOUND_MESSAGE_BYTES)
|
|
249
|
+
return;
|
|
250
|
+
const message = parseJsonRpcLine(line);
|
|
251
|
+
// Servers routinely emit non-JSON banner noise on the same stream. Dropping it is correct;
|
|
252
|
+
// throwing would let a stray log line kill an otherwise healthy session.
|
|
253
|
+
if (!message)
|
|
254
|
+
return;
|
|
255
|
+
// An inbound message carrying a `method` is the server calling US. host app advertises no
|
|
256
|
+
// capabilities in `initialize`, so there is nothing it may legitimately ask for — notably
|
|
257
|
+
// `sampling/createMessage`, which would have host app run model inference on the remote's behalf.
|
|
258
|
+
// Requests get a well-formed refusal; notifications (`tools/list_changed` included, see
|
|
259
|
+
// `trust.ts` R5) are ignored.
|
|
260
|
+
if (typeof message.method === "string") {
|
|
261
|
+
this.refuseUnsupportedServerRequest(message);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.resolvePendingRequest(message);
|
|
265
|
+
}
|
|
266
|
+
/** The "server called a method on us" half of {@link handleMessage} — split out purely to keep
|
|
267
|
+
* that function's complexity under the shop ceiling. A notification (no `id`) is silently
|
|
268
|
+
* ignored; a request gets a well-formed JSON-RPC refusal. */
|
|
269
|
+
refuseUnsupportedServerRequest(message) {
|
|
270
|
+
if (message.id === undefined || message.id === null)
|
|
271
|
+
return;
|
|
272
|
+
this.channel.send({ message: JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: `method '${message.method}' is not supported by this client` } }) });
|
|
273
|
+
}
|
|
274
|
+
/** The "this is a reply to one of our own requests" half of {@link handleMessage} — split out
|
|
275
|
+
* purely to keep that function's complexity under the shop ceiling. */
|
|
276
|
+
resolvePendingRequest(message) {
|
|
277
|
+
if (typeof message.id !== "number")
|
|
278
|
+
return;
|
|
279
|
+
const entry = this.pending.get(message.id);
|
|
280
|
+
if (!entry)
|
|
281
|
+
return;
|
|
282
|
+
this.pending.delete(message.id);
|
|
283
|
+
clearTimeout(entry.timer);
|
|
284
|
+
if (message.error) {
|
|
285
|
+
entry.reject(new McpProtocolError({ message: `mcp-federation: remote returned JSON-RPC error ${message.error.code ?? "?"}: ${message.error.message ?? "(no message)"}` }));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
entry.resolve(message.result);
|
|
289
|
+
}
|
|
290
|
+
handleClose(reason) {
|
|
291
|
+
if (this.closedReason !== null)
|
|
292
|
+
return;
|
|
293
|
+
this.closedReason = reason;
|
|
294
|
+
// Every in-flight request must be settled, or a caller awaits forever on a dead pipe.
|
|
295
|
+
for (const [id, entry] of this.pending) {
|
|
296
|
+
this.pending.delete(id);
|
|
297
|
+
clearTimeout(entry.timer);
|
|
298
|
+
entry.reject(new McpProtocolError({ message: `mcp-federation: session closed before the request completed (${reason})` }));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Performs the MCP handshake over `channel` and returns the connected session.
|
|
304
|
+
*
|
|
305
|
+
* @param deps.channel - The transport seam. `spawnMcpStdioChannel` in production, a scripted
|
|
306
|
+
* double in tests.
|
|
307
|
+
* @param deps.requestTimeoutMs - Per-request ceiling, applied to the handshake too.
|
|
308
|
+
* @throws {Error} If `initialize` fails, times out, or the channel dies during it.
|
|
309
|
+
* @complexity O(1) beyond the remote's round-trip.
|
|
310
|
+
*/
|
|
311
|
+
export async function connectMcpStdioSession(deps) {
|
|
312
|
+
const session = new McpStdioSession(deps);
|
|
313
|
+
try {
|
|
314
|
+
await session.initialize();
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
// A failed handshake must not leave a child process parented to the daemon forever.
|
|
318
|
+
await session.close({}).catch(() => undefined);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
return session;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The production {@link McpStdioChannel}: a child process, its stdin, and newline-framed stdout.
|
|
325
|
+
*
|
|
326
|
+
* Deliberately logic-free — reassembly and nothing else — because everything above it is already
|
|
327
|
+
* under test through the channel seam, and code that only runs when a real process exists is code
|
|
328
|
+
* that only fails in production.
|
|
329
|
+
*
|
|
330
|
+
* `env` REPLACES rather than extends the parent environment, which is the security-relevant choice:
|
|
331
|
+
* the daemon's own `process.env` holds `host daemon credential` (`daemon-auth.ts`), and handing
|
|
332
|
+
* that to a third-party vendor's child process would give it host app's own daemon credential — the
|
|
333
|
+
* exact inversion of the `mcp-injection.ts` grant, and a far worse one. A federated server receives
|
|
334
|
+
* what `config.ts` explicitly puts in `env`, plus what {@link buildMcpChildEnv} inherits and nothing else.
|
|
335
|
+
*
|
|
336
|
+
* Takes a {@link ResolvedStdioLaunch} rather than an {@link McpStdioLaunchSpec} (S2 of the
|
|
337
|
+
* desktop-npx plan): the launch has already passed through the injected `McpStdioLaunchResolver`
|
|
338
|
+
* by the time it gets here, so `resolved.command`/`resolved.args` are already whatever the resolver
|
|
339
|
+
* decided to run, and `resolved.launchEnv` is threaded into {@link buildMcpChildEnv} alongside the
|
|
340
|
+
* connection's own `env`. On every non-desktop deployment `resolved` is the identity resolver's
|
|
341
|
+
* output — `command`/`args`/`env` unchanged, `launchEnv: {}` — so this stays byte-identical to
|
|
342
|
+
* before the resolver existed.
|
|
343
|
+
*
|
|
344
|
+
* @complexity O(n) in bytes received.
|
|
345
|
+
*/
|
|
346
|
+
export function spawnMcpStdioChannel(resolved) {
|
|
347
|
+
const child = spawn(resolved.command, [...resolved.args], {
|
|
348
|
+
cwd: resolveStdioChildCwd({ resolved: resolved }),
|
|
349
|
+
env: buildMcpChildEnv({ command: resolved.command, specEnv: resolved.env }, { launchEnv: resolved.launchEnv }),
|
|
350
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
351
|
+
});
|
|
352
|
+
const messageListeners = [];
|
|
353
|
+
const closeListeners = [];
|
|
354
|
+
let buffer = "";
|
|
355
|
+
let closed = false;
|
|
356
|
+
// Set the instant the child exits, before the close reason is final (see the `exit` handler), so
|
|
357
|
+
// `send` never writes into a dead pipe — an EPIPE on `stdin` has no listener and would crash the
|
|
358
|
+
// daemon.
|
|
359
|
+
let exited = false;
|
|
360
|
+
const secretValues = Object.values(resolved.env);
|
|
361
|
+
const stderrTail = createStderrTail({ secretValues: secretValues });
|
|
362
|
+
const emitClose = (reason) => {
|
|
363
|
+
if (closed)
|
|
364
|
+
return;
|
|
365
|
+
closed = true;
|
|
366
|
+
for (const listener of closeListeners)
|
|
367
|
+
listener({ reason: reason });
|
|
368
|
+
};
|
|
369
|
+
child.stdout.setEncoding("utf8");
|
|
370
|
+
child.stdout.on("data", (chunk) => {
|
|
371
|
+
buffer += chunk;
|
|
372
|
+
if (buffer.length > MAX_INBOUND_MESSAGE_BYTES) {
|
|
373
|
+
// A remote streaming an unterminated line past the cap is not recoverable — the framing is
|
|
374
|
+
// already lost. Drop the connection rather than growing the buffer.
|
|
375
|
+
buffer = "";
|
|
376
|
+
emitClose("inbound message exceeded the size cap");
|
|
377
|
+
child.kill();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
let newline = buffer.indexOf("\n");
|
|
381
|
+
while (newline !== -1) {
|
|
382
|
+
const line = buffer.slice(0, newline).trim();
|
|
383
|
+
buffer = buffer.slice(newline + 1);
|
|
384
|
+
if (line.length > 0)
|
|
385
|
+
for (const listener of messageListeners)
|
|
386
|
+
listener({ message: line });
|
|
387
|
+
newline = buffer.indexOf("\n");
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
// stderr is a diagnostic channel for MCP servers, never a protocol one. Consumed so the pipe
|
|
391
|
+
// cannot fill and deadlock the child, and never parsed — but its tail is kept, because when a
|
|
392
|
+
// server dies at startup that is the ONLY place it says why (2026-09-29: `npx -y namecom-mcp`
|
|
393
|
+
// failing with npm's EUNSUPPORTEDPROTOCOL reached the operator as a bare "exited (code=1)").
|
|
394
|
+
child.stderr.setEncoding("utf8");
|
|
395
|
+
child.stderr.on("data", (chunk) => {
|
|
396
|
+
stderrTail.append({ chunk: chunk });
|
|
397
|
+
});
|
|
398
|
+
child.on("error", (error) => emitClose(`child process error: ${error.message}`));
|
|
399
|
+
child.on("exit", (code, signal) => {
|
|
400
|
+
exited = true;
|
|
401
|
+
// `exit` can fire before the child's last stderr bytes are read; `end` means they have been.
|
|
402
|
+
// Bounded, because a grandchild still holding the pipe (npx → node) could postpone `end` forever.
|
|
403
|
+
const finish = () => emitClose(describeChildExit({ code: code, signal: signal, stderrTail: stderrTail.text(), secretValues: secretValues }));
|
|
404
|
+
if (child.stderr.readableEnded) {
|
|
405
|
+
finish();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const drainTimer = setTimeout(finish, STDERR_DRAIN_MS);
|
|
409
|
+
child.stderr.once("end", () => {
|
|
410
|
+
clearTimeout(drainTimer);
|
|
411
|
+
finish();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
send({ message }) {
|
|
416
|
+
if (closed || exited)
|
|
417
|
+
throw new Error("mcp-federation: cannot write to a closed stdio channel");
|
|
418
|
+
child.stdin.write(`${message}\n`);
|
|
419
|
+
},
|
|
420
|
+
onMessage({ listener }) {
|
|
421
|
+
messageListeners.push(listener);
|
|
422
|
+
},
|
|
423
|
+
onClose({ listener }) {
|
|
424
|
+
closeListeners.push(listener);
|
|
425
|
+
},
|
|
426
|
+
close(_required) {
|
|
427
|
+
emitClose(resolved.messages.closedByHost);
|
|
428
|
+
child.stdin.end();
|
|
429
|
+
child.kill();
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The parent environment variables a federated server inherits on EVERY platform (win32 adds
|
|
435
|
+
* {@link WIN32_INHERITED_ENV_VARS}) — an explicit allowlist, so
|
|
436
|
+
* adding to it is a visible decision rather than a default.
|
|
437
|
+
*
|
|
438
|
+
* Not merely `PATH`: the Supabase preset launches via `npx`, which resolves its package cache under
|
|
439
|
+
* `HOME` (and writes to `TMPDIR`). A child spawned with `PATH` alone would fail to start at all, or
|
|
440
|
+
* silently re-download on every boot — the kind of bug that only appears in production, since no
|
|
441
|
+
* test spawns a real process.
|
|
442
|
+
*
|
|
443
|
+
* Deliberately absent: everything else. `host daemon credential` most of all (see this function's
|
|
444
|
+
* caller), but also proxy and CA variables — those can carry embedded credentials, and an operator
|
|
445
|
+
* who genuinely needs them should put them in a connection's own `env` where the grant is written
|
|
446
|
+
* down.
|
|
447
|
+
*/
|
|
448
|
+
const INHERITED_ENV_VARS = ["PATH", "HOME", "TMPDIR"];
|
|
449
|
+
/**
|
|
450
|
+
* win32 only: the further parent variables a Windows child needs to start at all. None carries a
|
|
451
|
+
* secret — each names a system or per-user directory, or the executable-extension list.
|
|
452
|
+
*
|
|
453
|
+
* Without them a Windows child can fail in ways that name none of these: `SystemRoot`/`windir` are
|
|
454
|
+
* read by Winsock and the C runtime during process start (a Node or Python child cannot open a
|
|
455
|
+
* socket without `SystemRoot`), `ComSpec` and `PATHEXT` are how a child resolves and runs other
|
|
456
|
+
* programs, `USERPROFILE`/`APPDATA`/`LOCALAPPDATA` are Windows' `HOME` (npm's prefix and cache live
|
|
457
|
+
* under them), and `TEMP`/`TMP` are Windows' `TMPDIR`.
|
|
458
|
+
*/
|
|
459
|
+
const WIN32_INHERITED_ENV_VARS = [
|
|
460
|
+
"SystemRoot",
|
|
461
|
+
"windir",
|
|
462
|
+
"ComSpec",
|
|
463
|
+
"PATHEXT",
|
|
464
|
+
"USERPROFILE",
|
|
465
|
+
"APPDATA",
|
|
466
|
+
"LOCALAPPDATA",
|
|
467
|
+
"TEMP",
|
|
468
|
+
"TMP",
|
|
469
|
+
];
|
|
470
|
+
/** Electron's run-as-Node switch. Env-only: Electron has no command-line flag for it. */
|
|
471
|
+
const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
|
472
|
+
/**
|
|
473
|
+
* The complete environment one federated MCP child is spawned with.
|
|
474
|
+
*
|
|
475
|
+
* On every platform: {@link INHERITED_ENV_VARS} from the parent, then `launchEnv`, then the
|
|
476
|
+
* connection's own `env` over the top of both. On darwin and linux, with `launchEnv` empty (every
|
|
477
|
+
* non-desktop deployment), that is ALL — byte-identical to what this adapter has always sent.
|
|
478
|
+
*
|
|
479
|
+
* On win32, two further additions:
|
|
480
|
+
*
|
|
481
|
+
* 1. {@link WIN32_INHERITED_ENV_VARS}, which Windows programs need to start.
|
|
482
|
+
* 2. `ELECTRON_RUN_AS_NODE`, but ONLY when the child's `command` is this process's own executable
|
|
483
|
+
* and this process itself runs with it set. host app Desktop registers its own Electron binary as the
|
|
484
|
+
* command of its `host-desktop` connection on win32, where no launcher script can be exec'd, and
|
|
485
|
+
* without this variable that binary opens a second GUI app instead of running the bridge script.
|
|
486
|
+
* Carrying it here rather than in the connection's `env` matters: a row's `env` is sealed with the
|
|
487
|
+
* site's root key, so a flag in it fails the whole save with `SECRET_STORE_UNCONFIGURED` on a
|
|
488
|
+
* site that has none. The rule is narrow on purpose — "a child that is this very binary runs in
|
|
489
|
+
* the mode this binary runs in" — so no third-party child ever has its runtime mode changed.
|
|
490
|
+
*
|
|
491
|
+
* Empty parent values are skipped, as they always were.
|
|
492
|
+
*
|
|
493
|
+
* @complexity O(k) in the number of inherited names.
|
|
494
|
+
*/
|
|
495
|
+
export function buildMcpChildEnv({ command, specEnv, }, { launchEnv = {}, platform = process.platform, parentEnv = process.env, execPath = process.execPath, } = {}) {
|
|
496
|
+
const isWin32 = platform === "win32";
|
|
497
|
+
const names = isWin32 ? [...INHERITED_ENV_VARS, ...WIN32_INHERITED_ENV_VARS] : INHERITED_ENV_VARS;
|
|
498
|
+
const inherited = {};
|
|
499
|
+
for (const name of names) {
|
|
500
|
+
const value = parentEnv[name];
|
|
501
|
+
if (typeof value === "string" && value.length > 0)
|
|
502
|
+
inherited[name] = value;
|
|
503
|
+
}
|
|
504
|
+
const runMode = parentEnv[ELECTRON_RUN_AS_NODE];
|
|
505
|
+
if (isWin32 && typeof runMode === "string" && runMode.length > 0 && isSameWin32Path(command, execPath)) {
|
|
506
|
+
inherited[ELECTRON_RUN_AS_NODE] = runMode;
|
|
507
|
+
}
|
|
508
|
+
return { ...inherited, ...launchEnv, ...specEnv };
|
|
509
|
+
}
|
|
510
|
+
/** Whether two win32 paths name the same file: resolved, then compared case-insensitively, as
|
|
511
|
+
* Windows' own file system does. @complexity O(n) in the path length. */
|
|
512
|
+
function isSameWin32Path(a, b) {
|
|
513
|
+
return path.win32.resolve(a).toLowerCase() === path.win32.resolve(b).toLowerCase();
|
|
514
|
+
}
|
|
515
|
+
/** Best-effort JSON-RPC parse of one inbound line, or `null` for anything that does not parse.
|
|
516
|
+
* Split out of {@link McpStdioSession.handleMessage} purely to keep that method's complexity under
|
|
517
|
+
* the shop ceiling. */
|
|
518
|
+
function parseJsonRpcLine(line) {
|
|
519
|
+
try {
|
|
520
|
+
return JSON.parse(line);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
//# sourceMappingURL=adapter.stdio.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FederationMessages } from "../messages.js";
|
|
2
|
+
import { connectMcpHttpSession, type McpBearerTokenSupplier, type McpAuthenticationChallengeHook } from "../adapter.http.js";
|
|
3
|
+
import type { ResolvedFederatedConnection } from "../config.js";
|
|
4
|
+
import type { McpSessionPort, McpStdioChannel, McpClientInfo } from "../ports.js";
|
|
5
|
+
import type { McpStdioLaunchResolver, ResolvedStdioLaunch } from "./stdio-launch-resolver.js";
|
|
6
|
+
/** Builds a transport session factory; warning deduplication is local to this instance.
|
|
7
|
+
* @complexity O(1) to construct; connection work is bounded by the configured timeouts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createDefaultConnect({ resolver, clientInfo, messages, bearerToken }: {
|
|
10
|
+
resolver: McpStdioLaunchResolver;
|
|
11
|
+
clientInfo: McpClientInfo;
|
|
12
|
+
messages: FederationMessages;
|
|
13
|
+
bearerToken: McpBearerTokenSupplier;
|
|
14
|
+
}, { spawnChannel, fetch: fetchImpl, connectHttp, onAuthenticationChallenge, logger }?: {
|
|
15
|
+
spawnChannel?: ((required: {
|
|
16
|
+
resolved: ResolvedStdioLaunch;
|
|
17
|
+
}) => McpStdioChannel) | undefined;
|
|
18
|
+
fetch?: typeof fetch | undefined;
|
|
19
|
+
connectHttp?: typeof connectMcpHttpSession | undefined;
|
|
20
|
+
onAuthenticationChallenge?: McpAuthenticationChallengeHook | undefined;
|
|
21
|
+
logger?: {
|
|
22
|
+
warn(required: {
|
|
23
|
+
message: string;
|
|
24
|
+
}): void;
|
|
25
|
+
} | undefined;
|
|
26
|
+
}): (request: {
|
|
27
|
+
connection: ResolvedFederatedConnection;
|
|
28
|
+
}) => Promise<McpSessionPort>;
|
|
29
|
+
//# sourceMappingURL=default-connect.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { connectMcpHttpSession, createFetchMcpHttpExchange } from "../adapter.http.js";
|
|
2
|
+
import { connectMcpStdioSession, spawnMcpStdioChannel } from "./adapter.stdio.js";
|
|
3
|
+
/** Builds a transport session factory; warning deduplication is local to this instance.
|
|
4
|
+
* @complexity O(1) to construct; connection work is bounded by the configured timeouts.
|
|
5
|
+
*/
|
|
6
|
+
export function createDefaultConnect({ resolver, clientInfo, messages, bearerToken }, { spawnChannel = ({ resolved }) => spawnMcpStdioChannel({ ...resolved, messages }), fetch: fetchImpl = globalThis.fetch, connectHttp = connectMcpHttpSession, onAuthenticationChallenge, logger = { warn: ({ message }) => console.warn(message) } } = {}) {
|
|
7
|
+
let warned = false;
|
|
8
|
+
const loggedResolver = {
|
|
9
|
+
resolve({ spec }) {
|
|
10
|
+
const resolved = resolver.resolve({ spec: spec });
|
|
11
|
+
if (resolved.warning && !warned) {
|
|
12
|
+
warned = true;
|
|
13
|
+
logger.warn({ message: resolved.warning });
|
|
14
|
+
}
|
|
15
|
+
return resolved;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return async function defaultConnect({ connection }) {
|
|
19
|
+
if ("url" in connection.launch) {
|
|
20
|
+
return connectHttp({
|
|
21
|
+
messages,
|
|
22
|
+
exchange: createFetchMcpHttpExchange({ fetch: fetchImpl }),
|
|
23
|
+
spec: connection.launch,
|
|
24
|
+
// Unlike the stdio arm, the hosted adapter has ONE timeout field, and it governs every
|
|
25
|
+
// request the session ever sends (`adapter.http.ts`'s `McpHttpSession.postWithTimeout`),
|
|
26
|
+
// the handshake included but not exclusively — every later `tools/call` reuses it too. So
|
|
27
|
+
// `callTimeoutMs` is the right bound here, matching the stdio arm below: `connectTimeoutMs`
|
|
28
|
+
// still needs its own outer race for that arm's `spawn`-can-hang gap, but this adapter's
|
|
29
|
+
// request-scoped `AbortSignal` already bounds its own handshake, so no such race is needed
|
|
30
|
+
// here — that much of the old comment stands, only the bound it fed was wrong.
|
|
31
|
+
requestTimeoutMs: connection.config.callTimeoutMs,
|
|
32
|
+
clientInfo,
|
|
33
|
+
bearerToken,
|
|
34
|
+
}, { onAuthenticationChallenge, logger });
|
|
35
|
+
}
|
|
36
|
+
return connectMcpStdioSessionWithSpawnTimeout(connection, connection.launch, loggedResolver, spawnChannel, clientInfo, messages);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The stdio session factory, with the outer timeout `spawn` requires.
|
|
41
|
+
*
|
|
42
|
+
* That timeout is not redundant with the adapter's per-request one. That one bounds a request whose
|
|
43
|
+
* channel is alive; this one bounds the case where `spawn` itself hangs — a command that blocks
|
|
44
|
+
* before it ever writes, an `npx` fetching a package on a stalled network — where no request has
|
|
45
|
+
* been sent yet and so nothing inside the adapter has started counting. The hosted transport has no
|
|
46
|
+
* equivalent gap, which is why only this arm needs the race.
|
|
47
|
+
*
|
|
48
|
+
* `resolver.resolve(launch)` runs BEFORE `spawnChannel`, inside the connection deadline: a throw here (desktop
|
|
49
|
+
* resolver's contract for an unresolvable command — `McpLaunchUnavailableError`) becomes this whole
|
|
50
|
+
* `connect` call's rejection, caught by {@link attachOneFederatedConnection}'s existing fail-open
|
|
51
|
+
* catch exactly like a real spawn failure. So `uvx`/`docker`/anything else the resolver could not
|
|
52
|
+
* find never reaches `spawnChannel` at all — no process, no hang, just the resolver's own message
|
|
53
|
+
* logged and the connection dropped.
|
|
54
|
+
*/
|
|
55
|
+
async function connectMcpStdioSessionWithSpawnTimeout(connection, launch, resolver, spawnChannel, clientInfo, messages) {
|
|
56
|
+
const timeoutMs = connection.config.connectTimeoutMs;
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
const timeoutError = () => new Error(`connect timed out after ${timeoutMs}ms`);
|
|
59
|
+
let channel;
|
|
60
|
+
let timer;
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([
|
|
63
|
+
Promise.resolve().then(() => {
|
|
64
|
+
// Synchronous ports cannot be interrupted by a JS timer. Check elapsed time after each
|
|
65
|
+
// returns so a slow resolver cannot spawn, and a late spawn cannot begin the handshake.
|
|
66
|
+
const resolved = resolver.resolve({ spec: launch });
|
|
67
|
+
if (Date.now() >= deadline)
|
|
68
|
+
throw timeoutError();
|
|
69
|
+
channel = spawnChannel({ resolved });
|
|
70
|
+
if (Date.now() >= deadline)
|
|
71
|
+
throw timeoutError();
|
|
72
|
+
return connectMcpStdioSession({ channel, requestTimeoutMs: connection.config.callTimeoutMs, clientInfo, messages });
|
|
73
|
+
}),
|
|
74
|
+
// Not `unref()`ed, for the same reason as `adapter.stdio.ts`'s per-request timer: an unref'd
|
|
75
|
+
// timer would let the loop drain and abandon this race unsettled instead of rejecting. The
|
|
76
|
+
// `finally` below clears it either way, so it never outlives the connect attempt.
|
|
77
|
+
new Promise((_resolve, reject) => {
|
|
78
|
+
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Whichever branch lost, the child process must not survive it.
|
|
84
|
+
channel?.close({});
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (timer)
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=default-connect.js.map
|