@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 +27 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/bin.d.ts +6 -0
- package/dist/bin.js +9 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +109 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +279 -0
- package/dist/config.d.ts +91 -0
- package/dist/config.js +237 -0
- package/dist/extension.d.ts +51 -0
- package/dist/extension.js +209 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +13 -0
- package/dist/oauth.d.ts +60 -0
- package/dist/oauth.js +238 -0
- package/dist/section.d.ts +58 -0
- package/dist/section.js +92 -0
- package/dist/tokens.d.ts +55 -0
- package/dist/tokens.js +118 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +2 -0
- package/package.json +58 -5
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
|
-
#
|
|
1
|
+
# @sigrun/ext-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
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".
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|