@sigrun/ext-mcp 0.0.0-stage → 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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The MCP config store: reads `mcp.json` — the MCP server registry.
3
+ *
4
+ * Resolution order (ruling 2):
5
+ * 1. `SIGRUN_MCP_CONFIG` env override — an explicit path to an mcp.json
6
+ * 2. `~/.config/sigrun/mcp.json` (the sigrun config dir)
7
+ *
8
+ * Shape:
9
+ * { "servers": { "<name>": {
10
+ * "command": string, "args": string[], "env"?: {...}, "disabled"?: boolean
11
+ * } } }
12
+ *
13
+ * Fail-loud on malformed config: unparseable JSON or a wrong-shaped body
14
+ * throws a clear error naming the path (the loader turns a throwing factory
15
+ * into quarantine semantics — this package never silently degrades).
16
+ */
17
+ /** How one tool's registration reaches callers (pi-durable 1.2.0):
18
+ * `codemode` → `callers: ["tools"]` (scripts only, never model-offered),
19
+ * `deferred` → `deferred: true` (model-callable once tool_search loads it),
20
+ * `direct` → normal registration (model-offered),
21
+ * `hidden` → not registered at all. */
22
+ export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";
23
+ /** The server-default exposure (spec slice 3: the default flip — a server
24
+ * with no `exposure` registers its tools script-only, NOT model-offered;
25
+ * `"exposure": "direct"` restores the pre-1.2.0 behavior). */
26
+ export declare const DEFAULT_EXPOSURE: McpExposure;
27
+ /** Default per-request wire timeout in seconds (config `timeout` overrides). */
28
+ export declare const DEFAULT_TIMEOUT_SECONDS = 60;
29
+ /** Resolve one discovered tool's exposure: an exact `toolExposure` name wins
30
+ * over patterns; among patterns the FIRST matching one in config order wins
31
+ * (pi's precedence); otherwise the server default (default "codemode"). */
32
+ export declare function resolveToolExposure(server: McpServerConfig, toolName: string): McpExposure;
33
+ /** Whether a configured server participates at all: `enabled: false` and
34
+ * `disabled: true` both skip it (parse-time validation rejects
35
+ * contradictions, so either false simply means off). */
36
+ export declare function serverEnabled(server: McpServerConfig): boolean;
37
+ /** Fields every server variant carries regardless of transport. */
38
+ interface McpServerCommon {
39
+ /** Skip this server entirely (default false). */
40
+ readonly disabled?: boolean;
41
+ /** Skip this server entirely (default true; contradicts `disabled`). */
42
+ readonly enabled?: boolean;
43
+ /** Server-default registration exposure (default "codemode"). */
44
+ readonly exposure?: McpExposure;
45
+ /** Per-tool exposure overrides: exact tool names beat patterns; the
46
+ * first matching `*`-pattern in config order wins. */
47
+ readonly toolExposure?: Readonly<Record<string, McpExposure>>;
48
+ /** Per-request wire timeout in seconds (default 60). */
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>>;
59
+ /** OAuth (T3c): when present, `sigrun-ext-mcp oauth <name>` can mint a
60
+ * token and spawn injects it as `MCP_ACCESS_TOKEN` (see tokens.ts). */
61
+ readonly oauth?: McpOAuthConfig;
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;
75
+ /** OAuth block for one server: how to run the authorization-code flow. */
76
+ export interface McpOAuthConfig {
77
+ /** The AS's authorize endpoint (GET; the user opens this in a browser). */
78
+ readonly authorizeUrl: string;
79
+ /** The AS's token endpoint (POST; the CLI exchanges the code here). */
80
+ readonly tokenUrl: string;
81
+ /** The registered client id. */
82
+ readonly clientId: string;
83
+ /** Requested scopes (optional). */
84
+ readonly scopes?: readonly string[];
85
+ /** PKCE method (optional; only "S256" is implemented — and always used). */
86
+ readonly codeChallengeMethod?: string;
87
+ }
88
+ /** The parsed `mcp.json` body. */
89
+ export interface McpConfig {
90
+ readonly servers: Readonly<Record<string, McpServerConfig>>;
91
+ }
92
+ /** The env override the config store reads. */
93
+ export declare const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
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). */
101
+ export declare function resolveMcpConfigPath(env?: NodeJS.ProcessEnv): string;
102
+ /**
103
+ * Parse and validate an `mcp.json` body. Throws a clear error naming the
104
+ * source when the JSON is unparseable or the shape is wrong.
105
+ */
106
+ export declare function parseMcpConfig(text: string, source: string): McpConfig;
107
+ /**
108
+ * Read and parse the config from the resolved path. A missing file is an
109
+ * empty config (zero servers — the extension installs and contributes
110
+ * nothing); an existing-but-malformed file throws.
111
+ */
112
+ export declare function loadMcpConfig(env?: NodeJS.ProcessEnv): McpConfig;
113
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The MCP config store: reads `mcp.json` — the MCP server registry.
3
+ *
4
+ * Resolution order (ruling 2):
5
+ * 1. `SIGRUN_MCP_CONFIG` env override — an explicit path to an mcp.json
6
+ * 2. `~/.config/sigrun/mcp.json` (the sigrun config dir)
7
+ *
8
+ * Shape:
9
+ * { "servers": { "<name>": {
10
+ * "command": string, "args": string[], "env"?: {...}, "disabled"?: boolean
11
+ * } } }
12
+ *
13
+ * Fail-loud on malformed config: unparseable JSON or a wrong-shaped body
14
+ * throws a clear error naming the path (the loader turns a throwing factory
15
+ * into quarantine semantics — this package never silently degrades).
16
+ */
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { isAbsolute, join, resolve } from "node:path";
20
+ /** Config-level exposure words: the four real ones plus pi's `codemode-deferred`
21
+ * alias (normalized to "codemode" at parse time — pi compat). */
22
+ const EXPOSURE_WORDS = [
23
+ "codemode",
24
+ "deferred",
25
+ "direct",
26
+ "hidden",
27
+ "codemode-deferred",
28
+ ];
29
+ /** The server-default exposure (spec slice 3: the default flip — a server
30
+ * with no `exposure` registers its tools script-only, NOT model-offered;
31
+ * `"exposure": "direct"` restores the pre-1.2.0 behavior). */
32
+ export const DEFAULT_EXPOSURE = "codemode";
33
+ /** Default per-request wire timeout in seconds (config `timeout` overrides). */
34
+ export const DEFAULT_TIMEOUT_SECONDS = 60;
35
+ /** Parse one exposure word; the alias normalizes to "codemode". */
36
+ function parseExposure(value, source, where) {
37
+ if (typeof value !== "string" || !EXPOSURE_WORDS.includes(value)) {
38
+ throw new Error(`mcp config ${source}: ${where} exposure must be one of ${EXPOSURE_WORDS.map((word) => JSON.stringify(word)).join(", ")}`);
39
+ }
40
+ const word = value;
41
+ return word === "codemode-deferred" ? "codemode" : word;
42
+ }
43
+ /** Glob match for `toolExposure` patterns: `*` matches any run of
44
+ * characters (including `/`); a pattern without `*` is exact-only. */
45
+ function globMatch(pattern, name) {
46
+ const parts = pattern.split("*");
47
+ if (parts.length === 1)
48
+ return pattern === name;
49
+ if (!name.startsWith(parts[0]))
50
+ return false;
51
+ let index = parts[0].length;
52
+ for (let i = 1; i < parts.length; i++) {
53
+ const part = parts[i];
54
+ const last = i === parts.length - 1;
55
+ const found = last ? name.lastIndexOf(part) : name.indexOf(part, index);
56
+ if (found < 0 || found < index)
57
+ return false;
58
+ index = found + part.length;
59
+ if (last && index !== name.length)
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+ /** Resolve one discovered tool's exposure: an exact `toolExposure` name wins
65
+ * over patterns; among patterns the FIRST matching one in config order wins
66
+ * (pi's precedence); otherwise the server default (default "codemode"). */
67
+ export function resolveToolExposure(server, toolName) {
68
+ const overrides = server.toolExposure;
69
+ if (overrides !== undefined) {
70
+ const exact = overrides[toolName];
71
+ if (exact !== undefined)
72
+ return exact;
73
+ for (const [pattern, exposure] of Object.entries(overrides)) {
74
+ if (!pattern.includes("*"))
75
+ continue;
76
+ if (globMatch(pattern, toolName))
77
+ return exposure;
78
+ }
79
+ }
80
+ return server.exposure ?? DEFAULT_EXPOSURE;
81
+ }
82
+ /** Whether a configured server participates at all: `enabled: false` and
83
+ * `disabled: true` both skip it (parse-time validation rejects
84
+ * contradictions, so either false simply means off). */
85
+ export function serverEnabled(server) {
86
+ return server.enabled !== false && server.disabled !== true;
87
+ }
88
+ /** Narrow to the stdio variant (the spawn path). */
89
+ export function isStdioServer(server) {
90
+ return "command" in server;
91
+ }
92
+ /** The env override the config store reads. */
93
+ export const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
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). */
101
+ export function resolveMcpConfigPath(env = process.env) {
102
+ const override = env[MCP_CONFIG_ENV];
103
+ if (override !== undefined && override !== "") {
104
+ // A relative override resolves against cwd, like sigrun's other paths.
105
+ return isAbsolute(override) ? override : resolve(override);
106
+ }
107
+ const configDir = env.SIGRUN_CONFIG_DIR;
108
+ if (configDir !== undefined && configDir !== "") {
109
+ return join(configDir, "mcp.json");
110
+ }
111
+ return resolve(homedir(), ".config", "sigrun", "mcp.json");
112
+ }
113
+ function isRecord(value) {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }
116
+ /**
117
+ * Parse and validate an `mcp.json` body. Throws a clear error naming the
118
+ * source when the JSON is unparseable or the shape is wrong.
119
+ */
120
+ export function parseMcpConfig(text, source) {
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(text);
124
+ }
125
+ catch (cause) {
126
+ throw new Error(`mcp config ${source} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
127
+ }
128
+ if (!isRecord(parsed)) {
129
+ throw new Error(`mcp config ${source} must be a JSON object`);
130
+ }
131
+ const servers = {};
132
+ const rawServers = parsed.servers;
133
+ if (!isRecord(rawServers)) {
134
+ throw new Error(`mcp config ${source} must have a "servers" object mapping names to server entries`);
135
+ }
136
+ for (const [name, entry] of Object.entries(rawServers)) {
137
+ if (!isRecord(entry)) {
138
+ throw new Error(`mcp config ${source}: server "${name}" must be an object`);
139
+ }
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" ||
162
+ !Array.isArray(entry.args) ||
163
+ entry.args.some((arg) => typeof arg !== "string")) {
164
+ throw new Error(`mcp config ${source}: server "${name}" needs a string "command" and a string[] "args"`);
165
+ }
166
+ if (entry.env !== undefined &&
167
+ (!isRecord(entry.env) ||
168
+ Object.values(entry.env).some((v) => typeof v !== "string"))) {
169
+ throw new Error(`mcp config ${source}: server "${name}" env must map strings to strings`);
170
+ }
171
+ if (entry.disabled !== undefined && typeof entry.disabled !== "boolean") {
172
+ throw new Error(`mcp config ${source}: server "${name}" disabled must be a boolean`);
173
+ }
174
+ if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") {
175
+ throw new Error(`mcp config ${source}: server "${name}" enabled must be a boolean`);
176
+ }
177
+ // enabled/disabled must agree when both present: a server that is
178
+ // simultaneously on and off is a config lie, not a default to guess.
179
+ if (entry.enabled !== undefined &&
180
+ entry.disabled !== undefined &&
181
+ entry.enabled === entry.disabled) {
182
+ throw new Error(`mcp config ${source}: server "${name}" enabled and disabled contradict each other`);
183
+ }
184
+ if (entry.timeout !== undefined) {
185
+ if (typeof entry.timeout !== "number" ||
186
+ !Number.isFinite(entry.timeout) ||
187
+ entry.timeout <= 0) {
188
+ throw new Error(`mcp config ${source}: server "${name}" timeout must be a positive number of seconds`);
189
+ }
190
+ }
191
+ const exposure = entry.exposure === undefined
192
+ ? undefined
193
+ : parseExposure(entry.exposure, source, `server "${name}"`);
194
+ let toolExposure;
195
+ if (entry.toolExposure !== undefined) {
196
+ if (!isRecord(entry.toolExposure)) {
197
+ throw new Error(`mcp config ${source}: server "${name}" toolExposure must be an object mapping tool names or patterns to exposure words`);
198
+ }
199
+ toolExposure = {};
200
+ for (const [key, value] of Object.entries(entry.toolExposure)) {
201
+ toolExposure[key] = parseExposure(value, source, `server "${name}" toolExposure "${key}"`);
202
+ }
203
+ }
204
+ const oauth = entry.oauth === undefined
205
+ ? undefined
206
+ : parseOauthEntry(entry.oauth, source, name);
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
+ };
234
+ }
235
+ return { servers };
236
+ }
237
+ /** Parse one server's oauth block. Fail-loud on a wrong shape: a
238
+ * half-configured block is a config error, not a silently-ignored field. */
239
+ function parseOauthEntry(value, source, name) {
240
+ if (!isRecord(value)) {
241
+ throw new Error(`mcp config ${source}: server "${name}" oauth must be an object`);
242
+ }
243
+ if (typeof value.authorizeUrl !== "string" ||
244
+ typeof value.tokenUrl !== "string" ||
245
+ typeof value.clientId !== "string") {
246
+ throw new Error(`mcp config ${source}: server "${name}" oauth needs string "authorizeUrl", "tokenUrl" and "clientId"`);
247
+ }
248
+ if (value.scopes !== undefined &&
249
+ (!Array.isArray(value.scopes) ||
250
+ value.scopes.some((scope) => typeof scope !== "string"))) {
251
+ throw new Error(`mcp config ${source}: server "${name}" oauth scopes must be a string[]`);
252
+ }
253
+ if (value.codeChallengeMethod !== undefined &&
254
+ typeof value.codeChallengeMethod !== "string") {
255
+ throw new Error(`mcp config ${source}: server "${name}" oauth codeChallengeMethod must be a string`);
256
+ }
257
+ // Only S256 is implemented; a config naming anything else would silently
258
+ // send S256 anyway — fail loudly instead (config lies must not pass).
259
+ if (value.codeChallengeMethod !== undefined &&
260
+ value.codeChallengeMethod !== "S256") {
261
+ throw new Error(`mcp config ${source}: server "${name}" oauth codeChallengeMethod must be "S256" (the only implemented method)`);
262
+ }
263
+ return {
264
+ authorizeUrl: value.authorizeUrl,
265
+ tokenUrl: value.tokenUrl,
266
+ clientId: value.clientId,
267
+ ...(value.scopes === undefined
268
+ ? {}
269
+ : { scopes: value.scopes }),
270
+ ...(value.codeChallengeMethod === undefined
271
+ ? {}
272
+ : { codeChallengeMethod: value.codeChallengeMethod }),
273
+ };
274
+ }
275
+ /**
276
+ * Read and parse the config from the resolved path. A missing file is an
277
+ * empty config (zero servers — the extension installs and contributes
278
+ * nothing); an existing-but-malformed file throws.
279
+ */
280
+ export function loadMcpConfig(env = process.env) {
281
+ const path = resolveMcpConfigPath(env);
282
+ if (!existsSync(path))
283
+ return { servers: {} };
284
+ const text = readFileSync(path, "utf8");
285
+ return parseMcpConfig(text, path);
286
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The MCP extension (sigrun-ext-mcp): registers every tool discovered on
3
+ * the configured MCP servers as a pi-durable tool named
4
+ * `<server>/<toolname>` (ruling 4).
5
+ *
6
+ * Contract (sigrun's src/harness/loader.ts): the package default-exports a
7
+ * FACTORY TAKING NO ARGUMENTS, returning `{ extension }`. The loader calls
8
+ * it synchronously at boot, before `Harness.open` reads the registry — so
9
+ * the factory returns immediately and the async connect/discover work lands
10
+ * through a LIVE tools array (the registry re-reads `extension.tools` on
11
+ * every snapshot, so tools discovered after install appear in the harness
12
+ * without a reinstall).
13
+ *
14
+ * Lifecycle (ruling 5): servers spawn eagerly at factory time (a server is
15
+ * a long-lived stdio process; lazy-at-first-use buys nothing and complicates
16
+ * discovery visibility). Each server is ISOLATED: a spawn or handshake or
17
+ * discovery failure quarantines just that server (warned, contributing no
18
+ * tools) and never affects the others. Children are killed on process exit
19
+ * and on `wiring.close()`; there is no loader-side uninstall callback to
20
+ * hook.
21
+ *
22
+ * Config (ruling 2): `mcp.json` resolved as `SIGRUN_MCP_CONFIG` env
23
+ * override → `~/.config/sigrun/mcp.json`. Malformed config THROWS from the
24
+ * factory (the loader's quarantine semantics take over); a missing config
25
+ * is an empty registry (the extension installs with zero tools).
26
+ */
27
+ import { type Extension } from "@earendil-works/pi-durable";
28
+ /** The extension's pi-durable registry name. */
29
+ export declare const MCP_EXTENSION_NAME = "sigrun.mcp";
30
+ /** What {@link createMcpExtension} returns (the loader contract: `.extension`). */
31
+ export interface McpWiring {
32
+ /** The extension to install into the harness registry. */
33
+ readonly extension: Extension;
34
+ /**
35
+ * Kill every connected MCP server child process (idempotent). Called on
36
+ * process exit automatically; hosts may call it on harness shutdown.
37
+ */
38
+ close(): void;
39
+ /**
40
+ * Resolves when every enabled server has connected (and discovered its
41
+ * tools) or failed (isolated). Hosts that want tools present before
42
+ * `Harness.open` reads the registry await this; the loader does not.
43
+ */
44
+ readonly ready: Promise<void>;
45
+ }
46
+ /**
47
+ * The extension factory (the loader contract: no arguments; config resolves
48
+ * from the package context — the `SIGRUN_MCP_CONFIG` override or the sigrun
49
+ * config dir).
50
+ */
51
+ export declare function createMcpExtension(): McpWiring;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The MCP extension (sigrun-ext-mcp): registers every tool discovered on
3
+ * the configured MCP servers as a pi-durable tool named
4
+ * `<server>/<toolname>` (ruling 4).
5
+ *
6
+ * Contract (sigrun's src/harness/loader.ts): the package default-exports a
7
+ * FACTORY TAKING NO ARGUMENTS, returning `{ extension }`. The loader calls
8
+ * it synchronously at boot, before `Harness.open` reads the registry — so
9
+ * the factory returns immediately and the async connect/discover work lands
10
+ * through a LIVE tools array (the registry re-reads `extension.tools` on
11
+ * every snapshot, so tools discovered after install appear in the harness
12
+ * without a reinstall).
13
+ *
14
+ * Lifecycle (ruling 5): servers spawn eagerly at factory time (a server is
15
+ * a long-lived stdio process; lazy-at-first-use buys nothing and complicates
16
+ * discovery visibility). Each server is ISOLATED: a spawn or handshake or
17
+ * discovery failure quarantines just that server (warned, contributing no
18
+ * tools) and never affects the others. Children are killed on process exit
19
+ * and on `wiring.close()`; there is no loader-side uninstall callback to
20
+ * hook.
21
+ *
22
+ * Config (ruling 2): `mcp.json` resolved as `SIGRUN_MCP_CONFIG` env
23
+ * override → `~/.config/sigrun/mcp.json`. Malformed config THROWS from the
24
+ * factory (the loader's quarantine semantics take over); a missing config
25
+ * is an empty registry (the extension installs with zero tools).
26
+ */
27
+ import { Type } from "@earendil-works/pi-ai";
28
+ import { defineExtension, defineTool, } from "@earendil-works/pi-durable";
29
+ import { connectMcpServer } from "./client.js";
30
+ import { isStdioServer, loadMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
31
+ import { MCP_SERVERS_SECTION_KEY, partitionForSection, renderMcpServersSection, } from "./section.js";
32
+ /** The extension's pi-durable registry name. */
33
+ export const MCP_EXTENSION_NAME = "sigrun.mcp";
34
+ /** Render one MCP content block as pi-ai tool-result content. */
35
+ function toContent(block) {
36
+ if (block.type === "text" && typeof block.text === "string") {
37
+ return { type: "text", text: block.text };
38
+ }
39
+ return { type: "text", text: JSON.stringify(block) };
40
+ }
41
+ /** Build the pi-durable tool for one discovered MCP tool. */
42
+ function mcpTool(serverName, tool, client, exposure, timeoutSeconds) {
43
+ // MCP advertises a plain JSON Schema for arguments. pi-durable validates
44
+ // with TypeBox's Compile, which accepts plain JSON Schema objects; pass it
45
+ // through as an unsafe schema (fallback: the empty object schema when the
46
+ // server omitted inputSchema).
47
+ const parameters = (tool.inputSchema !== undefined &&
48
+ typeof tool.inputSchema === "object" &&
49
+ tool.inputSchema !== null
50
+ ? tool.inputSchema
51
+ : { type: "object" });
52
+ const toolName = `${serverName}/${tool.name}`;
53
+ // The exposure mapping onto pi-durable 1.2.0 (spec slice 3):
54
+ // codemode → callers: ["tools"] (scripts only, never model-offered);
55
+ // deferred → deferred: true (invisible until tool_search loads it);
56
+ // direct → normal registration (model-offered when in the tool set);
57
+ // hidden → never registered (the caller checks and skips).
58
+ const exposureFlags = exposure === "codemode"
59
+ ? { callers: ["tools"] }
60
+ : exposure === "deferred"
61
+ ? { deferred: true }
62
+ : {};
63
+ return defineTool({
64
+ name: toolName,
65
+ description: tool.description !== undefined && tool.description !== ""
66
+ ? tool.description
67
+ : `MCP tool ${tool.name} on server ${serverName}.`,
68
+ parameters: Type.Unsafe(parameters),
69
+ // A tools/call round-trip is external I/O; never rerun on recovery.
70
+ replay: "unsafe",
71
+ ...exposureFlags,
72
+ async execute(args) {
73
+ const result = await client.callTool(tool.name, { ...args }, timeoutSeconds);
74
+ const blocks = result.content ?? [];
75
+ const content = blocks.length > 0 ? blocks.map(toContent) : [];
76
+ return {
77
+ output: content.length > 0 ? content : [
78
+ { type: "text", text: JSON.stringify(result) },
79
+ ],
80
+ isError: result.isError === true,
81
+ details: result,
82
+ };
83
+ },
84
+ });
85
+ }
86
+ /**
87
+ * The extension factory (the loader contract: no arguments; config resolves
88
+ * from the package context — the `SIGRUN_MCP_CONFIG` override or the sigrun
89
+ * config dir).
90
+ */
91
+ export function createMcpExtension() {
92
+ const config = loadMcpConfig();
93
+ const tools = []; // live: discovered tools land here
94
+ const clients = new Map();
95
+ /** Live per-server exposure mix: which registered tools use codemode /
96
+ * deferred (the section renders from this snapshot). */
97
+ const serverExposures = new Map();
98
+ let closed = false;
99
+ // Per-server isolation: each server connects independently; any failure
100
+ // quarantines that server alone (warned, zero tools) and the others
101
+ // proceed. A disabled server is skipped before it ever spawns.
102
+ const pending = [];
103
+ for (const [name, server] of Object.entries(config.servers)) {
104
+ if (!serverEnabled(server))
105
+ continue;
106
+ pending.push((async () => {
107
+ let client;
108
+ try {
109
+ client = await connectMcpServer(name, server);
110
+ }
111
+ catch (cause) {
112
+ console.warn(`mcp server "${name}" quarantined: ${cause instanceof Error ? cause.message : String(cause)}`);
113
+ return;
114
+ }
115
+ if (closed) {
116
+ client.close();
117
+ return;
118
+ }
119
+ clients.set(name, client);
120
+ try {
121
+ const discovered = await client.listTools();
122
+ const mix = { codemode: false, deferred: false, count: 0 };
123
+ for (const tool of discovered) {
124
+ const exposure = resolveToolExposure(server, tool.name);
125
+ if (exposure === "hidden")
126
+ continue; // not registered
127
+ if (exposure === "codemode")
128
+ mix.codemode = true;
129
+ if (exposure === "deferred")
130
+ mix.deferred = true;
131
+ mix.count += 1;
132
+ tools.push(mcpTool(name, tool, client, exposure, server.timeout));
133
+ }
134
+ if (mix.count > 0 && (mix.codemode || mix.deferred)) {
135
+ const origin = isStdioServer(server)
136
+ ? `${server.command} ${server.args.join(" ")}`.trim()
137
+ : server.url;
138
+ serverExposures.set(name, {
139
+ ...mix,
140
+ description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${origin}`.trim(),
141
+ });
142
+ }
143
+ }
144
+ catch (cause) {
145
+ clients.delete(name);
146
+ client.close();
147
+ console.warn(`mcp server "${name}" quarantined during discovery: ${cause instanceof Error ? cause.message : String(cause)}`);
148
+ }
149
+ })());
150
+ }
151
+ const killAll = () => {
152
+ if (closed)
153
+ return;
154
+ closed = true;
155
+ for (const client of clients.values())
156
+ client.close();
157
+ clients.clear();
158
+ tools.length = 0;
159
+ serverExposures.clear();
160
+ };
161
+ // Children must not outlive this process. The exit handler must be
162
+ // synchronous (exit listeners cannot await), so it only SIGTERMs.
163
+ process.on("exit", killAll);
164
+ // NOTE: no factory-time boot log for codemode/deferred servers —
165
+ // exposure is only known AFTER discovery (per-tool resolution), so a
166
+ // warn here could only fire on config guesses; the per-request
167
+ // `mcp_servers` section (below) is the real, accurate warning channel.
168
+ // The `mcp_servers` section (rev 3's corrected warning channel): renders
169
+ // per request from the live agent. A codemode/deferred server appears ONLY
170
+ // when its discovery tool (codemode / tool_search) is model-offered in
171
+ // THIS request; unreachable servers collapse into one notice line.
172
+ const mcpServersSection = (input) => {
173
+ const reaches = new Map();
174
+ for (const [name, mix] of serverExposures) {
175
+ reaches.set(name, mix.codemode && mix.deferred
176
+ ? "either"
177
+ : mix.codemode
178
+ ? "codemode"
179
+ : "tool_search");
180
+ }
181
+ const offers = new Set(input.agent.tools.map((tool) => tool.name));
182
+ const { listed, unreachable } = partitionForSection(reaches, offers);
183
+ const entries = listed.map((name) => {
184
+ const mix = serverExposures.get(name);
185
+ return {
186
+ name,
187
+ description: mix.description,
188
+ how: mix.codemode && mix.deferred
189
+ ? "codemode scripts and the tool_search tool"
190
+ : mix.codemode
191
+ ? "codemode scripts"
192
+ : "the tool_search tool",
193
+ };
194
+ });
195
+ return renderMcpServersSection(entries, unreachable.length);
196
+ };
197
+ const extension = defineExtension({
198
+ name: MCP_EXTENSION_NAME,
199
+ tools, // live array: the registry re-reads it on every snapshot
200
+ sections: [
201
+ {
202
+ key: MCP_SERVERS_SECTION_KEY,
203
+ render: mcpServersSection,
204
+ },
205
+ ],
206
+ });
207
+ return {
208
+ extension,
209
+ close: killAll,
210
+ ready: Promise.all(pending).then(() => undefined),
211
+ };
212
+ }