@sigrun/ext-mcp 0.2.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 CHANGED
@@ -6,6 +6,67 @@ 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
+ ### Security
10
+ - **stdio MCP servers receive an allowlisted environment instead of sigrun's
11
+ process environment.** `SIGRUN_API_TOKEN` and provider keys were inherited
12
+ by every spawned stdio server; they now see only the variables their
13
+ declaration names plus a minimal base. Previously published `0.2.0` is
14
+ affected; `0.2.1` is the first release with the fix.
15
+
16
+ ### Changed
17
+ - **Upgrade behavior:** catalog servers now default to `credentials: "user"`.
18
+ Existing installs that rely on one system-wide server must explicitly
19
+ select `credentials: "shared"` and provide `sharedAuthority`. Shared MCP
20
+ servers hand every permitted user's agent the admin's credential for that
21
+ service.
22
+
23
+ ### Fixed
24
+ - Per-user tool publication is declared even with an empty or shared-only
25
+ catalog, so lifecycle activation and prompt rendering no longer fail when
26
+ there are no personal catalog servers. Because the declaration is
27
+ unconditional, core's boot sweep now writes `sigrun.mcp@<owner>` into every
28
+ owned conversation's selection on the first boot after upgrading, even with
29
+ no `mcp.json` or `userServers: "none"`. That is a one-time durable write:
30
+ the name resolves to nothing while no tools are published, so nothing
31
+ changes for the conversation, and later boots report zero.
32
+ - Catalog enablement now mounts through `surfaces.settings` and uses core's
33
+ actual flat settings shape: each toggle and changed key is the bare server
34
+ name. Shared-authority help is punctuated and guarded against missing
35
+ programmatic input.
36
+ - Prompt sections resolve the conversation owner through the harness's
37
+ committed-read surface instead of the server-bound identity bridge, so
38
+ harness-only boots cannot hang while preparing a request.
39
+
40
+ ### Added
41
+ - User-supplied MCP servers in per-user settings, gated by `userServers:
42
+ "none" | "remote" | "all"` and validated with the operator config parser.
43
+ Tools publish as `u_<server>/<tool>`; live policy changes reconcile every
44
+ active user. The `all` mode also permits URL servers, but reports
45
+ user-supplied stdio as a named configuration error because the host's
46
+ static user-service contract cannot launch it safely.
47
+ - Per-user OAuth start/complete extension routes with ten-minute in-memory
48
+ PKCE state scoped by user and server. Completion stores the raw access
49
+ token in the caller's scoped secrets; the CLI refuses per-user servers.
50
+ - Token storage retires and reconnects the caller's catalog connection.
51
+ Core owns the process lifecycle: a still-live child retains its original
52
+ environment until idle-stop or exit because the host contract cannot force
53
+ a relaunch.
54
+ - MCP catalog entries default to per-user credentials, with lazy per-user
55
+ stdio services, scoped token injection, remote connections, and tools
56
+ published only for their owner. Opt-in availability and per-user settings
57
+ control enablement; disabling removes tools and drains active calls.
58
+ - Shared entries require `sharedAuthority`, retain system-tool behavior,
59
+ and enforce their optional user allowlist and enablement at call time.
60
+ Prompt sections list only servers available to the calling user.
61
+
62
+ ### Security
63
+ - Stdio servers no longer inherit sigrun's environment. They are spawned
64
+ with an allowlist (`PATH`, `HOME`, `TMPDIR`, `LANG`, `LC_*`, `TZ`,
65
+ `TERM`, `USER`, `LOGNAME`, `SHELL`, `NPM_CONFIG_CACHE`,
66
+ `SSH_AUTH_SOCK`) plus their configured `env` and stored token.
67
+ Previously every server could read `SIGRUN_API_TOKEN`,
68
+ `SIGRUN_PROXY_SECRET` and provider keys. A server that relied on an
69
+ inherited variable must now list it in its `env`.
9
70
 
10
71
  ## [0.2.0] - 2026-10-22
11
72
  ### 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
- - `env` (stdio) — extra environment variables for the child process.
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; see
50
- `sigrun-ext-mcp oauth <server>` (the CLI mints and stores a token; the spawn
51
- injects it).
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 { spawnEnvWithToken } from "./tokens.js";
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 ("url" in config) {
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
- // Token injection (ruling 3): a server with an oauth block AND a stored
670
- // token gets it as MCP_ACCESS_TOKEN, merged over the server's own env.
671
- const env = { ...process.env, ...spawnEnvWithToken(name, config) };
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,22 @@ 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
+ }
137
147
  if (!isRecord(entry)) {
138
148
  throw new Error(`mcp config ${source}: server "${name}" must be an object`);
139
149
  }
@@ -168,6 +178,28 @@ export function parseMcpConfig(text, source) {
168
178
  Object.values(entry.env).some((v) => typeof v !== "string"))) {
169
179
  throw new Error(`mcp config ${source}: server "${name}" env must map strings to strings`);
170
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
+ }
171
203
  if (entry.disabled !== undefined && typeof entry.disabled !== "boolean") {
172
204
  throw new Error(`mcp config ${source}: server "${name}" disabled must be a boolean`);
173
205
  }
@@ -204,6 +236,19 @@ export function parseMcpConfig(text, source) {
204
236
  const oauth = entry.oauth === undefined
205
237
  ? undefined
206
238
  : parseOauthEntry(entry.oauth, source, name);
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
246
+ ? {}
247
+ : { sharedAuthority: entry.sharedAuthority }),
248
+ ...(entry.users === undefined
249
+ ? {}
250
+ : { users: entry.users }),
251
+ };
207
252
  // The common optionals first, then the transport arm's own fields —
208
253
  // the union stays discriminable by "command" vs "url" presence.
209
254
  servers[name] = hasUrl
@@ -217,6 +262,7 @@ export function parseMcpConfig(text, source) {
217
262
  ...(exposure === undefined ? {} : { exposure }),
218
263
  ...(toolExposure === undefined ? {} : { toolExposure }),
219
264
  ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
265
+ ...catalog,
220
266
  }
221
267
  : {
222
268
  command: entry.command,
@@ -230,9 +276,10 @@ export function parseMcpConfig(text, source) {
230
276
  ...(toolExposure === undefined ? {} : { toolExposure }),
231
277
  ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }),
232
278
  ...(oauth === undefined ? {} : { oauth }),
279
+ ...catalog,
233
280
  };
234
281
  }
235
- return { servers };
282
+ return { servers, userServers };
236
283
  }
237
284
  /** Parse one server's oauth block. Fail-loud on a wrong shape: a
238
285
  * half-configured block is a config error, not a silently-ignored field. */
@@ -1,51 +1,32 @@
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
- */
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
- /** The extension's pi-durable registry name. */
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
- /** What {@link createMcpExtension} returns (the loader contract: `.extension`). */
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
- * 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
- */
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;