@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/dist/tokens.d.ts CHANGED
@@ -34,6 +34,8 @@ export declare function loadMcpTokens(env?: NodeJS.ProcessEnv): McpTokenStore;
34
34
  export declare function saveMcpTokens(store: McpTokenStore, env?: NodeJS.ProcessEnv): void;
35
35
  /** Store one server's token (merge over the existing store). */
36
36
  export declare function storeMcpToken(name: string, token: McpStoredToken, env?: NodeJS.ProcessEnv): McpTokenStore;
37
+ /** Store the exact credential shape read by per-user service launches. */
38
+ export declare function storeUserMcpToken(host: import("./user-servers.js").SigrunHost, user: string, server: string, accessToken: string): Promise<void>;
37
39
  /**
38
40
  * The env var a stored token is injected as (ruling 3): stdio MCP servers
39
41
  * take credentials through env, not HTTP headers. A server with both an
package/dist/tokens.js CHANGED
@@ -95,6 +95,10 @@ export function storeMcpToken(name, token, env = process.env) {
95
95
  saveMcpTokens(store, env);
96
96
  return store;
97
97
  }
98
+ /** Store the exact credential shape read by per-user service launches. */
99
+ export async function storeUserMcpToken(host, user, server, accessToken) {
100
+ await host.stores.user(user).secrets.set(`token:${server}`, accessToken);
101
+ }
98
102
  /**
99
103
  * The env var a stored token is injected as (ruling 3): stdio MCP servers
100
104
  * take credentials through env, not HTTP headers. A server with both an
@@ -0,0 +1,93 @@
1
+ /** Per-user catalog lifecycle and the structural sigrun host contract.
2
+ * This package mirrors the host types instead of depending on sigrun, so it
3
+ * remains independently buildable and installable. */
4
+ import { type ConversationId, type ToolRegistration } from "@earendil-works/pi-durable";
5
+ import { type McpClient, type McpServiceHandle } from "./client.js";
6
+ import { type McpServerConfig, type UserServersMode } from "./config.js";
7
+ export interface HandlerContext {
8
+ readonly abortSignal: AbortSignal | undefined;
9
+ }
10
+ export interface ServiceSpec {
11
+ readonly command: string;
12
+ readonly args: string[];
13
+ readonly env?: Record<string, string>;
14
+ readonly cwd?: string;
15
+ readonly readPaths?: string[];
16
+ }
17
+ export interface UserService {
18
+ readonly name: string;
19
+ start(user: string, ctx: HandlerContext): Promise<ServiceSpec | undefined>;
20
+ readonly idleMinutes?: number;
21
+ }
22
+ export interface ServiceHandle extends McpServiceHandle {
23
+ readonly stderr: ReadableStream<Uint8Array>;
24
+ }
25
+ export interface SigrunHost {
26
+ readonly tools: {
27
+ setUserTools(user: string, tools: readonly ToolRegistration[]): void;
28
+ };
29
+ readonly services: {
30
+ connect(name: string, user: string, ctx: HandlerContext): Promise<ServiceHandle>;
31
+ };
32
+ readonly stores: {
33
+ user(user: string): {
34
+ readonly secrets: {
35
+ get(key: string): Promise<string | undefined>;
36
+ set(key: string, value: string): Promise<void>;
37
+ delete(key: string): Promise<void>;
38
+ };
39
+ readonly settings: {
40
+ get(): Promise<Record<string, unknown>>;
41
+ set(value: Record<string, unknown>): Promise<void>;
42
+ };
43
+ readonly paths: {
44
+ readonly data: string;
45
+ readonly state: string;
46
+ readonly cache: string;
47
+ };
48
+ };
49
+ };
50
+ readonly identity: {
51
+ actingUser(conversationId: ConversationId): Promise<string | undefined>;
52
+ };
53
+ }
54
+ export interface OwnerEvent {
55
+ readonly conversationId: ConversationId;
56
+ readonly owner?: string;
57
+ }
58
+ export interface ServerExposure {
59
+ readonly codemode: boolean;
60
+ readonly deferred: boolean;
61
+ readonly description: string;
62
+ readonly count: number;
63
+ }
64
+ export interface DiscoveredTools {
65
+ readonly tools: ToolRegistration[];
66
+ readonly exposure: ServerExposure | undefined;
67
+ }
68
+ /** The one discovery-to-registration mapping shared by system and user tools. */
69
+ export declare function discoverMcpTools(name: string, server: McpServerConfig, client: McpClient): Promise<DiscoveredTools>;
70
+ export declare function catalogEnabledFromSettings(name: string, server: McpServerConfig, settings: Record<string, unknown>): boolean;
71
+ export declare function catalogEnabled(host: SigrunHost, user: string, name: string, server: McpServerConfig): Promise<boolean>;
72
+ export declare function sharedServerAllowed(host: SigrunHost, conversationId: ConversationId, name: string, server: McpServerConfig): Promise<boolean>;
73
+ export interface CatalogSettingsSection {
74
+ readonly id: string;
75
+ readonly title: string;
76
+ readonly scope: "user";
77
+ readonly form: {
78
+ readonly v: 1;
79
+ readonly root: Record<string, unknown>;
80
+ };
81
+ }
82
+ export interface UserCatalog {
83
+ readonly perUserTools: boolean;
84
+ readonly userServices: readonly UserService[];
85
+ readonly settings: readonly CatalogSettingsSection[];
86
+ activate(user: string, ctx?: HandlerContext): Promise<void>;
87
+ retire(user: string, server: string): Promise<void>;
88
+ exposures(user: string): ReadonlyMap<string, ServerExposure>;
89
+ reconcileAll(mode?: UserServersMode): Promise<void>;
90
+ close(): void;
91
+ }
92
+ export declare function userServerAllowed(kind: "url" | "stdio", mode: UserServersMode): boolean;
93
+ export declare function createUserCatalog(host: SigrunHost, servers: Readonly<Record<string, McpServerConfig>>, initialMode?: UserServersMode): UserCatalog;
@@ -0,0 +1,422 @@
1
+ /** Per-user catalog lifecycle and the structural sigrun host contract.
2
+ * This package mirrors the host types instead of depending on sigrun, so it
3
+ * remains independently buildable and installable. */
4
+ import { Type } from "@earendil-works/pi-ai";
5
+ import { defineTool, } from "@earendil-works/pi-durable";
6
+ import { connectMcpServer, } from "./client.js";
7
+ import { isStdioServer, parseMcpConfig, resolveToolExposure, serverEnabled, } from "./config.js";
8
+ function toContent(block) {
9
+ return block.type === "text" && typeof block.text === "string"
10
+ ? { type: "text", text: block.text }
11
+ : { type: "text", text: JSON.stringify(block) };
12
+ }
13
+ /** The one discovery-to-registration mapping shared by system and user tools. */
14
+ export async function discoverMcpTools(name, server, client) {
15
+ const discovered = await client.listTools();
16
+ const tools = [];
17
+ const mix = { codemode: false, deferred: false, count: 0 };
18
+ for (const tool of discovered) {
19
+ const exposure = resolveToolExposure(server, tool.name);
20
+ if (exposure === "hidden")
21
+ continue;
22
+ if (exposure === "codemode")
23
+ mix.codemode = true;
24
+ if (exposure === "deferred")
25
+ mix.deferred = true;
26
+ mix.count += 1;
27
+ // SAFETY: MCP advertises JSON Schema, which TypeBox accepts at runtime;
28
+ // its structural inputSchema type is weaker than TSchema.
29
+ const parameters = (tool.inputSchema !== undefined &&
30
+ typeof tool.inputSchema === "object" &&
31
+ tool.inputSchema !== null
32
+ ? tool.inputSchema
33
+ : { type: "object" });
34
+ const exposureFlags = exposure === "codemode"
35
+ ? { callers: ["tools"] }
36
+ : exposure === "deferred"
37
+ ? { deferred: true }
38
+ : {};
39
+ tools.push(defineTool({
40
+ name: `${name}/${tool.name}`,
41
+ description: tool.description !== undefined && tool.description !== ""
42
+ ? tool.description
43
+ : `MCP tool ${tool.name} on server ${name}.`,
44
+ parameters: Type.Unsafe(parameters),
45
+ // A tools/call round-trip is external I/O; never rerun on recovery.
46
+ replay: "unsafe",
47
+ ...exposureFlags,
48
+ async execute(args) {
49
+ const result = await client.callTool(tool.name, { ...args }, server.timeout);
50
+ const blocks = result.content ?? [];
51
+ const content = blocks.map(toContent);
52
+ return {
53
+ output: content.length > 0
54
+ ? content
55
+ : [{ type: "text", text: JSON.stringify(result) }],
56
+ isError: result.isError === true,
57
+ // SAFETY: MCP results are JSON-compatible protocol values.
58
+ details: result,
59
+ };
60
+ },
61
+ }));
62
+ }
63
+ if (mix.count === 0 || (!mix.codemode && !mix.deferred)) {
64
+ return { tools, exposure: undefined };
65
+ }
66
+ const origin = isStdioServer(server)
67
+ ? `${server.command} ${server.args.join(" ")}`.trim()
68
+ : server.url;
69
+ return {
70
+ tools,
71
+ exposure: {
72
+ ...mix,
73
+ description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${origin}`,
74
+ },
75
+ };
76
+ }
77
+ export function catalogEnabledFromSettings(name, server, settings) {
78
+ // Settings surfaces persist form fields as top-level values; the server
79
+ // name itself is the key, not a nested catalog object.
80
+ const enabled = settings[name];
81
+ if (enabled === undefined)
82
+ return server.availability !== "opt-in";
83
+ if (typeof enabled !== "boolean") {
84
+ throw new Error(`mcp user settings: ${name} must be a boolean`);
85
+ }
86
+ return enabled;
87
+ }
88
+ export async function catalogEnabled(host, user, name, server) {
89
+ return catalogEnabledFromSettings(name, server, await host.stores.user(user).settings.get());
90
+ }
91
+ export async function sharedServerAllowed(host, conversationId, name, server) {
92
+ const user = await host.identity.actingUser(conversationId);
93
+ // Shared tools lend operator authority: an unidentified caller fails closed
94
+ // even without a users allowlist, rather than inheriting ambient authority.
95
+ if (user === undefined)
96
+ return false;
97
+ if (server.users !== undefined && !server.users.includes(user))
98
+ return false;
99
+ return await catalogEnabled(host, user, name, server);
100
+ }
101
+ /** Retiring stops future calls immediately but defers close until active calls
102
+ * finish. Reviving while draining is safe; retirement after drain is terminal. */
103
+ function trackConnection(name, client, discovered) {
104
+ let active = 0;
105
+ let retired = false;
106
+ let closed = false;
107
+ const unavailable = () => ({
108
+ output: [{ type: "text", text: `MCP server "${name}" is disabled` }],
109
+ isError: true,
110
+ });
111
+ const drain = () => {
112
+ if (closed || !retired || active > 0)
113
+ return;
114
+ closed = true;
115
+ client.close();
116
+ };
117
+ const registrations = discovered.tools.map((tool) => ({
118
+ ...tool,
119
+ execute: async (args, api, context) => {
120
+ if (retired)
121
+ return unavailable();
122
+ active += 1;
123
+ try {
124
+ return await tool.execute(args, api, context);
125
+ }
126
+ finally {
127
+ active -= 1;
128
+ drain();
129
+ }
130
+ },
131
+ }));
132
+ return {
133
+ registrations,
134
+ exposure: discovered.exposure,
135
+ retire: () => {
136
+ retired = true;
137
+ drain();
138
+ },
139
+ revive: () => {
140
+ if (!closed)
141
+ retired = false;
142
+ },
143
+ close: () => {
144
+ if (closed)
145
+ return;
146
+ closed = true;
147
+ client.close();
148
+ },
149
+ };
150
+ }
151
+ function errorMessage(cause) {
152
+ return cause instanceof Error ? cause.message : String(cause);
153
+ }
154
+ export function userServerAllowed(kind, mode) {
155
+ return kind === "url" ? mode !== "none" : mode === "all";
156
+ }
157
+ function userServersFromSettings(settings) {
158
+ if (settings.servers === undefined)
159
+ return {};
160
+ let value = settings.servers;
161
+ // An untouched or cleared longtext field persists an empty string.
162
+ if (typeof value === "string" && value.trim() === "")
163
+ return {};
164
+ if (typeof value === "string") {
165
+ // Extension-scope longtext saves preserve strings, unlike the core
166
+ // settings adapter's JSON parsing, so both persisted shapes are valid.
167
+ try {
168
+ value = JSON.parse(value);
169
+ }
170
+ catch (cause) {
171
+ throw new Error(`mcp config user settings servers is not valid JSON: ${errorMessage(cause)}`);
172
+ }
173
+ }
174
+ // Reuse the wire validator so a user cannot supply fields or transports
175
+ // that the operator registry would reject under a different code path.
176
+ return parseMcpConfig(JSON.stringify({ servers: value }), "user settings servers").servers;
177
+ }
178
+ function sharedAuthorityHelp(reason) {
179
+ const authority = reason?.trim() ?? "Shared authority was not stated.";
180
+ const sentence = /[.!?]$/.test(authority) ? authority : `${authority}.`;
181
+ return `${sentence} Shared MCP servers hand every permitted user's agent the admin's credential for that service.`;
182
+ }
183
+ export function createUserCatalog(host, servers, initialMode = "none") {
184
+ let mode = initialMode;
185
+ const states = new Map();
186
+ const personal = Object.entries(servers).filter(([, server]) => server.credentials !== "shared" && serverEnabled(server));
187
+ let closed = false;
188
+ const userServices = personal.flatMap(([name, server]) => {
189
+ if (!isStdioServer(server))
190
+ return [];
191
+ return [{
192
+ name,
193
+ start: async (user, _ctx) => {
194
+ if (!(await catalogEnabled(host, user, name, server)))
195
+ return undefined;
196
+ // Per-user tokens use only scoped secrets; the file store remains
197
+ // exclusively the shared operator's credential path. A configured
198
+ // token is shared configuration and must never become the fallback.
199
+ const env = Object.fromEntries(Object.entries(server.env ?? {}).filter(([key]) => key !== "MCP_ACCESS_TOKEN"));
200
+ const token = await host.stores.user(user).secrets.get(`token:${name}`);
201
+ return {
202
+ command: server.command,
203
+ args: [...server.args],
204
+ env: {
205
+ ...env,
206
+ ...(token === undefined ? {} : { MCP_ACCESS_TOKEN: token }),
207
+ },
208
+ };
209
+ },
210
+ }];
211
+ });
212
+ const connectPersonal = async (name, server, user, ctx) => {
213
+ if (isStdioServer(server)) {
214
+ const handle = await host.services.connect(name, user, ctx);
215
+ try {
216
+ return await connectMcpServer(name, server, handle);
217
+ }
218
+ catch (cause) {
219
+ handle.release();
220
+ throw cause;
221
+ }
222
+ }
223
+ // Header names are case-insensitive; remove every configured spelling
224
+ // so one user's missing token cannot fall back to another credential.
225
+ const headers = Object.fromEntries(Object.entries(server.headers ?? {}).filter(([key]) => key.toLowerCase() !== "authorization"));
226
+ const token = await host.stores.user(user).secrets.get(`token:${name}`);
227
+ return await connectMcpServer(name, {
228
+ ...server,
229
+ headers: {
230
+ ...headers,
231
+ ...(token === undefined ? {} : { Authorization: `Bearer ${token}` }),
232
+ },
233
+ });
234
+ };
235
+ const publish = (user, state) => {
236
+ host.tools.setUserTools(user, [...state.connections.values()].flatMap((connection) => connection.registrations));
237
+ };
238
+ const refresh = async (user, ctx) => {
239
+ const state = states.get(user);
240
+ if (state === undefined || closed)
241
+ return;
242
+ let configured = {};
243
+ const settings = await host.stores.user(user).settings.get();
244
+ try {
245
+ configured = userServersFromSettings(settings);
246
+ }
247
+ catch (cause) {
248
+ console.warn(`mcp user "${user}" settings quarantined: ${errorMessage(cause)}`);
249
+ }
250
+ const activePersonal = personal.flatMap(([name, server]) => {
251
+ try {
252
+ return catalogEnabledFromSettings(name, server, settings) ? [[name, server]] : [];
253
+ }
254
+ catch (cause) {
255
+ console.warn(`mcp server "${name}" settings quarantined for user "${user}": ${errorMessage(cause)}`);
256
+ return [];
257
+ }
258
+ });
259
+ const activeSupplied = Object.entries(configured).filter(([name, server]) => {
260
+ if (!userServerAllowed(isStdioServer(server) ? "stdio" : "url", mode))
261
+ return false;
262
+ if (!isStdioServer(server))
263
+ return true;
264
+ // Dynamic user services cannot be represented by the static host
265
+ // contract; never run the user's command in sigrun's own process.
266
+ console.warn(`mcp server "${name}" for user "${user}" is a config error: the host declares per-user services statically at load and passes no server name to start, so a dynamic per-user stdio service cannot be launched`);
267
+ return false;
268
+ });
269
+ const active = new Map([
270
+ ...activePersonal,
271
+ ...activeSupplied.map(([name, server]) => [`u_${name}`, { ...server, credentials: "user" }]),
272
+ ]);
273
+ for (const [name, connection] of state.connections) {
274
+ if (active.has(name))
275
+ continue;
276
+ state.connections.delete(name);
277
+ connection.retire();
278
+ }
279
+ await Promise.all([...active].map(async ([name, server]) => {
280
+ const existing = state.connections.get(name);
281
+ if (existing !== undefined) {
282
+ existing.revive();
283
+ return;
284
+ }
285
+ let client;
286
+ try {
287
+ client = await connectPersonal(name, server, user, ctx);
288
+ if (closed) {
289
+ client.close();
290
+ return;
291
+ }
292
+ const connection = trackConnection(name, client, await discoverMcpTools(name, server, client));
293
+ if (closed) {
294
+ connection.close();
295
+ return;
296
+ }
297
+ state.connections.set(name, connection);
298
+ }
299
+ catch (cause) {
300
+ // Discovery can fail after the handshake owns a live service.
301
+ client?.close();
302
+ // One user's failed service is quarantined independently of every
303
+ // other user and every other configured server.
304
+ console.warn(`mcp server "${name}" quarantined for user "${user}": ${errorMessage(cause)}`);
305
+ }
306
+ }));
307
+ if (!closed)
308
+ publish(user, state);
309
+ };
310
+ const reconcileAll = async (nextMode) => {
311
+ if (nextMode !== undefined)
312
+ mode = nextMode;
313
+ await Promise.all([...states.keys()].map((user) => activate(user)));
314
+ };
315
+ const activate = (user, ctx = { abortSignal: undefined }) => {
316
+ if (closed)
317
+ return Promise.resolve();
318
+ let state = states.get(user);
319
+ if (state === undefined) {
320
+ state = { connections: new Map(), refresh: Promise.resolve() };
321
+ states.set(user, state);
322
+ }
323
+ // Per-user serialization prevents discovery/settings races from creating
324
+ // duplicate processes or republishing a disabled connection.
325
+ const current = state.refresh.then(() => refresh(user, ctx), () => refresh(user, ctx));
326
+ state.refresh = current;
327
+ return current;
328
+ };
329
+ const retire = async (user, server) => {
330
+ if (closed)
331
+ return;
332
+ const state = states.get(user);
333
+ if (state === undefined)
334
+ return;
335
+ const current = state.refresh.then(async () => {
336
+ if (closed)
337
+ return;
338
+ const connection = state.connections.get(server);
339
+ if (connection === undefined)
340
+ return;
341
+ state.connections.delete(server);
342
+ // Core owns the process lifecycle: releasing the last handle only
343
+ // arms idle-stop. A live child keeps its old environment until that
344
+ // timer fires or it crashes; this host contract cannot force a stop.
345
+ connection.retire();
346
+ publish(user, state);
347
+ });
348
+ state.refresh = current;
349
+ await current;
350
+ };
351
+ const enabledServers = Object.entries(servers).filter(([, server]) => serverEnabled(server));
352
+ const settings = mode === "none" ? [] : [{
353
+ id: "user-servers",
354
+ title: "Your MCP servers",
355
+ scope: "user",
356
+ form: {
357
+ v: 1,
358
+ root: {
359
+ type: "form",
360
+ id: "mcp-user-servers-form",
361
+ bind: "/settings",
362
+ mode: "submit",
363
+ onSubmit: "save",
364
+ children: [{
365
+ type: "longtext",
366
+ name: "servers",
367
+ label: "MCP server definitions",
368
+ default: "{}",
369
+ syntax: "code",
370
+ lang: "json",
371
+ help: "Define MCP servers as a JSON object. Remote servers use url; stdio availability depends on the operator's userServers policy.",
372
+ }],
373
+ },
374
+ },
375
+ }];
376
+ if (enabledServers.length > 0) {
377
+ settings.push({
378
+ id: "catalog",
379
+ title: "MCP servers",
380
+ scope: "user",
381
+ form: {
382
+ v: 1,
383
+ root: {
384
+ type: "form",
385
+ id: "mcp-catalog-form",
386
+ bind: "/settings",
387
+ mode: "submit",
388
+ onSubmit: "save",
389
+ children: enabledServers.map(([name, server]) => ({
390
+ type: "toggle",
391
+ name,
392
+ label: name,
393
+ default: server.availability !== "opt-in",
394
+ ...(server.credentials === "shared"
395
+ ? { help: sharedAuthorityHelp(server.sharedAuthority) }
396
+ : {}),
397
+ })),
398
+ },
399
+ },
400
+ });
401
+ }
402
+ return {
403
+ perUserTools: true,
404
+ userServices,
405
+ settings,
406
+ activate,
407
+ retire,
408
+ reconcileAll,
409
+ exposures: (user) => new Map([...(states.get(user)?.connections ?? [])].flatMap(([name, connection]) => connection.exposure === undefined ? [] : [[name, connection.exposure]])),
410
+ close: () => {
411
+ if (closed)
412
+ return;
413
+ closed = true;
414
+ for (const state of states.values()) {
415
+ for (const connection of state.connections.values())
416
+ connection.close();
417
+ state.connections.clear();
418
+ }
419
+ states.clear();
420
+ },
421
+ };
422
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/ext-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP extension for sigrun (pi-durable): registers MCP server tools as <server>/<toolname> tools",
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,7 +25,7 @@
25
25
  "test": "node scripts/check-manifest.mjs --expect-name @sigrun/ext-mcp && vitest run",
26
26
  "typecheck": "tsc --noEmit",
27
27
  "build": "tsc -p tsconfig.build.json",
28
- "prepare": "npm run build"
28
+ "prepublishOnly": "npm run build"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@earendil-works/chord": "1.3.0",
@@ -37,9 +37,10 @@
37
37
  "@earendil-works/chord": "1.3.0",
38
38
  "@earendil-works/pi-ai": "1.3.0",
39
39
  "@earendil-works/pi-durable": "1.3.0",
40
- "typebox": "1.3.27",
40
+ "@sigrun/sigrun": "file:../sigrun",
41
41
  "@types/node": "^26.6.4",
42
42
  "tsx": "^4.23.15",
43
+ "typebox": "1.3.27",
43
44
  "typescript": "^7.0.2",
44
45
  "vitest": "^5.0.3"
45
46
  },