@ronana/mcp 0.3.0 → 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
@@ -26,15 +26,19 @@ export function readCredentials() {
26
26
  function writeCredentials(value) {
27
27
  entry().setPassword(JSON.stringify(value));
28
28
  }
29
+ /** Ends a stored sign-in on the server, so it leaves the account's connections.
30
+ * Best effort: an unreachable server still lets the local step go ahead. */
31
+ async function revoke(credentials) {
32
+ await fetch(`${credentials.baseUrl}/api/auth/oauth2/revoke`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
35
+ body: new URLSearchParams({ token: credentials.refreshToken, client_id: credentials.clientId }),
36
+ }).catch(() => null);
37
+ }
29
38
  export async function logout() {
30
39
  const current = readCredentials();
31
- if (current) {
32
- await fetch(`${current.baseUrl}/api/auth/oauth2/revoke`, {
33
- method: "POST",
34
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
35
- body: new URLSearchParams({ token: current.refreshToken, client_id: current.clientId }),
36
- }).catch(() => null);
37
- }
40
+ if (current)
41
+ await revoke(current);
38
42
  try {
39
43
  entry().deletePassword();
40
44
  }
@@ -80,22 +84,37 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
80
84
  let fail;
81
85
  const code = new Promise((ok, no) => { settle = ok; fail = no; });
82
86
  const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run `ronana auth login` again.")), LOGIN_TIMEOUT_MS);
83
- 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
+ };
84
102
  const server = createServer((req, res) => {
85
103
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
86
- if (url.searchParams.get("state") !== state) {
87
- res.writeHead(400).end("Invalid OAuth state.");
88
- fail(new Error("OAuth state did not match."));
104
+ // Anything but our own callback (a favicon, a prefetch, a stale tab from an
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) {
107
+ res.writeHead(404).end();
108
+ return;
89
109
  }
90
- else if (url.searchParams.get("error")) {
91
- res.writeHead(400).end("Authorization was not completed.");
110
+ answered = true;
111
+ if (url.searchParams.get("error")) {
112
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Authorization was not completed.");
92
113
  fail(new Error(url.searchParams.get("error_description") ?? url.searchParams.get("error")));
114
+ return;
93
115
  }
94
- else {
95
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("Ronana is connected. You can close this window.");
96
- settle(url.searchParams.get("code") ?? "");
97
- }
98
- close();
116
+ held = res;
117
+ settle(url.searchParams.get("code") ?? "");
99
118
  });
100
119
  server.once("error", (error) => { clearTimeout(timer); reject(error); });
101
120
  server.listen(0, "127.0.0.1", () => {
@@ -104,7 +123,7 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
104
123
  close();
105
124
  return reject(new Error("Loopback callback failed."));
106
125
  }
107
- 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 });
108
127
  });
109
128
  });
110
129
  // Every exit below (refused registration, timeout, a refused token) frees the
@@ -154,6 +173,9 @@ ${authorize}
154
173
  });
155
174
  if (typeof tokens.access_token !== "string" || typeof tokens.refresh_token !== "string")
156
175
  throw new Error("OAuth response did not contain tokens.");
176
+ // Signing in again replaces the stored sign-in; end the old one too, or
177
+ // every login leaves another connection behind on the account.
178
+ const previous = readCredentials();
157
179
  writeCredentials({
158
180
  baseUrl,
159
181
  clientId: registered.client_id,
@@ -161,6 +183,9 @@ ${authorize}
161
183
  refreshToken: tokens.refresh_token,
162
184
  expiresAt: Date.now() + Number(tokens.expires_in ?? 900) * 1000,
163
185
  });
186
+ callback.finish("Ronana is connected. You can close this window.");
187
+ if (previous)
188
+ await revoke(previous);
164
189
  }
165
190
  finally {
166
191
  callback.close();
@@ -203,6 +228,8 @@ async function withRefreshLock(fn) {
203
228
  catch { /* Gone already. */ }
204
229
  }
205
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.";
206
233
  export async function accessToken() {
207
234
  const stored = readCredentials();
208
235
  if (!stored)
@@ -220,7 +247,21 @@ export async function accessToken() {
220
247
  client_id: current.clientId,
221
248
  refresh_token: current.refreshToken,
222
249
  resource: `${current.baseUrl}/mcp`,
250
+ }).catch((error) => {
251
+ // A network failure (fetch's TypeError) may pass; a refusal means this
252
+ // sign-in is over (disconnected on the website, or expired), and the
253
+ // server's own words ("session not found") would read as something else.
254
+ if (error instanceof TypeError)
255
+ throw error;
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);
223
262
  });
263
+ if (typeof tokens.replaced === "string")
264
+ return tokens.replaced;
224
265
  if (typeof tokens.access_token !== "string")
225
266
  throw new Error("OAuth refresh failed.");
226
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.0",
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",