@ronana/mcp 0.3.1 → 0.4.0

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/README.md CHANGED
@@ -39,6 +39,7 @@ ronana generate get <id> | wait <id> [--out dir]
39
39
  ronana generate list
40
40
  ronana upscale <id | file | url> --resolution 4k --out ./renders
41
41
  ronana folder list | add <folder> <id…>
42
+ ronana listen [--with claude|codex|gemini|ollama|command] [--model m]
42
43
  ```
43
44
 
44
45
  Every field from `model get` is a flag (`--aspect_ratio 16:9`, `--seed 7`).
@@ -53,6 +54,14 @@ the full list.
53
54
  the same command run twice makes two generations. Pass `--idempotency-key` to
54
55
  make a retry bill once.
55
56
 
57
+ `listen` lets the Ronana studio send work to an AI on your computer. While it
58
+ runs, the studio's assistant and agent list it under their model menu, and
59
+ Enhance, assistant messages and agent turns go to it instead of Ronana's own
60
+ models, so they cost no credits. Renders still do, and agent cards wait for
61
+ your Confirm. `--with` picks the AI (Claude Code by default); `--with command
62
+ --command "…"` runs any program that reads the prompt on stdin and prints its
63
+ answer.
64
+
56
65
  ## Use Ronana from an AI app
57
66
 
58
67
  Most AI apps connect to Ronana's remote MCP server directly. Add this URL as a
@@ -112,6 +121,7 @@ library.
112
121
  | --- | --- |
113
122
  | `list_models` | List the available models. |
114
123
  | `get_model` | Get one model's contract: its input schema. |
124
+ | `get_prompt_guide` | Get the guide for writing a prompt for one model. |
115
125
  | `estimate_cost` | Estimate the credit cost of a generation. Takes the same `model` and `input` as `generate`. |
116
126
  | `generate` | Start an image or video generation. |
117
127
  | `get_generation` | Get one generation and its download link. Pass `wait_seconds` (up to 45) to wait for the result in one call. |
@@ -124,6 +134,11 @@ library.
124
134
  | `list_folders` | List the folders in the library. |
125
135
  | `add_to_folder` | File library items into a folder. |
126
136
  | `get_account` | Get the plan and credit balance. |
137
+ | `wait_for_requests` | Wait for Enhance, assistant and agent work from the studio. While an app calls it, the studio lists it as listening. |
138
+ | `get_request` | Read one request the studio sent, by its id. |
139
+ | `request_progress` | Tell the studio you are still working on a request you took, so a slow answer is not treated as abandoned. |
140
+ | `agent_tool` | Look up models, prompt guides and prices for an agent request. |
141
+ | `reply` | Answer a request: a prompt for the composer, a message, or cards for the board. |
127
142
 
128
143
  A generate call takes a `model` and an `input`, exactly as `get_model` shows it
129
144
  in `input_schema`. A media field takes a public https URL or a Ronana id
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // ronana generate list [--limit n]
15
15
  // ronana upscale <id | file | https-url> [--resolution r] [--model m] [--wait] [--out dir]
16
16
  // ronana folder list | add <folder> <id…>
17
+ // ronana listen [--with claude|codex|gemini|ollama|command] [--model m] [--command "…"]
17
18
  import { randomUUID } from "node:crypto";
18
19
  import { createWriteStream, existsSync, statSync } from "node:fs";
19
20
  import { mkdir } from "node:fs/promises";
@@ -22,6 +23,7 @@ import { Readable } from "node:stream";
22
23
  import { pipeline } from "node:stream/promises";
23
24
  import { login, logout, readCredentials } from "./oauth.js";
24
25
  import { callTool, remoteClient, ToolError, uploadFile } from "./remote.js";
26
+ import { listen, runnerFor } from "./listen.js";
25
27
  import { buildInput, flag, has, isRemoteValue, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
26
28
  const HELP = `Usage: ronana <command>
27
29
 
@@ -40,6 +42,9 @@ const HELP = `Usage: ronana <command>
40
42
  upscale <id|file|url> Upscale a video (costs credits; --resolution, --model)
41
43
  folder list Your library folders
42
44
  folder add <folder> <id…> File items into a folder (made if missing)
45
+ listen Let the studio send Enhance and agent work to an AI
46
+ on this computer (--with claude|codex|gemini|ollama|command,
47
+ --model m, --command "…", --app "name"). No credits.
43
48
 
44
49
  Generate flags:
45
50
  --<field> <value> Any field from \`model get\`. Repeat for lists.
@@ -173,6 +178,14 @@ async function withRemote(fn) {
173
178
  await remote.close().catch(() => null);
174
179
  }
175
180
  }
181
+ /** A queued job: print its id, or with --wait / --out follow it to the end. */
182
+ async function queuedThenFinish(remote, queued, args) {
183
+ if (!has(args, "wait") && !has(args, "out")) {
184
+ return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
185
+ }
186
+ note(`Queued ${queued.id}.`);
187
+ await finish(await waitFor(remote, queued.id, args), args);
188
+ }
176
189
  async function generateCommand(sub, args) {
177
190
  const [target] = args.positional;
178
191
  if (sub === "list") {
@@ -208,11 +221,7 @@ async function generateCommand(sub, args) {
208
221
  command run twice in ten minutes would return the first generation. */
209
222
  const idempotency_key = flag(args, "idempotency_key") ?? randomUUID();
210
223
  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);
224
+ await queuedThenFinish(remote, queued, args);
216
225
  });
217
226
  }
218
227
  async function modelCommand(sub, args) {
@@ -263,11 +272,7 @@ async function upscaleCommand(args) {
263
272
  ...(flag(args, "resolution") ? { resolution: flag(args, "resolution") } : {}),
264
273
  idempotency_key: flag(args, "idempotency_key") ?? randomUUID(),
265
274
  });
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);
275
+ await queuedThenFinish(remote, queued, args);
271
276
  });
272
277
  }
273
278
  async function folderCommand(sub, args) {
@@ -317,7 +322,12 @@ async function main() {
317
322
  }
318
323
  if (action === "status") {
319
324
  const credentials = readCredentials();
320
- return emit({ signed_in: !!credentials, base_url: credentials?.baseUrl ?? null }, () => print(credentials ? `Signed in to ${credentials.baseUrl}.` : "Not signed in."));
325
+ if (!credentials)
326
+ return emit({ signed_in: false, base_url: null }, () => print("Not signed in."));
327
+ // Ask the server: a sign-in stored here may have been disconnected on
328
+ // the website or have expired, and only the server knows.
329
+ const problem = await withRemote((r) => callTool(r, "get_account")).then(() => null, (error) => error instanceof ToolError ? null : error instanceof Error ? error.message : String(error));
330
+ return emit({ signed_in: problem === null, base_url: credentials.baseUrl, ...(problem ? { error: problem } : {}) }, () => print(problem ?? `Signed in to ${credentials.baseUrl}.`));
321
331
  }
322
332
  throw new Error("Use auth login | logout | status.");
323
333
  }
@@ -342,6 +352,14 @@ async function main() {
342
352
  case "upscale": return upscaleCommand(args);
343
353
  case "folder":
344
354
  case "folders": return folderCommand(command === "folders" ? "list" : sub, args);
355
+ case "listen": {
356
+ const runner = runnerFor(flag(args, "with") ?? "claude", { model: flag(args, "model"), command: flag(args, "command"), app: flag(args, "app") });
357
+ const remote = await remoteClient();
358
+ const stop = () => { void remote.close().catch(() => null).finally(() => process.exit(0)); };
359
+ process.once("SIGINT", stop);
360
+ process.once("SIGTERM", stop);
361
+ return listen(remote, runner, (line) => process.stderr.write(`${line}\n`));
362
+ }
345
363
  default: throw new Error(`Unknown command "${command}". Run \`ronana --help\`.`);
346
364
  }
347
365
  }
@@ -0,0 +1,56 @@
1
+ import { type remoteClient } from "./remote.js";
2
+ type Remote = Awaited<ReturnType<typeof remoteClient>>;
3
+ export interface Runner {
4
+ app: string;
5
+ command: string;
6
+ args: string[];
7
+ shell: boolean;
8
+ }
9
+ /** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
10
+ export declare function runnerFor(name: string, options: {
11
+ model?: string;
12
+ command?: string;
13
+ app?: string;
14
+ }): Runner;
15
+ /** Run the AI once. Resolves with what it printed. */
16
+ export declare function runOnce(runner: Runner, prompt: string, timeoutMs: number): Promise<string>;
17
+ interface EnhanceRequest {
18
+ id: string;
19
+ kind: "enhance";
20
+ model: {
21
+ id: string;
22
+ name: string;
23
+ };
24
+ draft: string;
25
+ brief?: string;
26
+ guide: string;
27
+ max_chars: number;
28
+ }
29
+ interface AgentRequest {
30
+ id: string;
31
+ kind: "agent";
32
+ messages: {
33
+ role: string;
34
+ content: string;
35
+ }[];
36
+ references: {
37
+ target: unknown;
38
+ kind: string;
39
+ label: string;
40
+ duration_s?: number;
41
+ }[];
42
+ context?: string;
43
+ approval_mode: string;
44
+ max_cards: number;
45
+ card_schema: unknown;
46
+ }
47
+ export type StudioRequest = EnhanceRequest | AgentRequest;
48
+ /** The prompt between <prompt> tags, or the whole answer when it has none. */
49
+ export declare function extractPrompt(text: string): string;
50
+ /** The first JSON object in the answer, fenced or bare. */
51
+ export declare function extractJson(text: string): Record<string, unknown> | null;
52
+ export declare function enhancePrompt(request: EnhanceRequest, refusal?: string): string;
53
+ export declare function agentPrompt(request: AgentRequest, catalog: string, refusal?: string): string;
54
+ /** Listen until Ctrl+C. */
55
+ export declare function listen(remote: Remote, runner: Runner, log: (line: string) => void): Promise<never>;
56
+ export {};
package/dist/listen.js ADDED
@@ -0,0 +1,186 @@
1
+ // `ronana listen`: keep an AI on this computer listening for work from the
2
+ // Ronana studio. While it runs, the studio lists it under its model menus and
3
+ // sends it Enhance and agent requests instead of using Ronana's own models, so
4
+ // the thinking runs on the user's own AI and costs no Ronana credits.
5
+ //
6
+ // The loop holds `wait_for_requests` open, runs the chosen AI once per request
7
+ // with the prompt on stdin, and sends its answer with `reply`. The server
8
+ // checks every reply; a refused one is handed back to the AI once to fix.
9
+ import { spawn } from "node:child_process";
10
+ import { callTool, ToolError } from "./remote.js";
11
+ /** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
12
+ export function runnerFor(name, options) {
13
+ const shell = process.platform === "win32";
14
+ const app = options.app?.trim();
15
+ switch (name) {
16
+ case "claude": return { app: app || "Claude Code", command: "claude", args: ["-p", "--output-format", "text", ...(options.model ? ["--model", options.model] : [])], shell };
17
+ case "codex": return { app: app || "Codex", command: "codex", args: ["exec", ...(options.model ? ["-m", options.model] : []), "-"], shell };
18
+ case "gemini": return { app: app || "Gemini CLI", command: "gemini", args: options.model ? ["-m", options.model] : [], shell };
19
+ case "ollama":
20
+ if (!options.model)
21
+ throw new Error("--with ollama needs --model <name>, e.g. --model llama3.1.");
22
+ return { app: app || `Ollama ${options.model}`, command: "ollama", args: ["run", options.model], shell };
23
+ case "command":
24
+ if (!options.command?.trim())
25
+ throw new Error("--with command needs --command \"<your command>\"; it gets the prompt on stdin.");
26
+ return { app: app || "Desktop AI", command: options.command, args: [], shell: true };
27
+ default: throw new Error(`Unknown --with "${name}". Use claude, codex, gemini, ollama or command.`);
28
+ }
29
+ }
30
+ /** Run the AI once. Resolves with what it printed. */
31
+ export function runOnce(runner, prompt, timeoutMs) {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn(runner.command, runner.args, { shell: runner.shell, stdio: ["pipe", "pipe", "pipe"] });
34
+ let out = "";
35
+ let err = "";
36
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`${runner.app} took longer than ${Math.round(timeoutMs / 1000)}s.`)); }, timeoutMs);
37
+ child.stdout.on("data", (chunk) => { out += chunk.toString("utf8"); });
38
+ child.stderr.on("data", (chunk) => { err += chunk.toString("utf8"); });
39
+ child.on("error", (error) => { clearTimeout(timer); reject(new Error(`Could not start ${runner.command}: ${error.message}`)); });
40
+ child.on("close", (code) => {
41
+ clearTimeout(timer);
42
+ if (code === 0)
43
+ resolve(out);
44
+ else
45
+ reject(new Error(`${runner.app} exited with code ${code}${err.trim() ? `: ${err.trim().split("\n").slice(-3).join(" ")}` : ""}`));
46
+ });
47
+ child.stdin.end(prompt);
48
+ });
49
+ }
50
+ /** The prompt between <prompt> tags, or the whole answer when it has none. */
51
+ export function extractPrompt(text) {
52
+ const tagged = /<prompt>([\s\S]*?)(?:<\/prompt>|$)/i.exec(text);
53
+ return (tagged ? tagged[1] : text).trim();
54
+ }
55
+ /** The first JSON object in the answer, fenced or bare. */
56
+ export function extractJson(text) {
57
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
58
+ const candidates = [fenced?.[1], text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)];
59
+ for (const raw of candidates) {
60
+ if (!raw?.trim())
61
+ continue;
62
+ try {
63
+ const value = JSON.parse(raw);
64
+ if (value && typeof value === "object" && !Array.isArray(value))
65
+ return value;
66
+ }
67
+ catch { /* try the next shape */ }
68
+ }
69
+ return null;
70
+ }
71
+ export function enhancePrompt(request, refusal) {
72
+ return [
73
+ `Rewrite this draft into the best prompt for ${request.model.name}, following the guide.`,
74
+ "Keep every reference token exactly as the draft writes it (@image1, @video1, @audio1).",
75
+ `Answer with the final prompt only, between <prompt> and </prompt>, at most ${request.max_chars} characters.`,
76
+ "", "GUIDE:", request.guide,
77
+ ...(request.brief ? ["", "BRIEF:", request.brief] : []),
78
+ "", "DRAFT:", request.draft,
79
+ ...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
80
+ ].join("\n");
81
+ }
82
+ export function agentPrompt(request, catalog, refusal) {
83
+ const transcript = request.messages.map((m) => `${m.role === "user" ? "USER" : "AGENT"}: ${m.content}`).join("\n\n");
84
+ return [
85
+ "You are the agent on the user's Ronana board. Answer their latest message.",
86
+ `You may propose up to ${request.max_cards} generation cards; each waits for the user's Confirm and spends nothing until then.`,
87
+ "Answer with ONE JSON object and nothing else: {\"message\": \"what the user reads\", \"cards\": [ ... ]}.",
88
+ "Each card follows CARD SCHEMA; modelId must come from MODELS.",
89
+ "", "MODELS:", catalog,
90
+ "", "CARD SCHEMA:", JSON.stringify(request.card_schema),
91
+ ...(request.references.length ? ["", "ATTACHED:", JSON.stringify(request.references)] : []),
92
+ ...(request.context ? ["", "CONTEXT:", request.context] : []),
93
+ "", "CONVERSATION:", transcript,
94
+ ...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
95
+ ].join("\n");
96
+ }
97
+ const ENHANCE_TIMEOUT_MS = 120_000;
98
+ /** The least time between two empty waits. */
99
+ const IDLE_FLOOR_MS = 5_000;
100
+ // Inside the board's own wait for an answer.
101
+ const AGENT_TIMEOUT_MS = 150_000;
102
+ /** "Still working" every 15s while the AI writes. The studio treats a taken
103
+ * request that goes quiet for 45s as abandoned, so this is what lets a slow
104
+ * answer take its time. Best effort: a missed beat is not an error. */
105
+ const PROGRESS_EVERY_MS = 15_000;
106
+ async function answer(remote, runner, request, log) {
107
+ const beat = setInterval(() => { void callTool(remote, "request_progress", { id: request.id }).catch(() => { }); }, PROGRESS_EVERY_MS);
108
+ try {
109
+ await answerOnce(remote, runner, request, log);
110
+ }
111
+ finally {
112
+ clearInterval(beat);
113
+ }
114
+ }
115
+ async function answerOnce(remote, runner, request, log) {
116
+ let catalog = "";
117
+ if (request.kind === "agent") {
118
+ catalog = JSON.stringify(await callTool(remote, "agent_tool", { id: request.id, name: "list_models", input: {} }));
119
+ }
120
+ let refusal;
121
+ for (let attempt = 0; attempt < 2; attempt += 1) {
122
+ const prompt = request.kind === "enhance" ? enhancePrompt(request, refusal) : agentPrompt(request, catalog, refusal);
123
+ const output = await runOnce(runner, prompt, request.kind === "enhance" ? ENHANCE_TIMEOUT_MS : AGENT_TIMEOUT_MS);
124
+ let args;
125
+ if (request.kind === "enhance") {
126
+ args = { id: request.id, prompt: extractPrompt(output) };
127
+ }
128
+ else {
129
+ const json = extractJson(output);
130
+ if (!json) {
131
+ refusal = "it was not one JSON object.";
132
+ continue;
133
+ }
134
+ args = { id: request.id, message: typeof json.message === "string" ? json.message : "", cards: Array.isArray(json.cards) ? json.cards : [] };
135
+ }
136
+ try {
137
+ await callTool(remote, "reply", args);
138
+ return;
139
+ }
140
+ catch (error) {
141
+ if (!(error instanceof ToolError) || error.error.code !== "INVALID_REPLY")
142
+ throw error;
143
+ refusal = error.message;
144
+ log(` refused (${refusal}); asking ${runner.app} to fix it`);
145
+ }
146
+ }
147
+ throw new Error(`${runner.app} could not produce an answer the studio accepts${refusal ? `: ${refusal}` : "."}`);
148
+ }
149
+ /** Listen until Ctrl+C. */
150
+ export async function listen(remote, runner, log) {
151
+ log(`Listening as ${runner.app}. Ronana's studio now sends Enhance and agent requests here (Ctrl+C to stop).`);
152
+ let backoff = 0;
153
+ for (;;) {
154
+ try {
155
+ const asked = Date.now();
156
+ const { request } = await callTool(remote, "wait_for_requests", { app: runner.app });
157
+ backoff = 0;
158
+ if (!request) {
159
+ // The server answers at once (not after holding) when its budget of
160
+ // held calls is spent: pause instead of asking again in a tight loop.
161
+ if (Date.now() - asked < IDLE_FLOOR_MS)
162
+ await new Promise((resolve) => setTimeout(resolve, IDLE_FLOOR_MS));
163
+ continue;
164
+ }
165
+ const started = Date.now();
166
+ log(`● ${request.kind} ${request.id}${request.kind === "enhance" ? ` · ${request.model.name}` : ""}`);
167
+ try {
168
+ await answer(remote, runner, request, log);
169
+ log(`✓ sent · ${((Date.now() - started) / 1000).toFixed(1)}s`);
170
+ }
171
+ catch (error) {
172
+ log(`✗ ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+ catch (error) {
176
+ // A refused sign-in or a missing plan will not fix itself: stop.
177
+ if (error instanceof ToolError && (error.error.code === "SCOPE_REQUIRED" || error.error.code === "UNAUTHORIZED"))
178
+ throw error;
179
+ if (error instanceof Error && /sign.?in|401|pricing/i.test(error.message))
180
+ throw error;
181
+ backoff = Math.min(30_000, backoff ? backoff * 2 : 2_000);
182
+ log(`Connection problem (${error instanceof Error ? error.message : String(error)}). Retrying in ${backoff / 1000}s.`);
183
+ await new Promise((resolve) => setTimeout(resolve, backoff));
184
+ }
185
+ }
186
+ }
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.4.0",
4
4
  "description": "Ronana command line and local MCP bridge.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ronana.ai",