@sigrun/ext-mcp 0.1.0 → 0.2.1
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 +79 -0
- package/README.md +75 -6
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +35 -31
- package/dist/client.js +639 -136
- package/dist/config.d.ts +45 -9
- package/dist/config.js +111 -15
- package/dist/extension.d.ts +26 -45
- package/dist/extension.js +261 -136
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -5
- package/dist/oauth.d.ts +15 -0
- package/dist/oauth.js +50 -0
- package/dist/spawn-env.d.ts +23 -0
- package/dist/spawn-env.js +48 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +4 -0
- package/dist/user-servers.d.ts +93 -0
- package/dist/user-servers.js +422 -0
- package/package.json +4 -3
package/dist/config.d.ts
CHANGED
|
@@ -34,14 +34,20 @@ export declare function resolveToolExposure(server: McpServerConfig, toolName: s
|
|
|
34
34
|
* `disabled: true` both skip it (parse-time validation rejects
|
|
35
35
|
* contradictions, so either false simply means off). */
|
|
36
36
|
export declare function serverEnabled(server: McpServerConfig): boolean;
|
|
37
|
-
/**
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
readonly
|
|
37
|
+
/** Who supplies the server's authority and owns its connection lifecycle. */
|
|
38
|
+
export type McpCredentials = "user" | "shared";
|
|
39
|
+
/** Whether every user starts enabled or must explicitly opt in. */
|
|
40
|
+
export type McpAvailability = "all" | "opt-in";
|
|
41
|
+
/** Fields every server variant carries regardless of transport. */
|
|
42
|
+
interface McpServerCommon {
|
|
43
|
+
/** Per-user jailed connections by default; shared authority is opt-in. */
|
|
44
|
+
readonly credentials?: McpCredentials;
|
|
45
|
+
/** Required for shared entries: why lending this authority is acceptable. */
|
|
46
|
+
readonly sharedAuthority?: string;
|
|
47
|
+
/** Optional allowlist for a shared entry; rejected for user credentials. */
|
|
48
|
+
readonly users?: readonly string[];
|
|
49
|
+
/** Absent user settings mean enabled for all, disabled for opt-in. */
|
|
50
|
+
readonly availability?: McpAvailability;
|
|
45
51
|
/** Skip this server entirely (default false). */
|
|
46
52
|
readonly disabled?: boolean;
|
|
47
53
|
/** Skip this server entirely (default true; contradicts `disabled`). */
|
|
@@ -53,10 +59,31 @@ export interface McpServerConfig {
|
|
|
53
59
|
readonly toolExposure?: Readonly<Record<string, McpExposure>>;
|
|
54
60
|
/** Per-request wire timeout in seconds (default 60). */
|
|
55
61
|
readonly timeout?: number;
|
|
62
|
+
}
|
|
63
|
+
/** One configured MCP server (stdio transport). */
|
|
64
|
+
export interface McpStdioServerConfig extends McpServerCommon {
|
|
65
|
+
/** The executable to spawn. */
|
|
66
|
+
readonly command: string;
|
|
67
|
+
/** Arguments to the executable. */
|
|
68
|
+
readonly args: readonly string[];
|
|
69
|
+
/** Extra environment variables for the child process. */
|
|
70
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
56
71
|
/** OAuth (T3c): when present, `sigrun-ext-mcp oauth <name>` can mint a
|
|
57
72
|
* token and spawn injects it as `MCP_ACCESS_TOKEN` (see tokens.ts). */
|
|
58
73
|
readonly oauth?: McpOAuthConfig;
|
|
59
74
|
}
|
|
75
|
+
/** One configured MCP server (remote transport: streamable HTTP, with a
|
|
76
|
+
* legacy-SSE fallback negotiated on first contact). */
|
|
77
|
+
export interface McpUrlServerConfig extends McpServerCommon {
|
|
78
|
+
/** The server endpoint (http/https). */
|
|
79
|
+
readonly url: string;
|
|
80
|
+
/** Extra request headers (auth etc.), sent on every request. */
|
|
81
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
82
|
+
}
|
|
83
|
+
/** One configured MCP server: stdio OR remote. */
|
|
84
|
+
export type McpServerConfig = McpStdioServerConfig | McpUrlServerConfig;
|
|
85
|
+
/** Narrow to the stdio variant (the spawn path). */
|
|
86
|
+
export declare function isStdioServer(server: McpServerConfig): server is McpStdioServerConfig;
|
|
60
87
|
/** OAuth block for one server: how to run the authorization-code flow. */
|
|
61
88
|
export interface McpOAuthConfig {
|
|
62
89
|
/** The AS's authorize endpoint (GET; the user opens this in a browser). */
|
|
@@ -70,13 +97,21 @@ export interface McpOAuthConfig {
|
|
|
70
97
|
/** PKCE method (optional; only "S256" is implemented — and always used). */
|
|
71
98
|
readonly codeChallengeMethod?: string;
|
|
72
99
|
}
|
|
100
|
+
export type UserServersMode = "none" | "remote" | "all";
|
|
73
101
|
/** The parsed `mcp.json` body. */
|
|
74
102
|
export interface McpConfig {
|
|
75
103
|
readonly servers: Readonly<Record<string, McpServerConfig>>;
|
|
104
|
+
readonly userServers?: UserServersMode;
|
|
76
105
|
}
|
|
77
106
|
/** The env override the config store reads. */
|
|
78
107
|
export declare const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
|
|
79
|
-
/** Resolve the config path without reading it (for diagnostics).
|
|
108
|
+
/** Resolve the config path without reading it (for diagnostics).
|
|
109
|
+
*
|
|
110
|
+
* Resolution order: the `SIGRUN_MCP_CONFIG` override → the sigrun config
|
|
111
|
+
* dir (`SIGRUN_CONFIG_DIR`, the SAME root every sigrun store resolves
|
|
112
|
+
* through — a deployment setting one sets them all) → `~/.config/sigrun`
|
|
113
|
+
* (the pre-deployment fallback, kept so a bare user install keeps
|
|
114
|
+
* working). */
|
|
80
115
|
export declare function resolveMcpConfigPath(env?: NodeJS.ProcessEnv): string;
|
|
81
116
|
/**
|
|
82
117
|
* Parse and validate an `mcp.json` body. Throws a clear error naming the
|
|
@@ -89,3 +124,4 @@ export declare function parseMcpConfig(text: string, source: string): McpConfig;
|
|
|
89
124
|
* nothing); an existing-but-malformed file throws.
|
|
90
125
|
*/
|
|
91
126
|
export declare function loadMcpConfig(env?: NodeJS.ProcessEnv): McpConfig;
|
|
127
|
+
export {};
|
package/dist/config.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
|
-
import { isAbsolute, resolve } from "node:path";
|
|
19
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
20
20
|
/** Config-level exposure words: the four real ones plus pi's `codemode-deferred`
|
|
21
21
|
* alias (normalized to "codemode" at parse time — pi compat). */
|
|
22
22
|
const EXPOSURE_WORDS = [
|
|
@@ -85,15 +85,29 @@ export function resolveToolExposure(server, toolName) {
|
|
|
85
85
|
export function serverEnabled(server) {
|
|
86
86
|
return server.enabled !== false && server.disabled !== true;
|
|
87
87
|
}
|
|
88
|
+
/** Narrow to the stdio variant (the spawn path). */
|
|
89
|
+
export function isStdioServer(server) {
|
|
90
|
+
return "command" in server;
|
|
91
|
+
}
|
|
88
92
|
/** The env override the config store reads. */
|
|
89
93
|
export const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
|
|
90
|
-
/** Resolve the config path without reading it (for diagnostics).
|
|
94
|
+
/** Resolve the config path without reading it (for diagnostics).
|
|
95
|
+
*
|
|
96
|
+
* Resolution order: the `SIGRUN_MCP_CONFIG` override → the sigrun config
|
|
97
|
+
* dir (`SIGRUN_CONFIG_DIR`, the SAME root every sigrun store resolves
|
|
98
|
+
* through — a deployment setting one sets them all) → `~/.config/sigrun`
|
|
99
|
+
* (the pre-deployment fallback, kept so a bare user install keeps
|
|
100
|
+
* working). */
|
|
91
101
|
export function resolveMcpConfigPath(env = process.env) {
|
|
92
102
|
const override = env[MCP_CONFIG_ENV];
|
|
93
103
|
if (override !== undefined && override !== "") {
|
|
94
104
|
// A relative override resolves against cwd, like sigrun's other paths.
|
|
95
105
|
return isAbsolute(override) ? override : resolve(override);
|
|
96
106
|
}
|
|
107
|
+
const configDir = env.SIGRUN_CONFIG_DIR;
|
|
108
|
+
if (configDir !== undefined && configDir !== "") {
|
|
109
|
+
return join(configDir, "mcp.json");
|
|
110
|
+
}
|
|
97
111
|
return resolve(homedir(), ".config", "sigrun", "mcp.json");
|
|
98
112
|
}
|
|
99
113
|
function isRecord(value) {
|
|
@@ -114,16 +128,47 @@ export function parseMcpConfig(text, source) {
|
|
|
114
128
|
if (!isRecord(parsed)) {
|
|
115
129
|
throw new Error(`mcp config ${source} must be a JSON object`);
|
|
116
130
|
}
|
|
131
|
+
const userServers = parsed.userServers ?? "none";
|
|
132
|
+
if (userServers !== "none" && userServers !== "remote" && userServers !== "all") {
|
|
133
|
+
throw new Error(`mcp config ${source}: userServers must be one of "none", "remote", "all"`);
|
|
134
|
+
}
|
|
117
135
|
const servers = {};
|
|
118
136
|
const rawServers = parsed.servers;
|
|
119
137
|
if (!isRecord(rawServers)) {
|
|
120
138
|
throw new Error(`mcp config ${source} must have a "servers" object mapping names to server entries`);
|
|
121
139
|
}
|
|
122
140
|
for (const [name, entry] of Object.entries(rawServers)) {
|
|
141
|
+
if (name === "servers") {
|
|
142
|
+
throw new Error(`mcp config ${source}: server "servers" uses the reserved user settings namespace`);
|
|
143
|
+
}
|
|
144
|
+
if (name.startsWith("u_")) {
|
|
145
|
+
throw new Error(`mcp config ${source}: server "${name}" uses the reserved "u_" prefix`);
|
|
146
|
+
}
|
|
123
147
|
if (!isRecord(entry)) {
|
|
124
148
|
throw new Error(`mcp config ${source}: server "${name}" must be an object`);
|
|
125
149
|
}
|
|
126
|
-
|
|
150
|
+
// Transport dispatch: a server is EITHER a stdio child or a remote
|
|
151
|
+
// endpoint. Both arms share the optional fields below; each arm then
|
|
152
|
+
// validates its own required fields.
|
|
153
|
+
const hasCommand = entry.command !== undefined || entry.args !== undefined;
|
|
154
|
+
const hasUrl = entry.url !== undefined;
|
|
155
|
+
if (hasCommand && hasUrl) {
|
|
156
|
+
throw new Error(`mcp config ${source}: server "${name}" cannot carry both "command" (stdio) and "url" (remote) — pick one transport`);
|
|
157
|
+
}
|
|
158
|
+
if (!hasCommand && !hasUrl) {
|
|
159
|
+
throw new Error(`mcp config ${source}: server "${name}" needs either "command"+"args" (stdio) or "url" (remote)`);
|
|
160
|
+
}
|
|
161
|
+
if (hasUrl) {
|
|
162
|
+
if (typeof entry.url !== "string" || !/^https?:\/\//.test(entry.url)) {
|
|
163
|
+
throw new Error(`mcp config ${source}: server "${name}" url must be an http(s) URL`);
|
|
164
|
+
}
|
|
165
|
+
if (entry.headers !== undefined &&
|
|
166
|
+
(!isRecord(entry.headers) ||
|
|
167
|
+
Object.values(entry.headers).some((v) => typeof v !== "string"))) {
|
|
168
|
+
throw new Error(`mcp config ${source}: server "${name}" headers must map strings to strings`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (typeof entry.command !== "string" ||
|
|
127
172
|
!Array.isArray(entry.args) ||
|
|
128
173
|
entry.args.some((arg) => typeof arg !== "string")) {
|
|
129
174
|
throw new Error(`mcp config ${source}: server "${name}" needs a string "command" and a string[] "args"`);
|
|
@@ -133,6 +178,28 @@ export function parseMcpConfig(text, source) {
|
|
|
133
178
|
Object.values(entry.env).some((v) => typeof v !== "string"))) {
|
|
134
179
|
throw new Error(`mcp config ${source}: server "${name}" env must map strings to strings`);
|
|
135
180
|
}
|
|
181
|
+
const credentials = (entry.credentials ?? "user");
|
|
182
|
+
if (credentials !== "user" && credentials !== "shared") {
|
|
183
|
+
throw new Error(`mcp config ${source}: server "${name}" credentials must be "user" or "shared"`);
|
|
184
|
+
}
|
|
185
|
+
if (credentials === "shared" &&
|
|
186
|
+
(typeof entry.sharedAuthority !== "string" ||
|
|
187
|
+
entry.sharedAuthority.trim() === "")) {
|
|
188
|
+
throw new Error(`mcp config ${source}: server "${name}" requires a non-empty sharedAuthority when credentials are "shared"`);
|
|
189
|
+
}
|
|
190
|
+
if (entry.users !== undefined) {
|
|
191
|
+
if (credentials !== "shared") {
|
|
192
|
+
throw new Error(`mcp config ${source}: server "${name}" users is only valid with credentials "shared"`);
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(entry.users) ||
|
|
195
|
+
entry.users.some((user) => typeof user !== "string" || user.trim() === "")) {
|
|
196
|
+
throw new Error(`mcp config ${source}: server "${name}" users must be a string[] of non-empty users`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const availability = (entry.availability ?? "all");
|
|
200
|
+
if (availability !== "all" && availability !== "opt-in") {
|
|
201
|
+
throw new Error(`mcp config ${source}: server "${name}" availability must be "all" or "opt-in"`);
|
|
202
|
+
}
|
|
136
203
|
if (entry.disabled !== undefined && typeof entry.disabled !== "boolean") {
|
|
137
204
|
throw new Error(`mcp config ${source}: server "${name}" disabled must be a boolean`);
|
|
138
205
|
}
|
|
@@ -169,21 +236,50 @@ export function parseMcpConfig(text, source) {
|
|
|
169
236
|
const oauth = entry.oauth === undefined
|
|
170
237
|
? undefined
|
|
171
238
|
: parseOauthEntry(entry.oauth, source, name);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
239
|
+
const catalog = {
|
|
240
|
+
// Preserve the pre-catalog serialized shape when both fields are
|
|
241
|
+
// absent. Consumers use nullish defaults, so omitted and explicit
|
|
242
|
+
// default values are equivalent without changing legacy fixtures.
|
|
243
|
+
...(entry.credentials === undefined ? {} : { credentials }),
|
|
244
|
+
...(entry.availability === undefined ? {} : { availability }),
|
|
245
|
+
...(entry.sharedAuthority === undefined
|
|
176
246
|
? {}
|
|
177
|
-
: {
|
|
178
|
-
...(entry.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
182
|
-
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
183
|
-
...(oauth === undefined ? {} : { oauth }),
|
|
247
|
+
: { sharedAuthority: entry.sharedAuthority }),
|
|
248
|
+
...(entry.users === undefined
|
|
249
|
+
? {}
|
|
250
|
+
: { users: entry.users }),
|
|
184
251
|
};
|
|
252
|
+
// The common optionals first, then the transport arm's own fields —
|
|
253
|
+
// the union stays discriminable by "command" vs "url" presence.
|
|
254
|
+
servers[name] = hasUrl
|
|
255
|
+
? {
|
|
256
|
+
url: entry.url,
|
|
257
|
+
...(entry.headers === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { headers: entry.headers }),
|
|
260
|
+
...(entry.disabled === undefined ? {} : { disabled: entry.disabled }),
|
|
261
|
+
...(entry.enabled === undefined ? {} : { enabled: entry.enabled }),
|
|
262
|
+
...(exposure === undefined ? {} : { exposure }),
|
|
263
|
+
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
264
|
+
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
265
|
+
...catalog,
|
|
266
|
+
}
|
|
267
|
+
: {
|
|
268
|
+
command: entry.command,
|
|
269
|
+
args: entry.args,
|
|
270
|
+
...(entry.env === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: { env: entry.env }),
|
|
273
|
+
...(entry.disabled === undefined ? {} : { disabled: entry.disabled }),
|
|
274
|
+
...(entry.enabled === undefined ? {} : { enabled: entry.enabled }),
|
|
275
|
+
...(exposure === undefined ? {} : { exposure }),
|
|
276
|
+
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
277
|
+
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
278
|
+
...(oauth === undefined ? {} : { oauth }),
|
|
279
|
+
...catalog,
|
|
280
|
+
};
|
|
185
281
|
}
|
|
186
|
-
return { servers };
|
|
282
|
+
return { servers, userServers };
|
|
187
283
|
}
|
|
188
284
|
/** Parse one server's oauth block. Fail-loud on a wrong shape: a
|
|
189
285
|
* half-configured block is a config error, not a silently-ignored field. */
|
package/dist/extension.d.ts
CHANGED
|
@@ -1,51 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
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
|
-
*/
|
|
1
|
+
/** Catalog wiring: shared operator services remain system children, while
|
|
2
|
+
* per-user entries connect and publish through the structural sigrun host. */
|
|
27
3
|
import { type Extension } from "@earendil-works/pi-durable";
|
|
28
|
-
|
|
4
|
+
import { type CatalogSettingsSection, type OwnerEvent, type SigrunHost, type UserService } from "./user-servers.js";
|
|
29
5
|
export declare const MCP_EXTENSION_NAME = "sigrun.mcp";
|
|
30
|
-
|
|
6
|
+
export interface ExtensionRouteContribution {
|
|
7
|
+
readonly method: "POST";
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly auth: "user";
|
|
10
|
+
handler(request: Request, ctx: {
|
|
11
|
+
readonly principal?: {
|
|
12
|
+
readonly user?: string;
|
|
13
|
+
};
|
|
14
|
+
readonly host: SigrunHost;
|
|
15
|
+
}): Promise<Response>;
|
|
16
|
+
}
|
|
31
17
|
export interface McpWiring {
|
|
32
|
-
/** The extension to install into the harness registry. */
|
|
33
18
|
readonly extension: Extension;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
19
|
+
readonly perUserTools: boolean;
|
|
20
|
+
readonly routes: readonly ExtensionRouteContribution[];
|
|
21
|
+
readonly userServices: readonly UserService[];
|
|
22
|
+
readonly surfaces: {
|
|
23
|
+
readonly settings: readonly CatalogSettingsSection[];
|
|
24
|
+
};
|
|
25
|
+
conversationCreated(event: OwnerEvent): Promise<void>;
|
|
26
|
+
runSettled(event: OwnerEvent): Promise<void>;
|
|
27
|
+
onSettingsChanged(keys: readonly string[], user?: string): Promise<void>;
|
|
28
|
+
/** Stop system clients immediately; per-user clients drain in-flight calls. */
|
|
29
|
+
close(): Promise<void>;
|
|
44
30
|
readonly ready: Promise<void>;
|
|
45
31
|
}
|
|
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;
|
|
32
|
+
export declare function createMcpExtension(host: SigrunHost): McpWiring;
|