@sigrun/ext-mcp 0.1.0 → 0.2.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 +18 -0
- package/README.md +20 -6
- package/dist/cli.js +2 -2
- package/dist/client.d.ts +24 -30
- package/dist/client.js +538 -132
- package/dist/config.d.ts +31 -9
- package/dist/config.js +65 -16
- package/dist/extension.js +5 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-22
|
|
11
|
+
### Added
|
|
12
|
+
- Remote MCP servers: a `servers` entry may be `{url, headers?, exposure?,
|
|
13
|
+
toolExposure?, timeout?}` instead of a stdio `{command, args}`. Streamable
|
|
14
|
+
HTTP is the transport (POST per frame, JSON or SSE reply,
|
|
15
|
+
`mcp-session-id` captured and echoed); a legacy HTTP+SSE server is adopted
|
|
16
|
+
automatically — whether it 404/405s the POST or (crawl4ai's shape) answers
|
|
17
|
+
it with an `endpoint` event — so no supergateway-style adapter is needed.
|
|
18
|
+
- `mcp.json` now resolves from the sigrun config dir
|
|
19
|
+
(`SIGRUN_CONFIG_DIR/mcp.json`) before the home fallback, matching every
|
|
20
|
+
other sigrun store.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- SSE parsing handles CRLF line endings (the spec allows them; live servers
|
|
24
|
+
emit them).
|
|
25
|
+
- Streamable replies stop at the id-matching frame — servers legitimately
|
|
26
|
+
hold SSE streams open past the reply.
|
|
27
|
+
|
|
10
28
|
## [0.1.0] - 2026-10-13
|
|
11
29
|
### Added
|
|
12
30
|
- Renamed to `@sigrun/ext-mcp` for the 0.1.0 release (repo stays
|
package/README.md
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Register MCP server tools as pi-durable tools, named `<server>/<tool>`.
|
|
4
4
|
|
|
5
|
-
Config:
|
|
6
|
-
|
|
5
|
+
Config: the sigrun config dir's `mcp.json` (`SIGRUN_CONFIG_DIR/mcp.json`; a
|
|
6
|
+
bare user install falls back to `~/.config/sigrun/mcp.json`; the
|
|
7
|
+
`SIGRUN_MCP_CONFIG` env override points at any path). Each entry under
|
|
8
|
+
`"servers"` names an MCP server — stdio or remote:
|
|
7
9
|
|
|
8
10
|
```json
|
|
9
11
|
{
|
|
@@ -13,6 +15,11 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
|
|
|
13
15
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
14
16
|
"exposure": "codemode",
|
|
15
17
|
"timeout": 120
|
|
18
|
+
},
|
|
19
|
+
"search": {
|
|
20
|
+
"url": "https://mcp.example.test/mcp",
|
|
21
|
+
"headers": { "Authorization": "Bearer <token>" },
|
|
22
|
+
"exposure": "direct"
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
}
|
|
@@ -20,8 +27,14 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
|
|
|
20
27
|
|
|
21
28
|
## Server fields
|
|
22
29
|
|
|
23
|
-
- `command`, `args` (
|
|
24
|
-
- `
|
|
30
|
+
- `command`, `args` (stdio) — how to spawn the server.
|
|
31
|
+
- `url` (remote) — the server endpoint (http/https). Streamable HTTP is the
|
|
32
|
+
transport; a legacy HTTP+SSE server (2024-11-05 shape — GET stream +
|
|
33
|
+
`endpoint` event + message POSTs) is adopted automatically, so no adapter
|
|
34
|
+
process is needed.
|
|
35
|
+
- `headers` (remote) — extra request headers (auth etc.), sent on every
|
|
36
|
+
request.
|
|
37
|
+
- `env` (stdio) — extra environment variables for the child process.
|
|
25
38
|
- `enabled` (default `true`) / `disabled` — skip the server entirely. Set one,
|
|
26
39
|
never both: a server that is simultaneously on and off is a config error and
|
|
27
40
|
fails loudly.
|
|
@@ -33,8 +46,9 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
|
|
|
33
46
|
- `toolExposure` — per-tool overrides of `exposure`: an object mapping tool
|
|
34
47
|
names (exact) or `*`-globs (e.g. `"get-*"`) to exposure words. An exact name
|
|
35
48
|
always wins; among matching patterns the FIRST one in config order wins.
|
|
36
|
-
- `oauth` — the authorization-code (PKCE) flow block; see
|
|
37
|
-
<server>` (the CLI mints and stores a token; the spawn
|
|
49
|
+
- `oauth` (stdio) — the authorization-code (PKCE) flow block; see
|
|
50
|
+
`sigrun-ext-mcp oauth <server>` (the CLI mints and stores a token; the spawn
|
|
51
|
+
injects it).
|
|
38
52
|
|
|
39
53
|
## Exposure: how tools reach callers
|
|
40
54
|
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* flow / 5-minute timeout).
|
|
18
18
|
*/
|
|
19
19
|
import { createInterface } from "node:readline";
|
|
20
|
-
import { loadMcpConfig, MCP_CONFIG_ENV } from "./config.js";
|
|
20
|
+
import { isStdioServer, loadMcpConfig, MCP_CONFIG_ENV, } from "./config.js";
|
|
21
21
|
import { startOauth } from "./oauth.js";
|
|
22
22
|
import { resolveMcpTokenPath } from "./tokens.js";
|
|
23
23
|
/** The flow timeout (matches startOauth's default; explicit so the user's
|
|
@@ -71,7 +71,7 @@ export async function runCli(argv, io = { stdout: process.stdout, stderr: proces
|
|
|
71
71
|
io.stderr.write(`error: no server named "${parsed.server}" in the MCP config\n`);
|
|
72
72
|
return 1;
|
|
73
73
|
}
|
|
74
|
-
if (server.oauth === undefined) {
|
|
74
|
+
if (!isStdioServer(server) || server.oauth === undefined) {
|
|
75
75
|
io.stderr.write(`error: server "${parsed.server}" has no oauth block in the MCP config\n`);
|
|
76
76
|
return 1;
|
|
77
77
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The MCP client: a transport-neutral JSON-RPC 2.0 engine plus two
|
|
3
|
+
* transports.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* stdio (the original transport): newline-delimited JSON-RPC over a spawned
|
|
6
|
+
* child's pipes. Remote (streamable HTTP): every request POSTs to the
|
|
7
|
+
* configured URL; the reply is a JSON body OR an SSE stream. A server that
|
|
8
|
+
* 404/405s the POST (the pre-2025-03-26 "HTTP+SSE" transport — still common,
|
|
9
|
+
* e.g. many self-hosted servers) falls back to the legacy shape: a GET
|
|
10
|
+
* stream for server→client frames and a discovered message endpoint for
|
|
11
|
+
* client→server POSTs.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle (both transports): `initialize` handshake (protocolVersion
|
|
14
|
+
* "2025-06-18", clientInfo, capabilities) → `notifications/initialized` →
|
|
15
|
+
* `tools/list` (discovery) → `tools/call` (execution). Requests correlate
|
|
16
|
+
* by id; the response frame's `id` must match a pending request (a
|
|
17
|
+
* notification is never a reply). A connect failure rejects `connect`,
|
|
10
18
|
* never affecting any other server (per-server isolation).
|
|
11
19
|
*
|
|
12
20
|
* Per-request timeout (codemode parity, spec slice 3): every request REJECTS
|
|
@@ -14,35 +22,21 @@
|
|
|
14
22
|
* awaiting a hung server forever. A `notifications/progress` frame naming a
|
|
15
23
|
* pending request's id (sent as the request's `progressToken`) RESETS that
|
|
16
24
|
* request's deadline — a server that keeps reporting progress stays alive
|
|
17
|
-
* as long as it works. A timeout rejects the call but never kills the
|
|
18
|
-
* (per-server isolation: the next request gets a fresh chance).
|
|
25
|
+
* as long as it works. A timeout rejects the call but never kills the
|
|
26
|
+
* transport (per-server isolation: the next request gets a fresh chance).
|
|
19
27
|
*/
|
|
20
|
-
import { type ChildProcess } from "node:child_process";
|
|
21
28
|
import type { McpServerConfig, McpToolDefinition, McpToolResult } from "./types.js";
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
24
|
-
#private;
|
|
25
|
-
/** @internal constructed by {@link connectMcpServer}. */
|
|
26
|
-
constructor(name: string, child: ChildProcess);
|
|
27
|
-
/**
|
|
28
|
-
* The MCP handshake: `initialize` then `notifications/initialized`.
|
|
29
|
-
* The initialize reply is validated to carry the protocol version.
|
|
30
|
-
*/
|
|
31
|
-
handshake(): Promise<void>;
|
|
32
|
-
/** Discover the server's tools (`tools/list`). */
|
|
29
|
+
/** The public client surface (what the extension consumes). */
|
|
30
|
+
export interface McpClient {
|
|
33
31
|
listTools(): Promise<McpToolDefinition[]>;
|
|
34
|
-
/** Execute one tool (`tools/call`); an `isError` result resolves (it is a
|
|
35
|
-
* valid tool outcome, not a protocol failure). `timeoutSeconds` caps the
|
|
36
|
-
* wait per request (default 60; progress notifications reset it). */
|
|
37
32
|
callTool(name: string, args: Record<string, unknown>, timeoutSeconds?: number): Promise<McpToolResult>;
|
|
38
|
-
/**
|
|
39
|
-
exited(): Promise<Error | undefined>;
|
|
40
|
-
/** Kill the child and tear down (idempotent). */
|
|
33
|
+
/** Tear the connection down (idempotent). */
|
|
41
34
|
close(): void;
|
|
42
35
|
}
|
|
43
36
|
/**
|
|
44
|
-
* Spawn
|
|
45
|
-
*
|
|
46
|
-
*
|
|
37
|
+
* Spawn/connect one configured MCP server (stdio or remote by config
|
|
38
|
+
* shape). Throws on spawn/connect failure or a failed handshake —
|
|
39
|
+
* per-server isolation is the CALLER's policy (the extension connects all
|
|
40
|
+
* servers with `.catch` per server).
|
|
47
41
|
*/
|
|
48
42
|
export declare function connectMcpServer(name: string, config: McpServerConfig): Promise<McpClient>;
|
package/dist/client.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The MCP client: a transport-neutral JSON-RPC 2.0 engine plus two
|
|
3
|
+
* transports.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* stdio (the original transport): newline-delimited JSON-RPC over a spawned
|
|
6
|
+
* child's pipes. Remote (streamable HTTP): every request POSTs to the
|
|
7
|
+
* configured URL; the reply is a JSON body OR an SSE stream. A server that
|
|
8
|
+
* 404/405s the POST (the pre-2025-03-26 "HTTP+SSE" transport — still common,
|
|
9
|
+
* e.g. many self-hosted servers) falls back to the legacy shape: a GET
|
|
10
|
+
* stream for server→client frames and a discovered message endpoint for
|
|
11
|
+
* client→server POSTs.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle (both transports): `initialize` handshake (protocolVersion
|
|
14
|
+
* "2025-06-18", clientInfo, capabilities) → `notifications/initialized` →
|
|
15
|
+
* `tools/list` (discovery) → `tools/call` (execution). Requests correlate
|
|
16
|
+
* by id; the response frame's `id` must match a pending request (a
|
|
17
|
+
* notification is never a reply). A connect failure rejects `connect`,
|
|
10
18
|
* never affecting any other server (per-server isolation).
|
|
11
19
|
*
|
|
12
20
|
* Per-request timeout (codemode parity, spec slice 3): every request REJECTS
|
|
@@ -14,8 +22,8 @@
|
|
|
14
22
|
* awaiting a hung server forever. A `notifications/progress` frame naming a
|
|
15
23
|
* pending request's id (sent as the request's `progressToken`) RESETS that
|
|
16
24
|
* request's deadline — a server that keeps reporting progress stays alive
|
|
17
|
-
* as long as it works. A timeout rejects the call but never kills the
|
|
18
|
-
* (per-server isolation: the next request gets a fresh chance).
|
|
25
|
+
* as long as it works. A timeout rejects the call but never kills the
|
|
26
|
+
* transport (per-server isolation: the next request gets a fresh chance).
|
|
19
27
|
*/
|
|
20
28
|
import { spawn } from "node:child_process";
|
|
21
29
|
import { spawnEnvWithToken } from "./tokens.js";
|
|
@@ -24,59 +32,24 @@ const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
|
24
32
|
function isRecord(value) {
|
|
25
33
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
34
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The JSON-RPC engine: id correlation, per-request deadlines, progress
|
|
37
|
+
* resets, and the handshake/list/call methods. Transport-agnostic — frames
|
|
38
|
+
* in, frames out, death unwinds pending.
|
|
39
|
+
*/
|
|
40
|
+
class McpEngine {
|
|
32
41
|
#name;
|
|
42
|
+
#transport;
|
|
33
43
|
#nextId = 1;
|
|
34
|
-
#buffer = "";
|
|
35
44
|
#pending = new Map();
|
|
36
|
-
#
|
|
37
|
-
/** True when the child exited through an intentional close(). */
|
|
38
|
-
#exitClean = false;
|
|
39
|
-
#exitWaiters = [];
|
|
45
|
+
#dead;
|
|
40
46
|
#closed = false;
|
|
41
|
-
|
|
42
|
-
constructor(name, child) {
|
|
47
|
+
constructor(name, transport) {
|
|
43
48
|
this.#name = name;
|
|
44
|
-
this.#
|
|
45
|
-
if (child.stdin === null || child.stdout === null) {
|
|
46
|
-
throw new Error(`mcp server "${name}" spawned without stdio`);
|
|
47
|
-
}
|
|
48
|
-
this.#stdin = child.stdin;
|
|
49
|
-
this.#stdout = child.stdout;
|
|
50
|
-
this.#stdout.setEncoding("utf8");
|
|
51
|
-
this.#stdout.on("data", (chunk) => this.#onData(chunk));
|
|
52
|
-
this.#stdout.on("error", () => this.#failExits()); // closed pipe: ignore
|
|
53
|
-
// A broken stdin pipe (server died) must not crash the host with an
|
|
54
|
-
// unhandled stream error; surface it as the exit error instead.
|
|
55
|
-
this.#stdin.on("error", () => this.#failExits());
|
|
56
|
-
this.#process.on("error", (error) => this.#setExitError(error));
|
|
57
|
-
this.#process.on("exit", () => this.#setExitError(undefined));
|
|
58
|
-
}
|
|
59
|
-
#onData(chunk) {
|
|
60
|
-
this.#buffer += chunk;
|
|
61
|
-
for (;;) {
|
|
62
|
-
const newline = this.#buffer.indexOf("\n");
|
|
63
|
-
if (newline < 0)
|
|
64
|
-
return;
|
|
65
|
-
const line = this.#buffer.slice(0, newline).trim();
|
|
66
|
-
this.#buffer = this.#buffer.slice(newline + 1);
|
|
67
|
-
if (line === "")
|
|
68
|
-
continue;
|
|
69
|
-
this.#onLine(line);
|
|
70
|
-
}
|
|
49
|
+
this.#transport = transport;
|
|
71
50
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
frame = JSON.parse(line);
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return; // a non-JSON line (server chatter) is not a protocol reply
|
|
79
|
-
}
|
|
51
|
+
/** A transport delivered one parsed frame (reply or notification). */
|
|
52
|
+
handleFrame(frame) {
|
|
80
53
|
if (frame.id === undefined || frame.id === null) {
|
|
81
54
|
// A frame with no id is a server-initiated NOTIFICATION (a reply
|
|
82
55
|
// always carries the request's id). A `notifications/progress`
|
|
@@ -107,43 +80,18 @@ export class McpClient {
|
|
|
107
80
|
pending.resolve(frame.result);
|
|
108
81
|
}
|
|
109
82
|
}
|
|
110
|
-
|
|
111
|
-
|
|
83
|
+
/** The transport died (once): unwind everything pending with the error. */
|
|
84
|
+
handleDeath(error) {
|
|
85
|
+
if (this.#dead !== undefined)
|
|
112
86
|
return;
|
|
113
|
-
|
|
114
|
-
// error (a close is a shutdown, never a failure).
|
|
115
|
-
if (this.#closed)
|
|
116
|
-
this.#exitClean = true;
|
|
117
|
-
const waiters = this.#exitWaiters;
|
|
118
|
-
this.#exitWaiters = [];
|
|
119
|
-
if (!this.#exitClean) {
|
|
120
|
-
this.#exitError =
|
|
121
|
-
error ??
|
|
122
|
-
new Error(`mcp server "${this.#name}" exited early (code ${this.#process.exitCode ?? "?"})`);
|
|
123
|
-
}
|
|
124
|
-
for (const waiter of waiters)
|
|
125
|
-
waiter(this.#exitError);
|
|
126
|
-
this.#rejectPending();
|
|
127
|
-
}
|
|
128
|
-
#failExits() {
|
|
129
|
-
// stdout closed: surface as an exit error if the process has not yet
|
|
130
|
-
// reported one, so a hung `tools/call` unwinds.
|
|
131
|
-
this.#setExitError(undefined);
|
|
132
|
-
}
|
|
133
|
-
#rejectPending() {
|
|
87
|
+
this.#dead = error;
|
|
134
88
|
const pending = [...this.#pending.values()];
|
|
135
89
|
this.#pending.clear();
|
|
136
90
|
for (const entry of pending) {
|
|
137
91
|
entry.clearTimeout();
|
|
138
|
-
entry.reject(
|
|
92
|
+
entry.reject(error);
|
|
139
93
|
}
|
|
140
94
|
}
|
|
141
|
-
/** Write one frame as a single newline-terminated line. */
|
|
142
|
-
#write(frame) {
|
|
143
|
-
if (this.#closed)
|
|
144
|
-
throw new Error(`mcp server "${this.#name}" is closed`);
|
|
145
|
-
this.#stdin.write(JSON.stringify(frame) + "\n");
|
|
146
|
-
}
|
|
147
95
|
/**
|
|
148
96
|
* Send a request and await its reply (id-correlated) under a per-request
|
|
149
97
|
* deadline. `timeoutSeconds` defaults to 60; the request's id doubles as
|
|
@@ -151,6 +99,8 @@ export class McpClient {
|
|
|
151
99
|
* convention), so progress notifications can name it.
|
|
152
100
|
*/
|
|
153
101
|
#request(method, params, timeoutSeconds) {
|
|
102
|
+
if (this.#dead !== undefined)
|
|
103
|
+
return Promise.reject(this.#dead);
|
|
154
104
|
const id = this.#nextId++;
|
|
155
105
|
const seconds = timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
156
106
|
return new Promise((resolve, reject) => {
|
|
@@ -176,30 +126,20 @@ export class McpClient {
|
|
|
176
126
|
};
|
|
177
127
|
const deadline = () => fail(new Error(`mcp server "${this.#name}" ${method} timed out after ${seconds}s`));
|
|
178
128
|
this.#pending.set(id, entry);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
method,
|
|
191
|
-
params: requestParams,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
fail(error instanceof Error ? error : new Error(String(error)));
|
|
129
|
+
// MCP convention: the progressToken rides in params._meta (a
|
|
130
|
+
// spec-conforming server reads/echoes it from there). params is
|
|
131
|
+
// merged (never mutated) so an object request keeps its fields;
|
|
132
|
+
// a null-params request (tools/list) gets params just for _meta.
|
|
133
|
+
const requestParams = isRecord(params)
|
|
134
|
+
? { ...params, _meta: { progressToken: id } }
|
|
135
|
+
: { _meta: { progressToken: id } };
|
|
136
|
+
this.#transport
|
|
137
|
+
.send({ jsonrpc: "2.0", id, method, params: requestParams })
|
|
138
|
+
.catch((cause) => {
|
|
139
|
+
fail(cause instanceof Error ? cause : new Error(String(cause)));
|
|
196
140
|
return;
|
|
197
|
-
}
|
|
141
|
+
});
|
|
198
142
|
timer = setTimeout(deadline, seconds * 1000);
|
|
199
|
-
if (this.#exitError !== undefined) {
|
|
200
|
-
// The server already died: unwind this request too.
|
|
201
|
-
fail(this.#exitError);
|
|
202
|
-
}
|
|
203
143
|
});
|
|
204
144
|
}
|
|
205
145
|
/**
|
|
@@ -210,12 +150,15 @@ export class McpClient {
|
|
|
210
150
|
const result = (await this.#request("initialize", {
|
|
211
151
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
212
152
|
capabilities: {},
|
|
213
|
-
clientInfo: { name: "sigrun-ext-mcp", version: "0.
|
|
153
|
+
clientInfo: { name: "sigrun-ext-mcp", version: "0.2.0" },
|
|
214
154
|
}, undefined));
|
|
215
155
|
if (typeof result?.protocolVersion !== "string") {
|
|
216
156
|
throw new Error(`mcp server "${this.#name}" handshake reply lacks protocolVersion`);
|
|
217
157
|
}
|
|
218
|
-
this.#
|
|
158
|
+
await this.#transport.send({
|
|
159
|
+
jsonrpc: "2.0",
|
|
160
|
+
method: "notifications/initialized",
|
|
161
|
+
});
|
|
219
162
|
}
|
|
220
163
|
/** Discover the server's tools (`tools/list`). */
|
|
221
164
|
async listTools() {
|
|
@@ -232,48 +175,511 @@ export class McpClient {
|
|
|
232
175
|
}
|
|
233
176
|
return result;
|
|
234
177
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
178
|
+
close() {
|
|
179
|
+
if (this.#closed)
|
|
180
|
+
return;
|
|
181
|
+
this.#closed = true;
|
|
182
|
+
this.#transport.close();
|
|
183
|
+
this.handleDeath(new Error("mcp client closed"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// stdio transport (the original): today's behavior, moved verbatim in shape.
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
/** One connected stdio MCP server: a live child process speaking JSON-RPC. */
|
|
190
|
+
class StdioTransport {
|
|
191
|
+
#engine;
|
|
192
|
+
#process;
|
|
193
|
+
#stdin;
|
|
194
|
+
#stdout;
|
|
195
|
+
#buffer = "";
|
|
196
|
+
#closed = false;
|
|
197
|
+
constructor(name, command, args, env) {
|
|
198
|
+
// The engine is constructed with this transport; handleFrame/handleDeath
|
|
199
|
+
// route the child's output back into it.
|
|
200
|
+
this.#process = spawn(command, [...args], {
|
|
201
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
202
|
+
env,
|
|
203
|
+
});
|
|
204
|
+
this.#engine = new McpEngine(name, this);
|
|
205
|
+
if (this.#process.stdin === null || this.#process.stdout === null) {
|
|
206
|
+
this.#process.kill("SIGKILL");
|
|
207
|
+
throw new Error(`mcp server "${name}" spawned without stdio`);
|
|
241
208
|
}
|
|
242
|
-
|
|
243
|
-
|
|
209
|
+
this.#stdin = this.#process.stdin;
|
|
210
|
+
this.#stdout = this.#process.stdout;
|
|
211
|
+
this.#stdout.setEncoding("utf8");
|
|
212
|
+
this.#stdout.on("data", (chunk) => this.#onData(chunk));
|
|
213
|
+
// A closed pipe is transport death (never an unhandled stream error).
|
|
214
|
+
this.#stdout.on("error", () => this.#die());
|
|
215
|
+
this.#stdin.on("error", () => this.#die());
|
|
216
|
+
this.#process.on("error", (error) => this.#die(error));
|
|
217
|
+
this.#process.on("exit", () => this.#die());
|
|
218
|
+
}
|
|
219
|
+
get engine() {
|
|
220
|
+
return this.#engine;
|
|
221
|
+
}
|
|
222
|
+
#onData(chunk) {
|
|
223
|
+
this.#buffer += chunk;
|
|
224
|
+
for (;;) {
|
|
225
|
+
const newline = this.#buffer.indexOf("\n");
|
|
226
|
+
if (newline < 0)
|
|
227
|
+
return;
|
|
228
|
+
const line = this.#buffer.slice(0, newline).trim();
|
|
229
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
230
|
+
if (line === "")
|
|
231
|
+
continue;
|
|
232
|
+
let frame;
|
|
233
|
+
try {
|
|
234
|
+
frame = JSON.parse(line);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue; // a non-JSON line (server chatter) is not a protocol reply
|
|
238
|
+
}
|
|
239
|
+
this.#engine.handleFrame(frame);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
#die(error) {
|
|
243
|
+
if (this.#closed)
|
|
244
|
+
return;
|
|
245
|
+
this.#closed = true;
|
|
246
|
+
this.#engine.handleDeath(error ??
|
|
247
|
+
new Error(`mcp server exited early (code ${this.#process.exitCode ?? "?"})`));
|
|
248
|
+
}
|
|
249
|
+
send(frame) {
|
|
250
|
+
if (this.#closed) {
|
|
251
|
+
return Promise.reject(new Error("mcp stdio transport is closed"));
|
|
252
|
+
}
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
this.#stdin.write(JSON.stringify(frame) + "\n", (error) => {
|
|
255
|
+
if (error === undefined || error === null)
|
|
256
|
+
resolve();
|
|
257
|
+
else
|
|
258
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
259
|
+
});
|
|
244
260
|
});
|
|
245
261
|
}
|
|
246
|
-
/** Kill the child and tear down (idempotent). */
|
|
247
262
|
close() {
|
|
248
263
|
if (this.#closed)
|
|
249
264
|
return;
|
|
250
265
|
this.#closed = true;
|
|
251
266
|
this.#process.kill("SIGTERM");
|
|
252
|
-
this.#
|
|
267
|
+
this.#engine.handleDeath(new Error("mcp client closed"));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Remote transport: streamable HTTP with a legacy-SSE fallback.
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
/**
|
|
274
|
+
* Hand-rolled SSE parsing (no EventSource dependency — this is Node, and
|
|
275
|
+
* the stream is a fetch body, not a DOM event target): buffer bytes, split
|
|
276
|
+
* complete events on a blank line, read `data:` lines, JSON-parse each
|
|
277
|
+
* data payload as one JSON-RPC message per event. Non-`message` event
|
|
278
|
+
* names are read for transport control (the legacy `endpoint` event) and
|
|
279
|
+
* otherwise ignored.
|
|
280
|
+
*/
|
|
281
|
+
class SseParser {
|
|
282
|
+
#buffer = "";
|
|
283
|
+
#pendingData = [];
|
|
284
|
+
#pendingEvent = "";
|
|
285
|
+
/** Feed bytes; emits complete parsed frames through `onFrame` and
|
|
286
|
+
* legacy `endpoint` events through `onEndpoint`. */
|
|
287
|
+
feed(chunk, onFrame, onEndpoint) {
|
|
288
|
+
this.#buffer += chunk;
|
|
289
|
+
for (;;) {
|
|
290
|
+
// The SSE spec allows \r\n, \n, or \r line endings; live servers
|
|
291
|
+
// (e.g. behind certain proxies) emit CRLF, so a plain \n\n split
|
|
292
|
+
// never finds the event boundary. Normalize the whole buffer's line
|
|
293
|
+
// endings FIRST, then split events on blank lines.
|
|
294
|
+
this.#buffer = this.#buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
295
|
+
const boundary = this.#buffer.indexOf("\n\n");
|
|
296
|
+
if (boundary < 0)
|
|
297
|
+
return;
|
|
298
|
+
const raw = this.#buffer.slice(0, boundary);
|
|
299
|
+
this.#buffer = this.#buffer.slice(boundary + 2);
|
|
300
|
+
const dataLines = [];
|
|
301
|
+
let eventName = "";
|
|
302
|
+
for (const line of raw.split("\n")) {
|
|
303
|
+
if (line.startsWith("data:")) {
|
|
304
|
+
// One leading space after the colon is per-spec optional.
|
|
305
|
+
const value = line.slice(5);
|
|
306
|
+
dataLines.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
307
|
+
}
|
|
308
|
+
else if (line.startsWith("event:")) {
|
|
309
|
+
const value = line.slice(6);
|
|
310
|
+
eventName = (value.startsWith(" ") ? value.slice(1) : value).trim();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (dataLines.length === 0)
|
|
314
|
+
continue;
|
|
315
|
+
const payload = dataLines.join("\n");
|
|
316
|
+
if (eventName === "endpoint") {
|
|
317
|
+
onEndpoint?.(payload.trim());
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
onFrame(JSON.parse(payload));
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// A non-JSON data payload is not a protocol frame; skip it.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The remote transport. Starts in STREAMABLE mode: every frame POSTs to the
|
|
331
|
+
* configured URL; replies arrive as the POST response body (JSON or SSE).
|
|
332
|
+
* If the INITIAL POST answers 404/405 (a legacy HTTP+SSE server), the
|
|
333
|
+
* transport switches to LEGACY mode: a long-lived GET SSE stream delivers
|
|
334
|
+
* every server→client frame, and client→server frames POST to the message
|
|
335
|
+
* endpoint the server announces via an `endpoint` event.
|
|
336
|
+
*/
|
|
337
|
+
class HttpTransport {
|
|
338
|
+
#engine;
|
|
339
|
+
#name;
|
|
340
|
+
#url;
|
|
341
|
+
#headers;
|
|
342
|
+
/** Legacy mode's message endpoint (absolute); streamable mode's POST target stays #url. */
|
|
343
|
+
#messageUrl;
|
|
344
|
+
/** Streamable HTTP session id (echoed on every request once seen). */
|
|
345
|
+
#sessionId;
|
|
346
|
+
/** Legacy GET stream bookkeeping. */
|
|
347
|
+
#getController;
|
|
348
|
+
#mode = "streamable";
|
|
349
|
+
#closed = false;
|
|
350
|
+
constructor(name, config) {
|
|
351
|
+
this.#name = name;
|
|
352
|
+
this.#url = config.url;
|
|
353
|
+
this.#headers = { ...(config.headers ?? {}) };
|
|
354
|
+
this.#engine = new McpEngine(name, this);
|
|
355
|
+
}
|
|
356
|
+
get engine() {
|
|
357
|
+
return this.#engine;
|
|
358
|
+
}
|
|
359
|
+
#requestHeaders(extra) {
|
|
360
|
+
const headers = {
|
|
361
|
+
"content-type": "application/json",
|
|
362
|
+
accept: "application/json, text/event-stream",
|
|
363
|
+
...this.#headers,
|
|
364
|
+
...extra,
|
|
365
|
+
};
|
|
366
|
+
if (this.#sessionId !== undefined) {
|
|
367
|
+
headers["mcp-session-id"] = this.#sessionId;
|
|
368
|
+
}
|
|
369
|
+
return headers;
|
|
370
|
+
}
|
|
371
|
+
send(frame) {
|
|
372
|
+
if (this.#closed) {
|
|
373
|
+
return Promise.reject(new Error(`mcp server "${this.#name}" is closed`));
|
|
374
|
+
}
|
|
375
|
+
// A reply-bearing REQUEST: the reply may be the POST response body or
|
|
376
|
+
// (legacy) arrive on the GET stream — this send resolves when the POST
|
|
377
|
+
// is accepted; the ENGINE's pending entry settles when the frame lands.
|
|
378
|
+
// A NOTIFICATION never expects a body reply (202/204 are fine).
|
|
379
|
+
return this.#post(frame);
|
|
380
|
+
}
|
|
381
|
+
async #post(frame) {
|
|
382
|
+
const url = this.#messageUrl ?? this.#url;
|
|
383
|
+
const body = JSON.stringify(frame);
|
|
384
|
+
const controller = new AbortController();
|
|
385
|
+
const abortOnClose = () => controller.abort();
|
|
386
|
+
this.#closeHooks.add(abortOnClose);
|
|
387
|
+
try {
|
|
388
|
+
const response = await fetch(url, {
|
|
389
|
+
method: "POST",
|
|
390
|
+
headers: this.#requestHeaders({}),
|
|
391
|
+
body,
|
|
392
|
+
signal: controller.signal,
|
|
393
|
+
});
|
|
394
|
+
const sessionHeader = response.headers.get("mcp-session-id");
|
|
395
|
+
if (sessionHeader !== null && sessionHeader !== "") {
|
|
396
|
+
this.#sessionId = sessionHeader;
|
|
397
|
+
}
|
|
398
|
+
if (response.status === 404 || response.status === 405) {
|
|
399
|
+
if (this.#mode === "streamable" && frame.method === "initialize") {
|
|
400
|
+
// The legacy negotiation point: only on the FIRST request.
|
|
401
|
+
// Later 404/405s are ordinary errors (below).
|
|
402
|
+
await this.#startLegacy();
|
|
403
|
+
// The initialize POST must now go to the legacy message
|
|
404
|
+
// endpoint; the endpoint event has set #messageUrl.
|
|
405
|
+
if (this.#messageUrl === undefined) {
|
|
406
|
+
throw new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint`);
|
|
407
|
+
}
|
|
408
|
+
return this.#post(frame);
|
|
409
|
+
}
|
|
410
|
+
throw new Error(`mcp server "${this.#name}" POST ${url} failed: ${response.status} ${response.statusText}`);
|
|
411
|
+
}
|
|
412
|
+
if (!response.ok) {
|
|
413
|
+
const snippet = (await response.text()).slice(0, 200);
|
|
414
|
+
throw new Error(`mcp server "${this.#name}" POST ${url} failed: ${response.status} ${response.statusText} ${snippet}`.trim());
|
|
415
|
+
}
|
|
416
|
+
// A notification needs no body processing; a request's reply may
|
|
417
|
+
// be here (streamable) or on the GET stream (legacy — where the
|
|
418
|
+
// body is typically empty 202). 202/204 carry no reply body per
|
|
419
|
+
// spec — and a legacy server may 202 WITHOUT ever ending the
|
|
420
|
+
// response stream, so awaiting the body would hang: treat them as
|
|
421
|
+
// accepted-and-done (the reply, if any, rides the GET stream).
|
|
422
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
423
|
+
if (response.status === 202 || response.status === 204) {
|
|
424
|
+
// Nothing to read; cancel any body the server left open.
|
|
425
|
+
response.body?.cancel().catch(() => {
|
|
426
|
+
// A cancel failure (already-closed stream) needs no handling.
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
else if (contentType.includes("text/event-stream")) {
|
|
430
|
+
// Streamable SSE: the reply to THIS request arrives as an event
|
|
431
|
+
// on the response stream — but the stream legitimately STAYS OPEN
|
|
432
|
+
// after it (the server may push notifications). Read until the
|
|
433
|
+
// id-matching reply frame lands, then cancel the stream; a
|
|
434
|
+
// notification POST (no id) has no reply to wait for, so it just
|
|
435
|
+
// cancels. Reading past the reply would hang on servers that
|
|
436
|
+
// hold the stream open (e.g. searmcp).
|
|
437
|
+
//
|
|
438
|
+
// LEGACY DETECTION (generalized): some legacy servers (crawl4ai's
|
|
439
|
+
// shape) answer the initial POST itself with 200 + SSE + an
|
|
440
|
+
// `endpoint` event — no 404/405 ever fires. The endpoint event is
|
|
441
|
+
// unambiguous (streamable HTTP never sends one), so on the FIRST
|
|
442
|
+
// request an endpoint event means legacy: adopt this stream as
|
|
443
|
+
// the server→client channel and re-POST to the message endpoint.
|
|
444
|
+
if (this.#mode === "streamable" && frame.method === "initialize") {
|
|
445
|
+
const legacy = await this.#maybeAdoptLegacyFromPost(response);
|
|
446
|
+
if (legacy) {
|
|
447
|
+
if (this.#messageUrl === undefined) {
|
|
448
|
+
throw new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint`);
|
|
449
|
+
}
|
|
450
|
+
return this.#post(frame);
|
|
451
|
+
}
|
|
452
|
+
// Not legacy: fall through to the reply-wait drain.
|
|
453
|
+
}
|
|
454
|
+
const waitForId = typeof frame.id === "number" ? frame.id : undefined;
|
|
455
|
+
await this.#drainSseBody(response, (parsed) => {
|
|
456
|
+
this.#engine.handleFrame(parsed);
|
|
457
|
+
}, waitForId);
|
|
458
|
+
}
|
|
459
|
+
else if (frame.id !== undefined && frame.id !== null) {
|
|
460
|
+
const text = await response.text();
|
|
461
|
+
if (text !== "") {
|
|
462
|
+
try {
|
|
463
|
+
this.#engine.handleFrame(JSON.parse(text));
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// A non-JSON body for a request is a protocol break on
|
|
467
|
+
// this reply; the engine's deadline will surface it.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
// Drain (and discard) notification bodies.
|
|
473
|
+
await response.text().catch(() => "");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
this.#closeHooks.delete(abortOnClose);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/** On the INITIAL POST's SSE response: read events until either the
|
|
481
|
+
* id-matching reply arrives (streamable — report not-legacy) or an
|
|
482
|
+
* `endpoint` event appears (legacy — adopt the stream as the
|
|
483
|
+
* server→client channel, set #messageUrl, report legacy). */
|
|
484
|
+
async #maybeAdoptLegacyFromPost(response) {
|
|
485
|
+
const parser = new SseParser();
|
|
486
|
+
const reader = response.body?.getReader();
|
|
487
|
+
if (reader === undefined)
|
|
488
|
+
return false;
|
|
489
|
+
const decoder = new TextDecoder();
|
|
490
|
+
const self = this;
|
|
491
|
+
let sawReply = false;
|
|
492
|
+
const deliver = (parsed) => {
|
|
493
|
+
if (parsed.id !== undefined && parsed.id !== null)
|
|
494
|
+
sawReply = true;
|
|
495
|
+
this.#engine.handleFrame(parsed);
|
|
496
|
+
};
|
|
497
|
+
for (;;) {
|
|
498
|
+
const { done, value } = await reader.read();
|
|
499
|
+
if (done)
|
|
500
|
+
return false; // stream ended with no endpoint event
|
|
501
|
+
let sawEndpoint = false;
|
|
502
|
+
parser.feed(decoder.decode(value, { stream: true }), deliver, (endpoint) => {
|
|
503
|
+
sawEndpoint = true;
|
|
504
|
+
self.#messageUrl = new URL(endpoint, self.#url).toString();
|
|
505
|
+
});
|
|
506
|
+
if (sawEndpoint) {
|
|
507
|
+
// Adopt this stream as the legacy server→client channel: legacy
|
|
508
|
+
// mode from here; a background reader keeps feeding frames.
|
|
509
|
+
this.#mode = "legacy";
|
|
510
|
+
const controller = new AbortController();
|
|
511
|
+
this.#getController = controller;
|
|
512
|
+
const abortOnClose = () => controller.abort();
|
|
513
|
+
this.#closeHooks.add(abortOnClose);
|
|
514
|
+
const engine = this.#engine;
|
|
515
|
+
void (async () => {
|
|
516
|
+
try {
|
|
517
|
+
for (;;) {
|
|
518
|
+
const next = await reader.read();
|
|
519
|
+
if (next.done)
|
|
520
|
+
break;
|
|
521
|
+
parser.feed(decoder.decode(next.value, { stream: true }), (frame2) => engine.handleFrame(frame2));
|
|
522
|
+
}
|
|
523
|
+
engine.handleDeath(new Error(`mcp server "${self.#name}" legacy SSE stream ended`));
|
|
524
|
+
}
|
|
525
|
+
catch (cause) {
|
|
526
|
+
if (self.#closed)
|
|
527
|
+
return;
|
|
528
|
+
engine.handleDeath(cause instanceof Error
|
|
529
|
+
? cause
|
|
530
|
+
: new Error(`mcp server "${self.#name}" SSE stream failed`));
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
if (sawReply)
|
|
536
|
+
return false; // a reply already arrived: streamable
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Read a fetch response whose body is an SSE stream. When `waitForId`
|
|
540
|
+
* is set, stop (cancel the stream) once a reply frame with that id has
|
|
541
|
+
* been delivered — the streamable transport keeps streams open past the
|
|
542
|
+
* reply; without a wait the stream reads to completion. */
|
|
543
|
+
async #drainSseBody(response, onFrame, waitForId) {
|
|
544
|
+
const parser = new SseParser();
|
|
545
|
+
const reader = response.body?.getReader();
|
|
546
|
+
if (reader === undefined)
|
|
547
|
+
return;
|
|
548
|
+
const decoder = new TextDecoder();
|
|
549
|
+
let seen = false;
|
|
550
|
+
const deliver = (frame) => {
|
|
551
|
+
onFrame(frame);
|
|
552
|
+
if (waitForId !== undefined && frame.id === waitForId) {
|
|
553
|
+
seen = true;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
for (;;) {
|
|
557
|
+
const { done, value } = await reader.read();
|
|
558
|
+
if (done)
|
|
559
|
+
break;
|
|
560
|
+
parser.feed(decoder.decode(value, { stream: true }), deliver);
|
|
561
|
+
if (seen) {
|
|
562
|
+
await reader.cancel().catch(() => {
|
|
563
|
+
// Already-closed streams cancel noisily; harmless.
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
parser.feed(decoder.decode(), deliver);
|
|
569
|
+
}
|
|
570
|
+
/** Switch to the legacy transport: open the GET SSE stream and wait for
|
|
571
|
+
* the server's `endpoint` event naming the message POST URL. */
|
|
572
|
+
async #startLegacy() {
|
|
573
|
+
this.#mode = "legacy";
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
this.#getController = controller;
|
|
576
|
+
const abortOnClose = () => controller.abort();
|
|
577
|
+
this.#closeHooks.add(abortOnClose);
|
|
578
|
+
const response = await fetch(this.#url, {
|
|
579
|
+
method: "GET",
|
|
580
|
+
headers: {
|
|
581
|
+
accept: "text/event-stream",
|
|
582
|
+
...this.#headers,
|
|
583
|
+
},
|
|
584
|
+
signal: controller.signal,
|
|
585
|
+
});
|
|
586
|
+
if (!response.ok || !(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
587
|
+
controller.abort();
|
|
588
|
+
throw new Error(`mcp server "${this.#name}" legacy SSE stream failed: ${response.status} ${response.statusText}`);
|
|
589
|
+
}
|
|
590
|
+
const sessionHeader = response.headers.get("mcp-session-id");
|
|
591
|
+
if (sessionHeader !== null && sessionHeader !== "") {
|
|
592
|
+
this.#sessionId = sessionHeader;
|
|
593
|
+
}
|
|
594
|
+
// Read the stream; the endpoint event flips #messageUrl, frames feed
|
|
595
|
+
// the engine, stream end is transport death.
|
|
596
|
+
const reader = response.body?.getReader();
|
|
597
|
+
if (reader === undefined) {
|
|
598
|
+
throw new Error(`mcp server "${this.#name}" legacy SSE stream has no body`);
|
|
599
|
+
}
|
|
600
|
+
const parser = new SseParser();
|
|
601
|
+
const decoder = new TextDecoder();
|
|
602
|
+
const engine = this.#engine;
|
|
603
|
+
const self = this;
|
|
604
|
+
// The endpoint usually arrives first; race a bounded wait so a
|
|
605
|
+
// silent server cannot hang the connect forever (the engine's own
|
|
606
|
+
// initialize deadline still applies on top).
|
|
607
|
+
const endpointSeen = new Promise((resolveEndpoint) => {
|
|
608
|
+
void (async () => {
|
|
609
|
+
try {
|
|
610
|
+
for (;;) {
|
|
611
|
+
const { done, value } = await reader.read();
|
|
612
|
+
if (done)
|
|
613
|
+
break;
|
|
614
|
+
parser.feed(decoder.decode(value, { stream: true }), (frame) => engine.handleFrame(frame), (endpoint) => {
|
|
615
|
+
self.#messageUrl = new URL(endpoint, self.#url).toString();
|
|
616
|
+
resolveEndpoint();
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
const tail = decoder.decode();
|
|
620
|
+
if (tail !== "") {
|
|
621
|
+
parser.feed(tail, (frame) => engine.handleFrame(frame), (endpoint) => {
|
|
622
|
+
self.#messageUrl = new URL(endpoint, self.#url).toString();
|
|
623
|
+
resolveEndpoint();
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
engine.handleDeath(new Error(`mcp server "${self.#name}" legacy SSE stream ended`));
|
|
627
|
+
}
|
|
628
|
+
catch (cause) {
|
|
629
|
+
if (self.#closed)
|
|
630
|
+
return; // our own abort: not a failure
|
|
631
|
+
engine.handleDeath(cause instanceof Error
|
|
632
|
+
? cause
|
|
633
|
+
: new Error(`mcp server "${self.#name}" SSE stream failed`));
|
|
634
|
+
}
|
|
635
|
+
})();
|
|
636
|
+
});
|
|
637
|
+
// A 15s cap on waiting for the endpoint event — plenty for any
|
|
638
|
+
// compliant server, and short enough to fail loud on a broken one.
|
|
639
|
+
await Promise.race([
|
|
640
|
+
endpointSeen,
|
|
641
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint within 15s`)), 15_000)),
|
|
642
|
+
]);
|
|
643
|
+
}
|
|
644
|
+
#closeHooks = new Set();
|
|
645
|
+
close() {
|
|
646
|
+
if (this.#closed)
|
|
647
|
+
return;
|
|
648
|
+
this.#closed = true;
|
|
649
|
+
for (const hook of this.#closeHooks)
|
|
650
|
+
hook();
|
|
651
|
+
this.#closeHooks.clear();
|
|
652
|
+
this.#getController?.abort();
|
|
653
|
+
this.#engine.handleDeath(new Error("mcp client closed"));
|
|
253
654
|
}
|
|
254
655
|
}
|
|
255
656
|
/**
|
|
256
|
-
* Spawn
|
|
257
|
-
*
|
|
258
|
-
*
|
|
657
|
+
* Spawn/connect one configured MCP server (stdio or remote by config
|
|
658
|
+
* shape). Throws on spawn/connect failure or a failed handshake —
|
|
659
|
+
* per-server isolation is the CALLER's policy (the extension connects all
|
|
660
|
+
* servers with `.catch` per server).
|
|
259
661
|
*/
|
|
260
662
|
export async function connectMcpServer(name, config) {
|
|
261
|
-
|
|
262
|
-
|
|
663
|
+
let engine;
|
|
664
|
+
if ("url" in config) {
|
|
665
|
+
const transport = new HttpTransport(name, config);
|
|
666
|
+
engine = transport.engine;
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
263
669
|
// Token injection (ruling 3): a server with an oauth block AND a stored
|
|
264
670
|
// token gets it as MCP_ACCESS_TOKEN, merged over the server's own env.
|
|
265
|
-
env
|
|
266
|
-
|
|
267
|
-
|
|
671
|
+
const env = { ...process.env, ...spawnEnvWithToken(name, config) };
|
|
672
|
+
const transport = new StdioTransport(name, config.command, config.args, env);
|
|
673
|
+
engine = transport.engine;
|
|
674
|
+
}
|
|
268
675
|
try {
|
|
269
|
-
await
|
|
676
|
+
await engine.handshake();
|
|
270
677
|
}
|
|
271
678
|
catch (cause) {
|
|
272
|
-
|
|
273
|
-
child.kill("SIGKILL");
|
|
679
|
+
engine.close();
|
|
274
680
|
throw cause instanceof Error
|
|
275
681
|
? cause
|
|
276
682
|
: new Error(`mcp server "${name}" handshake failed`);
|
|
277
683
|
}
|
|
278
|
-
return
|
|
684
|
+
return engine;
|
|
279
685
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -34,14 +34,8 @@ export declare function resolveToolExposure(server: McpServerConfig, toolName: s
|
|
|
34
34
|
* `disabled: true` both skip it (parse-time validation rejects
|
|
35
35
|
* contradictions, so either false simply means off). */
|
|
36
36
|
export declare function serverEnabled(server: McpServerConfig): boolean;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
/** The executable to spawn. */
|
|
40
|
-
readonly command: string;
|
|
41
|
-
/** Arguments to the executable. */
|
|
42
|
-
readonly args: readonly string[];
|
|
43
|
-
/** Extra environment variables for the child process. */
|
|
44
|
-
readonly env?: Readonly<Record<string, string>>;
|
|
37
|
+
/** Fields every server variant carries regardless of transport. */
|
|
38
|
+
interface McpServerCommon {
|
|
45
39
|
/** Skip this server entirely (default false). */
|
|
46
40
|
readonly disabled?: boolean;
|
|
47
41
|
/** Skip this server entirely (default true; contradicts `disabled`). */
|
|
@@ -53,10 +47,31 @@ export interface McpServerConfig {
|
|
|
53
47
|
readonly toolExposure?: Readonly<Record<string, McpExposure>>;
|
|
54
48
|
/** Per-request wire timeout in seconds (default 60). */
|
|
55
49
|
readonly timeout?: number;
|
|
50
|
+
}
|
|
51
|
+
/** One configured MCP server (stdio transport). */
|
|
52
|
+
export interface McpStdioServerConfig extends McpServerCommon {
|
|
53
|
+
/** The executable to spawn. */
|
|
54
|
+
readonly command: string;
|
|
55
|
+
/** Arguments to the executable. */
|
|
56
|
+
readonly args: readonly string[];
|
|
57
|
+
/** Extra environment variables for the child process. */
|
|
58
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
56
59
|
/** OAuth (T3c): when present, `sigrun-ext-mcp oauth <name>` can mint a
|
|
57
60
|
* token and spawn injects it as `MCP_ACCESS_TOKEN` (see tokens.ts). */
|
|
58
61
|
readonly oauth?: McpOAuthConfig;
|
|
59
62
|
}
|
|
63
|
+
/** One configured MCP server (remote transport: streamable HTTP, with a
|
|
64
|
+
* legacy-SSE fallback negotiated on first contact). */
|
|
65
|
+
export interface McpUrlServerConfig extends McpServerCommon {
|
|
66
|
+
/** The server endpoint (http/https). */
|
|
67
|
+
readonly url: string;
|
|
68
|
+
/** Extra request headers (auth etc.), sent on every request. */
|
|
69
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
70
|
+
}
|
|
71
|
+
/** One configured MCP server: stdio OR remote. */
|
|
72
|
+
export type McpServerConfig = McpStdioServerConfig | McpUrlServerConfig;
|
|
73
|
+
/** Narrow to the stdio variant (the spawn path). */
|
|
74
|
+
export declare function isStdioServer(server: McpServerConfig): server is McpStdioServerConfig;
|
|
60
75
|
/** OAuth block for one server: how to run the authorization-code flow. */
|
|
61
76
|
export interface McpOAuthConfig {
|
|
62
77
|
/** The AS's authorize endpoint (GET; the user opens this in a browser). */
|
|
@@ -76,7 +91,13 @@ export interface McpConfig {
|
|
|
76
91
|
}
|
|
77
92
|
/** The env override the config store reads. */
|
|
78
93
|
export declare const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
|
|
79
|
-
/** Resolve the config path without reading it (for diagnostics).
|
|
94
|
+
/** Resolve the config path without reading it (for diagnostics).
|
|
95
|
+
*
|
|
96
|
+
* Resolution order: the `SIGRUN_MCP_CONFIG` override → the sigrun config
|
|
97
|
+
* dir (`SIGRUN_CONFIG_DIR`, the SAME root every sigrun store resolves
|
|
98
|
+
* through — a deployment setting one sets them all) → `~/.config/sigrun`
|
|
99
|
+
* (the pre-deployment fallback, kept so a bare user install keeps
|
|
100
|
+
* working). */
|
|
80
101
|
export declare function resolveMcpConfigPath(env?: NodeJS.ProcessEnv): string;
|
|
81
102
|
/**
|
|
82
103
|
* Parse and validate an `mcp.json` body. Throws a clear error naming the
|
|
@@ -89,3 +110,4 @@ export declare function parseMcpConfig(text: string, source: string): McpConfig;
|
|
|
89
110
|
* nothing); an existing-but-malformed file throws.
|
|
90
111
|
*/
|
|
91
112
|
export declare function loadMcpConfig(env?: NodeJS.ProcessEnv): McpConfig;
|
|
113
|
+
export {};
|
package/dist/config.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
|
-
import { isAbsolute, resolve } from "node:path";
|
|
19
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
20
20
|
/** Config-level exposure words: the four real ones plus pi's `codemode-deferred`
|
|
21
21
|
* alias (normalized to "codemode" at parse time — pi compat). */
|
|
22
22
|
const EXPOSURE_WORDS = [
|
|
@@ -85,15 +85,29 @@ export function resolveToolExposure(server, toolName) {
|
|
|
85
85
|
export function serverEnabled(server) {
|
|
86
86
|
return server.enabled !== false && server.disabled !== true;
|
|
87
87
|
}
|
|
88
|
+
/** Narrow to the stdio variant (the spawn path). */
|
|
89
|
+
export function isStdioServer(server) {
|
|
90
|
+
return "command" in server;
|
|
91
|
+
}
|
|
88
92
|
/** The env override the config store reads. */
|
|
89
93
|
export const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
|
|
90
|
-
/** Resolve the config path without reading it (for diagnostics).
|
|
94
|
+
/** Resolve the config path without reading it (for diagnostics).
|
|
95
|
+
*
|
|
96
|
+
* Resolution order: the `SIGRUN_MCP_CONFIG` override → the sigrun config
|
|
97
|
+
* dir (`SIGRUN_CONFIG_DIR`, the SAME root every sigrun store resolves
|
|
98
|
+
* through — a deployment setting one sets them all) → `~/.config/sigrun`
|
|
99
|
+
* (the pre-deployment fallback, kept so a bare user install keeps
|
|
100
|
+
* working). */
|
|
91
101
|
export function resolveMcpConfigPath(env = process.env) {
|
|
92
102
|
const override = env[MCP_CONFIG_ENV];
|
|
93
103
|
if (override !== undefined && override !== "") {
|
|
94
104
|
// A relative override resolves against cwd, like sigrun's other paths.
|
|
95
105
|
return isAbsolute(override) ? override : resolve(override);
|
|
96
106
|
}
|
|
107
|
+
const configDir = env.SIGRUN_CONFIG_DIR;
|
|
108
|
+
if (configDir !== undefined && configDir !== "") {
|
|
109
|
+
return join(configDir, "mcp.json");
|
|
110
|
+
}
|
|
97
111
|
return resolve(homedir(), ".config", "sigrun", "mcp.json");
|
|
98
112
|
}
|
|
99
113
|
function isRecord(value) {
|
|
@@ -123,7 +137,28 @@ export function parseMcpConfig(text, source) {
|
|
|
123
137
|
if (!isRecord(entry)) {
|
|
124
138
|
throw new Error(`mcp config ${source}: server "${name}" must be an object`);
|
|
125
139
|
}
|
|
126
|
-
|
|
140
|
+
// Transport dispatch: a server is EITHER a stdio child or a remote
|
|
141
|
+
// endpoint. Both arms share the optional fields below; each arm then
|
|
142
|
+
// validates its own required fields.
|
|
143
|
+
const hasCommand = entry.command !== undefined || entry.args !== undefined;
|
|
144
|
+
const hasUrl = entry.url !== undefined;
|
|
145
|
+
if (hasCommand && hasUrl) {
|
|
146
|
+
throw new Error(`mcp config ${source}: server "${name}" cannot carry both "command" (stdio) and "url" (remote) — pick one transport`);
|
|
147
|
+
}
|
|
148
|
+
if (!hasCommand && !hasUrl) {
|
|
149
|
+
throw new Error(`mcp config ${source}: server "${name}" needs either "command"+"args" (stdio) or "url" (remote)`);
|
|
150
|
+
}
|
|
151
|
+
if (hasUrl) {
|
|
152
|
+
if (typeof entry.url !== "string" || !/^https?:\/\//.test(entry.url)) {
|
|
153
|
+
throw new Error(`mcp config ${source}: server "${name}" url must be an http(s) URL`);
|
|
154
|
+
}
|
|
155
|
+
if (entry.headers !== undefined &&
|
|
156
|
+
(!isRecord(entry.headers) ||
|
|
157
|
+
Object.values(entry.headers).some((v) => typeof v !== "string"))) {
|
|
158
|
+
throw new Error(`mcp config ${source}: server "${name}" headers must map strings to strings`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (typeof entry.command !== "string" ||
|
|
127
162
|
!Array.isArray(entry.args) ||
|
|
128
163
|
entry.args.some((arg) => typeof arg !== "string")) {
|
|
129
164
|
throw new Error(`mcp config ${source}: server "${name}" needs a string "command" and a string[] "args"`);
|
|
@@ -169,19 +204,33 @@ export function parseMcpConfig(text, source) {
|
|
|
169
204
|
const oauth = entry.oauth === undefined
|
|
170
205
|
? undefined
|
|
171
206
|
: parseOauthEntry(entry.oauth, source, name);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
207
|
+
// The common optionals first, then the transport arm's own fields —
|
|
208
|
+
// the union stays discriminable by "command" vs "url" presence.
|
|
209
|
+
servers[name] = hasUrl
|
|
210
|
+
? {
|
|
211
|
+
url: entry.url,
|
|
212
|
+
...(entry.headers === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { headers: entry.headers }),
|
|
215
|
+
...(entry.disabled === undefined ? {} : { disabled: entry.disabled }),
|
|
216
|
+
...(entry.enabled === undefined ? {} : { enabled: entry.enabled }),
|
|
217
|
+
...(exposure === undefined ? {} : { exposure }),
|
|
218
|
+
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
219
|
+
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
220
|
+
}
|
|
221
|
+
: {
|
|
222
|
+
command: entry.command,
|
|
223
|
+
args: entry.args,
|
|
224
|
+
...(entry.env === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { env: entry.env }),
|
|
227
|
+
...(entry.disabled === undefined ? {} : { disabled: entry.disabled }),
|
|
228
|
+
...(entry.enabled === undefined ? {} : { enabled: entry.enabled }),
|
|
229
|
+
...(exposure === undefined ? {} : { exposure }),
|
|
230
|
+
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
231
|
+
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
232
|
+
...(oauth === undefined ? {} : { oauth }),
|
|
233
|
+
};
|
|
185
234
|
}
|
|
186
235
|
return { servers };
|
|
187
236
|
}
|
package/dist/extension.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { Type } from "@earendil-works/pi-ai";
|
|
28
28
|
import { defineExtension, defineTool, } from "@earendil-works/pi-durable";
|
|
29
29
|
import { connectMcpServer } from "./client.js";
|
|
30
|
-
import { loadMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
|
|
30
|
+
import { isStdioServer, loadMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
|
|
31
31
|
import { MCP_SERVERS_SECTION_KEY, partitionForSection, renderMcpServersSection, } from "./section.js";
|
|
32
32
|
/** The extension's pi-durable registry name. */
|
|
33
33
|
export const MCP_EXTENSION_NAME = "sigrun.mcp";
|
|
@@ -132,9 +132,12 @@ export function createMcpExtension() {
|
|
|
132
132
|
tools.push(mcpTool(name, tool, client, exposure, server.timeout));
|
|
133
133
|
}
|
|
134
134
|
if (mix.count > 0 && (mix.codemode || mix.deferred)) {
|
|
135
|
+
const origin = isStdioServer(server)
|
|
136
|
+
? `${server.command} ${server.args.join(" ")}`.trim()
|
|
137
|
+
: server.url;
|
|
135
138
|
serverExposures.set(name, {
|
|
136
139
|
...mix,
|
|
137
|
-
description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${
|
|
140
|
+
description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${origin}`.trim(),
|
|
138
141
|
});
|
|
139
142
|
}
|
|
140
143
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** sigrun-ext-mcp: register MCP server tools as pi-durable tools. */
|
|
2
2
|
export { createMcpExtension, MCP_EXTENSION_NAME } from "./extension.js";
|
|
3
|
-
export { connectMcpServer
|
|
3
|
+
export { connectMcpServer } from "./client.js";
|
|
4
4
|
export { loadMcpConfig, MCP_CONFIG_ENV, parseMcpConfig, resolveMcpConfigPath, } from "./config.js";
|
|
5
5
|
export { loadMcpTokens, MCP_ACCESS_TOKEN_ENV, parseMcpTokenStore, resolveMcpTokenPath, saveMcpTokens, spawnEnvWithToken, storeMcpToken, } from "./tokens.js";
|
|
6
6
|
export { buildAuthorizeUrl, exchangeCode, extractCode, makePkcePair, startOauth, } from "./oauth.js";
|