antechamber 1.0.27 → 1.0.28
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 +7 -0
- package/dist/mcp.d.ts +6 -1
- package/dist/mcp.js +123 -29
- package/dist/server.js +2 -1
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,10 +7,17 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.28] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- fix(mcp): sign in to MCP servers without a running OmO session (#79)
|
|
13
|
+
|
|
10
14
|
## [1.0.27] - 2026-10-28
|
|
11
15
|
|
|
12
16
|
### Fixed
|
|
13
17
|
|
|
18
|
+
- Sign in to an MCP server from OpenChamber without a running OmO session in that directory. The sign-in now runs in
|
|
19
|
+
an agent started for it alone, which stops once the sign-in completes, is cancelled or expires, and every running
|
|
20
|
+
session reconnects the server afterwards so it picks up the new token.
|
|
14
21
|
- Find OmO's builtin `/mcp` command when an extension such as `pi-mcp-adapter` also registers `mcp` and OmO renames
|
|
15
22
|
the builtin to `mcp:1`. MCP sign-in used to fail there with "Unknown command /mcp".
|
|
16
23
|
|
package/dist/mcp.d.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
6
|
* dormant session is never revived just to answer an MCP request. Status reads for a
|
|
7
7
|
* directory without one fall back to the most recently used live session elsewhere.
|
|
8
|
-
* Authorization
|
|
8
|
+
* Authorization in a directory without one runs in an agent spawned for that flow alone
|
|
9
|
+
* (`--no-session`), disposed once the flow completes, fails, is cancelled or expires.
|
|
10
|
+
* OmO stores tokens globally, so a completed sign-in reconnects the server in every other
|
|
11
|
+
* live session.
|
|
9
12
|
*
|
|
10
13
|
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
11
14
|
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
@@ -69,6 +72,8 @@ export declare function authorizationUrl(text: string, name: string): string | u
|
|
|
69
72
|
* authorization request's own `redirect_uri` and `state`.
|
|
70
73
|
*/
|
|
71
74
|
export declare function redirectInput(authorization: string, input: string): string | undefined;
|
|
75
|
+
/** Stops the agents spawned for sign-ins; live sessions are stopped by their own manager. */
|
|
76
|
+
export declare function disposeMcpAgents(): Promise<void>;
|
|
72
77
|
export declare function mcpRoute(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
73
78
|
export declare function mcpRouteV2(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
|
|
74
79
|
export {};
|
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* Everything therefore targets the most recently used LIVE session in the directory; a
|
|
6
6
|
* dormant session is never revived just to answer an MCP request. Status reads for a
|
|
7
7
|
* directory without one fall back to the most recently used live session elsewhere.
|
|
8
|
-
* Authorization
|
|
8
|
+
* Authorization in a directory without one runs in an agent spawned for that flow alone
|
|
9
|
+
* (`--no-session`), disposed once the flow completes, fails, is cancelled or expires.
|
|
10
|
+
* OmO stores tokens globally, so a completed sign-in reconnects the server in every other
|
|
11
|
+
* live session.
|
|
9
12
|
*
|
|
10
13
|
* Status comes from the read-only `get_loaded_surfaces` RPC. Authorization drives OmO's
|
|
11
14
|
* documented `/mcp` subcommands (docs/mcp.md "Auth"):
|
|
@@ -22,6 +25,7 @@
|
|
|
22
25
|
*/
|
|
23
26
|
import { randomUUID } from "node:crypto";
|
|
24
27
|
import { errorBody } from "./http.js";
|
|
28
|
+
import { PiRpcClient } from "./pi-rpc.js";
|
|
25
29
|
/** Bounds how long an HTTP caller waits on a read-only RPC; the transport keeps its own budget. */
|
|
26
30
|
const MCP_READ_WAIT_MS = 5_000;
|
|
27
31
|
/** OmO announces the authorization URL before it waits for the browser. */
|
|
@@ -181,8 +185,8 @@ function runMcpCommand(sessions, s, pi, name, commandName, args, observe = {}) {
|
|
|
181
185
|
};
|
|
182
186
|
pi.onEvent(onEvent);
|
|
183
187
|
pi.onExtensionUI(onNotify);
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
const send = () => pi.command({ type: "prompt", message: `/${commandName} ${args}`, expandPromptTemplates: true }, MCP_AUTH_COMMAND_TIMEOUT_MS);
|
|
189
|
+
return (s === undefined ? send() : sessions.lifecycle.use(s, send))
|
|
186
190
|
.then((response) => {
|
|
187
191
|
if (!response.success)
|
|
188
192
|
throw new Error(response.error ?? "OmO rejected the /mcp command");
|
|
@@ -200,11 +204,57 @@ function runMcpCommand(sessions, s, pi, name, commandName, args, observe = {}) {
|
|
|
200
204
|
function announce(services, directory, name) {
|
|
201
205
|
services.broadcast(directory, { type: "mcp.tools.changed", properties: { server: name } });
|
|
202
206
|
}
|
|
207
|
+
/** Other live sessions keep their own connection, which still lacks the new token until it reconnects. */
|
|
208
|
+
function authorized(services, directory, name, flow) {
|
|
209
|
+
announce(services, directory, name);
|
|
210
|
+
const reconnects = services.sessions.liveSessions().flatMap((s) => {
|
|
211
|
+
const pi = s.pi;
|
|
212
|
+
if (pi === undefined || pi === flow.pi)
|
|
213
|
+
return [];
|
|
214
|
+
return [
|
|
215
|
+
mcpCommandName(pi)
|
|
216
|
+
.then((commandName) => commandName === undefined
|
|
217
|
+
? undefined
|
|
218
|
+
: runMcpCommand(services.sessions, s, pi, name, commandName, `reconnect ${name}`))
|
|
219
|
+
.catch((error) => console.error(`[mcp] reconnecting ${name} in session ${s.info.id} failed: ${errorMessage(error)}`)),
|
|
220
|
+
];
|
|
221
|
+
});
|
|
222
|
+
if (reconnects.length > 0)
|
|
223
|
+
void Promise.all(reconnects).then(() => announce(services, directory, name));
|
|
224
|
+
}
|
|
225
|
+
function agentCurrent(flow) {
|
|
226
|
+
return flow.s === undefined ? !flow.released : flow.s.pi === flow.pi;
|
|
227
|
+
}
|
|
228
|
+
function release(flow) {
|
|
229
|
+
if (flow.s !== undefined)
|
|
230
|
+
return;
|
|
231
|
+
flow.released = true;
|
|
232
|
+
clearTimeout(flow.expiry);
|
|
233
|
+
void flow.pi
|
|
234
|
+
.dispose()
|
|
235
|
+
.catch((error) => console.error(`[mcp] stopping an auth agent failed: ${errorMessage(error)}`));
|
|
236
|
+
}
|
|
237
|
+
/** Stops the agents spawned for sign-ins; live sessions are stopped by their own manager. */
|
|
238
|
+
export async function disposeMcpAgents() {
|
|
239
|
+
const dedicated = [...flows.values()].filter((flow) => flow.s === undefined && !flow.released);
|
|
240
|
+
for (const flow of dedicated) {
|
|
241
|
+
flow.released = true;
|
|
242
|
+
clearTimeout(flow.expiry);
|
|
243
|
+
}
|
|
244
|
+
await Promise.all(dedicated.map((flow) => flow.pi.dispose()));
|
|
245
|
+
}
|
|
246
|
+
async function flowStatus(flow, name) {
|
|
247
|
+
if (flow.server !== undefined || !agentCurrent(flow))
|
|
248
|
+
return flow.server;
|
|
249
|
+
return currentStatus(flow.pi, name);
|
|
250
|
+
}
|
|
203
251
|
function pruneFlows() {
|
|
204
252
|
const now = Date.now();
|
|
205
253
|
for (const [key, flow] of flows)
|
|
206
|
-
if (!flow.running && (flow
|
|
254
|
+
if (!flow.running && (!agentCurrent(flow) || now - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)) {
|
|
207
255
|
flows.delete(key);
|
|
256
|
+
release(flow);
|
|
257
|
+
}
|
|
208
258
|
}
|
|
209
259
|
/** Concurrent clicks share one admission, so a second `/mcp auth` never races the first. */
|
|
210
260
|
function admitFlow(services, directory, name, command) {
|
|
@@ -223,26 +273,35 @@ const inProgress = (name) => ({
|
|
|
223
273
|
async function startFlow(services, directory, name, command) {
|
|
224
274
|
if (!SERVER_NAME.test(name))
|
|
225
275
|
return { error: { status: 400, body: errorBody("InvalidRequestError", "invalid MCP server name") } };
|
|
226
|
-
const live = liveSession(services.sessions, directory);
|
|
227
|
-
if (live === undefined)
|
|
228
|
-
return {
|
|
229
|
-
error: {
|
|
230
|
-
status: 400,
|
|
231
|
-
body: errorBody("McpUnavailableError", "MCP authorization needs a running OmO session in this directory"),
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
const { s, pi } = live;
|
|
235
276
|
const key = flowKey(directory, name);
|
|
236
277
|
pruneFlows();
|
|
237
278
|
const existing = flows.get(key);
|
|
238
|
-
if (existing?.running && existing
|
|
279
|
+
if (existing?.running && agentCurrent(existing))
|
|
239
280
|
return existing.command === command ? { flow: existing } : inProgress(name);
|
|
240
|
-
const
|
|
281
|
+
const live = liveSession(services.sessions, directory);
|
|
282
|
+
const s = live?.s;
|
|
283
|
+
const pi = live?.pi ?? new PiRpcClient({ cwd: directory, args: ["--no-session"] });
|
|
284
|
+
const refuse = async (status, error, message) => {
|
|
285
|
+
if (live === undefined)
|
|
286
|
+
await pi.dispose();
|
|
287
|
+
return { error: { status, body: errorBody(error, message) } };
|
|
288
|
+
};
|
|
289
|
+
let servers;
|
|
290
|
+
let commandName;
|
|
291
|
+
try {
|
|
292
|
+
// A spawned agent answers its first RPC only once it has started.
|
|
293
|
+
if (live === undefined)
|
|
294
|
+
await pi.command({ type: "get_state" });
|
|
295
|
+
servers = await loadedServers(pi);
|
|
296
|
+
commandName = await mcpCommandName(pi);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return refuse(400, "McpUnavailableError", `OmO could not start for MCP authorization: ${errorMessage(error)}`);
|
|
300
|
+
}
|
|
241
301
|
if (!servers.some((server) => server.name === name))
|
|
242
|
-
return
|
|
243
|
-
const commandName = await mcpCommandName(pi);
|
|
302
|
+
return refuse(404, "McpServerNotFoundError", `MCP server not found: ${name}`);
|
|
244
303
|
if (commandName === undefined)
|
|
245
|
-
return
|
|
304
|
+
return refuse(400, "UnsupportedError", "This OmO build has no /mcp command");
|
|
246
305
|
const url = Promise.withResolvers();
|
|
247
306
|
const flow = {
|
|
248
307
|
s,
|
|
@@ -255,7 +314,15 @@ async function startFlow(services, directory, name, command) {
|
|
|
255
314
|
done: Promise.resolve(),
|
|
256
315
|
running: true,
|
|
257
316
|
authorized: false,
|
|
317
|
+
released: false,
|
|
258
318
|
};
|
|
319
|
+
if (live === undefined) {
|
|
320
|
+
pi.onExit(() => {
|
|
321
|
+
flow.released = true;
|
|
322
|
+
});
|
|
323
|
+
flow.expiry = setTimeout(() => release(flow), MCP_AUTH_ATTEMPT_TTL_MS);
|
|
324
|
+
flow.expiry.unref();
|
|
325
|
+
}
|
|
259
326
|
flow.done = runMcpCommand(services.sessions, s, pi, name, commandName, `${command} ${name}`, {
|
|
260
327
|
url: (found) => {
|
|
261
328
|
flow.authorizationUrl ??= found;
|
|
@@ -265,12 +332,20 @@ async function startFlow(services, directory, name, command) {
|
|
|
265
332
|
flow.authorized = true;
|
|
266
333
|
},
|
|
267
334
|
})
|
|
268
|
-
.then(() => {
|
|
269
|
-
if (flow.authorized)
|
|
270
|
-
|
|
335
|
+
.then(async () => {
|
|
336
|
+
if (!flow.authorized)
|
|
337
|
+
return;
|
|
338
|
+
if (live === undefined) {
|
|
339
|
+
const server = await currentStatus(pi, name).catch(() => undefined);
|
|
340
|
+
if (server !== undefined)
|
|
341
|
+
flow.server = server;
|
|
342
|
+
release(flow);
|
|
343
|
+
}
|
|
344
|
+
authorized(services, directory, name, flow);
|
|
271
345
|
})
|
|
272
346
|
.catch((error) => {
|
|
273
347
|
flow.error = errorMessage(error);
|
|
348
|
+
release(flow);
|
|
274
349
|
throw error;
|
|
275
350
|
})
|
|
276
351
|
.finally(() => {
|
|
@@ -278,6 +353,8 @@ async function startFlow(services, directory, name, command) {
|
|
|
278
353
|
});
|
|
279
354
|
// The caller that started the flow may already have its URL; a later failure is still reported.
|
|
280
355
|
void flow.done.catch((error) => console.error(`[mcp] authorization of ${name} failed: ${errorMessage(error)}`));
|
|
356
|
+
if (existing !== undefined)
|
|
357
|
+
release(existing);
|
|
281
358
|
flows.set(key, flow);
|
|
282
359
|
return { flow };
|
|
283
360
|
}
|
|
@@ -310,10 +387,12 @@ async function completeFlow(services, directory, name, input) {
|
|
|
310
387
|
const fail = (status, name_, message) => ({
|
|
311
388
|
error: { status, body: errorBody(name_, message) },
|
|
312
389
|
});
|
|
313
|
-
if (flow === undefined ||
|
|
390
|
+
if (flow === undefined || Date.now() - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)
|
|
314
391
|
return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
|
|
315
392
|
if (flow.authorized)
|
|
316
|
-
return { server: await
|
|
393
|
+
return { server: await flowStatus(flow, name) };
|
|
394
|
+
if (!agentCurrent(flow))
|
|
395
|
+
return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
|
|
317
396
|
if (flow.error !== undefined)
|
|
318
397
|
return fail(400, "McpAuthError", `Authorization of ${name} failed (${flow.error}); start it again`);
|
|
319
398
|
if (typeof input !== "string" || flow.authorizationUrl === undefined)
|
|
@@ -321,11 +400,15 @@ async function completeFlow(services, directory, name, input) {
|
|
|
321
400
|
const redirect = redirectInput(flow.authorizationUrl, input);
|
|
322
401
|
if (redirect === undefined)
|
|
323
402
|
return fail(400, "InvalidRequestError", "code must be an authorization code or redirect URL");
|
|
403
|
+
// OmO consumes the pending state on any answer, so a dedicated agent has nothing left to wait for.
|
|
404
|
+
let attempted = false;
|
|
405
|
+
let server;
|
|
324
406
|
try {
|
|
325
407
|
if (flow.running && flow.command === "auth") {
|
|
326
408
|
const target = new URL(redirect);
|
|
327
409
|
if (!sameCallback(target, flow.authorizationUrl))
|
|
328
410
|
return fail(400, "McpAuthError", `OmO is waiting for ${name} on its loopback callback; paste that redirect`);
|
|
411
|
+
attempted = true;
|
|
329
412
|
const delivered = await fetch(target, { redirect: "manual", signal: AbortSignal.timeout(MCP_READ_WAIT_MS) });
|
|
330
413
|
if (!delivered.ok) {
|
|
331
414
|
// OmO ends the flow on a rejected callback; settle it so a retry cannot join the dead flow.
|
|
@@ -335,21 +418,30 @@ async function completeFlow(services, directory, name, input) {
|
|
|
335
418
|
await flow.done;
|
|
336
419
|
}
|
|
337
420
|
else {
|
|
421
|
+
attempted = true;
|
|
338
422
|
await runMcpCommand(services.sessions, flow.s, flow.pi, name, flow.commandName, `auth-complete ${name} ${redirect}`, {
|
|
339
423
|
authorized: () => {
|
|
340
424
|
flow.authorized = true;
|
|
341
425
|
},
|
|
342
426
|
});
|
|
343
|
-
|
|
427
|
+
if (flow.authorized)
|
|
428
|
+
authorized(services, directory, name, flow);
|
|
429
|
+
else
|
|
430
|
+
announce(services, directory, name);
|
|
344
431
|
}
|
|
432
|
+
if (!flow.authorized)
|
|
433
|
+
return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
|
|
434
|
+
server = await flowStatus(flow, name);
|
|
345
435
|
}
|
|
346
436
|
catch (error) {
|
|
347
437
|
flow.error = errorMessage(error);
|
|
348
438
|
return fail(400, "McpAuthError", flow.error);
|
|
349
439
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
440
|
+
finally {
|
|
441
|
+
if (attempted)
|
|
442
|
+
release(flow);
|
|
443
|
+
}
|
|
444
|
+
return { server };
|
|
353
445
|
}
|
|
354
446
|
function decodeName(raw) {
|
|
355
447
|
try {
|
|
@@ -397,7 +489,7 @@ export async function mcpRoute(services, method, path, directory, body) {
|
|
|
397
489
|
status: 400,
|
|
398
490
|
body: errorBody("McpAuthError", `MCP server ${name} is waiting for its authorization code; paste it to finish`),
|
|
399
491
|
};
|
|
400
|
-
const server = await
|
|
492
|
+
const server = await flowStatus(flow, name);
|
|
401
493
|
return { status: 200, body: server === undefined ? { status: "disabled" } : openCodeMcpStatus(server) };
|
|
402
494
|
}
|
|
403
495
|
const announced = await awaitAuthorizationUrl(flow, name);
|
|
@@ -489,8 +581,10 @@ export async function mcpRouteV2(services, method, path, directory, body) {
|
|
|
489
581
|
return "error" in result ? result.error : { status: 204 };
|
|
490
582
|
}
|
|
491
583
|
if (method === "DELETE") {
|
|
492
|
-
if (flow !== undefined)
|
|
584
|
+
if (flow !== undefined) {
|
|
493
585
|
flows.delete(flowKey(directory, name));
|
|
586
|
+
release(flow);
|
|
587
|
+
}
|
|
494
588
|
return { status: 204 };
|
|
495
589
|
}
|
|
496
590
|
if (method !== "GET")
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** HTTP/SSE composition root for the OpenCode API backed by pi. */
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import { disposeMcpAgents } from "./mcp.js";
|
|
4
5
|
import { debugLog } from "./misc-routes.js";
|
|
5
6
|
import { SessionManager } from "./sessions.js";
|
|
6
7
|
import { loadCatalog } from "./catalog.js";
|
|
@@ -64,7 +65,7 @@ export class AdapterServer {
|
|
|
64
65
|
socket.destroy();
|
|
65
66
|
this.webSocketClients.clear();
|
|
66
67
|
try {
|
|
67
|
-
await this.sessions.disposeAll();
|
|
68
|
+
await Promise.all([this.sessions.disposeAll(), disposeMcpAgents()]);
|
|
68
69
|
}
|
|
69
70
|
finally {
|
|
70
71
|
// An in-flight HTTP request must not hold shutdown open after teardown.
|