@ronana/mcp 0.3.1 → 0.3.2

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/cli.js CHANGED
@@ -173,6 +173,14 @@ async function withRemote(fn) {
173
173
  await remote.close().catch(() => null);
174
174
  }
175
175
  }
176
+ /** A queued job: print its id, or with --wait / --out follow it to the end. */
177
+ async function queuedThenFinish(remote, queued, args) {
178
+ if (!has(args, "wait") && !has(args, "out")) {
179
+ return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
180
+ }
181
+ note(`Queued ${queued.id}.`);
182
+ await finish(await waitFor(remote, queued.id, args), args);
183
+ }
176
184
  async function generateCommand(sub, args) {
177
185
  const [target] = args.positional;
178
186
  if (sub === "list") {
@@ -208,11 +216,7 @@ async function generateCommand(sub, args) {
208
216
  command run twice in ten minutes would return the first generation. */
209
217
  const idempotency_key = flag(args, "idempotency_key") ?? randomUUID();
210
218
  const queued = await callTool(remote, "generate", { model: contract.id, input, idempotency_key });
211
- if (!has(args, "wait") && !has(args, "out")) {
212
- return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
213
- }
214
- note(`Queued ${queued.id}.`);
215
- await finish(await waitFor(remote, queued.id, args), args);
219
+ await queuedThenFinish(remote, queued, args);
216
220
  });
217
221
  }
218
222
  async function modelCommand(sub, args) {
@@ -263,11 +267,7 @@ async function upscaleCommand(args) {
263
267
  ...(flag(args, "resolution") ? { resolution: flag(args, "resolution") } : {}),
264
268
  idempotency_key: flag(args, "idempotency_key") ?? randomUUID(),
265
269
  });
266
- if (!has(args, "wait") && !has(args, "out")) {
267
- return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
268
- }
269
- note(`Queued ${queued.id}.`);
270
- await finish(await waitFor(remote, queued.id, args), args);
270
+ await queuedThenFinish(remote, queued, args);
271
271
  });
272
272
  }
273
273
  async function folderCommand(sub, args) {
@@ -317,7 +317,12 @@ async function main() {
317
317
  }
318
318
  if (action === "status") {
319
319
  const credentials = readCredentials();
320
- return emit({ signed_in: !!credentials, base_url: credentials?.baseUrl ?? null }, () => print(credentials ? `Signed in to ${credentials.baseUrl}.` : "Not signed in."));
320
+ if (!credentials)
321
+ return emit({ signed_in: false, base_url: null }, () => print("Not signed in."));
322
+ // Ask the server: a sign-in stored here may have been disconnected on
323
+ // the website or have expired, and only the server knows.
324
+ const problem = await withRemote((r) => callTool(r, "get_account")).then(() => null, (error) => error instanceof ToolError ? null : error instanceof Error ? error.message : String(error));
325
+ return emit({ signed_in: problem === null, base_url: credentials.baseUrl, ...(problem ? { error: problem } : {}) }, () => print(problem ?? `Signed in to ${credentials.baseUrl}.`));
321
326
  }
322
327
  throw new Error("Use auth login | logout | status.");
323
328
  }
package/dist/oauth.d.ts CHANGED
@@ -8,4 +8,6 @@ export interface StoredCredentials {
8
8
  export declare function readCredentials(): StoredCredentials | null;
9
9
  export declare function logout(): Promise<void>;
10
10
  export declare function login(baseUrl?: string, openInBrowser?: boolean): Promise<void>;
11
+ /** What a user reads when the stored sign-in no longer works on the server. */
12
+ export declare const SIGN_IN_ENDED = "Your Ronana sign-in has ended (disconnected or expired). Run `ronana auth login` again.";
11
13
  export declare function accessToken(): Promise<string>;
package/dist/oauth.js CHANGED
@@ -84,24 +84,37 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
84
84
  let fail;
85
85
  const code = new Promise((ok, no) => { settle = ok; fail = no; });
86
86
  const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run `ronana auth login` again.")), LOGIN_TIMEOUT_MS);
87
- const close = () => { clearTimeout(timer); server.close(); };
87
+ /* The browser tab waits on this response until the code has been traded
88
+ for tokens, so it only says "connected" once that is true. */
89
+ let held = null;
90
+ let answered = false;
91
+ const finish = (text, status = 200) => {
92
+ if (!held)
93
+ return;
94
+ held.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(text);
95
+ held = null;
96
+ };
97
+ const close = () => {
98
+ clearTimeout(timer);
99
+ finish("Sign-in did not finish. See your terminal for why.", 500);
100
+ server.close();
101
+ };
88
102
  const server = createServer((req, res) => {
89
103
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
90
104
  // Anything but our own callback (a favicon, a prefetch, a stale tab from an
91
- // earlier sign-in) is ignored, so it cannot end the sign-in it isn't part of.
92
- if (url.pathname !== "/callback" || url.searchParams.get("state") !== state) {
105
+ // earlier sign-in, a repeat) is ignored, so it cannot end the sign-in.
106
+ if (answered || url.pathname !== "/callback" || url.searchParams.get("state") !== state) {
93
107
  res.writeHead(404).end();
94
108
  return;
95
109
  }
110
+ answered = true;
96
111
  if (url.searchParams.get("error")) {
97
- res.writeHead(400).end("Authorization was not completed.");
112
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Authorization was not completed.");
98
113
  fail(new Error(url.searchParams.get("error_description") ?? url.searchParams.get("error")));
114
+ return;
99
115
  }
100
- else {
101
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("Ronana is connected. You can close this window.");
102
- settle(url.searchParams.get("code") ?? "");
103
- }
104
- close();
116
+ held = res;
117
+ settle(url.searchParams.get("code") ?? "");
105
118
  });
106
119
  server.once("error", (error) => { clearTimeout(timer); reject(error); });
107
120
  server.listen(0, "127.0.0.1", () => {
@@ -110,7 +123,7 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
110
123
  close();
111
124
  return reject(new Error("Loopback callback failed."));
112
125
  }
113
- resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, close });
126
+ resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, finish, close });
114
127
  });
115
128
  });
116
129
  // Every exit below (refused registration, timeout, a refused token) frees the
@@ -170,7 +183,8 @@ ${authorize}
170
183
  refreshToken: tokens.refresh_token,
171
184
  expiresAt: Date.now() + Number(tokens.expires_in ?? 900) * 1000,
172
185
  });
173
- if (previous && previous.clientId !== registered.client_id)
186
+ callback.finish("Ronana is connected. You can close this window.");
187
+ if (previous)
174
188
  await revoke(previous);
175
189
  }
176
190
  finally {
@@ -214,6 +228,8 @@ async function withRefreshLock(fn) {
214
228
  catch { /* Gone already. */ }
215
229
  }
216
230
  }
231
+ /** What a user reads when the stored sign-in no longer works on the server. */
232
+ export const SIGN_IN_ENDED = "Your Ronana sign-in has ended (disconnected or expired). Run `ronana auth login` again.";
217
233
  export async function accessToken() {
218
234
  const stored = readCredentials();
219
235
  if (!stored)
@@ -237,8 +253,15 @@ export async function accessToken() {
237
253
  // server's own words ("session not found") would read as something else.
238
254
  if (error instanceof TypeError)
239
255
  throw error;
240
- throw new Error("Your Ronana sign-in has ended (disconnected or expired). Run `ronana auth login` again.");
256
+ // A login in another process replaces the stored sign-in and revokes the
257
+ // one we were refreshing: carry on with the new one instead.
258
+ const replaced = readCredentials();
259
+ if (replaced && replaced.clientId !== current.clientId && fresh(replaced))
260
+ return { replaced: replaced.accessToken };
261
+ throw new Error(SIGN_IN_ENDED);
241
262
  });
263
+ if (typeof tokens.replaced === "string")
264
+ return tokens.replaced;
242
265
  if (typeof tokens.access_token !== "string")
243
266
  throw new Error("OAuth refresh failed.");
244
267
  const next = {
package/dist/remote.js CHANGED
@@ -6,7 +6,7 @@ import { readFile } from "node:fs/promises";
6
6
  import { basename, extname } from "node:path";
7
7
  import { Readable } from "node:stream";
8
8
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
9
- import { accessToken, readCredentials } from "./oauth.js";
9
+ import { accessToken, readCredentials, SIGN_IN_ENDED } from "./oauth.js";
10
10
  export async function remoteClient() {
11
11
  const credentials = readCredentials();
12
12
  if (!credentials)
@@ -20,6 +20,10 @@ export async function remoteClient() {
20
20
  await client.connect(transport);
21
21
  }
22
22
  catch (error) {
23
+ // A 401 on a token that was still in date: the server answers that for a
24
+ // connection disconnected on the website.
25
+ if (error instanceof Error && /\b401\b|unauthori[sz]ed|revoked/i.test(error.message))
26
+ throw new Error(SIGN_IN_ENDED);
23
27
  // A 404 means either an account outside the MCP rollout or a server with no
24
28
  // MCP at all (a wrong --base-url). Only a server that advertises the MCP
25
29
  // resource can be refusing the account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronana/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Ronana command line and local MCP bridge.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ronana.ai",