@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 +16 -11
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +60 -19
- package/dist/remote.js +5 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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.
|