@ronana/mcp 0.1.0 → 0.2.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 +11 -3
- package/dist/cli.js +64 -5
- package/dist/oauth.js +64 -50
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -38,6 +38,9 @@ ronana generate cost google/nano-banana-2-lite/edit --prompt "make it dusk" --
|
|
|
38
38
|
ronana generate create google/nano-banana-2-lite/edit --prompt "make it dusk" --image ./photo.png --out ./renders
|
|
39
39
|
ronana generate get <id> | wait <id> [--out dir]
|
|
40
40
|
ronana generate list
|
|
41
|
+
ronana upscale <id | file | url> --resolution 4k --out ./renders
|
|
42
|
+
ronana improve google/nano-banana-2-lite/edit --prompt "make it dusk"
|
|
43
|
+
ronana folder list | add <folder> <id…>
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
Every field from `model get` is a flag (`--aspect_ratio 16:9`, `--seed 7`).
|
|
@@ -95,18 +98,23 @@ library.
|
|
|
95
98
|
| `get_model` | Get one model's contract: its input schema. |
|
|
96
99
|
| `estimate_cost` | Estimate the credit cost of a generation. Takes the same `model` and `input` as `generate`. |
|
|
97
100
|
| `generate` | Start an image or video generation. |
|
|
98
|
-
| `get_generation` | Get one generation and its download link. |
|
|
101
|
+
| `get_generation` | Get one generation and its download link. Pass `wait_seconds` (up to 45) to wait for the result in one call. |
|
|
102
|
+
| `upscale` | Upscale a video to a higher resolution. |
|
|
103
|
+
| `improve_prompt` | Rewrite a prompt so it works better on one model. |
|
|
99
104
|
| `list_generations` | List recent generations. |
|
|
100
105
|
| `list_references` | List library references. |
|
|
101
106
|
| `upload_reference` | Add a file to the library from a URL (up to 25 MB) or from base64 (under 3 MB). |
|
|
102
107
|
| `create_upload` | Get a private upload link for a larger local file (up to 200 MB). |
|
|
103
108
|
| `finish_upload` | Add a file uploaded with `create_upload` to the library. |
|
|
109
|
+
| `list_folders` | List the folders in the library. |
|
|
110
|
+
| `add_to_folder` | File library items into a folder. |
|
|
104
111
|
| `get_account` | Get the plan and credit balance. |
|
|
105
112
|
|
|
106
113
|
A generate call takes a `model` and an `input`, exactly as `get_model` shows it
|
|
107
114
|
in `input_schema`. A media field takes a public https URL or a Ronana id
|
|
108
|
-
(`ref_…`, `img_…`, `gen_…`). Generation tools return an id immediately.
|
|
109
|
-
`get_generation` until the status is `completed` or
|
|
115
|
+
(`ref_…`, `img_…`, `gen_…`). Generation tools return an id immediately. Call
|
|
116
|
+
`get_generation` with `wait_seconds` until the status is `completed` or
|
|
117
|
+
`failed`. Apps that support MCP Apps show the image or video right in the chat.
|
|
110
118
|
|
|
111
119
|
```json
|
|
112
120
|
{ "model": "google/nano-banana-2/edit", "input": { "prompt": "a lantern shop at dusk", "images": ["https://example.com/ref.png"] } }
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// ronana generate cost <model> [same flags as create]
|
|
13
13
|
// ronana generate get | wait <id> [--out dir]
|
|
14
14
|
// ronana generate list [--limit n]
|
|
15
|
+
// ronana upscale <id | file | https-url> [--resolution r] [--model m] [--wait] [--out dir]
|
|
16
|
+
// ronana improve <model> --prompt "…"
|
|
17
|
+
// ronana folder list | add <folder> <id…>
|
|
15
18
|
import { randomUUID } from "node:crypto";
|
|
16
19
|
import { createWriteStream } from "node:fs";
|
|
17
20
|
import { mkdir } from "node:fs/promises";
|
|
@@ -20,7 +23,7 @@ import { Readable } from "node:stream";
|
|
|
20
23
|
import { pipeline } from "node:stream/promises";
|
|
21
24
|
import { login, logout, readCredentials } from "./oauth.js";
|
|
22
25
|
import { callTool, remoteClient, ToolError, uploadFile } from "./remote.js";
|
|
23
|
-
import { buildInput, flag, has, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
|
|
26
|
+
import { buildInput, flag, has, isRemoteValue, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
|
|
24
27
|
const HELP = `Usage: ronana <command>
|
|
25
28
|
|
|
26
29
|
auth login Sign in with your browser (--base-url, --no-browser)
|
|
@@ -35,6 +38,10 @@ const HELP = `Usage: ronana <command>
|
|
|
35
38
|
generate get <id> Status and download links
|
|
36
39
|
generate wait <id> Wait for a generation to finish
|
|
37
40
|
generate list Recent generations (--limit n)
|
|
41
|
+
upscale <id|file|url> Upscale a video (costs credits; --resolution, --model)
|
|
42
|
+
improve <model> --prompt "…" Rewrite a prompt for a model (costs a little)
|
|
43
|
+
folder list Your library folders
|
|
44
|
+
folder add <folder> <id…> File items into a folder (made if missing)
|
|
38
45
|
|
|
39
46
|
Generate flags:
|
|
40
47
|
--<field> <value> Any field from \`model get\`. Repeat for lists.
|
|
@@ -80,8 +87,12 @@ async function waitFor(remote, id, args) {
|
|
|
80
87
|
let last = "";
|
|
81
88
|
for (;;) {
|
|
82
89
|
let gen;
|
|
90
|
+
const asked = Date.now();
|
|
83
91
|
try {
|
|
84
|
-
|
|
92
|
+
// The server holds the call until the job settles (up to 25s), so this
|
|
93
|
+
// loop makes one request per 25s rather than one every few seconds.
|
|
94
|
+
const wait_seconds = Math.max(0, Math.min(25, Math.floor((deadline - asked) / 1000)));
|
|
95
|
+
gen = await callTool(remote, "get_generation", { id, wait_seconds });
|
|
85
96
|
}
|
|
86
97
|
catch (error) {
|
|
87
98
|
// A server answer (not found, no access) is final; anything else is the
|
|
@@ -99,7 +110,11 @@ ${id} is still running. Resume with \`ronana generate wait ${id}\`.`);
|
|
|
99
110
|
return gen;
|
|
100
111
|
if (Date.now() > deadline)
|
|
101
112
|
throw new Error(`Still ${gen.status} after ${timeoutMs / 1000}s. Check later with \`ronana generate wait ${id}\`.`);
|
|
102
|
-
|
|
113
|
+
// A server that answered at once (an older one, or a short wait) still gets
|
|
114
|
+
// the local pause, so the loop never spins.
|
|
115
|
+
const left = intervalMs - (Date.now() - asked);
|
|
116
|
+
if (left > 0)
|
|
117
|
+
await new Promise((r) => setTimeout(r, left));
|
|
103
118
|
}
|
|
104
119
|
}
|
|
105
120
|
const EXT_BY_TYPE = {
|
|
@@ -202,7 +217,7 @@ async function modelCommand(sub, args) {
|
|
|
202
217
|
const filter = args.positional[0]?.toLowerCase();
|
|
203
218
|
const models = (await withRemote((r) => callTool(r, "list_models", kind ? { kind } : {})))
|
|
204
219
|
.filter((m) => !filter || m.id.toLowerCase().includes(filter) || m.name.toLowerCase().includes(filter));
|
|
205
|
-
return emit(models, () => models.forEach((m) => print(`${m.id.padEnd(52)} ${m.kind.padEnd(6)} ${m.name}`)));
|
|
220
|
+
return emit(models, () => models.forEach((m) => print(`${m.id.padEnd(52)} ${m.kind.padEnd(6)} ${m.name}${m.best_for ? ` ${m.best_for}` : ""}`)));
|
|
206
221
|
}
|
|
207
222
|
if (sub !== "get")
|
|
208
223
|
throw new Error("Use model list | get.");
|
|
@@ -225,9 +240,42 @@ async function modelCommand(sub, args) {
|
|
|
225
240
|
print(`Priced by: ${contract.priced_fields.join(", ")}`);
|
|
226
241
|
});
|
|
227
242
|
}
|
|
243
|
+
async function upscaleCommand(args) {
|
|
244
|
+
const [source] = args.positional;
|
|
245
|
+
if (!source)
|
|
246
|
+
throw new Error("Usage: ronana upscale <gen_…|ref_… id | file | https-url> [--resolution 4k] [--out dir]");
|
|
247
|
+
await withRemote(async (remote) => {
|
|
248
|
+
// A local file uploads first; an id or a URL goes as-is.
|
|
249
|
+
const resolved = isRemoteValue(source) ? source : (note(`Uploading ${basename(source)}…`), (await uploadFile(remote, resolve(source))).id);
|
|
250
|
+
const queued = await callTool(remote, "upscale", {
|
|
251
|
+
source: resolved,
|
|
252
|
+
...(flag(args, "model") ? { model: flag(args, "model") } : {}),
|
|
253
|
+
...(flag(args, "resolution") ? { resolution: flag(args, "resolution") } : {}),
|
|
254
|
+
idempotency_key: flag(args, "idempotency_key") ?? randomUUID(),
|
|
255
|
+
});
|
|
256
|
+
if (!has(args, "wait") && !has(args, "out")) {
|
|
257
|
+
return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
|
|
258
|
+
}
|
|
259
|
+
note(`Queued ${queued.id}.`);
|
|
260
|
+
await finish(await waitFor(remote, queued.id, args), args);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async function folderCommand(sub, args) {
|
|
264
|
+
if (sub === "list" || sub === undefined) {
|
|
265
|
+
const folders = await withRemote((r) => callTool(r, "list_folders"));
|
|
266
|
+
return emit(folders, () => folders.forEach((f) => print(`${f.id.padEnd(28)} ${String(f.count).padStart(5)} ${f.name}`)));
|
|
267
|
+
}
|
|
268
|
+
if (sub !== "add")
|
|
269
|
+
throw new Error("Use folder list | add.");
|
|
270
|
+
const [folder, ...ids] = args.positional;
|
|
271
|
+
if (!folder || ids.length === 0)
|
|
272
|
+
throw new Error("Usage: ronana folder add <folder> <id…>");
|
|
273
|
+
const filed = await withRemote((r) => callTool(r, "add_to_folder", { ids, folder, create: true }));
|
|
274
|
+
return emit(filed, () => print(`Filed ${filed.filed.length} in "${filed.folder.name}".`));
|
|
275
|
+
}
|
|
228
276
|
async function main() {
|
|
229
277
|
const [command, sub, ...rest] = process.argv.slice(2);
|
|
230
|
-
const takesSub = command === "auth" || command === "model" || command === "generate";
|
|
278
|
+
const takesSub = command === "auth" || command === "model" || command === "generate" || command === "folder";
|
|
231
279
|
const args = parseArgs(takesSub ? rest : [sub, ...rest].filter((v) => v !== undefined));
|
|
232
280
|
JSON_OUT = has(args, "json");
|
|
233
281
|
if (!command || ["help", "--help", "-h"].includes(command) || has(args, "help"))
|
|
@@ -268,6 +316,17 @@ async function main() {
|
|
|
268
316
|
return emit(ref, () => print(ref.id));
|
|
269
317
|
}
|
|
270
318
|
case "generate": return generateCommand(sub, args);
|
|
319
|
+
case "upscale": return upscaleCommand(args);
|
|
320
|
+
case "improve": {
|
|
321
|
+
const model = args.positional[0];
|
|
322
|
+
const prompt = flag(args, "prompt");
|
|
323
|
+
if (!model || !prompt)
|
|
324
|
+
throw new Error("Usage: ronana improve <model> --prompt \"…\"");
|
|
325
|
+
const improved = await withRemote((r) => callTool(r, "improve_prompt", { model, prompt }));
|
|
326
|
+
return emit(improved, () => print(improved.prompt));
|
|
327
|
+
}
|
|
328
|
+
case "folder":
|
|
329
|
+
case "folders": return folderCommand(command === "folders" ? "list" : sub, args);
|
|
271
330
|
default: throw new Error(`Unknown command "${command}". Run \`ronana --help\`.`);
|
|
272
331
|
}
|
|
273
332
|
}
|
package/dist/oauth.js
CHANGED
|
@@ -8,6 +8,9 @@ import { Entry } from "@napi-rs/keyring";
|
|
|
8
8
|
const SERVICE = "Ronana MCP";
|
|
9
9
|
const ACCOUNT = "oauth";
|
|
10
10
|
const SCOPES = "mcp:read mcp:upload mcp:generate offline_access";
|
|
11
|
+
/** How long a sign-in may sit in the browser. An AI agent often runs login for
|
|
12
|
+
* the user, and a closed tab must end the command rather than hang it. */
|
|
13
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
11
14
|
const entry = () => new Entry(SERVICE, ACCOUNT);
|
|
12
15
|
export function readCredentials() {
|
|
13
16
|
const value = entry().getPassword();
|
|
@@ -68,6 +71,8 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
|
|
|
68
71
|
let settle;
|
|
69
72
|
let fail;
|
|
70
73
|
const code = new Promise((ok, no) => { settle = ok; fail = no; });
|
|
74
|
+
const timer = setTimeout(() => fail(new Error("Sign-in timed out. Run `ronana auth login` again.")), LOGIN_TIMEOUT_MS);
|
|
75
|
+
const close = () => { clearTimeout(timer); server.close(); };
|
|
71
76
|
const server = createServer((req, res) => {
|
|
72
77
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
73
78
|
if (url.searchParams.get("state") !== state) {
|
|
@@ -82,65 +87,74 @@ export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ro
|
|
|
82
87
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("Ronana is connected. You can close this window.");
|
|
83
88
|
settle(url.searchParams.get("code") ?? "");
|
|
84
89
|
}
|
|
85
|
-
|
|
90
|
+
close();
|
|
86
91
|
});
|
|
87
|
-
server.once("error", reject);
|
|
92
|
+
server.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
88
93
|
server.listen(0, "127.0.0.1", () => {
|
|
89
94
|
const address = server.address();
|
|
90
|
-
if (!address || typeof address === "string")
|
|
95
|
+
if (!address || typeof address === "string") {
|
|
96
|
+
close();
|
|
91
97
|
return reject(new Error("Loopback callback failed."));
|
|
92
|
-
|
|
98
|
+
}
|
|
99
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, close });
|
|
93
100
|
});
|
|
94
101
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Every exit below (refused registration, timeout, a refused token) frees the
|
|
103
|
+
// loopback port and the timer, so the process can end.
|
|
104
|
+
try {
|
|
105
|
+
const registration = await fetch(`${baseUrl}/api/auth/oauth2/register`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
client_name: "Ronana CLI",
|
|
110
|
+
redirect_uris: [callback.redirectUri],
|
|
111
|
+
token_endpoint_auth_method: "none",
|
|
112
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
113
|
+
response_types: ["code"],
|
|
114
|
+
scope: SCOPES,
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const registered = await registration.json();
|
|
118
|
+
if (!registration.ok || typeof registered.client_id !== "string")
|
|
119
|
+
throw new Error("OAuth client registration failed.");
|
|
120
|
+
const authorize = new URL(`${baseUrl}/api/auth/oauth2/authorize`);
|
|
121
|
+
authorize.search = new URLSearchParams({
|
|
122
|
+
response_type: "code",
|
|
123
|
+
client_id: registered.client_id,
|
|
124
|
+
redirect_uri: callback.redirectUri,
|
|
104
125
|
scope: SCOPES,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
authorize.search = new URLSearchParams({
|
|
112
|
-
response_type: "code",
|
|
113
|
-
client_id: registered.client_id,
|
|
114
|
-
redirect_uri: callback.redirectUri,
|
|
115
|
-
scope: SCOPES,
|
|
116
|
-
resource: `${baseUrl}/mcp`,
|
|
117
|
-
code_challenge: challenge,
|
|
118
|
-
code_challenge_method: "S256",
|
|
119
|
-
state,
|
|
120
|
-
}).toString();
|
|
121
|
-
process.stderr.write(`${openInBrowser ? "Opening your browser to sign in. If it does not open, visit" : "Open this link to sign in"}:
|
|
126
|
+
resource: `${baseUrl}/mcp`,
|
|
127
|
+
code_challenge: challenge,
|
|
128
|
+
code_challenge_method: "S256",
|
|
129
|
+
state,
|
|
130
|
+
}).toString();
|
|
131
|
+
process.stderr.write(`${openInBrowser ? "Opening your browser to sign in. If it does not open, visit" : "Open this link to sign in"}:
|
|
122
132
|
${authorize}
|
|
123
133
|
`);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
if (openInBrowser)
|
|
135
|
+
openBrowser(authorize.toString());
|
|
136
|
+
const code = await callback.code;
|
|
137
|
+
const tokens = await tokenRequest(baseUrl, {
|
|
138
|
+
grant_type: "authorization_code",
|
|
139
|
+
client_id: registered.client_id,
|
|
140
|
+
redirect_uri: callback.redirectUri,
|
|
141
|
+
code,
|
|
142
|
+
code_verifier: verifier,
|
|
143
|
+
resource: `${baseUrl}/mcp`,
|
|
144
|
+
});
|
|
145
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.refresh_token !== "string")
|
|
146
|
+
throw new Error("OAuth response did not contain tokens.");
|
|
147
|
+
writeCredentials({
|
|
148
|
+
baseUrl,
|
|
149
|
+
clientId: registered.client_id,
|
|
150
|
+
accessToken: tokens.access_token,
|
|
151
|
+
refreshToken: tokens.refresh_token,
|
|
152
|
+
expiresAt: Date.now() + Number(tokens.expires_in ?? 900) * 1000,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
callback.close();
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
const fresh = (c) => c.expiresAt > Date.now() + 60_000;
|
|
146
160
|
/** Several CLI runs (and the MCP bridge) share one stored refresh token, and the
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ronana/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Ronana command line and local MCP bridge.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://ronana.ai",
|
|
7
7
|
"keywords": ["ronana", "mcp", "cli", "image generation", "video generation"],
|
|
8
8
|
"type": "module",
|
|
9
|
-
"bin": { "ronana": "
|
|
9
|
+
"bin": { "ronana": "dist/cli.js", "ronana-mcp": "dist/server.js" },
|
|
10
10
|
"files": ["dist", "README.md"],
|
|
11
11
|
"publishConfig": { "access": "public" },
|
|
12
12
|
"scripts": {
|