antechamber 1.0.26 → 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,6 +7,20 @@ 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
+
14
+ ## [1.0.27] - 2026-10-28
15
+
16
+ ### Fixed
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.
21
+ - Find OmO's builtin `/mcp` command when an extension such as `pi-mcp-adapter` also registers `mcp` and OmO renames
22
+ the builtin to `mcp:1`. MCP sign-in used to fail there with "Unknown command /mcp".
23
+
10
24
  ## [1.0.26] - 2026-10-28
11
25
 
12
26
  ### Fixed
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"):
@@ -17,6 +20,8 @@
17
20
  * Commands are sent straight to the agent, outside the conversation queue: they neither
18
21
  * wait behind a running turn nor create a user message, and the session is held active so
19
22
  * the reaper cannot stop the process (and its listener or pending state) mid-flow.
23
+ * An extension that registers its own `mcp` command makes OmO rename the builtin (`mcp:1`),
24
+ * so the builtin is found by its source, not by name.
20
25
  */
21
26
  import type { RouteServices } from "./route-services.js";
22
27
  type OCMcpStatus = {
@@ -67,6 +72,8 @@ export declare function authorizationUrl(text: string, name: string): string | u
67
72
  * authorization request's own `redirect_uri` and `state`.
68
73
  */
69
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>;
70
77
  export declare function mcpRoute(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
71
78
  export declare function mcpRouteV2(services: RouteServices, method: string, path: string, directory: string, body: () => Promise<unknown>): Promise<RouteResult | undefined>;
72
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"):
@@ -17,9 +20,12 @@
17
20
  * Commands are sent straight to the agent, outside the conversation queue: they neither
18
21
  * wait behind a running turn nor create a user message, and the session is held active so
19
22
  * the reaper cannot stop the process (and its listener or pending state) mid-flow.
23
+ * An extension that registers its own `mcp` command makes OmO rename the builtin (`mcp:1`),
24
+ * so the builtin is found by its source, not by name.
20
25
  */
21
26
  import { randomUUID } from "node:crypto";
22
27
  import { errorBody } from "./http.js";
28
+ import { PiRpcClient } from "./pi-rpc.js";
23
29
  /** Bounds how long an HTTP caller waits on a read-only RPC; the transport keeps its own budget. */
24
30
  const MCP_READ_WAIT_MS = 5_000;
25
31
  /** OmO announces the authorization URL before it waits for the browser. */
@@ -32,6 +38,7 @@ const MCP_AUTH_ATTEMPT_TTL_MS = 10 * 60_000;
32
38
  const SERVER_NAME = /^[^\s"'\\]+$/;
33
39
  const INTEGRATION_PREFIX = "mcp_";
34
40
  const OAUTH_METHOD_ID = "oauth";
41
+ const BUILTIN_MCP_PATH = "<builtin:mcp>";
35
42
  const flows = new Map();
36
43
  const admissions = new Map();
37
44
  function isRecord(value) {
@@ -95,11 +102,15 @@ async function directoryServers(sessions, directory) {
95
102
  const live = liveSession(sessions, directory) ?? (fallback === undefined ? undefined : { pi: fallback });
96
103
  return live === undefined ? [] : loadedServers(live.pi).catch(() => []);
97
104
  }
98
- async function hasMcpCommand(pi) {
105
+ /** The builtin MCP command's name in this agent; builds without `sourceInfo` only know it as `mcp`. */
106
+ async function mcpCommandName(pi) {
99
107
  const response = await pi.probe({ type: "get_commands" }, undefined, AbortSignal.timeout(MCP_READ_WAIT_MS));
100
108
  if (!response.success || !isRecord(response.data) || !Array.isArray(response.data.commands))
101
- return false;
102
- return response.data.commands.some((command) => isRecord(command) && command.name === "mcp" && command.source === "extension");
109
+ return undefined;
110
+ const commands = response.data.commands.flatMap((command) => isRecord(command) && command.source === "extension" && typeof command.name === "string"
111
+ ? [{ name: command.name, builtin: isRecord(command.sourceInfo) && command.sourceInfo.path === BUILTIN_MCP_PATH }]
112
+ : []);
113
+ return (commands.find((command) => command.builtin) ?? commands.find((command) => command.name === "mcp"))?.name;
103
114
  }
104
115
  /** The URL OmO prints on its own line after naming the server (`auth` and `auth-start` both do). */
105
116
  export function authorizationUrl(text, name) {
@@ -151,7 +162,7 @@ function sameCallback(target, authorization) {
151
162
  const expected = new URL(announced);
152
163
  return target.origin === expected.origin && target.pathname === expected.pathname;
153
164
  }
154
- function runMcpCommand(sessions, s, pi, name, args, observe = {}) {
165
+ function runMcpCommand(sessions, s, pi, name, commandName, args, observe = {}) {
155
166
  const errors = [];
156
167
  const onEvent = (event) => {
157
168
  const entry = event.entry;
@@ -174,8 +185,8 @@ function runMcpCommand(sessions, s, pi, name, args, observe = {}) {
174
185
  };
175
186
  pi.onEvent(onEvent);
176
187
  pi.onExtensionUI(onNotify);
177
- return sessions.lifecycle
178
- .use(s, () => pi.command({ type: "prompt", message: `/mcp ${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))
179
190
  .then((response) => {
180
191
  if (!response.success)
181
192
  throw new Error(response.error ?? "OmO rejected the /mcp command");
@@ -193,11 +204,57 @@ function runMcpCommand(sessions, s, pi, name, args, observe = {}) {
193
204
  function announce(services, directory, name) {
194
205
  services.broadcast(directory, { type: "mcp.tools.changed", properties: { server: name } });
195
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
+ }
196
251
  function pruneFlows() {
197
252
  const now = Date.now();
198
253
  for (const [key, flow] of flows)
199
- 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)) {
200
255
  flows.delete(key);
256
+ release(flow);
257
+ }
201
258
  }
202
259
  /** Concurrent clicks share one admission, so a second `/mcp auth` never races the first. */
203
260
  function admitFlow(services, directory, name, command) {
@@ -216,29 +273,40 @@ const inProgress = (name) => ({
216
273
  async function startFlow(services, directory, name, command) {
217
274
  if (!SERVER_NAME.test(name))
218
275
  return { error: { status: 400, body: errorBody("InvalidRequestError", "invalid MCP server name") } };
219
- const live = liveSession(services.sessions, directory);
220
- if (live === undefined)
221
- return {
222
- error: {
223
- status: 400,
224
- body: errorBody("McpUnavailableError", "MCP authorization needs a running OmO session in this directory"),
225
- },
226
- };
227
- const { s, pi } = live;
228
276
  const key = flowKey(directory, name);
229
277
  pruneFlows();
230
278
  const existing = flows.get(key);
231
- if (existing?.running && existing.pi === pi)
279
+ if (existing?.running && agentCurrent(existing))
232
280
  return existing.command === command ? { flow: existing } : inProgress(name);
233
- 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
+ }
234
301
  if (!servers.some((server) => server.name === name))
235
- return { error: { status: 404, body: errorBody("McpServerNotFoundError", `MCP server not found: ${name}`) } };
236
- if (!(await hasMcpCommand(pi)))
237
- return { error: { status: 400, body: errorBody("UnsupportedError", "This OmO build has no /mcp command") } };
302
+ return refuse(404, "McpServerNotFoundError", `MCP server not found: ${name}`);
303
+ if (commandName === undefined)
304
+ return refuse(400, "UnsupportedError", "This OmO build has no /mcp command");
238
305
  const url = Promise.withResolvers();
239
306
  const flow = {
240
307
  s,
241
308
  pi,
309
+ commandName,
242
310
  command,
243
311
  attemptID: randomUUID(),
244
312
  created: Date.now(),
@@ -246,8 +314,16 @@ async function startFlow(services, directory, name, command) {
246
314
  done: Promise.resolve(),
247
315
  running: true,
248
316
  authorized: false,
317
+ released: false,
249
318
  };
250
- flow.done = runMcpCommand(services.sessions, s, pi, name, `${command} ${name}`, {
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
+ }
326
+ flow.done = runMcpCommand(services.sessions, s, pi, name, commandName, `${command} ${name}`, {
251
327
  url: (found) => {
252
328
  flow.authorizationUrl ??= found;
253
329
  url.resolve(found);
@@ -256,12 +332,20 @@ async function startFlow(services, directory, name, command) {
256
332
  flow.authorized = true;
257
333
  },
258
334
  })
259
- .then(() => {
260
- if (flow.authorized)
261
- 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);
262
345
  })
263
346
  .catch((error) => {
264
347
  flow.error = errorMessage(error);
348
+ release(flow);
265
349
  throw error;
266
350
  })
267
351
  .finally(() => {
@@ -269,6 +353,8 @@ async function startFlow(services, directory, name, command) {
269
353
  });
270
354
  // The caller that started the flow may already have its URL; a later failure is still reported.
271
355
  void flow.done.catch((error) => console.error(`[mcp] authorization of ${name} failed: ${errorMessage(error)}`));
356
+ if (existing !== undefined)
357
+ release(existing);
272
358
  flows.set(key, flow);
273
359
  return { flow };
274
360
  }
@@ -301,10 +387,12 @@ async function completeFlow(services, directory, name, input) {
301
387
  const fail = (status, name_, message) => ({
302
388
  error: { status, body: errorBody(name_, message) },
303
389
  });
304
- 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)
305
391
  return fail(400, "McpAuthError", `No pending authorization for ${name}; start it again`);
306
392
  if (flow.authorized)
307
- 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`);
308
396
  if (flow.error !== undefined)
309
397
  return fail(400, "McpAuthError", `Authorization of ${name} failed (${flow.error}); start it again`);
310
398
  if (typeof input !== "string" || flow.authorizationUrl === undefined)
@@ -312,11 +400,15 @@ async function completeFlow(services, directory, name, input) {
312
400
  const redirect = redirectInput(flow.authorizationUrl, input);
313
401
  if (redirect === undefined)
314
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;
315
406
  try {
316
407
  if (flow.running && flow.command === "auth") {
317
408
  const target = new URL(redirect);
318
409
  if (!sameCallback(target, flow.authorizationUrl))
319
410
  return fail(400, "McpAuthError", `OmO is waiting for ${name} on its loopback callback; paste that redirect`);
411
+ attempted = true;
320
412
  const delivered = await fetch(target, { redirect: "manual", signal: AbortSignal.timeout(MCP_READ_WAIT_MS) });
321
413
  if (!delivered.ok) {
322
414
  // OmO ends the flow on a rejected callback; settle it so a retry cannot join the dead flow.
@@ -326,21 +418,30 @@ async function completeFlow(services, directory, name, input) {
326
418
  await flow.done;
327
419
  }
328
420
  else {
329
- await runMcpCommand(services.sessions, flow.s, flow.pi, name, `auth-complete ${name} ${redirect}`, {
421
+ attempted = true;
422
+ await runMcpCommand(services.sessions, flow.s, flow.pi, name, flow.commandName, `auth-complete ${name} ${redirect}`, {
330
423
  authorized: () => {
331
424
  flow.authorized = true;
332
425
  },
333
426
  });
334
- announce(services, directory, name);
427
+ if (flow.authorized)
428
+ authorized(services, directory, name, flow);
429
+ else
430
+ announce(services, directory, name);
335
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);
336
435
  }
337
436
  catch (error) {
338
437
  flow.error = errorMessage(error);
339
438
  return fail(400, "McpAuthError", flow.error);
340
439
  }
341
- if (!flow.authorized)
342
- return fail(400, "McpAuthError", flow.error ?? `MCP server ${name} did not complete authorization`);
343
- return { server: await currentStatus(flow.pi, name) };
440
+ finally {
441
+ if (attempted)
442
+ release(flow);
443
+ }
444
+ return { server };
344
445
  }
345
446
  function decodeName(raw) {
346
447
  try {
@@ -388,7 +489,7 @@ export async function mcpRoute(services, method, path, directory, body) {
388
489
  status: 400,
389
490
  body: errorBody("McpAuthError", `MCP server ${name} is waiting for its authorization code; paste it to finish`),
390
491
  };
391
- const server = await currentStatus(flow.pi, name);
492
+ const server = await flowStatus(flow, name);
392
493
  return { status: 200, body: server === undefined ? { status: "disabled" } : openCodeMcpStatus(server) };
393
494
  }
394
495
  const announced = await awaitAuthorizationUrl(flow, name);
@@ -480,8 +581,10 @@ export async function mcpRouteV2(services, method, path, directory, body) {
480
581
  return "error" in result ? result.error : { status: 204 };
481
582
  }
482
583
  if (method === "DELETE") {
483
- if (flow !== undefined)
584
+ if (flow !== undefined) {
484
585
  flows.delete(flowKey(directory, name));
586
+ release(flow);
587
+ }
485
588
  return { status: 204 };
486
589
  }
487
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.26",
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.26",
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.26",
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.26",
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",