@sigrun/ext-mcp 0.2.0 → 0.2.2
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 +70 -0
- package/README.md +60 -5
- package/dist/cli.js +4 -0
- package/dist/client.d.ts +11 -1
- package/dist/client.js +106 -9
- package/dist/config.d.ts +14 -0
- package/dist/config.js +55 -1
- package/dist/extension.d.ts +26 -45
- package/dist/extension.js +262 -139
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -4
- 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 +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,76 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
|
+
### Changed
|
|
10
|
+
- Forward-compatible with pi-durable 1.4.0 (explicit `checkpointWhen`;
|
|
11
|
+
widened peer range). No behaviour change.
|
|
12
|
+
- Block-validator tests now compile against core's real generated
|
|
13
|
+
`@sigrun/sigrun/blocks` declarations instead of a local ambient shim.
|
|
14
|
+
A content-hash build stamp rejects missing or stale sibling output with
|
|
15
|
+
an explicit core rebuild instruction; the sibling CI path builds core
|
|
16
|
+
before the validator-form test.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- **stdio MCP servers receive an allowlisted environment instead of sigrun's
|
|
20
|
+
process environment.** `SIGRUN_API_TOKEN` and provider keys were inherited
|
|
21
|
+
by every spawned stdio server; they now see only the variables their
|
|
22
|
+
declaration names plus a minimal base. Previously published `0.2.0` is
|
|
23
|
+
affected; `0.2.1` is the first release with the fix.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Upgrade behavior:** catalog servers now default to `credentials: "user"`.
|
|
27
|
+
Existing installs that rely on one system-wide server must explicitly
|
|
28
|
+
select `credentials: "shared"` and provide `sharedAuthority`. Shared MCP
|
|
29
|
+
servers hand every permitted user's agent the admin's credential for that
|
|
30
|
+
service.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- Per-user tool publication is declared even with an empty or shared-only
|
|
34
|
+
catalog, so lifecycle activation and prompt rendering no longer fail when
|
|
35
|
+
there are no personal catalog servers. Because the declaration is
|
|
36
|
+
unconditional, core's boot sweep now writes `sigrun.mcp@<owner>` into every
|
|
37
|
+
owned conversation's selection on the first boot after upgrading, even with
|
|
38
|
+
no `mcp.json` or `userServers: "none"`. That is a one-time durable write:
|
|
39
|
+
the name resolves to nothing while no tools are published, so nothing
|
|
40
|
+
changes for the conversation, and later boots report zero.
|
|
41
|
+
- Catalog enablement now mounts through `surfaces.settings` and uses core's
|
|
42
|
+
actual flat settings shape: each toggle and changed key is the bare server
|
|
43
|
+
name. Shared-authority help is punctuated and guarded against missing
|
|
44
|
+
programmatic input.
|
|
45
|
+
- Prompt sections resolve the conversation owner through the harness's
|
|
46
|
+
committed-read surface instead of the server-bound identity bridge, so
|
|
47
|
+
harness-only boots cannot hang while preparing a request.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- User-supplied MCP servers in per-user settings, gated by `userServers:
|
|
51
|
+
"none" | "remote" | "all"` and validated with the operator config parser.
|
|
52
|
+
Tools publish as `u_<server>/<tool>`; live policy changes reconcile every
|
|
53
|
+
active user. The `all` mode also permits URL servers, but reports
|
|
54
|
+
user-supplied stdio as a named configuration error because the host's
|
|
55
|
+
static user-service contract cannot launch it safely.
|
|
56
|
+
- Per-user OAuth start/complete extension routes with ten-minute in-memory
|
|
57
|
+
PKCE state scoped by user and server. Completion stores the raw access
|
|
58
|
+
token in the caller's scoped secrets; the CLI refuses per-user servers.
|
|
59
|
+
- Token storage retires and reconnects the caller's catalog connection.
|
|
60
|
+
Core owns the process lifecycle: a still-live child retains its original
|
|
61
|
+
environment until idle-stop or exit because the host contract cannot force
|
|
62
|
+
a relaunch.
|
|
63
|
+
- MCP catalog entries default to per-user credentials, with lazy per-user
|
|
64
|
+
stdio services, scoped token injection, remote connections, and tools
|
|
65
|
+
published only for their owner. Opt-in availability and per-user settings
|
|
66
|
+
control enablement; disabling removes tools and drains active calls.
|
|
67
|
+
- Shared entries require `sharedAuthority`, retain system-tool behavior,
|
|
68
|
+
and enforce their optional user allowlist and enablement at call time.
|
|
69
|
+
Prompt sections list only servers available to the calling user.
|
|
70
|
+
|
|
71
|
+
### Security
|
|
72
|
+
- Stdio servers no longer inherit sigrun's environment. They are spawned
|
|
73
|
+
with an allowlist (`PATH`, `HOME`, `TMPDIR`, `LANG`, `LC_*`, `TZ`,
|
|
74
|
+
`TERM`, `USER`, `LOGNAME`, `SHELL`, `NPM_CONFIG_CACHE`,
|
|
75
|
+
`SSH_AUTH_SOCK`) plus their configured `env` and stored token.
|
|
76
|
+
Previously every server could read `SIGRUN_API_TOKEN`,
|
|
77
|
+
`SIGRUN_PROXY_SECRET` and provider keys. A server that relied on an
|
|
78
|
+
inherited variable must now list it in its `env`.
|
|
9
79
|
|
|
10
80
|
## [0.2.0] - 2026-10-22
|
|
11
81
|
### Added
|
package/README.md
CHANGED
|
@@ -33,8 +33,25 @@ bare user install falls back to `~/.config/sigrun/mcp.json`; the
|
|
|
33
33
|
`endpoint` event + message POSTs) is adopted automatically, so no adapter
|
|
34
34
|
process is needed.
|
|
35
35
|
- `headers` (remote) — extra request headers (auth etc.), sent on every
|
|
36
|
-
request.
|
|
37
|
-
|
|
36
|
+
request. For per-user servers, configured `Authorization` is removed;
|
|
37
|
+
only that user's scoped token supplies it.
|
|
38
|
+
- `env` (stdio) — extra environment variables for the child process. For
|
|
39
|
+
per-user servers, configured `MCP_ACCESS_TOKEN` is removed; only that
|
|
40
|
+
user's scoped token supplies it.
|
|
41
|
+
- `credentials` — `"user"` (default) or `"shared"`. User-credential servers
|
|
42
|
+
start per user in the host's service jail; remote user servers connect
|
|
43
|
+
with the user's scoped token. Existing installs needing one system-wide
|
|
44
|
+
server must explicitly select `"shared"`.
|
|
45
|
+
- `sharedAuthority` — required, non-empty one-line reason when
|
|
46
|
+
`credentials` is `"shared"`. Shared MCP servers hand every permitted
|
|
47
|
+
user's agent the admin's credential for that service; the settings
|
|
48
|
+
section displays this reason and warning beside the server.
|
|
49
|
+
- `users` — optional string allowlist for a shared server. Only those
|
|
50
|
+
users may call its tools; this field is invalid for user-credential
|
|
51
|
+
servers. Ownerless calls fail closed.
|
|
52
|
+
- `availability` — `"all"` (default) or `"opt-in"`. The per-user settings
|
|
53
|
+
section stores each server's enablement as a flat boolean keyed by its
|
|
54
|
+
bare server name. `"all"` starts enabled; `"opt-in"` starts disabled.
|
|
38
55
|
- `enabled` (default `true`) / `disabled` — skip the server entirely. Set one,
|
|
39
56
|
never both: a server that is simultaneously on and off is a config error and
|
|
40
57
|
fails loudly.
|
|
@@ -46,9 +63,47 @@ bare user install falls back to `~/.config/sigrun/mcp.json`; the
|
|
|
46
63
|
- `toolExposure` — per-tool overrides of `exposure`: an object mapping tool
|
|
47
64
|
names (exact) or `*`-globs (e.g. `"get-*"`) to exposure words. An exact name
|
|
48
65
|
always wins; among matching patterns the FIRST one in config order wins.
|
|
49
|
-
- `oauth` (stdio) — the authorization-code (PKCE) flow block
|
|
50
|
-
`sigrun-ext-mcp oauth <server>`
|
|
51
|
-
|
|
66
|
+
- `oauth` (stdio) — the authorization-code (PKCE) flow block. Shared servers
|
|
67
|
+
use `sigrun-ext-mcp oauth <server>` and the file token store. Per-user
|
|
68
|
+
servers use `POST /v1/ext/sigrun-mcp/oauth/<server>/start`, then
|
|
69
|
+
`POST /v1/ext/sigrun-mcp/oauth/<server>/complete` with
|
|
70
|
+
`{ "redirectedUrl": "<full redirected URL>" }`; the CLI refuses these
|
|
71
|
+
servers so their credentials cannot enter the shared store.
|
|
72
|
+
|
|
73
|
+
Per-user PKCE state stays in memory for ten minutes and is scoped to the
|
|
74
|
+
authenticated user and server. Completion stores only the raw access token in
|
|
75
|
+
that user's `token:<server>` secret. It then retires and reconnects that
|
|
76
|
+
user's catalog connection. Core owns the process lifecycle: releasing the last
|
|
77
|
+
handle arms idle-stop, so a still-live child keeps its original environment
|
|
78
|
+
until that timer fires or it crashes; the extension cannot force a relaunch
|
|
79
|
+
through the host contract. An already idle-stopped server launches fresh on
|
|
80
|
+
the next activation and reads the new token immediately.
|
|
81
|
+
|
|
82
|
+
`userServers` is the separate admin policy for user-supplied servers:
|
|
83
|
+
`"none"` (default), `"remote"`, or `"all"`. It is distinct from the admin
|
|
84
|
+
catalog's `credentials` and `availability` fields above. Catalog names
|
|
85
|
+
starting `u_` are reserved for those user-supplied servers and rejected.
|
|
86
|
+
|
|
87
|
+
When enabled, the **Your MCP servers** settings section accepts a JSON object
|
|
88
|
+
under the `servers` key. Entries use the same validation rules as `mcp.json`;
|
|
89
|
+
a user server name may not start with `u_`. Its tools publish only to that
|
|
90
|
+
user as `u_<server>/<tool>`. `"remote"` permits URL servers and ignores stdio;
|
|
91
|
+
`"all"` permits URL servers too. A live policy change reconciles every active
|
|
92
|
+
user, retiring disallowed connections and removing their tools without a
|
|
93
|
+
restart. Lifecycle events re-read the policy from `mcp.json`, since this
|
|
94
|
+
operator file is not part of core's settings document. The contributed
|
|
95
|
+
settings sections are selected at extension load: changing `none` to an
|
|
96
|
+
enabled policy needs a restart to add the editor, and changing to `none`
|
|
97
|
+
needs a restart to remove it; connection gating itself takes effect at the
|
|
98
|
+
next lifecycle event. A catalog server literally named `servers` is rejected
|
|
99
|
+
because it would shadow this settings namespace.
|
|
100
|
+
|
|
101
|
+
**Named limitation — `all` and user-supplied stdio:** sigrun declares per-user
|
|
102
|
+
services statically at load and passes no server name to `UserService.start`.
|
|
103
|
+
Consequently a dynamic, named user-supplied stdio service cannot be launched.
|
|
104
|
+
Under `"all"`, such an entry is reported as a configuration error naming the
|
|
105
|
+
server; its command is never spawned in sigrun's process, and no tools are
|
|
106
|
+
published. `"all"` does **not** make user-supplied stdio functional.
|
|
52
107
|
|
|
53
108
|
## Exposure: how tools reach callers
|
|
54
109
|
|
package/dist/cli.js
CHANGED
|
@@ -71,6 +71,10 @@ export async function runCli(argv, io = { stdout: process.stdout, stderr: proces
|
|
|
71
71
|
io.stderr.write(`error: no server named "${parsed.server}" in the MCP config\n`);
|
|
72
72
|
return 1;
|
|
73
73
|
}
|
|
74
|
+
if (server.credentials !== "shared") {
|
|
75
|
+
io.stderr.write(`error: server "${parsed.server}" uses per-user credentials; use POST /v1/ext/sigrun-mcp/oauth/${parsed.server}/start instead\n`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
74
78
|
if (!isStdioServer(server) || server.oauth === undefined) {
|
|
75
79
|
io.stderr.write(`error: server "${parsed.server}" has no oauth block in the MCP config\n`);
|
|
76
80
|
return 1;
|
package/dist/client.d.ts
CHANGED
|
@@ -33,10 +33,20 @@ export interface McpClient {
|
|
|
33
33
|
/** Tear the connection down (idempotent). */
|
|
34
34
|
close(): void;
|
|
35
35
|
}
|
|
36
|
+
/** The byte streams of a core-launched per-user service. */
|
|
37
|
+
export interface McpServiceHandle {
|
|
38
|
+
readonly stdin: WritableStream<Uint8Array>;
|
|
39
|
+
readonly stdout: ReadableStream<Uint8Array>;
|
|
40
|
+
readonly exited: Promise<{
|
|
41
|
+
code: number | null;
|
|
42
|
+
signal: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
release(): void;
|
|
45
|
+
}
|
|
36
46
|
/**
|
|
37
47
|
* Spawn/connect one configured MCP server (stdio or remote by config
|
|
38
48
|
* shape). Throws on spawn/connect failure or a failed handshake —
|
|
39
49
|
* per-server isolation is the CALLER's policy (the extension connects all
|
|
40
50
|
* servers with `.catch` per server).
|
|
41
51
|
*/
|
|
42
|
-
export declare function connectMcpServer(name: string, config: McpServerConfig): Promise<McpClient>;
|
|
52
|
+
export declare function connectMcpServer(name: string, config: McpServerConfig, handle?: McpServiceHandle): Promise<McpClient>;
|
package/dist/client.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* transport (per-server isolation: the next request gets a fresh chance).
|
|
27
27
|
*/
|
|
28
28
|
import { spawn } from "node:child_process";
|
|
29
|
-
import {
|
|
29
|
+
import { serverSpawnEnv } from "./spawn-env.js";
|
|
30
30
|
import { DEFAULT_TIMEOUT_SECONDS } from "./config.js";
|
|
31
31
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
32
32
|
function isRecord(value) {
|
|
@@ -183,9 +183,6 @@ class McpEngine {
|
|
|
183
183
|
this.handleDeath(new Error("mcp client closed"));
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
// stdio transport (the original): today's behavior, moved verbatim in shape.
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
186
|
/** One connected stdio MCP server: a live child process speaking JSON-RPC. */
|
|
190
187
|
class StdioTransport {
|
|
191
188
|
#engine;
|
|
@@ -267,6 +264,98 @@ class StdioTransport {
|
|
|
267
264
|
this.#engine.handleDeath(new Error("mcp client closed"));
|
|
268
265
|
}
|
|
269
266
|
}
|
|
267
|
+
/** MCP framing over a core-owned service's Web-standard streams. The host
|
|
268
|
+
* owns the jail and process; this transport owns only its stream handle. */
|
|
269
|
+
class HandleTransport {
|
|
270
|
+
#engine;
|
|
271
|
+
#handle;
|
|
272
|
+
#writer;
|
|
273
|
+
#closed = false;
|
|
274
|
+
#released = false;
|
|
275
|
+
#read;
|
|
276
|
+
constructor(name, handle) {
|
|
277
|
+
this.#handle = handle;
|
|
278
|
+
this.#engine = new McpEngine(name, this);
|
|
279
|
+
this.#writer = handle.stdin.getWriter();
|
|
280
|
+
this.#read = this.#readStdout(name);
|
|
281
|
+
void handle.exited.then(({ code, signal }) => {
|
|
282
|
+
this.#die(new Error(`mcp service exited (code ${code ?? "?"}, signal ${signal ?? "?"})`));
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
get engine() {
|
|
286
|
+
return this.#engine;
|
|
287
|
+
}
|
|
288
|
+
async #readStdout(name) {
|
|
289
|
+
const reader = this.#handle.stdout.getReader();
|
|
290
|
+
const decoder = new TextDecoder();
|
|
291
|
+
let buffer = "";
|
|
292
|
+
try {
|
|
293
|
+
for (;;) {
|
|
294
|
+
const { done, value } = await reader.read();
|
|
295
|
+
if (done)
|
|
296
|
+
break;
|
|
297
|
+
buffer += decoder.decode(value, { stream: true });
|
|
298
|
+
for (;;) {
|
|
299
|
+
const newline = buffer.indexOf("\n");
|
|
300
|
+
if (newline < 0)
|
|
301
|
+
break;
|
|
302
|
+
const line = buffer.slice(0, newline).trim();
|
|
303
|
+
buffer = buffer.slice(newline + 1);
|
|
304
|
+
if (line === "")
|
|
305
|
+
continue;
|
|
306
|
+
try {
|
|
307
|
+
this.#engine.handleFrame(JSON.parse(line));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// A non-JSON line is server chatter, not a protocol frame.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!this.#closed) {
|
|
315
|
+
this.#die(new Error(`mcp service "${name}" stdout ended`));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
if (!this.#closed) {
|
|
320
|
+
this.#die(cause instanceof Error
|
|
321
|
+
? cause
|
|
322
|
+
: new Error(`mcp service "${name}" stream failed`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
reader.releaseLock();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
#die(error) {
|
|
330
|
+
if (this.#closed)
|
|
331
|
+
return;
|
|
332
|
+
this.#closed = true;
|
|
333
|
+
this.#engine.handleDeath(error);
|
|
334
|
+
this.#release();
|
|
335
|
+
}
|
|
336
|
+
#release() {
|
|
337
|
+
if (this.#released)
|
|
338
|
+
return;
|
|
339
|
+
this.#released = true;
|
|
340
|
+
this.#handle.release();
|
|
341
|
+
}
|
|
342
|
+
send(frame) {
|
|
343
|
+
if (this.#closed) {
|
|
344
|
+
return Promise.reject(new Error("mcp service transport is closed"));
|
|
345
|
+
}
|
|
346
|
+
return this.#writer.write(new TextEncoder().encode(JSON.stringify(frame) + "\n"));
|
|
347
|
+
}
|
|
348
|
+
close() {
|
|
349
|
+
if (this.#closed)
|
|
350
|
+
return;
|
|
351
|
+
this.#closed = true;
|
|
352
|
+
void this.#writer.close().catch((cause) => {
|
|
353
|
+
console.warn(`mcp service stdin close failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
354
|
+
});
|
|
355
|
+
this.#engine.handleDeath(new Error("mcp client closed"));
|
|
356
|
+
this.#release();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
270
359
|
// ---------------------------------------------------------------------------
|
|
271
360
|
// Remote transport: streamable HTTP with a legacy-SSE fallback.
|
|
272
361
|
// ---------------------------------------------------------------------------
|
|
@@ -659,16 +748,24 @@ class HttpTransport {
|
|
|
659
748
|
* per-server isolation is the CALLER's policy (the extension connects all
|
|
660
749
|
* servers with `.catch` per server).
|
|
661
750
|
*/
|
|
662
|
-
export async function connectMcpServer(name, config) {
|
|
751
|
+
export async function connectMcpServer(name, config, handle) {
|
|
663
752
|
let engine;
|
|
664
|
-
if (
|
|
753
|
+
if (handle !== undefined) {
|
|
754
|
+
if (!("command" in config)) {
|
|
755
|
+
handle.release();
|
|
756
|
+
throw new Error(`mcp server "${name}" remote transport cannot use a service handle`);
|
|
757
|
+
}
|
|
758
|
+
const transport = new HandleTransport(name, handle);
|
|
759
|
+
engine = transport.engine;
|
|
760
|
+
}
|
|
761
|
+
else if ("url" in config) {
|
|
665
762
|
const transport = new HttpTransport(name, config);
|
|
666
763
|
engine = transport.engine;
|
|
667
764
|
}
|
|
668
765
|
else {
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
const env =
|
|
766
|
+
// Never sigrun's own environment: an allowlist, the server's config
|
|
767
|
+
// env, then its stored token as MCP_ACCESS_TOKEN (ruling 3).
|
|
768
|
+
const env = serverSpawnEnv(name, config);
|
|
672
769
|
const transport = new StdioTransport(name, config.command, config.args, env);
|
|
673
770
|
engine = transport.engine;
|
|
674
771
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -34,8 +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
|
+
/** 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";
|
|
37
41
|
/** Fields every server variant carries regardless of transport. */
|
|
38
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;
|
|
39
51
|
/** Skip this server entirely (default false). */
|
|
40
52
|
readonly disabled?: boolean;
|
|
41
53
|
/** Skip this server entirely (default true; contradicts `disabled`). */
|
|
@@ -85,9 +97,11 @@ export interface McpOAuthConfig {
|
|
|
85
97
|
/** PKCE method (optional; only "S256" is implemented — and always used). */
|
|
86
98
|
readonly codeChallengeMethod?: string;
|
|
87
99
|
}
|
|
100
|
+
export type UserServersMode = "none" | "remote" | "all";
|
|
88
101
|
/** The parsed `mcp.json` body. */
|
|
89
102
|
export interface McpConfig {
|
|
90
103
|
readonly servers: Readonly<Record<string, McpServerConfig>>;
|
|
104
|
+
readonly userServers?: UserServersMode;
|
|
91
105
|
}
|
|
92
106
|
/** The env override the config store reads. */
|
|
93
107
|
export declare const MCP_CONFIG_ENV = "SIGRUN_MCP_CONFIG";
|
package/dist/config.js
CHANGED
|
@@ -128,12 +128,29 @@ export function parseMcpConfig(text, source) {
|
|
|
128
128
|
if (!isRecord(parsed)) {
|
|
129
129
|
throw new Error(`mcp config ${source} must be a JSON object`);
|
|
130
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
|
+
}
|
|
131
135
|
const servers = {};
|
|
132
136
|
const rawServers = parsed.servers;
|
|
133
137
|
if (!isRecord(rawServers)) {
|
|
134
138
|
throw new Error(`mcp config ${source} must have a "servers" object mapping names to server entries`);
|
|
135
139
|
}
|
|
136
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
|
+
}
|
|
147
|
+
// The tool namespace is `<server>/<tool>` and settings keys use dots
|
|
148
|
+
// for paths, so a server named `acme.tools` or `acme/tools` would
|
|
149
|
+
// produce ambiguous or conflicting keys. Reject at the source, before
|
|
150
|
+
// any settings section can be generated from the bad name.
|
|
151
|
+
if (name.includes(".") || name.includes("/")) {
|
|
152
|
+
throw new Error(`mcp config ${source}: server "${name}" contains forbidden characters ("." or "/") — dots conflict with settings key paths and slashes conflict with the <server>/<tool> namespace`);
|
|
153
|
+
}
|
|
137
154
|
if (!isRecord(entry)) {
|
|
138
155
|
throw new Error(`mcp config ${source}: server "${name}" must be an object`);
|
|
139
156
|
}
|
|
@@ -168,6 +185,28 @@ export function parseMcpConfig(text, source) {
|
|
|
168
185
|
Object.values(entry.env).some((v) => typeof v !== "string"))) {
|
|
169
186
|
throw new Error(`mcp config ${source}: server "${name}" env must map strings to strings`);
|
|
170
187
|
}
|
|
188
|
+
const credentials = (entry.credentials ?? "user");
|
|
189
|
+
if (credentials !== "user" && credentials !== "shared") {
|
|
190
|
+
throw new Error(`mcp config ${source}: server "${name}" credentials must be "user" or "shared"`);
|
|
191
|
+
}
|
|
192
|
+
if (credentials === "shared" &&
|
|
193
|
+
(typeof entry.sharedAuthority !== "string" ||
|
|
194
|
+
entry.sharedAuthority.trim() === "")) {
|
|
195
|
+
throw new Error(`mcp config ${source}: server "${name}" requires a non-empty sharedAuthority when credentials are "shared"`);
|
|
196
|
+
}
|
|
197
|
+
if (entry.users !== undefined) {
|
|
198
|
+
if (credentials !== "shared") {
|
|
199
|
+
throw new Error(`mcp config ${source}: server "${name}" users is only valid with credentials "shared"`);
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(entry.users) ||
|
|
202
|
+
entry.users.some((user) => typeof user !== "string" || user.trim() === "")) {
|
|
203
|
+
throw new Error(`mcp config ${source}: server "${name}" users must be a string[] of non-empty users`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const availability = (entry.availability ?? "all");
|
|
207
|
+
if (availability !== "all" && availability !== "opt-in") {
|
|
208
|
+
throw new Error(`mcp config ${source}: server "${name}" availability must be "all" or "opt-in"`);
|
|
209
|
+
}
|
|
171
210
|
if (entry.disabled !== undefined && typeof entry.disabled !== "boolean") {
|
|
172
211
|
throw new Error(`mcp config ${source}: server "${name}" disabled must be a boolean`);
|
|
173
212
|
}
|
|
@@ -204,6 +243,19 @@ export function parseMcpConfig(text, source) {
|
|
|
204
243
|
const oauth = entry.oauth === undefined
|
|
205
244
|
? undefined
|
|
206
245
|
: parseOauthEntry(entry.oauth, source, name);
|
|
246
|
+
const catalog = {
|
|
247
|
+
// Preserve the pre-catalog serialized shape when both fields are
|
|
248
|
+
// absent. Consumers use nullish defaults, so omitted and explicit
|
|
249
|
+
// default values are equivalent without changing legacy fixtures.
|
|
250
|
+
...(entry.credentials === undefined ? {} : { credentials }),
|
|
251
|
+
...(entry.availability === undefined ? {} : { availability }),
|
|
252
|
+
...(entry.sharedAuthority === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: { sharedAuthority: entry.sharedAuthority }),
|
|
255
|
+
...(entry.users === undefined
|
|
256
|
+
? {}
|
|
257
|
+
: { users: entry.users }),
|
|
258
|
+
};
|
|
207
259
|
// The common optionals first, then the transport arm's own fields —
|
|
208
260
|
// the union stays discriminable by "command" vs "url" presence.
|
|
209
261
|
servers[name] = hasUrl
|
|
@@ -217,6 +269,7 @@ export function parseMcpConfig(text, source) {
|
|
|
217
269
|
...(exposure === undefined ? {} : { exposure }),
|
|
218
270
|
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
219
271
|
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
272
|
+
...catalog,
|
|
220
273
|
}
|
|
221
274
|
: {
|
|
222
275
|
command: entry.command,
|
|
@@ -230,9 +283,10 @@ export function parseMcpConfig(text, source) {
|
|
|
230
283
|
...(toolExposure === undefined ? {} : { toolExposure }),
|
|
231
284
|
...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
|
|
232
285
|
...(oauth === undefined ? {} : { oauth }),
|
|
286
|
+
...catalog,
|
|
233
287
|
};
|
|
234
288
|
}
|
|
235
|
-
return { servers };
|
|
289
|
+
return { servers, userServers };
|
|
236
290
|
}
|
|
237
291
|
/** Parse one server's oauth block. Fail-loud on a wrong shape: a
|
|
238
292
|
* 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;
|