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 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 stays scoped to the directory.
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 stays scoped to the directory.
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
- return sessions.lifecycle
185
- .use(s, () => pi.command({ type: "prompt", message: `/${commandName} ${args}`, expandPromptTemplates: true }, MCP_AUTH_COMMAND_TIMEOUT_MS))
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.s.pi !== flow.pi || now - flow.created > MCP_AUTH_ATTEMPT_TTL_MS))
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.pi === pi)
279
+ if (existing?.running && agentCurrent(existing))
239
280
  return existing.command === command ? { flow: existing } : inProgress(name);
240
- const servers = await loadedServers(pi);
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 { error: { status: 404, body: errorBody("McpServerNotFoundError", `MCP server not found: ${name}`) } };
243
- const commandName = await mcpCommandName(pi);
302
+ return refuse(404, "McpServerNotFoundError", `MCP server not found: ${name}`);
244
303
  if (commandName === undefined)
245
- return { error: { status: 400, body: errorBody("UnsupportedError", "This OmO build has no /mcp command") } };
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
- announce(services, directory, name);
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 || flow.s.pi !== flow.pi || Date.now() - flow.created > MCP_AUTH_ATTEMPT_TTL_MS)
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 currentStatus(flow.pi, name) };
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
- announce(services, directory, name);
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
- if (!flow.authorized)
351
- return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
352
- return { server: await currentStatus(flow.pi, name) };
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 currentStatus(flow.pi, name);
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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "OpenChamber extension: displays OmO tool calls the way OmO means them when OpenChamber runs on antechamber",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "OpenChamber extension: live, interactive graph of the OmO sub-agents behind the current session",
5
5
  "private": true,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-work",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "OpenChamber 2.x Work Status section: OmO todo phases, monitors, eval cells and background work of the current session",
5
5
  "private": true,
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "OpenCode-compatible HTTP/SSE server that lets OpenChamber drive the pi coding agent (and OmO) over RPC",
5
5
  "type": "module",
6
6
  "license": "MIT",