@ronana/mcp 0.0.0-stage → 0.1.1
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 +115 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +286 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +128 -0
- package/dist/input.d.ts +39 -0
- package/dist/input.js +166 -0
- package/dist/oauth.d.ts +11 -0
- package/dist/oauth.js +225 -0
- package/dist/remote.d.ts +20 -0
- package/dist/remote.js +86 -0
- package/dist/schemas.d.ts +101 -0
- package/dist/schemas.js +55 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +83 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.js +11 -0
- package/package.json +26 -4
package/dist/input.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The CLI's input handling, kept free of any network or terminal I/O so it
|
|
2
|
+
// can be tested: argv parsing, and turning flags into a model's provider-native
|
|
3
|
+
// `input` body using the contract `get_model` publishes.
|
|
4
|
+
import { existsSync, statSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
const BOOLEAN_FLAGS = new Set(["json", "wait", "help", "no_browser"]);
|
|
8
|
+
/** CLI options that are meaningless without a value. A model's own fields stay
|
|
9
|
+
* lenient, since a bare `--sound` means true. */
|
|
10
|
+
const VALUE_FLAGS = new Set(["out", "input", "idempotency_key", "base_url", "wait_timeout", "wait_interval", "limit", "kind"]);
|
|
11
|
+
export function parseArgs(argv) {
|
|
12
|
+
const positional = [];
|
|
13
|
+
const flags = new Map();
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const arg = argv[i];
|
|
16
|
+
if (arg === "-h") {
|
|
17
|
+
flags.set("help", ["true"]);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!arg.startsWith("--")) {
|
|
21
|
+
positional.push(arg);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const eq = arg.indexOf("=");
|
|
25
|
+
const name = (eq > 0 ? arg.slice(2, eq) : arg.slice(2)).replaceAll("-", "_");
|
|
26
|
+
let value;
|
|
27
|
+
if (eq > 0)
|
|
28
|
+
value = arg.slice(eq + 1);
|
|
29
|
+
else if (!BOOLEAN_FLAGS.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith("--"))
|
|
30
|
+
value = argv[++i];
|
|
31
|
+
else if (VALUE_FLAGS.has(name))
|
|
32
|
+
throw new Error(`--${name.replaceAll("_", "-")} needs a value.`);
|
|
33
|
+
else
|
|
34
|
+
value = "true";
|
|
35
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
36
|
+
}
|
|
37
|
+
return { positional, flags };
|
|
38
|
+
}
|
|
39
|
+
export const flag = (args, name) => args.flags.get(name)?.at(-1);
|
|
40
|
+
export const has = (args, name) => args.flags.has(name);
|
|
41
|
+
export const takeFlag = (args, name) => { const v = flag(args, name); args.flags.delete(name); return v; };
|
|
42
|
+
export const typeOf = (schema) => (Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type) ?? "string";
|
|
43
|
+
const RESERVED = new Set(["json", "wait", "out", "input", "wait_timeout", "wait_interval", "idempotency_key", "help"]);
|
|
44
|
+
/** A URL (the server says which schemes it accepts) or a Ronana id. */
|
|
45
|
+
export const isRemoteValue = (v) => /^https?:\/\//i.test(v) || /^(ref|img|gen)_[A-Za-z0-9]+$/.test(v);
|
|
46
|
+
const isLocalFile = (path) => existsSync(path) && statSync(path).isFile();
|
|
47
|
+
function coerce(field, raw, schema) {
|
|
48
|
+
const type = schema ? typeOf(schema) : "string";
|
|
49
|
+
if (type === "integer" || type === "number") {
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
if (!Number.isFinite(n) || (type === "integer" && !Number.isInteger(n)))
|
|
52
|
+
throw new Error(`--${field} must be a ${type}.`);
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
if (type === "boolean") {
|
|
56
|
+
if (raw !== "true" && raw !== "false")
|
|
57
|
+
throw new Error(`--${field} must be true or false.`);
|
|
58
|
+
return raw === "true";
|
|
59
|
+
}
|
|
60
|
+
if (type === "object") {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new Error(`--${field} must be JSON.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
async function readInputFlag(raw) {
|
|
71
|
+
if (!raw)
|
|
72
|
+
return {};
|
|
73
|
+
const text = raw.startsWith("@") ? await readFile(raw.slice(1), "utf8") : raw;
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error("--input must be a JSON object (or @file.json).");
|
|
80
|
+
}
|
|
81
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
82
|
+
throw new Error("--input must be a JSON object.");
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
/** A flag value written as a JSON array (`--images '["a","b"]'`), else null —
|
|
86
|
+
* so a plain value that merely starts with "[" stays a plain value. */
|
|
87
|
+
function jsonArray(raw) {
|
|
88
|
+
if (!raw.startsWith("["))
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Top-level media fields of one kind, for the --image/--video/--audio shorthands. */
|
|
99
|
+
function shorthandTarget(contract, kind) {
|
|
100
|
+
const fields = contract.media_fields
|
|
101
|
+
.filter((m) => m.kind === kind && !m.mask)
|
|
102
|
+
.map((m) => m.field.replace(/\[\]$/, ""))
|
|
103
|
+
.filter((f) => !f.includes("."));
|
|
104
|
+
return fields.length ? fields[0] : undefined;
|
|
105
|
+
}
|
|
106
|
+
export async function buildInput(contract, args) {
|
|
107
|
+
const input = await readInputFlag(takeFlag(args, "input"));
|
|
108
|
+
const properties = contract.input_schema.properties ?? {};
|
|
109
|
+
for (const [rawName, values] of args.flags) {
|
|
110
|
+
if (RESERVED.has(rawName))
|
|
111
|
+
continue;
|
|
112
|
+
let name = rawName in properties ? rawName : rawName.replaceAll("_", "-") in properties ? rawName.replaceAll("_", "-") : rawName;
|
|
113
|
+
if (!(name in properties) && ["image", "video", "audio"].includes(name))
|
|
114
|
+
name = shorthandTarget(contract, name) ?? name;
|
|
115
|
+
const schema = properties[name];
|
|
116
|
+
if (!schema) {
|
|
117
|
+
throw new Error(`${contract.id} has no field "${rawName}". Fields: ${Object.keys(properties).join(", ")}.`);
|
|
118
|
+
}
|
|
119
|
+
if (typeOf(schema) === "array") {
|
|
120
|
+
const items = values.flatMap((v) => jsonArray(v) ?? [coerce(name, v, schema.items)]);
|
|
121
|
+
input[name] = [...(Array.isArray(input[name]) ? input[name] : []), ...items];
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
input[name] = coerce(name, values.at(-1), schema);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return input;
|
|
128
|
+
}
|
|
129
|
+
/** Replace every local file named in a media field with an uploaded ref id.
|
|
130
|
+
* Only media fields are looked at, so a prompt that happens to match a file
|
|
131
|
+
* name is never uploaded. A file named twice uploads once. */
|
|
132
|
+
export async function replaceLocalMedia(contract, input, upload) {
|
|
133
|
+
const cache = new Map();
|
|
134
|
+
const resolveValue = async (value) => {
|
|
135
|
+
if (typeof value !== "string")
|
|
136
|
+
return value;
|
|
137
|
+
// A file on disk wins, so a local file named like an id still uploads.
|
|
138
|
+
const path = resolve(value);
|
|
139
|
+
if (!isLocalFile(path)) {
|
|
140
|
+
if (isRemoteValue(value))
|
|
141
|
+
return value;
|
|
142
|
+
throw new Error(`"${value}" is not a file, a URL, or a ref_/img_/gen_ id.`);
|
|
143
|
+
}
|
|
144
|
+
if (!cache.has(path))
|
|
145
|
+
cache.set(path, upload(path));
|
|
146
|
+
return cache.get(path);
|
|
147
|
+
};
|
|
148
|
+
const walk = async (node, segments) => {
|
|
149
|
+
if (!segments.length)
|
|
150
|
+
return resolveValue(node);
|
|
151
|
+
if (!node || typeof node !== "object")
|
|
152
|
+
return node;
|
|
153
|
+
const [head, ...rest] = segments;
|
|
154
|
+
const isList = head.endsWith("[]");
|
|
155
|
+
const key = isList ? head.slice(0, -2) : head;
|
|
156
|
+
const target = node;
|
|
157
|
+
if (!(key in target))
|
|
158
|
+
return node;
|
|
159
|
+
target[key] = isList && Array.isArray(target[key])
|
|
160
|
+
? await Promise.all(target[key].map((item) => walk(item, rest)))
|
|
161
|
+
: await walk(target[key], rest);
|
|
162
|
+
return node;
|
|
163
|
+
};
|
|
164
|
+
for (const media of contract.media_fields)
|
|
165
|
+
await walk(input, media.field.split("."));
|
|
166
|
+
}
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface StoredCredentials {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
accessToken: string;
|
|
5
|
+
refreshToken: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function readCredentials(): StoredCredentials | null;
|
|
9
|
+
export declare function logout(): Promise<void>;
|
|
10
|
+
export declare function login(baseUrl?: string, openInBrowser?: boolean): Promise<void>;
|
|
11
|
+
export declare function accessToken(): Promise<string>;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { closeSync, openSync, statSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { Entry } from "@napi-rs/keyring";
|
|
8
|
+
const SERVICE = "Ronana MCP";
|
|
9
|
+
const ACCOUNT = "oauth";
|
|
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;
|
|
14
|
+
const entry = () => new Entry(SERVICE, ACCOUNT);
|
|
15
|
+
export function readCredentials() {
|
|
16
|
+
const value = entry().getPassword();
|
|
17
|
+
if (!value)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeCredentials(value) {
|
|
27
|
+
entry().setPassword(JSON.stringify(value));
|
|
28
|
+
}
|
|
29
|
+
export async function logout() {
|
|
30
|
+
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
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
entry().deletePassword();
|
|
40
|
+
}
|
|
41
|
+
catch { /* No stored credential. */ }
|
|
42
|
+
}
|
|
43
|
+
function openBrowser(url) {
|
|
44
|
+
if (process.platform === "win32") {
|
|
45
|
+
spawn("rundll32", ["url.dll,FileProtocolHandler", url], { detached: true, stdio: "ignore" }).unref();
|
|
46
|
+
}
|
|
47
|
+
else if (process.platform === "darwin") {
|
|
48
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function tokenRequest(baseUrl, fields) {
|
|
55
|
+
const response = await fetch(`${baseUrl}/api/auth/oauth2/token`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
58
|
+
body: new URLSearchParams(fields),
|
|
59
|
+
});
|
|
60
|
+
const body = await response.json();
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new Error(String(body.error_description ?? body.error ?? "OAuth token request failed."));
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
export async function login(baseUrl = process.env.RONANA_BASE_URL ?? "https://ronana.ai", openInBrowser = true) {
|
|
66
|
+
baseUrl = baseUrl.replace(/\/$/, "");
|
|
67
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
68
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
69
|
+
const state = randomBytes(24).toString("base64url");
|
|
70
|
+
const callback = await new Promise((resolve, reject) => {
|
|
71
|
+
let settle;
|
|
72
|
+
let fail;
|
|
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(); };
|
|
76
|
+
const server = createServer((req, res) => {
|
|
77
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
78
|
+
if (url.searchParams.get("state") !== state) {
|
|
79
|
+
res.writeHead(400).end("Invalid OAuth state.");
|
|
80
|
+
fail(new Error("OAuth state did not match."));
|
|
81
|
+
}
|
|
82
|
+
else if (url.searchParams.get("error")) {
|
|
83
|
+
res.writeHead(400).end("Authorization was not completed.");
|
|
84
|
+
fail(new Error(url.searchParams.get("error_description") ?? url.searchParams.get("error")));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("Ronana is connected. You can close this window.");
|
|
88
|
+
settle(url.searchParams.get("code") ?? "");
|
|
89
|
+
}
|
|
90
|
+
close();
|
|
91
|
+
});
|
|
92
|
+
server.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
93
|
+
server.listen(0, "127.0.0.1", () => {
|
|
94
|
+
const address = server.address();
|
|
95
|
+
if (!address || typeof address === "string") {
|
|
96
|
+
close();
|
|
97
|
+
return reject(new Error("Loopback callback failed."));
|
|
98
|
+
}
|
|
99
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, code, close });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
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,
|
|
125
|
+
scope: SCOPES,
|
|
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"}:
|
|
132
|
+
${authorize}
|
|
133
|
+
`);
|
|
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const fresh = (c) => c.expiresAt > Date.now() + 60_000;
|
|
160
|
+
/** Several CLI runs (and the MCP bridge) share one stored refresh token, and the
|
|
161
|
+
* server rotates it on use: two processes refreshing at once would race, and
|
|
162
|
+
* the loser's stale token could sign the user out. A lock file makes the
|
|
163
|
+
* refresh one-at-a-time; whoever waits re-reads the winner's new token. */
|
|
164
|
+
async function withRefreshLock(fn) {
|
|
165
|
+
const lock = join(tmpdir(), "ronana-oauth-refresh.lock");
|
|
166
|
+
let held = false;
|
|
167
|
+
for (const deadline = Date.now() + 15_000;;) {
|
|
168
|
+
try {
|
|
169
|
+
closeSync(openSync(lock, "wx"));
|
|
170
|
+
held = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// A crashed holder leaves the file behind; a refresh never takes 30s.
|
|
175
|
+
try {
|
|
176
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000)
|
|
177
|
+
unlinkSync(lock);
|
|
178
|
+
}
|
|
179
|
+
catch { /* Gone already. */ }
|
|
180
|
+
if (Date.now() > deadline)
|
|
181
|
+
break; // Proceed unlocked rather than hang.
|
|
182
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return await fn();
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
if (held)
|
|
190
|
+
try {
|
|
191
|
+
unlinkSync(lock);
|
|
192
|
+
}
|
|
193
|
+
catch { /* Gone already. */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function accessToken() {
|
|
197
|
+
const stored = readCredentials();
|
|
198
|
+
if (!stored)
|
|
199
|
+
throw new Error("Not signed in. Run `ronana auth login` first.");
|
|
200
|
+
if (fresh(stored))
|
|
201
|
+
return stored.accessToken;
|
|
202
|
+
return withRefreshLock(async () => {
|
|
203
|
+
const current = readCredentials();
|
|
204
|
+
if (!current)
|
|
205
|
+
throw new Error("Not signed in. Run `ronana auth login` first.");
|
|
206
|
+
if (fresh(current))
|
|
207
|
+
return current.accessToken; // Another process refreshed.
|
|
208
|
+
const tokens = await tokenRequest(current.baseUrl, {
|
|
209
|
+
grant_type: "refresh_token",
|
|
210
|
+
client_id: current.clientId,
|
|
211
|
+
refresh_token: current.refreshToken,
|
|
212
|
+
resource: `${current.baseUrl}/mcp`,
|
|
213
|
+
});
|
|
214
|
+
if (typeof tokens.access_token !== "string")
|
|
215
|
+
throw new Error("OAuth refresh failed.");
|
|
216
|
+
const next = {
|
|
217
|
+
...current,
|
|
218
|
+
accessToken: tokens.access_token,
|
|
219
|
+
refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : current.refreshToken,
|
|
220
|
+
expiresAt: Date.now() + Number(tokens.expires_in ?? 900) * 1000,
|
|
221
|
+
};
|
|
222
|
+
writeCredentials(next);
|
|
223
|
+
return next.accessToken;
|
|
224
|
+
});
|
|
225
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
2
|
+
export declare function remoteClient(): Promise<Client>;
|
|
3
|
+
/** A tool failure, carrying the server's structured error (code, message, and
|
|
4
|
+
* whatever context the gate attached: a billing url, the offending field). */
|
|
5
|
+
export declare class ToolError extends Error {
|
|
6
|
+
readonly error: Record<string, unknown>;
|
|
7
|
+
constructor(error: Record<string, unknown>);
|
|
8
|
+
}
|
|
9
|
+
type Remote = Awaited<ReturnType<typeof remoteClient>>;
|
|
10
|
+
/** Call one tool and unwrap the `{ ok, data | error }` envelope every Ronana
|
|
11
|
+
* tool answers with. */
|
|
12
|
+
export declare function callTool<T = unknown>(remote: Remote, name: string, args?: Record<string, unknown>): Promise<T>;
|
|
13
|
+
/** Add a local file to the library and return its ref. Small files go inline
|
|
14
|
+
* as base64; larger ones stream straight to storage through a signed PUT. */
|
|
15
|
+
export declare function uploadFile(remote: Remote, path: string): Promise<{
|
|
16
|
+
id: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
reused: boolean;
|
|
19
|
+
}>;
|
|
20
|
+
export {};
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The one connection to Ronana's remote MCP endpoint, shared by the stdio
|
|
2
|
+
// bridge (server.ts) and the CLI (cli.ts). Both sign in once, with the same
|
|
3
|
+
// stored OAuth credential, and every call runs the server's own gates.
|
|
4
|
+
import { createReadStream, statSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename, extname } from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
9
|
+
import { accessToken, readCredentials } from "./oauth.js";
|
|
10
|
+
export async function remoteClient() {
|
|
11
|
+
const credentials = readCredentials();
|
|
12
|
+
if (!credentials)
|
|
13
|
+
throw new Error("Not signed in. Run `ronana auth login` first.");
|
|
14
|
+
const client = new Client({ name: "ronana-local", version: "1.0.0" });
|
|
15
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${credentials.baseUrl}/mcp`), {
|
|
16
|
+
authProvider: { token: accessToken },
|
|
17
|
+
onInsufficientScope: "throw",
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
await client.connect(transport);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
// A 404 means either an account outside the MCP rollout or a server with no
|
|
24
|
+
// MCP at all (a wrong --base-url). Only a server that advertises the MCP
|
|
25
|
+
// resource can be refusing the account.
|
|
26
|
+
if (error instanceof Error && /not found/i.test(error.message)) {
|
|
27
|
+
const advertised = await fetch(`${credentials.baseUrl}/.well-known/oauth-protected-resource/mcp`)
|
|
28
|
+
.then((r) => r.ok, () => false);
|
|
29
|
+
throw new Error(advertised
|
|
30
|
+
? "This account does not have access to Ronana's CLI and MCP yet."
|
|
31
|
+
: `No Ronana MCP endpoint at ${credentials.baseUrl}. Sign in again with the right --base-url.`);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
37
|
+
/** A tool failure, carrying the server's structured error (code, message, and
|
|
38
|
+
* whatever context the gate attached: a billing url, the offending field). */
|
|
39
|
+
export class ToolError extends Error {
|
|
40
|
+
error;
|
|
41
|
+
constructor(error) {
|
|
42
|
+
super(typeof error.message === "string" ? error.message : "The request failed.");
|
|
43
|
+
this.error = error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Call one tool and unwrap the `{ ok, data | error }` envelope every Ronana
|
|
47
|
+
* tool answers with. */
|
|
48
|
+
export async function callTool(remote, name, args = {}) {
|
|
49
|
+
const response = await remote.callTool({ name, arguments: args });
|
|
50
|
+
const body = response.structuredContent;
|
|
51
|
+
if (body?.ok)
|
|
52
|
+
return body.data;
|
|
53
|
+
if (body?.error)
|
|
54
|
+
throw new ToolError(body.error);
|
|
55
|
+
const text = Array.isArray(response.content) ? response.content.map((c) => ("text" in c ? c.text : "")).join(" ") : "";
|
|
56
|
+
throw new ToolError({ code: "TOOL_FAILED", message: text || `${name} failed.` });
|
|
57
|
+
}
|
|
58
|
+
/* Must match upload_reference's `data` cap (3 MB of bytes ≈ 4.2 MB of base64,
|
|
59
|
+
the most Vercel's 4.5 MB body cap lets through). */
|
|
60
|
+
const INLINE_LIMIT = 3 * 1024 * 1024;
|
|
61
|
+
const CONTENT_TYPES = {
|
|
62
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
|
|
63
|
+
".heic": "image/heic", ".avif": "image/avif", ".gif": "image/gif", ".mp4": "video/mp4",
|
|
64
|
+
".mov": "video/quicktime", ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
65
|
+
};
|
|
66
|
+
/** Add a local file to the library and return its ref. Small files go inline
|
|
67
|
+
* as base64; larger ones stream straight to storage through a signed PUT. */
|
|
68
|
+
export async function uploadFile(remote, path) {
|
|
69
|
+
const filename = basename(path);
|
|
70
|
+
const { size } = statSync(path);
|
|
71
|
+
if (size <= INLINE_LIMIT) {
|
|
72
|
+
const data = (await readFile(path)).toString("base64");
|
|
73
|
+
return callTool(remote, "upload_reference", { data, filename });
|
|
74
|
+
}
|
|
75
|
+
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
76
|
+
if (!contentType)
|
|
77
|
+
throw new Error(`Unsupported file type: ${filename}`);
|
|
78
|
+
const ticket = await callTool(remote, "create_upload", { filename, content_type: contentType, size });
|
|
79
|
+
const put = await fetch(ticket.upload_url, {
|
|
80
|
+
method: "PUT", headers: ticket.headers,
|
|
81
|
+
body: Readable.toWeb(createReadStream(path)), duplex: "half",
|
|
82
|
+
});
|
|
83
|
+
if (!put.ok)
|
|
84
|
+
throw new Error(`The upload failed (storage answered ${put.status}).`);
|
|
85
|
+
return callTool(remote, "finish_upload", { upload_id: ticket.upload_id, filename });
|
|
86
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** MCP request schemas mirror the public HTTP schemas.
|
|
3
|
+
* Contract tests compare their fields and boundaries with those schemas. */
|
|
4
|
+
export declare const estimateCostInputSchema: {
|
|
5
|
+
readonly modelId: z.ZodString;
|
|
6
|
+
readonly resolution: z.ZodOptional<z.ZodString>;
|
|
7
|
+
readonly aspectRatio: z.ZodOptional<z.ZodString>;
|
|
8
|
+
readonly durationSeconds: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
readonly quality: z.ZodOptional<z.ZodEnum<["low", "medium", "high"]>>;
|
|
10
|
+
readonly hasVideoRefs: z.ZodOptional<z.ZodBoolean>;
|
|
11
|
+
readonly generateAudio: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
+
readonly params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>>>;
|
|
13
|
+
};
|
|
14
|
+
export declare const generateInputSchema: {
|
|
15
|
+
readonly model: z.ZodString;
|
|
16
|
+
readonly prompt: z.ZodOptional<z.ZodString>;
|
|
17
|
+
readonly negativePrompt: z.ZodOptional<z.ZodString>;
|
|
18
|
+
readonly aspectRatio: z.ZodOptional<z.ZodString>;
|
|
19
|
+
readonly resolution: z.ZodOptional<z.ZodString>;
|
|
20
|
+
readonly attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
21
|
+
source: z.ZodEnum<["ref", "video_generation", "image_generation", "media_asset"]>;
|
|
22
|
+
id: z.ZodString;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
id: string;
|
|
25
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
26
|
+
}, {
|
|
27
|
+
id: string;
|
|
28
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
29
|
+
}>, "many">>;
|
|
30
|
+
readonly seed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
31
|
+
readonly params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>>>;
|
|
32
|
+
readonly idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
33
|
+
readonly wait: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
};
|
|
35
|
+
export declare const videoGenerationInputSchema: {
|
|
36
|
+
readonly slotBindings: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
37
|
+
readonly keyframes: z.ZodOptional<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodNumber>, Record<string, number>, Record<string, number>>>;
|
|
38
|
+
readonly duration: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
readonly generateAudio: z.ZodOptional<z.ZodBoolean>;
|
|
40
|
+
readonly movementAmplitude: z.ZodOptional<z.ZodEnum<["auto", "small", "medium", "large"]>>;
|
|
41
|
+
readonly shotType: z.ZodOptional<z.ZodEnum<["intelligence", "customize"]>>;
|
|
42
|
+
readonly multiPrompt: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
43
|
+
prompt: z.ZodString;
|
|
44
|
+
duration: z.ZodNumber;
|
|
45
|
+
shotSize: z.ZodOptional<z.ZodEnum<["auto", "wide", "medium", "closeup", "macro", "pov"]>>;
|
|
46
|
+
cameraMove: z.ZodOptional<z.ZodEnum<["auto", "static", "pan", "tracking", "dollyIn", "dollyOut", "zoom"]>>;
|
|
47
|
+
}, "strip", z.ZodTypeAny, {
|
|
48
|
+
prompt: string;
|
|
49
|
+
duration: number;
|
|
50
|
+
shotSize?: "medium" | "auto" | "wide" | "closeup" | "macro" | "pov" | undefined;
|
|
51
|
+
cameraMove?: "auto" | "static" | "pan" | "tracking" | "dollyIn" | "dollyOut" | "zoom" | undefined;
|
|
52
|
+
}, {
|
|
53
|
+
prompt: string;
|
|
54
|
+
duration: number;
|
|
55
|
+
shotSize?: "medium" | "auto" | "wide" | "closeup" | "macro" | "pov" | undefined;
|
|
56
|
+
cameraMove?: "auto" | "static" | "pan" | "tracking" | "dollyIn" | "dollyOut" | "zoom" | undefined;
|
|
57
|
+
}>, "many">>;
|
|
58
|
+
readonly model: z.ZodString;
|
|
59
|
+
readonly prompt: z.ZodOptional<z.ZodString>;
|
|
60
|
+
readonly negativePrompt: z.ZodOptional<z.ZodString>;
|
|
61
|
+
readonly aspectRatio: z.ZodOptional<z.ZodString>;
|
|
62
|
+
readonly resolution: z.ZodOptional<z.ZodString>;
|
|
63
|
+
readonly attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
64
|
+
source: z.ZodEnum<["ref", "video_generation", "image_generation", "media_asset"]>;
|
|
65
|
+
id: z.ZodString;
|
|
66
|
+
}, "strip", z.ZodTypeAny, {
|
|
67
|
+
id: string;
|
|
68
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
69
|
+
}, {
|
|
70
|
+
id: string;
|
|
71
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
72
|
+
}>, "many">>;
|
|
73
|
+
readonly seed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
74
|
+
readonly params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>>>;
|
|
75
|
+
readonly idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
76
|
+
readonly wait: z.ZodOptional<z.ZodBoolean>;
|
|
77
|
+
};
|
|
78
|
+
export declare const imageGenerationInputSchema: {
|
|
79
|
+
readonly quality: z.ZodOptional<z.ZodEnum<["low", "medium", "high"]>>;
|
|
80
|
+
readonly webSearch: z.ZodOptional<z.ZodBoolean>;
|
|
81
|
+
readonly maskDataUrl: z.ZodOptional<z.ZodString>;
|
|
82
|
+
readonly model: z.ZodString;
|
|
83
|
+
readonly prompt: z.ZodOptional<z.ZodString>;
|
|
84
|
+
readonly negativePrompt: z.ZodOptional<z.ZodString>;
|
|
85
|
+
readonly aspectRatio: z.ZodOptional<z.ZodString>;
|
|
86
|
+
readonly resolution: z.ZodOptional<z.ZodString>;
|
|
87
|
+
readonly attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
88
|
+
source: z.ZodEnum<["ref", "video_generation", "image_generation", "media_asset"]>;
|
|
89
|
+
id: z.ZodString;
|
|
90
|
+
}, "strip", z.ZodTypeAny, {
|
|
91
|
+
id: string;
|
|
92
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
93
|
+
}, {
|
|
94
|
+
id: string;
|
|
95
|
+
source: "ref" | "video_generation" | "image_generation" | "media_asset";
|
|
96
|
+
}>, "many">>;
|
|
97
|
+
readonly seed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
98
|
+
readonly params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean]>>>;
|
|
99
|
+
readonly idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
100
|
+
readonly wait: z.ZodOptional<z.ZodBoolean>;
|
|
101
|
+
};
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const MAX_ATTACHMENTS_PER_SUBMIT = 50;
|
|
3
|
+
/** MCP request schemas mirror the public HTTP schemas.
|
|
4
|
+
* Contract tests compare their fields and boundaries with those schemas. */
|
|
5
|
+
export const estimateCostInputSchema = {
|
|
6
|
+
modelId: z.string().min(1).max(300).describe("A model id from list_models."),
|
|
7
|
+
resolution: z.string().max(40).optional(),
|
|
8
|
+
aspectRatio: z.string().max(20).optional(),
|
|
9
|
+
durationSeconds: z.number().min(-1).max(600).optional(),
|
|
10
|
+
quality: z.enum(["low", "medium", "high"]).optional(),
|
|
11
|
+
hasVideoRefs: z.boolean().optional(),
|
|
12
|
+
generateAudio: z.boolean().optional(),
|
|
13
|
+
params: z.record(z.string().max(64), z.union([
|
|
14
|
+
z.number(), z.string().max(64), z.boolean(),
|
|
15
|
+
])).optional(),
|
|
16
|
+
};
|
|
17
|
+
const attachment = z.object({
|
|
18
|
+
source: z.enum(["ref", "video_generation", "image_generation", "media_asset"]),
|
|
19
|
+
id: z.string().min(1).max(128),
|
|
20
|
+
});
|
|
21
|
+
export const generateInputSchema = {
|
|
22
|
+
model: z.string().min(1).max(120).describe("A model id from list_models."),
|
|
23
|
+
prompt: z.string().min(1).max(25_000).optional(),
|
|
24
|
+
negativePrompt: z.string().max(1000).optional(),
|
|
25
|
+
aspectRatio: z.string().min(1).max(16).optional(),
|
|
26
|
+
resolution: z.string().min(1).max(16).optional(),
|
|
27
|
+
attachments: z.array(attachment).max(MAX_ATTACHMENTS_PER_SUBMIT).optional(),
|
|
28
|
+
seed: z.number().int().min(0).max(4294967295).nullable().optional(),
|
|
29
|
+
params: z.record(z.string().max(64), z.union([
|
|
30
|
+
z.number(), z.string().max(64), z.boolean(),
|
|
31
|
+
])).optional(),
|
|
32
|
+
idempotencyKey: z.string().max(64).optional(),
|
|
33
|
+
wait: z.boolean().optional(),
|
|
34
|
+
};
|
|
35
|
+
export const videoGenerationInputSchema = {
|
|
36
|
+
...generateInputSchema,
|
|
37
|
+
slotBindings: z.record(z.string().max(32), z.string().max(128)).optional(),
|
|
38
|
+
keyframes: z.record(z.string().max(128), z.number().min(0).max(3600)).refine((value) => Object.keys(value).length <= 20, "Maximum 20 keyframes.").optional(),
|
|
39
|
+
duration: z.number().int().min(-1).max(60).optional(),
|
|
40
|
+
generateAudio: z.boolean().optional(),
|
|
41
|
+
movementAmplitude: z.enum(["auto", "small", "medium", "large"]).optional(),
|
|
42
|
+
shotType: z.enum(["intelligence", "customize"]).optional(),
|
|
43
|
+
multiPrompt: z.array(z.object({
|
|
44
|
+
prompt: z.string().min(1).max(2500),
|
|
45
|
+
duration: z.number().int().min(1).max(15),
|
|
46
|
+
shotSize: z.enum(["auto", "wide", "medium", "closeup", "macro", "pov"]).optional(),
|
|
47
|
+
cameraMove: z.enum(["auto", "static", "pan", "tracking", "dollyIn", "dollyOut", "zoom"]).optional(),
|
|
48
|
+
})).max(6).optional(),
|
|
49
|
+
};
|
|
50
|
+
export const imageGenerationInputSchema = {
|
|
51
|
+
...generateInputSchema,
|
|
52
|
+
quality: z.enum(["low", "medium", "high"]).optional(),
|
|
53
|
+
webSearch: z.boolean().optional(),
|
|
54
|
+
maskDataUrl: z.string().max(7_000_000).optional(),
|
|
55
|
+
};
|
package/dist/server.d.ts
ADDED