@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +108 -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 +42 -0
- package/dist/client.js +685 -0
- package/dist/config.d.ts +113 -0
- package/dist/config.js +286 -0
- package/dist/extension.d.ts +51 -0
- package/dist/extension.js +212 -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/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|