@sigrun/ext-mcp 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-13
11
+ ### Added
12
+ - Renamed to `@sigrun/ext-mcp` for the 0.1.0 release (repo stays
13
+ `sigrun-ai/sigrun-ext-mcp`): published package metadata
14
+ (`publishConfig` access/provenance), host modules moved to
15
+ `peerDependencies` mirrored in `devDependencies` at exact pins
16
+ (`typebox` now declared — it was an undeclared host import),
17
+ CHANGELOG.md joined the files whitelist, and a manifest hygiene check
18
+ wired into `npm test`.
19
+ - MCP client over stdio: server discovery, tool registration (`<server>/<tool>`
20
+ naming), OAuth (PKCE paste-the-URL flow) with a durable token store.
21
+ - The MCP exposure model on pi-durable 1.2.0: per-server
22
+ `exposure: codemode|deferred|direct|hidden` (default `codemode`),
23
+ per-tool `toolExposure` overrides (exact over patterns, first wins),
24
+ `enabled`, and per-request `timeout` with progress-notification reset.
25
+ - The `mcp_servers` prompt section: live-rendered, 4KB whole-line-truncated,
26
+ gated on the discovery tool being model-offered, with an honest
27
+ enable-notice line for unreachable servers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # @sigrun/ext-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Register MCP server tools as pi-durable tools, named `<server>/<tool>`.
4
+
5
+ Config: `~/.config/sigrun/mcp.json` (or the `SIGRUN_MCP_CONFIG` env override
6
+ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
7
+
8
+ ```json
9
+ {
10
+ "servers": {
11
+ "github": {
12
+ "command": "npx",
13
+ "args": ["-y", "@modelcontextprotocol/server-github"],
14
+ "exposure": "codemode",
15
+ "timeout": 120
16
+ }
17
+ }
18
+ }
19
+ ```
20
+
21
+ ## Server fields
22
+
23
+ - `command`, `args` (required) — how to spawn the server.
24
+ - `env` — extra environment variables for the child process.
25
+ - `enabled` (default `true`) / `disabled` — skip the server entirely. Set one,
26
+ never both: a server that is simultaneously on and off is a config error and
27
+ fails loudly.
28
+ - `timeout` — per-request wire timeout in seconds (default `60`). A request
29
+ that gets no reply by its deadline rejects. A server that keeps sending
30
+ `notifications/progress` for a pending request never dies at the deadline
31
+ while it keeps reporting work — progress resets the deadline.
32
+ - `exposure` — how the server's tools register (see below). Default `"codemode"`.
33
+ - `toolExposure` — per-tool overrides of `exposure`: an object mapping tool
34
+ names (exact) or `*`-globs (e.g. `"get-*"`) to exposure words. An exact name
35
+ always wins; among matching patterns the FIRST one in config order wins.
36
+ - `oauth` — the authorization-code (PKCE) flow block; see `sigrun-ext-mcp oauth
37
+ <server>` (the CLI mints and stores a token; the spawn injects it).
38
+
39
+ ## Exposure: how tools reach callers
40
+
41
+ On pi-durable 1.2.0 semantics, `"exposure"` decides who can call a server's
42
+ tools:
43
+
44
+ | exposure | registration | who can call |
45
+ | ----------- | ------------------------- | ------------------------------------- |
46
+ | `codemode` | `callers: ["tools"]` | scripts only (never model-offered) |
47
+ | `deferred` | `deferred: true` | the model, once `tool_search` loads it |
48
+ | `direct` | normal | the model (offered when in the set) |
49
+ | `hidden` | not registered | nobody (config entry kept) |
50
+
51
+ The `codemode-deferred` word is kept as an alias for `codemode` (pi compat).
52
+
53
+ ### The default flip — a breaking change
54
+
55
+ **Before 1.2.0, an `mcp.json` server entry with no `exposure` field had its
56
+ tools offered to the model. Now the default is `"codemode"`:** a
57
+ default-exposure server's tools are callable by scripts (codemode), never
58
+ offered to the model directly. To restore the old behavior, set the server
59
+ (or the tool, via `toolExposure`) to `"exposure": "direct"`:
60
+
61
+ ```json
62
+ { "exposure": "direct" }
63
+ ```
64
+
65
+ The `mcp_servers` system-prompt section lists codemode/deferred servers when
66
+ their discovery tool (`codemode`, `tool_search`) is offered in the request;
67
+ when such servers exist but their tools are unreachable, it appends one
68
+ notice line naming the count and the Settings → default tools fix.
69
+
70
+ ## Tool naming: `<server>/<tool>` (decision)
71
+
72
+ Tools keep the existing `<server>/<tool>` shape rather than upstream pi's
73
+ `mcp__<server>__<tool>`:
74
+
75
+ - The upstream codemode section groups tools by `CodemodeNamespace.prefix`
76
+ (longest match wins), and the namespaces are **config-side** — the host
77
+ passes the groups, so any naming can namespace trivially: ours groups as
78
+ `prefix: "<server>/"` with no rename at all.
79
+ - The name-addressed surface makes per-tool replacement and `toolExposure`
80
+ patterns readable (`"github/*"`), and the existing wire name is already
81
+ deployed in configs and scripts.
82
+
83
+ No section-rendering compat requires the upstream spelling; the decision is
84
+ recorded here per the spec's open item.
85
+
86
+ ## Replacement semantics
87
+
88
+ Replacement works by not installing ours and installing yours: the
89
+ name-addressed tool surface (`<server>/<tool>`) makes per-name replacement
90
+ trivial. Co-installing this package with another that registers the same
91
+ tool names shadows silently (pi-durable resolves cross-extension duplicates
92
+ last-selected-wins) — **do not co-install**. Sigrun's extension loader
93
+ (made the shadowing visible at boot: it `console.warn`s once per duplicate
94
+ tool name, naming the extensions and the last-wins resolution) but the
95
+ warning changes nothing: last-wins stands. Replace, don't co-install.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The package bin entry (`sigrun-ext-mcp` → dist/bin.js): parse-and-run the
3
+ * CLI, mapping the result to the process exit code. The body lives in
4
+ * ./cli.ts so tests can exercise it without spawning a process.
5
+ */
6
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The package bin entry (`sigrun-ext-mcp` → dist/bin.js): parse-and-run the
3
+ * CLI, mapping the result to the process exit code. The body lives in
4
+ * ./cli.ts so tests can exercise it without spawning a process.
5
+ */
6
+ import { runCli } from "./cli.js";
7
+ void runCli(process.argv.slice(2)).then((code) => {
8
+ process.exitCode = code;
9
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The `sigrun-ext-mcp` CLI bin (ruling 4): `sigrun-ext-mcp oauth <server>`
3
+ * runs the paste-the-URL OAuth flow for one configured server.
4
+ *
5
+ * argv surface (parsed minimally — no new deps):
6
+ * sigrun-ext-mcp oauth <server> [--paste]
7
+ *
8
+ * Default mode: the loopback listener runs AND stdin is read (paste
9
+ * fallback) — first arrival wins. `--paste` is accepted for hosts that want
10
+ * the semantics explicit; the flow is identical either way.
11
+ *
12
+ * Prints to stdout:
13
+ * - the authorize URL (open this in a browser)
14
+ * - the redirect_uri (what the AS will redirect to)
15
+ * - a paste prompt
16
+ * Exits 0 with the token stored, or 1 with a clear error (usage / config /
17
+ * flow / 5-minute timeout).
18
+ */
19
+ /** Parse argv minimally: `oauth <server> [--paste]`. */
20
+ export declare function parseCliArgs(argv: readonly string[]): {
21
+ readonly command: "oauth";
22
+ readonly server: string;
23
+ readonly paste: boolean;
24
+ };
25
+ /** The CLI body (exported for tests); returns the process exit code. */
26
+ export declare function runCli(argv: readonly string[], io?: {
27
+ readonly stdout: NodeJS.WriteStream;
28
+ readonly stderr: NodeJS.WriteStream;
29
+ }): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The `sigrun-ext-mcp` CLI bin (ruling 4): `sigrun-ext-mcp oauth <server>`
3
+ * runs the paste-the-URL OAuth flow for one configured server.
4
+ *
5
+ * argv surface (parsed minimally — no new deps):
6
+ * sigrun-ext-mcp oauth <server> [--paste]
7
+ *
8
+ * Default mode: the loopback listener runs AND stdin is read (paste
9
+ * fallback) — first arrival wins. `--paste` is accepted for hosts that want
10
+ * the semantics explicit; the flow is identical either way.
11
+ *
12
+ * Prints to stdout:
13
+ * - the authorize URL (open this in a browser)
14
+ * - the redirect_uri (what the AS will redirect to)
15
+ * - a paste prompt
16
+ * Exits 0 with the token stored, or 1 with a clear error (usage / config /
17
+ * flow / 5-minute timeout).
18
+ */
19
+ import { createInterface } from "node:readline";
20
+ import { loadMcpConfig, MCP_CONFIG_ENV } from "./config.js";
21
+ import { startOauth } from "./oauth.js";
22
+ import { resolveMcpTokenPath } from "./tokens.js";
23
+ /** The flow timeout (matches startOauth's default; explicit so the user's
24
+ * error message is deterministic). */
25
+ const TIMEOUT_MS = 5 * 60_000;
26
+ /** Print the usage banner. */
27
+ function usage(out) {
28
+ out.write("usage: sigrun-ext-mcp oauth <server> [--paste]\n");
29
+ out.write(`reads the MCP config (env ${MCP_CONFIG_ENV} or ~/.config/sigrun/mcp.json);\n` +
30
+ `the named server needs an oauth block; the token is stored beside the\n` +
31
+ `config as mcp-tokens.json (0600)\n`);
32
+ }
33
+ /** Parse argv minimally: `oauth <server> [--paste]`. */
34
+ export function parseCliArgs(argv) {
35
+ const args = [...argv];
36
+ const head = args.shift();
37
+ if (head !== "oauth") {
38
+ throw new Error(`unknown command: ${String(head)}`);
39
+ }
40
+ const paste = args.includes("--paste");
41
+ const positional = args.filter((arg) => arg !== "--paste");
42
+ if (positional.length !== 1) {
43
+ throw new Error("expected exactly one server name");
44
+ }
45
+ return { command: "oauth", server: positional[0], paste };
46
+ }
47
+ /** Load the config, turning a malformed config into exit-1 (not a crash). */
48
+ function loadMcpConfigSafe(stderr) {
49
+ try {
50
+ return loadMcpConfig();
51
+ }
52
+ catch (cause) {
53
+ stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
54
+ process.exit(1);
55
+ }
56
+ }
57
+ /** The CLI body (exported for tests); returns the process exit code. */
58
+ export async function runCli(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
59
+ let parsed;
60
+ try {
61
+ parsed = parseCliArgs(argv);
62
+ }
63
+ catch (cause) {
64
+ usage(io.stderr);
65
+ io.stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
66
+ return 1;
67
+ }
68
+ const config = loadMcpConfigSafe(io.stderr);
69
+ const server = config.servers[parsed.server];
70
+ if (server === undefined) {
71
+ io.stderr.write(`error: no server named "${parsed.server}" in the MCP config\n`);
72
+ return 1;
73
+ }
74
+ if (server.oauth === undefined) {
75
+ io.stderr.write(`error: server "${parsed.server}" has no oauth block in the MCP config\n`);
76
+ return 1;
77
+ }
78
+ const flow = await startOauth(parsed.server, server.oauth, {
79
+ timeoutMs: TIMEOUT_MS,
80
+ });
81
+ io.stdout.write(`Authorize URL:\n ${flow.authorizeUrl}\n`);
82
+ io.stdout.write(`Redirect URI:\n ${flow.redirectUri}\n`);
83
+ io.stdout.write("Open the Authorize URL in a browser and approve. If the redirect does\n" +
84
+ "not reach this machine, paste the FULL redirected URL here instead:\n");
85
+ // The paste fallback: every stdin line is fed to the flow; first arrival
86
+ // wins (the loopback listener or the paste settles the code). pasteUrl
87
+ // never throws (lines with no code are ignored), so the readline handler
88
+ // cannot crash the bin.
89
+ const lines = createInterface({ input: process.stdin });
90
+ lines.on("line", (line) => {
91
+ const trimmed = line.trim();
92
+ if (trimmed !== "")
93
+ flow.pasteUrl(trimmed);
94
+ });
95
+ try {
96
+ await flow.done;
97
+ lines.close();
98
+ io.stdout.write(`Token stored for "${parsed.server}" in:\n ${resolveMcpTokenPath()}\n`);
99
+ return 0;
100
+ }
101
+ catch (cause) {
102
+ lines.close();
103
+ flow.stop();
104
+ io.stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
105
+ return 1;
106
+ }
107
+ }
108
+ // This module is the CLI body only (pure exports; importable for tests).
109
+ // The bin entry lives in ./bin.ts — dist/bin.js is the package.json "bin".
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Minimal hand-rolled MCP stdio client (ruling 3): JSON-RPC 2.0 over
3
+ * newline-delimited stdio, no pi-mcp dependency.
4
+ *
5
+ * Lifecycle: spawn → `initialize` handshake (protocolVersion "2025-06-18",
6
+ * clientInfo, capabilities) → `notifications/initialized` → `tools/list`
7
+ * (discovery) → `tools/call` (execution). Requests correlate by id; the
8
+ * response frame's `id` must match a pending request (a notification is
9
+ * never a reply). A spawn failure or handshake failure rejects `connect`,
10
+ * never affecting any other server (per-server isolation).
11
+ *
12
+ * Per-request timeout (codemode parity, spec slice 3): every request REJECTS
13
+ * at its deadline (default 60s; config `timeout` per server) instead of
14
+ * awaiting a hung server forever. A `notifications/progress` frame naming a
15
+ * pending request's id (sent as the request's `progressToken`) RESETS that
16
+ * 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 child
18
+ * (per-server isolation: the next request gets a fresh chance).
19
+ */
20
+ import { type ChildProcess } from "node:child_process";
21
+ import type { McpServerConfig, McpToolDefinition, McpToolResult } from "./types.js";
22
+ /** One connected MCP server: a live child process speaking JSON-RPC. */
23
+ export declare class McpClient {
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`). */
33
+ 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
+ callTool(name: string, args: Record<string, unknown>, timeoutSeconds?: number): Promise<McpToolResult>;
38
+ /** Wait for the child to exit (used by tests / shutdown checks). */
39
+ exited(): Promise<Error | undefined>;
40
+ /** Kill the child and tear down (idempotent). */
41
+ close(): void;
42
+ }
43
+ /**
44
+ * Spawn and connect one configured MCP server. Throws on spawn failure or a
45
+ * failed handshake — per-server isolation is the CALLER's policy (the
46
+ * extension connects all servers with `.catch` per server).
47
+ */
48
+ export declare function connectMcpServer(name: string, config: McpServerConfig): Promise<McpClient>;
package/dist/client.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Minimal hand-rolled MCP stdio client (ruling 3): JSON-RPC 2.0 over
3
+ * newline-delimited stdio, no pi-mcp dependency.
4
+ *
5
+ * Lifecycle: spawn → `initialize` handshake (protocolVersion "2025-06-18",
6
+ * clientInfo, capabilities) → `notifications/initialized` → `tools/list`
7
+ * (discovery) → `tools/call` (execution). Requests correlate by id; the
8
+ * response frame's `id` must match a pending request (a notification is
9
+ * never a reply). A spawn failure or handshake failure rejects `connect`,
10
+ * never affecting any other server (per-server isolation).
11
+ *
12
+ * Per-request timeout (codemode parity, spec slice 3): every request REJECTS
13
+ * at its deadline (default 60s; config `timeout` per server) instead of
14
+ * awaiting a hung server forever. A `notifications/progress` frame naming a
15
+ * pending request's id (sent as the request's `progressToken`) RESETS that
16
+ * 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 child
18
+ * (per-server isolation: the next request gets a fresh chance).
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { spawnEnvWithToken } from "./tokens.js";
22
+ import { DEFAULT_TIMEOUT_SECONDS } from "./config.js";
23
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ /** One connected MCP server: a live child process speaking JSON-RPC. */
28
+ export class McpClient {
29
+ #process;
30
+ #stdin;
31
+ #stdout;
32
+ #name;
33
+ #nextId = 1;
34
+ #buffer = "";
35
+ #pending = new Map();
36
+ #exitError;
37
+ /** True when the child exited through an intentional close(). */
38
+ #exitClean = false;
39
+ #exitWaiters = [];
40
+ #closed = false;
41
+ /** @internal constructed by {@link connectMcpServer}. */
42
+ constructor(name, child) {
43
+ this.#name = name;
44
+ this.#process = child;
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
+ }
71
+ }
72
+ #onLine(line) {
73
+ let frame;
74
+ try {
75
+ frame = JSON.parse(line);
76
+ }
77
+ catch {
78
+ return; // a non-JSON line (server chatter) is not a protocol reply
79
+ }
80
+ if (frame.id === undefined || frame.id === null) {
81
+ // A frame with no id is a server-initiated NOTIFICATION (a reply
82
+ // always carries the request's id). A `notifications/progress`
83
+ // frame whose `progressToken` names a PENDING request resets that
84
+ // request's deadline — the server is alive and still working, so
85
+ // a long job must not die at the deadline while it keeps
86
+ // reporting progress. Every other notification is ignored.
87
+ if (frame.method === "notifications/progress" &&
88
+ isRecord(frame.params) &&
89
+ typeof frame.params.progressToken === "number") {
90
+ this.#pending.get(frame.params.progressToken)?.resetTimeout();
91
+ }
92
+ return;
93
+ }
94
+ // A reply: its id must be one of ours (a notification is never a
95
+ // reply, and a non-numeric id is not one of our requests).
96
+ if (typeof frame.id !== "number")
97
+ return;
98
+ const pending = this.#pending.get(frame.id);
99
+ if (pending === undefined)
100
+ return;
101
+ this.#pending.delete(frame.id);
102
+ pending.clearTimeout();
103
+ if (frame.error !== undefined) {
104
+ pending.reject(new Error(`mcp server "${this.#name}" ${frame.error.message} (code ${frame.error.code})`));
105
+ }
106
+ else {
107
+ pending.resolve(frame.result);
108
+ }
109
+ }
110
+ #setExitError(error) {
111
+ if (this.#exitError !== undefined)
112
+ return;
113
+ // An INTENTIONAL close() is a clean shutdown: resolve waiters with no
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() {
134
+ const pending = [...this.#pending.values()];
135
+ this.#pending.clear();
136
+ for (const entry of pending) {
137
+ entry.clearTimeout();
138
+ entry.reject(this.#exitError ?? new Error("mcp server closed"));
139
+ }
140
+ }
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
+ /**
148
+ * Send a request and await its reply (id-correlated) under a per-request
149
+ * deadline. `timeoutSeconds` defaults to 60; the request's id doubles as
150
+ * the `progressToken` it advertises (in `params._meta`, per the MCP
151
+ * convention), so progress notifications can name it.
152
+ */
153
+ #request(method, params, timeoutSeconds) {
154
+ const id = this.#nextId++;
155
+ const seconds = timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
156
+ return new Promise((resolve, reject) => {
157
+ let timer;
158
+ const entry = {
159
+ resolve,
160
+ reject,
161
+ // A progress notification naming this request id restarts the
162
+ // deadline wait (the server is alive and still working).
163
+ resetTimeout: () => {
164
+ clearTimeout(timer);
165
+ timer = setTimeout(deadline, seconds * 1000);
166
+ },
167
+ clearTimeout: () => {
168
+ clearTimeout(timer);
169
+ timer = undefined;
170
+ },
171
+ };
172
+ const fail = (error) => {
173
+ this.#pending.delete(id);
174
+ entry.clearTimeout();
175
+ reject(error);
176
+ };
177
+ const deadline = () => fail(new Error(`mcp server "${this.#name}" ${method} timed out after ${seconds}s`));
178
+ this.#pending.set(id, entry);
179
+ try {
180
+ // MCP convention: the progressToken rides in params._meta (a
181
+ // spec-conforming server reads/echoes it from there). params is
182
+ // merged (never mutated) so an object request keeps its fields;
183
+ // a null-params request (tools/list) gets params just for _meta.
184
+ const requestParams = isRecord(params)
185
+ ? { ...params, _meta: { progressToken: id } }
186
+ : { _meta: { progressToken: id } };
187
+ this.#write({
188
+ jsonrpc: "2.0",
189
+ id,
190
+ method,
191
+ params: requestParams,
192
+ });
193
+ }
194
+ catch (error) {
195
+ fail(error instanceof Error ? error : new Error(String(error)));
196
+ return;
197
+ }
198
+ 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
+ });
204
+ }
205
+ /**
206
+ * The MCP handshake: `initialize` then `notifications/initialized`.
207
+ * The initialize reply is validated to carry the protocol version.
208
+ */
209
+ async handshake() {
210
+ const result = (await this.#request("initialize", {
211
+ protocolVersion: MCP_PROTOCOL_VERSION,
212
+ capabilities: {},
213
+ clientInfo: { name: "sigrun-ext-mcp", version: "0.1.0" },
214
+ }, undefined));
215
+ if (typeof result?.protocolVersion !== "string") {
216
+ throw new Error(`mcp server "${this.#name}" handshake reply lacks protocolVersion`);
217
+ }
218
+ this.#write({ jsonrpc: "2.0", method: "notifications/initialized" });
219
+ }
220
+ /** Discover the server's tools (`tools/list`). */
221
+ async listTools() {
222
+ const result = (await this.#request("tools/list", undefined, undefined));
223
+ return result?.tools ?? [];
224
+ }
225
+ /** Execute one tool (`tools/call`); an `isError` result resolves (it is a
226
+ * valid tool outcome, not a protocol failure). `timeoutSeconds` caps the
227
+ * wait per request (default 60; progress notifications reset it). */
228
+ async callTool(name, args, timeoutSeconds) {
229
+ const result = (await this.#request("tools/call", { name, arguments: args }, timeoutSeconds));
230
+ if (result === undefined) {
231
+ throw new Error(`mcp server "${this.#name}" tools/call "${name}" returned no result`);
232
+ }
233
+ return result;
234
+ }
235
+ /** Wait for the child to exit (used by tests / shutdown checks). */
236
+ async exited() {
237
+ if (this.#exitError !== undefined)
238
+ return this.#exitError;
239
+ if (this.#process.exitCode !== null || this.#process.signalCode !== null) {
240
+ return undefined; // exited cleanly; already unwound pending
241
+ }
242
+ return new Promise((resolve) => {
243
+ this.#exitWaiters.push(resolve);
244
+ });
245
+ }
246
+ /** Kill the child and tear down (idempotent). */
247
+ close() {
248
+ if (this.#closed)
249
+ return;
250
+ this.#closed = true;
251
+ this.#process.kill("SIGTERM");
252
+ this.#rejectPending();
253
+ }
254
+ }
255
+ /**
256
+ * Spawn and connect one configured MCP server. Throws on spawn failure or a
257
+ * failed handshake — per-server isolation is the CALLER's policy (the
258
+ * extension connects all servers with `.catch` per server).
259
+ */
260
+ export async function connectMcpServer(name, config) {
261
+ const child = spawn(config.command, [...config.args], {
262
+ stdio: ["pipe", "pipe", "pipe"],
263
+ // Token injection (ruling 3): a server with an oauth block AND a stored
264
+ // token gets it as MCP_ACCESS_TOKEN, merged over the server's own env.
265
+ env: { ...process.env, ...spawnEnvWithToken(name, config) },
266
+ });
267
+ const client = new McpClient(name, child);
268
+ try {
269
+ await client.handshake();
270
+ }
271
+ catch (cause) {
272
+ client.close();
273
+ child.kill("SIGKILL");
274
+ throw cause instanceof Error
275
+ ? cause
276
+ : new Error(`mcp server "${name}" handshake failed`);
277
+ }
278
+ return client;
279
+ }