@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/dist/extension.js CHANGED
@@ -1,115 +1,107 @@
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";
1
+ /** Catalog wiring: shared operator services remain system children, while
2
+ * per-user entries connect and publish through the structural sigrun host. */
3
+ import { defineDoc, defineExtension } from "@earendil-works/pi-durable";
29
4
  import { connectMcpServer } from "./client.js";
30
- import { isStdioServer, loadMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
5
+ import { loadMcpConfig, MCP_CONFIG_ENV, resolveMcpConfigPath, serverEnabled, } from "./config.js";
6
+ import { createUserOauthFlows, exchangeCode, extractCode, } from "./oauth.js";
7
+ import { storeUserMcpToken } from "./tokens.js";
31
8
  import { MCP_SERVERS_SECTION_KEY, partitionForSection, renderMcpServersSection, } from "./section.js";
32
- /** The extension's pi-durable registry name. */
9
+ import { catalogEnabledFromSettings, createUserCatalog, sharedServerAllowed, discoverMcpTools, } from "./user-servers.js";
33
10
  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) };
11
+ /** Standalone tokens need no registry install; the kind/version must match
12
+ * core's session-meta document so its committed owner is readable here. */
13
+ const SessionOwnerDoc = defineDoc({
14
+ kind: "sigrun.session-meta",
15
+ version: 1,
16
+ scope: "conversation",
17
+ history: "latest",
18
+ fork: "current",
19
+ initial: () => ({}),
20
+ checkpointWhen: () => false,
21
+ });
22
+ function denied(message) {
23
+ return {
24
+ output: [{ type: "text", text: message }],
25
+ isError: true,
26
+ };
40
27
  }
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
- };
28
+ /** The shared tool wrapper is the authority boundary: registry visibility is
29
+ * only convenience, and it cannot be bypassed by selecting an extension. */
30
+ function sharedTool(host, name, server, tool) {
31
+ return {
32
+ ...tool,
33
+ execute: async (args, api, context) => {
34
+ try {
35
+ if (!(await sharedServerAllowed(host, api.conversationId, name, server))) {
36
+ return denied(`MCP server "${name}" is not available for this user`);
37
+ }
38
+ }
39
+ catch (cause) {
40
+ return denied(`MCP server "${name}" access check failed: ${cause instanceof Error ? cause.message : String(cause)}`);
41
+ }
42
+ return tool.execute(args, api, context);
83
43
  },
84
- });
44
+ };
85
45
  }
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() {
46
+ function errorMessage(cause) {
47
+ return cause instanceof Error ? cause.message : String(cause);
48
+ }
49
+ // The host parameter is REQUIRED: sigrun passes it only to factories whose
50
+ // length is greater than zero. An optional/defaulted parameter has length 0
51
+ // and would silently receive no host, disabling every per-user operation.
52
+ export function createMcpExtension(host) {
53
+ // Pin the boot-selected path so policy reloads survive callers restoring
54
+ // their process environment immediately after the factory returns.
55
+ const configPath = resolveMcpConfigPath();
92
56
  const config = loadMcpConfig();
93
- const tools = []; // live: discovered tools land here
57
+ const tools = [];
94
58
  const clients = new Map();
95
- /** Live per-server exposure mix: which registered tools use codemode /
96
- * deferred (the section renders from this snapshot). */
97
59
  const serverExposures = new Map();
60
+ const shared = Object.entries(config.servers).filter(([, server]) => server.credentials === "shared" && serverEnabled(server));
61
+ const catalog = createUserCatalog(host, config.servers, config.userServers ?? "none");
62
+ const oauthFlows = createUserOauthFlows();
98
63
  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.
64
+ let liveUserServers = config.userServers ?? "none";
65
+ let policyRefresh = Promise.resolve();
66
+ let policyErrorReported = false;
67
+ const refreshPolicy = async () => {
68
+ // Lifecycle events are the production file-change signal: mcp.json is
69
+ // operator configuration, not part of core's settings document.
70
+ let next = liveUserServers;
71
+ try {
72
+ next = loadMcpConfig({
73
+ ...process.env,
74
+ [MCP_CONFIG_ENV]: configPath,
75
+ }).userServers ?? "none";
76
+ policyErrorReported = false;
77
+ }
78
+ catch (cause) {
79
+ // A transient operator edit must not suppress this event's user
80
+ // reconciliation or discard the last known-good policy.
81
+ if (!policyErrorReported) {
82
+ policyErrorReported = true;
83
+ console.warn(`mcp policy reload failed for ${configPath}; keeping ${liveUserServers}: ${errorMessage(cause)}`);
84
+ }
85
+ return;
86
+ }
87
+ if (next === liveUserServers)
88
+ return policyRefresh;
89
+ liveUserServers = next;
90
+ const chain = policyRefresh.then(() => catalog.reconcileAll(next), () => catalog.reconcileAll(next));
91
+ // Retain a settled recovery point: one failed reconcile must not
92
+ // poison every subsequent activation with the same stale rejection.
93
+ policyRefresh = chain.catch(() => undefined);
94
+ await chain;
95
+ };
102
96
  const pending = [];
103
- for (const [name, server] of Object.entries(config.servers)) {
104
- if (!serverEnabled(server))
105
- continue;
97
+ for (const [name, server] of shared) {
106
98
  pending.push((async () => {
107
99
  let client;
108
100
  try {
109
101
  client = await connectMcpServer(name, server);
110
102
  }
111
103
  catch (cause) {
112
- console.warn(`mcp server "${name}" quarantined: ${cause instanceof Error ? cause.message : String(cause)}`);
104
+ console.warn(`mcp server "${name}" quarantined: ${errorMessage(cause)}`);
113
105
  return;
114
106
  }
115
107
  if (closed) {
@@ -118,60 +110,182 @@ export function createMcpExtension() {
118
110
  }
119
111
  clients.set(name, client);
120
112
  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));
113
+ const discovered = await discoverMcpTools(name, server, client);
114
+ if (closed) {
115
+ client.close();
116
+ return;
133
117
  }
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
- });
118
+ tools.push(...discovered.tools.map((tool) => sharedTool(host, name, server, tool)));
119
+ if (discovered.exposure !== undefined) {
120
+ serverExposures.set(name, discovered.exposure);
142
121
  }
143
122
  }
144
123
  catch (cause) {
145
124
  clients.delete(name);
146
125
  client.close();
147
- console.warn(`mcp server "${name}" quarantined during discovery: ${cause instanceof Error ? cause.message : String(cause)}`);
126
+ console.warn(`mcp server "${name}" quarantined during discovery: ${errorMessage(cause)}`);
148
127
  }
149
128
  })());
150
129
  }
151
- const killAll = () => {
130
+ const activate = async (event) => {
131
+ await refreshPolicy();
132
+ if (event.owner !== undefined)
133
+ await catalog.activate(event.owner);
134
+ };
135
+ const errorResponse = (status, error) => Response.json({ error }, { status });
136
+ const serverFromRequest = (request) => {
137
+ const match = new URL(request.url).pathname.match(/\/v1\/ext\/sigrun-mcp\/oauth\/([^/]+)\/(?:start|complete)$/);
138
+ if (match === null)
139
+ return undefined;
140
+ try {
141
+ return decodeURIComponent(match[1]);
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ };
147
+ const oauthServer = (name) => {
148
+ if (name === undefined)
149
+ return undefined;
150
+ const server = config.servers[name];
151
+ if (server === undefined ||
152
+ server.credentials === "shared" ||
153
+ !serverEnabled(server) ||
154
+ !("oauth" in server) ||
155
+ server.oauth === undefined)
156
+ return undefined;
157
+ return { ...server, oauth: server.oauth };
158
+ };
159
+ const userFrom = (ctx) => ctx.principal?.user;
160
+ const routes = [
161
+ {
162
+ method: "POST",
163
+ path: "oauth/:server/start",
164
+ auth: "user",
165
+ handler: async (request, ctx) => {
166
+ const user = userFrom(ctx);
167
+ if (user === undefined)
168
+ return errorResponse(400, "no_pending_flow");
169
+ const name = serverFromRequest(request);
170
+ const server = oauthServer(name);
171
+ if (name === undefined || server === undefined) {
172
+ return errorResponse(404, "unknown_server");
173
+ }
174
+ // No listener: the browser is redirected back to the complete
175
+ // endpoint, whose URL the user can paste from the address bar.
176
+ const redirectUri = new URL(`/v1/ext/sigrun-mcp/oauth/${encodeURIComponent(name)}/complete`, request.url).toString();
177
+ const url = oauthFlows.start(user, name, server.oauth, redirectUri);
178
+ return Response.json({ url });
179
+ },
180
+ },
181
+ {
182
+ method: "POST",
183
+ path: "oauth/:server/complete",
184
+ auth: "user",
185
+ handler: async (request, ctx) => {
186
+ const user = userFrom(ctx);
187
+ if (user === undefined)
188
+ return errorResponse(400, "no_pending_flow");
189
+ const name = serverFromRequest(request);
190
+ const server = oauthServer(name);
191
+ if (name === undefined || server === undefined) {
192
+ return errorResponse(404, "unknown_server");
193
+ }
194
+ let redirectedUrl;
195
+ try {
196
+ const body = await request.json();
197
+ if (typeof body !== "object" || body === null || Array.isArray(body) ||
198
+ !("redirectedUrl" in body) ||
199
+ typeof body.redirectedUrl !== "string" || body.redirectedUrl === "")
200
+ throw new Error("invalid body");
201
+ redirectedUrl = body.redirectedUrl;
202
+ }
203
+ catch {
204
+ return errorResponse(400, "invalid_body");
205
+ }
206
+ const flow = oauthFlows.take(user, name);
207
+ if (flow === undefined)
208
+ return errorResponse(400, "no_pending_flow");
209
+ let code;
210
+ try {
211
+ const extracted = extractCode(redirectedUrl);
212
+ if (!("code" in extracted))
213
+ throw new Error("authorization failed");
214
+ code = extracted.code;
215
+ }
216
+ catch {
217
+ oauthFlows.release(user, name, flow);
218
+ return errorResponse(400, "invalid_redirect");
219
+ }
220
+ let token;
221
+ try {
222
+ token = await exchangeCode(server.oauth, code, flow.redirectUri, flow.verifier);
223
+ }
224
+ catch {
225
+ return errorResponse(502, "exchange_failed");
226
+ }
227
+ await storeUserMcpToken(ctx.host, user, name, token.access_token);
228
+ await catalog.retire(user, name);
229
+ await catalog.activate(user);
230
+ return new Response(null, { status: 204 });
231
+ },
232
+ },
233
+ ];
234
+ const close = async () => {
152
235
  if (closed)
153
236
  return;
154
237
  closed = true;
238
+ process.off("exit", closeSystemClients);
155
239
  for (const client of clients.values())
156
240
  client.close();
157
241
  clients.clear();
158
242
  tools.length = 0;
159
243
  serverExposures.clear();
244
+ await Promise.all(pending);
245
+ oauthFlows.clear();
246
+ catalog.close();
160
247
  };
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) => {
248
+ const closeSystemClients = () => {
249
+ for (const client of clients.values())
250
+ client.close();
251
+ clients.clear();
252
+ catalog.close();
253
+ };
254
+ process.on("exit", closeSystemClients);
255
+ const mcpServersSection = async (input, context) => {
256
+ let user;
257
+ let settings;
258
+ try {
259
+ // PromptInput.read uses only committed harness state. The host identity
260
+ // bridge is server-bound and may never resolve in harness-only boots.
261
+ user = (await input.read.snapshot(SessionOwnerDoc, input.conversationId, context))?.owner;
262
+ if (user !== undefined) {
263
+ settings = await host.stores.user(user).settings.get();
264
+ }
265
+ }
266
+ catch (cause) {
267
+ console.warn(`mcp section access read failed: ${errorMessage(cause)}`);
268
+ return undefined;
269
+ }
270
+ const reachable = new Map();
271
+ for (const [name, server] of shared) {
272
+ if (user !== undefined &&
273
+ settings !== undefined &&
274
+ (server.users === undefined || server.users.includes(user)) &&
275
+ catalogEnabledFromSettings(name, server, settings)) {
276
+ const exposure = serverExposures.get(name);
277
+ if (exposure !== undefined)
278
+ reachable.set(name, exposure);
279
+ }
280
+ }
281
+ if (user !== undefined) {
282
+ await catalog.activate(user);
283
+ for (const [name, exposure] of catalog.exposures(user)) {
284
+ reachable.set(name, exposure);
285
+ }
286
+ }
173
287
  const reaches = new Map();
174
- for (const [name, mix] of serverExposures) {
288
+ for (const [name, mix] of reachable) {
175
289
  reaches.set(name, mix.codemode && mix.deferred
176
290
  ? "either"
177
291
  : mix.codemode
@@ -181,7 +295,7 @@ export function createMcpExtension() {
181
295
  const offers = new Set(input.agent.tools.map((tool) => tool.name));
182
296
  const { listed, unreachable } = partitionForSection(reaches, offers);
183
297
  const entries = listed.map((name) => {
184
- const mix = serverExposures.get(name);
298
+ const mix = reachable.get(name);
185
299
  return {
186
300
  name,
187
301
  description: mix.description,
@@ -196,17 +310,26 @@ export function createMcpExtension() {
196
310
  };
197
311
  const extension = defineExtension({
198
312
  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
- ],
313
+ tools,
314
+ sections: [{ key: MCP_SERVERS_SECTION_KEY, render: mcpServersSection }],
206
315
  });
207
316
  return {
208
317
  extension,
209
- close: killAll,
318
+ perUserTools: catalog.perUserTools,
319
+ routes,
320
+ userServices: catalog.userServices,
321
+ surfaces: { settings: catalog.settings },
322
+ conversationCreated: activate,
323
+ runSettled: activate,
324
+ onSettingsChanged: async (keys, user) => {
325
+ if (user !== undefined &&
326
+ (keys.includes("servers") || keys.some((key) => Object.hasOwn(config.servers, key)))) {
327
+ await catalog.activate(user);
328
+ }
329
+ // The host has no settings-read hook. A visit that changes nothing
330
+ // starts nothing; the committed write is the actionable trigger.
331
+ },
332
+ close,
210
333
  ready: Promise.all(pending).then(() => undefined),
211
334
  };
212
335
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** sigrun-ext-mcp: register MCP server tools as pi-durable tools. */
2
2
  export { createMcpExtension, MCP_EXTENSION_NAME } from "./extension.js";
3
3
  export type { McpWiring } from "./extension.js";
4
+ export type { SigrunHost, UserService, ServiceSpec } from "./user-servers.js";
4
5
  export { connectMcpServer, McpClient } from "./client.js";
5
6
  export { loadMcpConfig, MCP_CONFIG_ENV, parseMcpConfig, resolveMcpConfigPath, } from "./config.js";
6
7
  export type { McpConfig, McpOAuthConfig, McpServerConfig, } from "./config.js";
@@ -10,9 +11,7 @@ export { buildAuthorizeUrl, exchangeCode, extractCode, makePkcePair, startOauth,
10
11
  export type { OauthFlow, StartOauthOptions } from "./oauth.js";
11
12
  export { parseCliArgs, runCli } from "./cli.js";
12
13
  import { createMcpExtension } from "./extension.js";
13
- /**
14
- * The sigrun extension-package contract (src/harness/loader.ts): the default
15
- * export is a factory taking no arguments, returning `{ extension }`.
16
- */
14
+ /** The required host parameter keeps this factory at arity one: sigrun's
15
+ * loader does not pass a host to optional/defaulted (length-zero) factories. */
17
16
  declare const _default: typeof createMcpExtension;
18
17
  export default _default;
package/dist/index.js CHANGED
@@ -6,8 +6,6 @@ export { loadMcpTokens, MCP_ACCESS_TOKEN_ENV, parseMcpTokenStore, resolveMcpToke
6
6
  export { buildAuthorizeUrl, exchangeCode, extractCode, makePkcePair, startOauth, } from "./oauth.js";
7
7
  export { parseCliArgs, runCli } from "./cli.js";
8
8
  import { createMcpExtension } from "./extension.js";
9
- /**
10
- * The sigrun extension-package contract (src/harness/loader.ts): the default
11
- * export is a factory taking no arguments, returning `{ extension }`.
12
- */
9
+ /** The required host parameter keeps this factory at arity one: sigrun's
10
+ * loader does not pass a host to optional/defaulted (length-zero) factories. */
13
11
  export default createMcpExtension;
package/dist/oauth.d.ts CHANGED
@@ -44,6 +44,20 @@ export declare function makePkcePair(): {
44
44
  };
45
45
  /** Build the authorize URL (PKCE S256 + optional scopes + redirect_uri). */
46
46
  export declare function buildAuthorizeUrl(oauth: McpOAuthConfig, redirectUri: string, codeChallenge: string): string;
47
+ interface PendingUserFlow {
48
+ readonly verifier: string;
49
+ readonly redirectUri: string;
50
+ readonly expiresAt: number;
51
+ }
52
+ export type UserOauthFlow = PendingUserFlow;
53
+ /** In-memory PKCE state for extension routes; the caller owns token storage. */
54
+ export declare function createUserOauthFlows(now?: () => number): {
55
+ start(user: string, server: string, oauth: McpOAuthConfig, redirectUri: string): string;
56
+ take(user: string, server: string): UserOauthFlow | undefined;
57
+ release(user: string, server: string, flow: UserOauthFlow): void;
58
+ pendingCount(): number;
59
+ clear(): void;
60
+ };
47
61
  /** Oauth start options (defaults match the CLI: 5-minute timeout). */
48
62
  export interface StartOauthOptions {
49
63
  /** How long to wait for a code (listener or paste) before failing. */
@@ -58,3 +72,4 @@ export interface StartOauthOptions {
58
72
  * resolves with the STORED token (written to the token store).
59
73
  */
60
74
  export declare function startOauth(serverName: string, oauth: McpOAuthConfig, options?: StartOauthOptions): Promise<OauthFlow>;
75
+ export {};
package/dist/oauth.js CHANGED
@@ -128,6 +128,56 @@ export function buildAuthorizeUrl(oauth, redirectUri, codeChallenge) {
128
128
  }
129
129
  return url.toString();
130
130
  }
131
+ const USER_FLOW_TTL_MS = 10 * 60_000;
132
+ /** In-memory PKCE state for extension routes; the caller owns token storage. */
133
+ export function createUserOauthFlows(now = Date.now) {
134
+ const pending = new Map();
135
+ const key = (user, server) => JSON.stringify([user, server]);
136
+ return {
137
+ start(user, server, oauth, redirectUri) {
138
+ const startedAt = now();
139
+ // Opportunistic sweeping avoids factory timers while bounding state
140
+ // to distinct user/server flows started within one TTL window.
141
+ for (const [flowKey, flow] of pending) {
142
+ if (flow.expiresAt <= startedAt)
143
+ pending.delete(flowKey);
144
+ }
145
+ const { verifier, challenge } = makePkcePair();
146
+ pending.set(key(user, server), {
147
+ verifier,
148
+ redirectUri,
149
+ expiresAt: startedAt + USER_FLOW_TTL_MS,
150
+ });
151
+ return buildAuthorizeUrl(oauth, redirectUri, challenge);
152
+ },
153
+ take(user, server) {
154
+ const flowKey = key(user, server);
155
+ const flow = pending.get(flowKey);
156
+ if (flow === undefined)
157
+ return undefined;
158
+ // Claim before any asynchronous exchange so concurrent completions
159
+ // cannot reuse one authorization code or PKCE verifier.
160
+ pending.delete(flowKey);
161
+ if (flow.expiresAt <= now())
162
+ return undefined;
163
+ return flow;
164
+ },
165
+ release(user, server, flow) {
166
+ const flowKey = key(user, server);
167
+ // A newer start supersedes the old flow; an invalid paste must not
168
+ // restore stale state over its replacement.
169
+ if (!pending.has(flowKey) && flow.expiresAt > now()) {
170
+ pending.set(flowKey, flow);
171
+ }
172
+ },
173
+ pendingCount() {
174
+ return pending.size;
175
+ },
176
+ clear() {
177
+ pending.clear();
178
+ },
179
+ };
180
+ }
131
181
  /**
132
182
  * Run the flow for one server (the CLI-equivalent `runOauth`): start the
133
183
  * loopback listener, build + return the authorize URL, and expose
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The environment a stdio MCP server is spawned with (sigrun multi-user
3
+ * spec §8, the env-leak fix).
4
+ *
5
+ * Spawning with `{...process.env, ...}` handed every server sigrun's own
6
+ * environment: `SIGRUN_API_TOKEN`, `SIGRUN_PROXY_SECRET`, provider keys.
7
+ * A server now gets an allowlist copied from sigrun's environment, then
8
+ * its configured `env`, then its stored token (`spawnEnvWithToken`, which
9
+ * keeps its precedence: token over config).
10
+ *
11
+ * The allowlist mirrors core's agent-shell list (`src/harness/agent-env.ts`
12
+ * in sigrun). It is a copy, not an import, because an extension may only
13
+ * import core's types-only contract; the two lists change together.
14
+ */
15
+ import type { McpStdioServerConfig } from "./config.js";
16
+ /** Variables copied from sigrun's environment into every server. */
17
+ export declare const SERVER_ENV_KEYS: readonly string[];
18
+ /**
19
+ * The spawn env for one stdio server. `source` is sigrun's environment:
20
+ * it supplies the allowlisted values and locates the token store, and
21
+ * nothing else from it reaches the server.
22
+ */
23
+ export declare function serverSpawnEnv(name: string, server: Pick<McpStdioServerConfig, "env" | "oauth">, source?: NodeJS.ProcessEnv): Record<string, string>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The environment a stdio MCP server is spawned with (sigrun multi-user
3
+ * spec §8, the env-leak fix).
4
+ *
5
+ * Spawning with `{...process.env, ...}` handed every server sigrun's own
6
+ * environment: `SIGRUN_API_TOKEN`, `SIGRUN_PROXY_SECRET`, provider keys.
7
+ * A server now gets an allowlist copied from sigrun's environment, then
8
+ * its configured `env`, then its stored token (`spawnEnvWithToken`, which
9
+ * keeps its precedence: token over config).
10
+ *
11
+ * The allowlist mirrors core's agent-shell list (`src/harness/agent-env.ts`
12
+ * in sigrun). It is a copy, not an import, because an extension may only
13
+ * import core's types-only contract; the two lists change together.
14
+ */
15
+ import { spawnEnvWithToken } from "./tokens.js";
16
+ /** Variables copied from sigrun's environment into every server. */
17
+ export const SERVER_ENV_KEYS = [
18
+ "PATH",
19
+ "HOME",
20
+ "TMPDIR",
21
+ "LANG",
22
+ "TZ",
23
+ "TERM",
24
+ "USER",
25
+ "LOGNAME",
26
+ "SHELL",
27
+ "NPM_CONFIG_CACHE",
28
+ "SSH_AUTH_SOCK",
29
+ ];
30
+ /** Locale variables (`LC_ALL`, `LC_CTYPE`, …) pass through by prefix. */
31
+ const SERVER_ENV_PREFIXES = ["LC_"];
32
+ /**
33
+ * The spawn env for one stdio server. `source` is sigrun's environment:
34
+ * it supplies the allowlisted values and locates the token store, and
35
+ * nothing else from it reaches the server.
36
+ */
37
+ export function serverSpawnEnv(name, server, source = process.env) {
38
+ const env = {};
39
+ for (const [key, value] of Object.entries(source)) {
40
+ if (value === undefined)
41
+ continue;
42
+ if (SERVER_ENV_KEYS.includes(key) ||
43
+ SERVER_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) {
44
+ env[key] = value;
45
+ }
46
+ }
47
+ return { ...env, ...spawnEnvWithToken(name, server, source) };
48
+ }