@sigrun/ext-mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +75 -6
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +35 -31
- package/dist/client.js +639 -136
- package/dist/config.d.ts +45 -9
- package/dist/config.js +111 -15
- package/dist/extension.d.ts +26 -45
- package/dist/extension.js +261 -136
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -5
- package/dist/oauth.d.ts +15 -0
- package/dist/oauth.js +50 -0
- package/dist/spawn-env.d.ts +23 -0
- package/dist/spawn-env.js +48 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +4 -0
- package/dist/user-servers.d.ts +93 -0
- package/dist/user-servers.js +422 -0
- package/package.json +4 -3
package/dist/extension.js
CHANGED
|
@@ -1,115 +1,106 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
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 { loadMcpConfig,
|
|
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
|
-
|
|
9
|
+
import { catalogEnabledFromSettings, createUserCatalog, sharedServerAllowed, discoverMcpTools, } from "./user-servers.js";
|
|
33
10
|
export const MCP_EXTENSION_NAME = "sigrun.mcp";
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
});
|
|
21
|
+
function denied(message) {
|
|
22
|
+
return {
|
|
23
|
+
output: [{ type: "text", text: message }],
|
|
24
|
+
isError: true,
|
|
25
|
+
};
|
|
40
26
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
};
|
|
27
|
+
/** The shared tool wrapper is the authority boundary: registry visibility is
|
|
28
|
+
* only convenience, and it cannot be bypassed by selecting an extension. */
|
|
29
|
+
function sharedTool(host, name, server, tool) {
|
|
30
|
+
return {
|
|
31
|
+
...tool,
|
|
32
|
+
execute: async (args, api, context) => {
|
|
33
|
+
try {
|
|
34
|
+
if (!(await sharedServerAllowed(host, api.conversationId, name, server))) {
|
|
35
|
+
return denied(`MCP server "${name}" is not available for this user`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (cause) {
|
|
39
|
+
return denied(`MCP server "${name}" access check failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
40
|
+
}
|
|
41
|
+
return tool.execute(args, api, context);
|
|
83
42
|
},
|
|
84
|
-
}
|
|
43
|
+
};
|
|
85
44
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
45
|
+
function errorMessage(cause) {
|
|
46
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
47
|
+
}
|
|
48
|
+
// The host parameter is REQUIRED: sigrun passes it only to factories whose
|
|
49
|
+
// length is greater than zero. An optional/defaulted parameter has length 0
|
|
50
|
+
// and would silently receive no host, disabling every per-user operation.
|
|
51
|
+
export function createMcpExtension(host) {
|
|
52
|
+
// Pin the boot-selected path so policy reloads survive callers restoring
|
|
53
|
+
// their process environment immediately after the factory returns.
|
|
54
|
+
const configPath = resolveMcpConfigPath();
|
|
92
55
|
const config = loadMcpConfig();
|
|
93
|
-
const tools = [];
|
|
56
|
+
const tools = [];
|
|
94
57
|
const clients = new Map();
|
|
95
|
-
/** Live per-server exposure mix: which registered tools use codemode /
|
|
96
|
-
* deferred (the section renders from this snapshot). */
|
|
97
58
|
const serverExposures = new Map();
|
|
59
|
+
const shared = Object.entries(config.servers).filter(([, server]) => server.credentials === "shared" && serverEnabled(server));
|
|
60
|
+
const catalog = createUserCatalog(host, config.servers, config.userServers ?? "none");
|
|
61
|
+
const oauthFlows = createUserOauthFlows();
|
|
98
62
|
let closed = false;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
63
|
+
let liveUserServers = config.userServers ?? "none";
|
|
64
|
+
let policyRefresh = Promise.resolve();
|
|
65
|
+
let policyErrorReported = false;
|
|
66
|
+
const refreshPolicy = async () => {
|
|
67
|
+
// Lifecycle events are the production file-change signal: mcp.json is
|
|
68
|
+
// operator configuration, not part of core's settings document.
|
|
69
|
+
let next = liveUserServers;
|
|
70
|
+
try {
|
|
71
|
+
next = loadMcpConfig({
|
|
72
|
+
...process.env,
|
|
73
|
+
[MCP_CONFIG_ENV]: configPath,
|
|
74
|
+
}).userServers ?? "none";
|
|
75
|
+
policyErrorReported = false;
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
// A transient operator edit must not suppress this event's user
|
|
79
|
+
// reconciliation or discard the last known-good policy.
|
|
80
|
+
if (!policyErrorReported) {
|
|
81
|
+
policyErrorReported = true;
|
|
82
|
+
console.warn(`mcp policy reload failed for ${configPath}; keeping ${liveUserServers}: ${errorMessage(cause)}`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (next === liveUserServers)
|
|
87
|
+
return policyRefresh;
|
|
88
|
+
liveUserServers = next;
|
|
89
|
+
const chain = policyRefresh.then(() => catalog.reconcileAll(next), () => catalog.reconcileAll(next));
|
|
90
|
+
// Retain a settled recovery point: one failed reconcile must not
|
|
91
|
+
// poison every subsequent activation with the same stale rejection.
|
|
92
|
+
policyRefresh = chain.catch(() => undefined);
|
|
93
|
+
await chain;
|
|
94
|
+
};
|
|
102
95
|
const pending = [];
|
|
103
|
-
for (const [name, server] of
|
|
104
|
-
if (!serverEnabled(server))
|
|
105
|
-
continue;
|
|
96
|
+
for (const [name, server] of shared) {
|
|
106
97
|
pending.push((async () => {
|
|
107
98
|
let client;
|
|
108
99
|
try {
|
|
109
100
|
client = await connectMcpServer(name, server);
|
|
110
101
|
}
|
|
111
102
|
catch (cause) {
|
|
112
|
-
console.warn(`mcp server "${name}" quarantined: ${
|
|
103
|
+
console.warn(`mcp server "${name}" quarantined: ${errorMessage(cause)}`);
|
|
113
104
|
return;
|
|
114
105
|
}
|
|
115
106
|
if (closed) {
|
|
@@ -118,57 +109,182 @@ export function createMcpExtension() {
|
|
|
118
109
|
}
|
|
119
110
|
clients.set(name, client);
|
|
120
111
|
try {
|
|
121
|
-
const discovered = await client
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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));
|
|
112
|
+
const discovered = await discoverMcpTools(name, server, client);
|
|
113
|
+
if (closed) {
|
|
114
|
+
client.close();
|
|
115
|
+
return;
|
|
133
116
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
description: `${mix.count} tool${mix.count === 1 ? "" : "s"} from ${server.command} ${server.args.join(" ")}`.trim(),
|
|
138
|
-
});
|
|
117
|
+
tools.push(...discovered.tools.map((tool) => sharedTool(host, name, server, tool)));
|
|
118
|
+
if (discovered.exposure !== undefined) {
|
|
119
|
+
serverExposures.set(name, discovered.exposure);
|
|
139
120
|
}
|
|
140
121
|
}
|
|
141
122
|
catch (cause) {
|
|
142
123
|
clients.delete(name);
|
|
143
124
|
client.close();
|
|
144
|
-
console.warn(`mcp server "${name}" quarantined during discovery: ${
|
|
125
|
+
console.warn(`mcp server "${name}" quarantined during discovery: ${errorMessage(cause)}`);
|
|
145
126
|
}
|
|
146
127
|
})());
|
|
147
128
|
}
|
|
148
|
-
const
|
|
129
|
+
const activate = async (event) => {
|
|
130
|
+
await refreshPolicy();
|
|
131
|
+
if (event.owner !== undefined)
|
|
132
|
+
await catalog.activate(event.owner);
|
|
133
|
+
};
|
|
134
|
+
const errorResponse = (status, error) => Response.json({ error }, { status });
|
|
135
|
+
const serverFromRequest = (request) => {
|
|
136
|
+
const match = new URL(request.url).pathname.match(/\/v1\/ext\/sigrun-mcp\/oauth\/([^/]+)\/(?:start|complete)$/);
|
|
137
|
+
if (match === null)
|
|
138
|
+
return undefined;
|
|
139
|
+
try {
|
|
140
|
+
return decodeURIComponent(match[1]);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const oauthServer = (name) => {
|
|
147
|
+
if (name === undefined)
|
|
148
|
+
return undefined;
|
|
149
|
+
const server = config.servers[name];
|
|
150
|
+
if (server === undefined ||
|
|
151
|
+
server.credentials === "shared" ||
|
|
152
|
+
!serverEnabled(server) ||
|
|
153
|
+
!("oauth" in server) ||
|
|
154
|
+
server.oauth === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
return { ...server, oauth: server.oauth };
|
|
157
|
+
};
|
|
158
|
+
const userFrom = (ctx) => ctx.principal?.user;
|
|
159
|
+
const routes = [
|
|
160
|
+
{
|
|
161
|
+
method: "POST",
|
|
162
|
+
path: "oauth/:server/start",
|
|
163
|
+
auth: "user",
|
|
164
|
+
handler: async (request, ctx) => {
|
|
165
|
+
const user = userFrom(ctx);
|
|
166
|
+
if (user === undefined)
|
|
167
|
+
return errorResponse(400, "no_pending_flow");
|
|
168
|
+
const name = serverFromRequest(request);
|
|
169
|
+
const server = oauthServer(name);
|
|
170
|
+
if (name === undefined || server === undefined) {
|
|
171
|
+
return errorResponse(404, "unknown_server");
|
|
172
|
+
}
|
|
173
|
+
// No listener: the browser is redirected back to the complete
|
|
174
|
+
// endpoint, whose URL the user can paste from the address bar.
|
|
175
|
+
const redirectUri = new URL(`/v1/ext/sigrun-mcp/oauth/${encodeURIComponent(name)}/complete`, request.url).toString();
|
|
176
|
+
const url = oauthFlows.start(user, name, server.oauth, redirectUri);
|
|
177
|
+
return Response.json({ url });
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
method: "POST",
|
|
182
|
+
path: "oauth/:server/complete",
|
|
183
|
+
auth: "user",
|
|
184
|
+
handler: async (request, ctx) => {
|
|
185
|
+
const user = userFrom(ctx);
|
|
186
|
+
if (user === undefined)
|
|
187
|
+
return errorResponse(400, "no_pending_flow");
|
|
188
|
+
const name = serverFromRequest(request);
|
|
189
|
+
const server = oauthServer(name);
|
|
190
|
+
if (name === undefined || server === undefined) {
|
|
191
|
+
return errorResponse(404, "unknown_server");
|
|
192
|
+
}
|
|
193
|
+
let redirectedUrl;
|
|
194
|
+
try {
|
|
195
|
+
const body = await request.json();
|
|
196
|
+
if (typeof body !== "object" || body === null || Array.isArray(body) ||
|
|
197
|
+
!("redirectedUrl" in body) ||
|
|
198
|
+
typeof body.redirectedUrl !== "string" || body.redirectedUrl === "")
|
|
199
|
+
throw new Error("invalid body");
|
|
200
|
+
redirectedUrl = body.redirectedUrl;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return errorResponse(400, "invalid_body");
|
|
204
|
+
}
|
|
205
|
+
const flow = oauthFlows.take(user, name);
|
|
206
|
+
if (flow === undefined)
|
|
207
|
+
return errorResponse(400, "no_pending_flow");
|
|
208
|
+
let code;
|
|
209
|
+
try {
|
|
210
|
+
const extracted = extractCode(redirectedUrl);
|
|
211
|
+
if (!("code" in extracted))
|
|
212
|
+
throw new Error("authorization failed");
|
|
213
|
+
code = extracted.code;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
oauthFlows.release(user, name, flow);
|
|
217
|
+
return errorResponse(400, "invalid_redirect");
|
|
218
|
+
}
|
|
219
|
+
let token;
|
|
220
|
+
try {
|
|
221
|
+
token = await exchangeCode(server.oauth, code, flow.redirectUri, flow.verifier);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return errorResponse(502, "exchange_failed");
|
|
225
|
+
}
|
|
226
|
+
await storeUserMcpToken(ctx.host, user, name, token.access_token);
|
|
227
|
+
await catalog.retire(user, name);
|
|
228
|
+
await catalog.activate(user);
|
|
229
|
+
return new Response(null, { status: 204 });
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
const close = async () => {
|
|
149
234
|
if (closed)
|
|
150
235
|
return;
|
|
151
236
|
closed = true;
|
|
237
|
+
process.off("exit", closeSystemClients);
|
|
152
238
|
for (const client of clients.values())
|
|
153
239
|
client.close();
|
|
154
240
|
clients.clear();
|
|
155
241
|
tools.length = 0;
|
|
156
242
|
serverExposures.clear();
|
|
243
|
+
await Promise.all(pending);
|
|
244
|
+
oauthFlows.clear();
|
|
245
|
+
catalog.close();
|
|
157
246
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
247
|
+
const closeSystemClients = () => {
|
|
248
|
+
for (const client of clients.values())
|
|
249
|
+
client.close();
|
|
250
|
+
clients.clear();
|
|
251
|
+
catalog.close();
|
|
252
|
+
};
|
|
253
|
+
process.on("exit", closeSystemClients);
|
|
254
|
+
const mcpServersSection = async (input, context) => {
|
|
255
|
+
let user;
|
|
256
|
+
let settings;
|
|
257
|
+
try {
|
|
258
|
+
// PromptInput.read uses only committed harness state. The host identity
|
|
259
|
+
// bridge is server-bound and may never resolve in harness-only boots.
|
|
260
|
+
user = (await input.read.snapshot(SessionOwnerDoc, input.conversationId, context))?.owner;
|
|
261
|
+
if (user !== undefined) {
|
|
262
|
+
settings = await host.stores.user(user).settings.get();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (cause) {
|
|
266
|
+
console.warn(`mcp section access read failed: ${errorMessage(cause)}`);
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const reachable = new Map();
|
|
270
|
+
for (const [name, server] of shared) {
|
|
271
|
+
if (user !== undefined &&
|
|
272
|
+
settings !== undefined &&
|
|
273
|
+
(server.users === undefined || server.users.includes(user)) &&
|
|
274
|
+
catalogEnabledFromSettings(name, server, settings)) {
|
|
275
|
+
const exposure = serverExposures.get(name);
|
|
276
|
+
if (exposure !== undefined)
|
|
277
|
+
reachable.set(name, exposure);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (user !== undefined) {
|
|
281
|
+
await catalog.activate(user);
|
|
282
|
+
for (const [name, exposure] of catalog.exposures(user)) {
|
|
283
|
+
reachable.set(name, exposure);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
170
286
|
const reaches = new Map();
|
|
171
|
-
for (const [name, mix] of
|
|
287
|
+
for (const [name, mix] of reachable) {
|
|
172
288
|
reaches.set(name, mix.codemode && mix.deferred
|
|
173
289
|
? "either"
|
|
174
290
|
: mix.codemode
|
|
@@ -178,7 +294,7 @@ export function createMcpExtension() {
|
|
|
178
294
|
const offers = new Set(input.agent.tools.map((tool) => tool.name));
|
|
179
295
|
const { listed, unreachable } = partitionForSection(reaches, offers);
|
|
180
296
|
const entries = listed.map((name) => {
|
|
181
|
-
const mix =
|
|
297
|
+
const mix = reachable.get(name);
|
|
182
298
|
return {
|
|
183
299
|
name,
|
|
184
300
|
description: mix.description,
|
|
@@ -193,17 +309,26 @@ export function createMcpExtension() {
|
|
|
193
309
|
};
|
|
194
310
|
const extension = defineExtension({
|
|
195
311
|
name: MCP_EXTENSION_NAME,
|
|
196
|
-
tools,
|
|
197
|
-
sections: [
|
|
198
|
-
{
|
|
199
|
-
key: MCP_SERVERS_SECTION_KEY,
|
|
200
|
-
render: mcpServersSection,
|
|
201
|
-
},
|
|
202
|
-
],
|
|
312
|
+
tools,
|
|
313
|
+
sections: [{ key: MCP_SERVERS_SECTION_KEY, render: mcpServersSection }],
|
|
203
314
|
});
|
|
204
315
|
return {
|
|
205
316
|
extension,
|
|
206
|
-
|
|
317
|
+
perUserTools: catalog.perUserTools,
|
|
318
|
+
routes,
|
|
319
|
+
userServices: catalog.userServices,
|
|
320
|
+
surfaces: { settings: catalog.settings },
|
|
321
|
+
conversationCreated: activate,
|
|
322
|
+
runSettled: activate,
|
|
323
|
+
onSettingsChanged: async (keys, user) => {
|
|
324
|
+
if (user !== undefined &&
|
|
325
|
+
(keys.includes("servers") || keys.some((key) => Object.hasOwn(config.servers, key)))) {
|
|
326
|
+
await catalog.activate(user);
|
|
327
|
+
}
|
|
328
|
+
// The host has no settings-read hook. A visit that changes nothing
|
|
329
|
+
// starts nothing; the committed write is the actionable trigger.
|
|
330
|
+
},
|
|
331
|
+
close,
|
|
207
332
|
ready: Promise.all(pending).then(() => undefined),
|
|
208
333
|
};
|
|
209
334
|
}
|
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
|
-
*
|
|
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
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/** sigrun-ext-mcp: register MCP server tools as pi-durable tools. */
|
|
2
2
|
export { createMcpExtension, MCP_EXTENSION_NAME } from "./extension.js";
|
|
3
|
-
export { connectMcpServer
|
|
3
|
+
export { connectMcpServer } from "./client.js";
|
|
4
4
|
export { loadMcpConfig, MCP_CONFIG_ENV, parseMcpConfig, resolveMcpConfigPath, } from "./config.js";
|
|
5
5
|
export { loadMcpTokens, MCP_ACCESS_TOKEN_ENV, parseMcpTokenStore, resolveMcpTokenPath, saveMcpTokens, spawnEnvWithToken, storeMcpToken, } from "./tokens.js";
|
|
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
|
-
*
|
|
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
|
+
}
|