botverse-mcp 1.0.3 → 1.1.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/Dockerfile ADDED
@@ -0,0 +1,9 @@
1
+ # Dockerfile for Glama (and any container-based MCP host).
2
+ # Builds the thin stdio bridge. No dependencies, no build step.
3
+ # Glama starts the container and sends an introspection request
4
+ # (initialize / tools/list) — both answered locally from tools.json,
5
+ # so the check passes with no API key and no network access.
6
+ FROM node:20-alpine
7
+ WORKDIR /app
8
+ COPY package.json index.js tools.json ./
9
+ ENTRYPOINT ["node", "index.js"]
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # botverse-mcp
2
2
 
3
- MCP server for [Botverse](https://botverse.cloud) — video transcoding and document conversion for AI agents.
3
+ MCP server **and command-line tool** for [Botverse](https://botverse.cloud) — video transcoding and document conversion for AI agents and the humans who configure them.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/botverse-mcp)](https://www.npmjs.com/package/botverse-mcp)
6
6
 
@@ -9,7 +9,7 @@ MCP server for [Botverse](https://botverse.cloud) — video transcoding and docu
9
9
  - **Video transcoding** — MP4 (H.264), WebM (VP9), ProRes 422, GIF, MP3 extraction · $0.25/job
10
10
  - **Document conversion** — Markdown ↔ DOCX ↔ PDF ↔ HTML ↔ XLSX · $0.05/file
11
11
 
12
- Three tool calls. No AWS. No FFmpeg. No infrastructure.
12
+ Two ways to use it: an **MCP server** for your AI agents, and a **`botverse` CLI** for the shell — evaluation, CI/CD, cron, scripts, and local coding agents. No AWS. No FFmpeg. No infrastructure.
13
13
 
14
14
  ## Setup
15
15
 
@@ -51,16 +51,41 @@ Or with a connector URL (recommended for claude.ai):
51
51
  }
52
52
  ```
53
53
 
54
- ## Tools
54
+ ## Command line (`botverse`)
55
+
56
+ The same package ships a `botverse` CLI for the shell — it reads files from disk and
57
+ streams them straight to the API (no content goes through an LLM), so it's the fast
58
+ path for evaluation, automation, and local coding agents.
59
+
60
+ ```bash
61
+ export BOTVERSE_API_KEY=bv_live_… # or BOTVERSE_CONNECTOR_URL=…?token=bv_sess_…
62
+
63
+ npx botverse convert report.md --to pdf
64
+ npx botverse convert *.md --to docx,pdf -o ./out
65
+ npx botverse transcode clip.mov --to mp4 -o ./out
66
+ npx botverse transcribe call.mp4 --to docx --attendees "Sarah Chen,Mike Torres"
67
+ npx botverse balance
68
+ ```
69
+
70
+ Each job uploads → polls → downloads the finished file to `-o` (default: current dir).
71
+ Globs and multiple `--to` formats run as a batch.
72
+
73
+ > **Sandbox note:** the CLI needs outbound network to `botverse.cloud` and S3, so it does
74
+ > **not** run inside sandboxed agent environments (claude.ai / Claude Desktop), whose
75
+ > egress is allowlisted. There, use the MCP tools (`convert_content` / `get_output_content`).
76
+
77
+ ## Tools (MCP)
55
78
 
56
79
  | Tool | Description |
57
80
  |---|---|
58
81
  | `transcode_from_url` | Transcode video from a public URL |
59
82
  | `transcode_video` | Transcode an uploaded video file |
60
- | `convert_content` | Convert document content inline |
83
+ | `convert_content` | Convert document content inline (up to 4 MB; sandbox-safe) |
61
84
  | `convert_from_url` | Convert a document from a public URL |
85
+ | `convert_file` | Convert an uploaded document |
62
86
  | `get_job_status` | Poll a job until complete |
63
87
  | `get_download_url` | Get the signed download URL |
88
+ | `get_output_content` | Get finished output bytes inline (sandbox-safe download) |
64
89
  | `get_wallet_balance` | Check wallet balance |
65
90
 
66
91
  ## Pricing
package/cli.js ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * botverse — command-line interface for Botverse.
4
+ *
5
+ * For humans and shell-capable automation (CI, cron, local coding agents) — it
6
+ * reads files from disk and streams them to the API directly, so it never serializes
7
+ * content through an LLM the way the in-chat MCP route must. Talks to the same
8
+ * hosted endpoint (botverse.cloud/mcp) with a bv_live_ key.
9
+ *
10
+ * export BOTVERSE_API_KEY=bv_live_xxx
11
+ * botverse convert report.md --to pdf
12
+ * botverse convert *.md --to docx,pdf -o ./out
13
+ * botverse transcode clip.mov --to mp4 -o ./out
14
+ * botverse transcribe call.mp4 --to docx --attendees "Sarah Chen,Mike Torres"
15
+ * botverse balance
16
+ *
17
+ * NOTE: this needs outbound network to botverse.cloud and S3. It does NOT work inside
18
+ * sandboxed agent environments (claude.ai / Claude Desktop) whose egress is allowlisted —
19
+ * there, use the MCP tools (convert_content / get_output_content) instead.
20
+ */
21
+
22
+ "use strict";
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const https = require("https");
26
+ const { URL } = require("url");
27
+
28
+ const VERSION = "1.1.0";
29
+ const BASE_URL = process.env.BOTVERSE_MCP_URL || "https://botverse.cloud/mcp";
30
+
31
+ // ── tiny ANSI helpers ─────────────────────────────────────────────────────────
32
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
33
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
34
+ const dim = (s) => c("2", s), bold = (s) => c("1", s), green = (s) => c("32", s), red = (s) => c("31", s), cyan = (s) => c("36", s);
35
+ const log = (...a) => process.stderr.write(a.join(" ") + "\n");
36
+
37
+ function die(msg) { log(red("error: ") + msg); process.exit(1); }
38
+
39
+ // ── format maps ───────────────────────────────────────────────────────────────
40
+ const CONTENT_TYPES = {
41
+ md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html",
42
+ rst: "text/x-rst", txt: "text/plain", doc: "application/msword",
43
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
44
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", avi: "video/x-msvideo",
45
+ mkv: "video/x-matroska", m4v: "video/x-m4v", wav: "audio/wav", m4a: "audio/mp4",
46
+ mp3: "audio/mpeg", flac: "audio/flac", wma: "audio/x-ms-wma",
47
+ };
48
+ const TEXT_INPUTS = new Set(["md", "markdown", "html", "htm", "rst", "txt"]);
49
+ const EXT_OF = (f) => (path.extname(f).slice(1) || "").toLowerCase();
50
+ const MAX_INLINE = 4 * 1024 * 1024; // proxy inline ceiling
51
+
52
+ // ── HTTP / JSON-RPC ───────────────────────────────────────────────────────────
53
+ // Auth: either a bv_live_ API key (Authorization: Bearer) or a full connector URL
54
+ // containing ?token=bv_sess_… (BOTVERSE_CONNECTOR_URL). Returns the endpoint + headers.
55
+ function authTarget(contentLength) {
56
+ const connector = argv.flags["connector-url"] || process.env.BOTVERSE_CONNECTOR_URL;
57
+ const key = argv.flags["api-key"] || process.env.BOTVERSE_API_KEY;
58
+ if (!connector && !key) {
59
+ die("no credentials. Set BOTVERSE_API_KEY=bv_live_… (or BOTVERSE_CONNECTOR_URL=…?token=bv_sess_…). Get a key at https://botverse.cloud/dashboard/api-keys");
60
+ }
61
+ const headers = { "Content-Type": "application/json", "Content-Length": contentLength, "User-Agent": "botverse-cli/" + VERSION };
62
+ if (!connector && key) headers["Authorization"] = `Bearer ${key}`;
63
+ return { url: connector || BASE_URL, headers };
64
+ }
65
+
66
+ function request(urlStr, { method = "POST", headers = {}, body }) {
67
+ return new Promise((resolve, reject) => {
68
+ const u = new URL(urlStr);
69
+ const req = https.request(
70
+ { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers },
71
+ (res) => {
72
+ const chunks = [];
73
+ res.on("data", (d) => chunks.push(d));
74
+ res.on("end", () => resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) }));
75
+ }
76
+ );
77
+ req.on("error", reject);
78
+ req.setTimeout(180000, () => req.destroy(new Error("request timed out")));
79
+ if (body) req.write(body);
80
+ req.end();
81
+ });
82
+ }
83
+
84
+ let RPC_ID = 0;
85
+ async function mcp(tool, args) {
86
+ const body = JSON.stringify({ jsonrpc: "2.0", id: ++RPC_ID, method: "tools/call", params: { name: tool, arguments: args } });
87
+ const { url, headers } = authTarget(Buffer.byteLength(body));
88
+ const { status, buffer } = await request(url, { headers, body });
89
+ let json;
90
+ try { json = JSON.parse(buffer.toString()); }
91
+ catch { throw new Error(`HTTP ${status}: ${buffer.toString().slice(0, 200)}`); }
92
+ if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
93
+ const text = json.result?.structuredContent ?? json.result?.content?.[0]?.text;
94
+ if (text == null) throw new Error("unexpected response shape");
95
+ return typeof text === "string" ? JSON.parse(text) : text;
96
+ }
97
+
98
+ // ── S3 multipart upload (presigned POST) ──────────────────────────────────────
99
+ async function uploadFile(filePath) {
100
+ const filename = path.basename(filePath);
101
+ const ext = EXT_OF(filename);
102
+ const ct = CONTENT_TYPES[ext] || "application/octet-stream";
103
+ const up = await mcp("get_upload_url", { filename, content_type: ct });
104
+ const fields = up.upload_fields || {};
105
+ const fileBuf = fs.readFileSync(filePath);
106
+
107
+ const boundary = "----botverse" + Math.random().toString(16).slice(2);
108
+ const pre = [];
109
+ for (const [k, v] of Object.entries(fields)) {
110
+ pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`);
111
+ }
112
+ pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${fields["Content-Type"] || ct}\r\n\r\n`);
113
+ const body = Buffer.concat([Buffer.from(pre.join("")), fileBuf, Buffer.from(`\r\n--${boundary}--\r\n`)]);
114
+
115
+ const { status, buffer } = await request(up.upload_url, {
116
+ headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": body.length },
117
+ body,
118
+ });
119
+ if (status !== 204 && status !== 201 && status !== 200) {
120
+ throw new Error(`S3 upload failed (HTTP ${status}): ${buffer.toString().slice(0, 200)}`);
121
+ }
122
+ return up.object_key;
123
+ }
124
+
125
+ // ── job polling + download ────────────────────────────────────────────────────
126
+ async function poll(jobId) {
127
+ const start = Date.now();
128
+ for (;;) {
129
+ const s = await mcp("get_job_status", { job_id: jobId });
130
+ if (s.status === "complete") return s;
131
+ if (s.status === "failed") throw new Error(s.error || "job failed");
132
+ if (Date.now() - start > 30 * 60 * 1000) throw new Error("timed out waiting for job");
133
+ if (s.stage_message) process.stderr.write("\r" + dim(" " + s.stage_message.padEnd(48)));
134
+ await new Promise((r) => setTimeout(r, 3000));
135
+ }
136
+ }
137
+
138
+ async function downloadOutput(jobId, outPath) {
139
+ const dl = await mcp("get_download_url", { job_id: jobId });
140
+ const { status, buffer } = await request(dl.download_url, { method: "GET" });
141
+ if (status !== 200) throw new Error(`download failed (HTTP ${status})`);
142
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
143
+ fs.writeFileSync(outPath, buffer);
144
+ return buffer.length;
145
+ }
146
+
147
+ // ── submit helpers ────────────────────────────────────────────────────────────
148
+ async function submitConvert(filePath, outFmt) {
149
+ const ext = EXT_OF(filePath);
150
+ const size = fs.statSync(filePath).size;
151
+ // Small text files go inline (no upload round-trip); large or binary go via S3.
152
+ if (TEXT_INPUTS.has(ext) && size <= MAX_INLINE) {
153
+ const r = await mcp("convert_content", {
154
+ content: fs.readFileSync(filePath, "utf8"),
155
+ input_format: ext === "markdown" ? "md" : ext === "htm" ? "html" : ext,
156
+ output_format: outFmt,
157
+ });
158
+ return r.job_id;
159
+ }
160
+ const key = await uploadFile(filePath);
161
+ const r = await mcp("convert_file", { object_key: key, output_format: outFmt });
162
+ return r.job_id;
163
+ }
164
+
165
+ async function submitTranscode(filePath, outFmt, opts) {
166
+ const key = await uploadFile(filePath);
167
+ const r = await mcp("transcode_video", { object_key: key, output_format: outFmt, ...(opts ? { options: opts } : {}) });
168
+ return r.job_id;
169
+ }
170
+
171
+ async function submitTranscribe(filePath, outFmt, opts) {
172
+ const key = await uploadFile(filePath);
173
+ const r = await mcp("transcribe_media", { object_key: key, output_format: outFmt, ...(opts ? { options: opts } : {}) });
174
+ return r.job_id;
175
+ }
176
+
177
+ // ── commands ──────────────────────────────────────────────────────────────────
178
+ async function runBatch(files, formats, submit, outDir) {
179
+ if (!files.length) die("no input files");
180
+ let failures = 0;
181
+ for (const file of files) {
182
+ if (!fs.existsSync(file)) { log(red("✗ ") + file + dim(" — not found")); failures++; continue; }
183
+ for (const fmt of formats) {
184
+ const t0 = Date.now();
185
+ const base = path.basename(file, path.extname(file));
186
+ const outPath = path.join(outDir, `${base}.${fmt}`);
187
+ process.stderr.write(dim(`· ${path.basename(file)} → ${fmt} …`));
188
+ try {
189
+ const jobId = await submit(file, fmt);
190
+ await poll(jobId);
191
+ const bytes = await downloadOutput(jobId, outPath);
192
+ process.stderr.write("\r" + green("✓ ") + outPath + dim(` (${(bytes / 1024).toFixed(0)} KB, ${((Date.now() - t0) / 1000).toFixed(1)}s)`).padEnd(20) + "\n");
193
+ } catch (e) {
194
+ process.stderr.write("\r" + red("✗ ") + `${path.basename(file)} → ${fmt}` + dim(" " + (e.message || e)) + "\n");
195
+ failures++;
196
+ }
197
+ }
198
+ }
199
+ if (failures) process.exitCode = 1;
200
+ }
201
+
202
+ function parseFormats(flag, allowed, label) {
203
+ if (!flag) die(`--to is required (${label}). e.g. --to ${allowed[0]}`);
204
+ const fmts = String(flag).split(",").map((s) => s.trim()).filter(Boolean);
205
+ for (const f of fmts) if (!allowed.includes(f)) die(`unsupported --to "${f}". Allowed: ${allowed.join(", ")}`);
206
+ return fmts;
207
+ }
208
+
209
+ const COMMANDS = {
210
+ async convert() {
211
+ const fmts = parseFormats(argv.flags.to, ["docx", "pdf", "html", "txt", "md", "rst", "xlsx"], "convert");
212
+ await runBatch(argv.files, fmts, (f, fmt) => submitConvert(f, fmt), argv.flags.o || argv.flags.out || ".");
213
+ },
214
+ async transcode() {
215
+ const fmts = parseFormats(argv.flags.to, ["mp4", "webm", "mov_prores", "mp3", "gif"], "transcode");
216
+ const opts = {};
217
+ if (argv.flags.resolution) opts.height = ({ "4k": 2160, "1080p": 1080, "720p": 720, "480p": 480, "360p": 360 }[argv.flags.resolution]) || undefined;
218
+ await runBatch(argv.files, fmts, (f, fmt) => submitTranscode(f, fmt, Object.keys(opts).length ? opts : null), argv.flags.o || argv.flags.out || ".");
219
+ },
220
+ async transcribe() {
221
+ const fmts = parseFormats(argv.flags.to, ["txt", "json", "srt", "vtt", "docx", "pdf"], "transcribe");
222
+ const opts = {};
223
+ if (argv.flags.attendees) opts.attendees = String(argv.flags.attendees).split(",").map((n) => ({ name: n.trim() })).filter((a) => a.name);
224
+ if (argv.flags.language) opts.language = argv.flags.language;
225
+ await runBatch(argv.files, fmts, (f, fmt) => submitTranscribe(f, fmt, Object.keys(opts).length ? opts : null), argv.flags.o || argv.flags.out || ".");
226
+ },
227
+ async balance() {
228
+ const r = await mcp("get_wallet_balance", {});
229
+ log(bold("Wallet: ") + green(`$${Number(r.balance_usd).toFixed(2)}`) + (r.auto_refill_enabled ? dim(" (auto-refill on)") : ""));
230
+ },
231
+ };
232
+
233
+ function usage() {
234
+ log(`${bold("botverse")} ${dim("v" + VERSION)} — Botverse from the command line
235
+
236
+ ${bold("Usage:")}
237
+ botverse convert <files…> --to <fmt[,fmt]> [-o dir]
238
+ botverse transcode <files…> --to <fmt> [--resolution 1080p] [-o dir]
239
+ botverse transcribe <files…> --to <fmt> [--attendees "A,B"] [--language en-US] [-o dir]
240
+ botverse balance
241
+
242
+ ${bold("Auth:")} export BOTVERSE_API_KEY=bv_live_… (or --api-key)
243
+
244
+ ${bold("Examples:")}
245
+ ${cyan("botverse convert report.md --to pdf")}
246
+ ${cyan("botverse convert *.md --to docx,pdf -o ./out")}
247
+ ${cyan("botverse transcode clip.mov --to mp4 -o ./out")}
248
+ ${cyan("botverse transcribe call.mp4 --to docx --attendees \"Sarah Chen,Mike Torres\"")}
249
+
250
+ Docs: https://botverse.cloud/docs/cli`);
251
+ }
252
+
253
+ // ── arg parsing ───────────────────────────────────────────────────────────────
254
+ function parseArgs(args) {
255
+ const flags = {}; const files = [];
256
+ for (let i = 0; i < args.length; i++) {
257
+ const a = args[i];
258
+ if (a.startsWith("--")) {
259
+ const key = a.slice(2);
260
+ const next = args[i + 1];
261
+ if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
262
+ } else if (a.startsWith("-")) {
263
+ const key = a.slice(1);
264
+ const next = args[i + 1];
265
+ if (next !== undefined && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
266
+ } else files.push(a);
267
+ }
268
+ return { flags, files };
269
+ }
270
+
271
+ const rawArgs = process.argv.slice(2);
272
+ const command = rawArgs[0];
273
+ const argv = parseArgs(rawArgs.slice(1));
274
+
275
+ (async () => {
276
+ if (!command || command === "help" || argv.flags.help || argv.flags.h) return usage();
277
+ if (command === "version" || argv.flags.version || argv.flags.v) return log("botverse " + VERSION);
278
+ const fn = COMMANDS[command];
279
+ if (!fn) { log(red(`unknown command: ${command}`)); usage(); process.exit(1); }
280
+ try { await fn(); }
281
+ catch (e) { die(e.message || String(e)); }
282
+ })();
package/index.js CHANGED
@@ -2,48 +2,71 @@
2
2
  /**
3
3
  * botverse-mcp — stdio bridge for the Botverse MCP server.
4
4
  *
5
- * Reads MCP JSON-RPC from stdin, forwards to botverse.cloud/mcp,
6
- * writes responses to stdout. Compatible with Claude Desktop, Cursor,
7
- * VS Code, Windsurf, Zed, and any MCP-compatible agent runtime.
5
+ * Answers `initialize` and `tools/list` locally from tools.json (no key,
6
+ * no network so the server is introspectable in any sandbox). Proxies
7
+ * actual tool calls to botverse.cloud/mcp, which requires auth.
8
8
  *
9
- * Auth (set one):
10
- * BOTVERSE_API_KEY=bv_live_... — API key, sent as Authorization: Bearer
9
+ * Auth (set one — only needed to *call* tools, not to introspect):
10
+ * BOTVERSE_API_KEY=bv_live_... — API key, sent as Authorization: Bearer
11
11
  * BOTVERSE_CONNECTOR_URL=https://... — Full connector URL with ?token=bv_sess_...
12
+ *
13
+ * Compatible with Claude Desktop, Cursor, VS Code, Windsurf, Zed, and any
14
+ * MCP-compatible agent runtime.
12
15
  */
13
16
 
14
17
  const { createInterface } = require("readline");
18
+ const { readFileSync } = require("fs");
19
+ const path = require("path");
15
20
  const https = require("https");
16
21
  const { URL } = require("url");
17
22
 
18
23
  const CONNECTOR_URL = process.env.BOTVERSE_CONNECTOR_URL;
19
24
  const API_KEY = process.env.BOTVERSE_API_KEY;
20
25
  const BASE_URL = "https://botverse.cloud/mcp";
26
+ const VERSION = "1.0.4";
27
+
28
+ let TOOLS = [];
29
+ try {
30
+ TOOLS = JSON.parse(readFileSync(path.join(__dirname, "tools.json"), "utf8"));
31
+ } catch {
32
+ TOOLS = []; // introspection still responds, just with an empty tool list
33
+ }
21
34
 
22
- if (!CONNECTOR_URL && !API_KEY) {
23
- process.stderr.write(
24
- "[botverse-mcp] Error: set BOTVERSE_API_KEY or BOTVERSE_CONNECTOR_URL.\n" +
25
- " Get credentials at https://botverse.cloud/dashboard/api-keys\n"
26
- );
27
- process.exit(1);
35
+ const SERVER_INFO = {
36
+ name: "Botverse",
37
+ version: VERSION,
38
+ description:
39
+ "Offload compute-heavy tasks to Botverse — video transcoding and document " +
40
+ "conversion run server-side and return download links. Video (MP4, WebM, " +
41
+ "ProRes, MP3, GIF) and documents (Markdown/HTML/DOCX to DOCX, PDF, HTML, XLSX, TXT).",
42
+ };
43
+
44
+ function reply(obj) {
45
+ process.stdout.write(JSON.stringify(obj) + "\n");
46
+ }
47
+ function ok(id, result) {
48
+ reply({ jsonrpc: "2.0", id, result });
49
+ }
50
+ function err(id, message, code = -32603) {
51
+ reply({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
28
52
  }
29
53
 
30
54
  function getTargetUrl() {
31
55
  return CONNECTOR_URL || BASE_URL;
32
56
  }
33
-
34
57
  function getHeaders(bodyLength) {
35
58
  const headers = {
36
59
  "Content-Type": "application/json",
60
+ Accept: "application/json, text/event-stream",
37
61
  "Content-Length": bodyLength,
38
- "User-Agent": "botverse-mcp/1.0.3",
62
+ "User-Agent": "botverse-mcp/" + VERSION,
39
63
  };
40
- if (!CONNECTOR_URL && API_KEY) {
41
- headers["Authorization"] = `Bearer ${API_KEY}`;
42
- }
64
+ if (!CONNECTOR_URL && API_KEY) headers["Authorization"] = `Bearer ${API_KEY}`;
43
65
  return headers;
44
66
  }
45
67
 
46
- function post(body) {
68
+ // Proxy a request to the hosted Botverse MCP endpoint (used for tool calls).
69
+ function proxy(body) {
47
70
  return new Promise((resolve, reject) => {
48
71
  const raw = JSON.stringify(body);
49
72
  const target = new URL(getTargetUrl());
@@ -54,10 +77,9 @@ function post(body) {
54
77
  method: "POST",
55
78
  headers: getHeaders(Buffer.byteLength(raw)),
56
79
  };
57
-
58
80
  const req = https.request(options, (res) => {
59
81
  let data = "";
60
- res.on("data", (chunk) => { data += chunk; });
82
+ res.on("data", (c) => (data += c));
61
83
  res.on("end", () => {
62
84
  try {
63
85
  resolve(JSON.parse(data));
@@ -66,9 +88,8 @@ function post(body) {
66
88
  }
67
89
  });
68
90
  });
69
-
70
91
  req.on("error", reject);
71
- req.setTimeout(60000, () => { req.destroy(new Error("Request timed out")); });
92
+ req.setTimeout(120000, () => req.destroy(new Error("Request timed out")));
72
93
  req.write(raw);
73
94
  req.end();
74
95
  });
@@ -80,27 +101,47 @@ rl.on("line", async (line) => {
80
101
  const trimmed = line.trim();
81
102
  if (!trimmed) return;
82
103
 
83
- let message;
104
+ let msg;
84
105
  try {
85
- message = JSON.parse(trimmed);
106
+ msg = JSON.parse(trimmed);
86
107
  } catch {
87
108
  return; // ignore malformed input
88
109
  }
89
110
 
111
+ const { id, method } = msg;
112
+
113
+ // Notifications (no id) — acknowledge silently per MCP.
114
+ if (id === undefined || id === null) return;
115
+
116
+ // Handle introspection locally — no key, no network.
117
+ if (method === "initialize") {
118
+ return ok(id, {
119
+ protocolVersion: msg.params?.protocolVersion || "2024-11-05",
120
+ capabilities: { tools: {} },
121
+ serverInfo: SERVER_INFO,
122
+ });
123
+ }
124
+ if (method === "tools/list") {
125
+ return ok(id, { tools: TOOLS });
126
+ }
127
+ if (method === "ping") {
128
+ return ok(id, {});
129
+ }
130
+
131
+ // Everything else (tools/call, etc.) needs auth and goes to the hosted server.
132
+ if (!CONNECTOR_URL && !API_KEY) {
133
+ return err(
134
+ id,
135
+ "Botverse needs credentials to run a job. Set BOTVERSE_API_KEY or " +
136
+ "BOTVERSE_CONNECTOR_URL. Get one at https://botverse.cloud/dashboard/api-keys"
137
+ );
138
+ }
90
139
  try {
91
- const response = await post(message);
92
- process.stdout.write(JSON.stringify(response) + "\n");
93
- } catch (err) {
94
- const errorResponse = {
95
- jsonrpc: "2.0",
96
- id: message.id ?? null,
97
- error: {
98
- code: -32603,
99
- message: err instanceof Error ? err.message : "Internal error",
100
- },
101
- };
102
- process.stdout.write(JSON.stringify(errorResponse) + "\n");
140
+ const response = await proxy(msg);
141
+ reply(response);
142
+ } catch (e) {
143
+ err(id, e instanceof Error ? e.message : "Internal error");
103
144
  }
104
145
  });
105
146
 
106
- rl.on("close", () => { process.exit(0); });
147
+ rl.on("close", () => process.exit(0));
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "botverse-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.MkTurner74/botverse",
5
- "description": "MCP server for Botverse — video transcoding and document conversion for AI agents. $0.25/transcode · $0.05/convert · No AWS required.",
5
+ "description": "Botverse for AI agents and the command line — video transcoding and document conversion. MCP server + `botverse` CLI. $0.25/transcode · $0.05/convert · No AWS required.",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "botverse-mcp": "index.js"
8
+ "botverse-mcp": "index.js",
9
+ "botverse": "cli.js"
9
10
  },
10
11
  "scripts": {
11
12
  "start": "node index.js"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/MkTurner74/botverse-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.3",
9
+ "version": "1.0.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "botverse-mcp",
14
- "version": "1.0.3",
14
+ "version": "1.0.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
package/tools.json ADDED
@@ -0,0 +1,750 @@
1
+ [
2
+ {
3
+ "name": "get_upload_url",
4
+ "description": "Get a presigned PUT URL to upload any file — video, audio, or document (markdown, HTML, DOCX, etc.). The URL expires in 15 minutes. PUT raw file bytes directly to the URL. After upload, pass the object_key to transcode_video (for video) or convert_file (for documents).",
5
+ "inputSchema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "filename": {
9
+ "type": "string",
10
+ "description": "Original filename including extension, e.g. report.md or footage.mp4"
11
+ },
12
+ "content_type": {
13
+ "type": "string",
14
+ "description": "MIME type. Video: \"video/mp4\". Documents: \"text/markdown\", \"text/html\", \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\", \"text/plain\"."
15
+ }
16
+ },
17
+ "required": [
18
+ "filename",
19
+ "content_type"
20
+ ]
21
+ },
22
+ "outputSchema": {
23
+ "type": "object",
24
+ "properties": {
25
+ "upload_url": {
26
+ "type": "string",
27
+ "description": "Presigned HTTPS PUT URL. Send raw file bytes as the request body."
28
+ },
29
+ "object_key": {
30
+ "type": "string",
31
+ "description": "storage object key — pass this to transcode_video or convert_file."
32
+ },
33
+ "expires_in": {
34
+ "type": "number",
35
+ "description": "Seconds until the upload URL expires (900 = 15 minutes)."
36
+ }
37
+ },
38
+ "required": [
39
+ "upload_url",
40
+ "object_key",
41
+ "expires_in"
42
+ ]
43
+ },
44
+ "annotations": {
45
+ "readOnlyHint": false,
46
+ "destructiveHint": false,
47
+ "idempotentHint": false,
48
+ "openWorldHint": true
49
+ }
50
+ },
51
+ {
52
+ "name": "transcode_from_url",
53
+ "description": "Offload a video or audio transcode to Botverse using a public URL — no upload step needed. Accepts Dropbox, Google Drive, OneDrive, SharePoint, and Box share links directly — pass the share URL as-is, no manual conversion needed. Also works with any direct HTTPS download URL (CDN, object storage, etc.). Limited to 2 GB. Returns a job_id immediately. IMPORTANT: tell the user the job_id right away so they can track it. Then poll get_job_status every 5 seconds. Large video files (>100 MB) can take 5–15 minutes — keep polling until status is 'complete' or 'failed', no matter how many polls it takes. Never give up early. Wallet debited on completion. Use options.start_time and options.duration to trim — e.g. start_time='00:01:00', duration=120 for a 2-minute clip.",
54
+ "inputSchema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "source_url": {
58
+ "type": "string",
59
+ "description": "Public HTTPS URL of the source video or audio file."
60
+ },
61
+ "output_format": {
62
+ "type": "string",
63
+ "enum": [
64
+ "mp4",
65
+ "webm",
66
+ "mov_prores",
67
+ "mp3",
68
+ "gif"
69
+ ],
70
+ "description": "Target output format. One of: mp4 (H.264), webm (VP9), mov_prores (ProRes 422), mp3 (audio extraction), gif."
71
+ },
72
+ "options": {
73
+ "type": "object",
74
+ "description": "Optional encoding parameters. All are optional.",
75
+ "properties": {
76
+ "start_time": {
77
+ "type": "string",
78
+ "description": "Seek to this position before encoding, e.g. '00:01:30' or '90'. Useful for trimming long files."
79
+ },
80
+ "duration": {
81
+ "type": "number",
82
+ "description": "Encode only this many seconds of content after start_time. Use with start_time to extract a clip."
83
+ },
84
+ "width": {
85
+ "type": "number",
86
+ "description": "Output width in pixels. Height scales proportionally if only width is set."
87
+ },
88
+ "height": {
89
+ "type": "number",
90
+ "description": "Output height in pixels. Width scales proportionally if only height is set."
91
+ },
92
+ "bitrate": {
93
+ "type": "string",
94
+ "description": "Video bitrate, e.g. '2M' for 2 Mbps."
95
+ },
96
+ "framerate": {
97
+ "type": "number",
98
+ "description": "Output frame rate, e.g. 24 or 30."
99
+ },
100
+ "audio_bitrate": {
101
+ "type": "string",
102
+ "description": "Audio bitrate for mp3 output, e.g. '128k' or '320k'."
103
+ },
104
+ "h264_profile": {
105
+ "type": "string",
106
+ "enum": [
107
+ "baseline",
108
+ "main",
109
+ "high"
110
+ ],
111
+ "description": "H.264 encoding profile for mp4 output. baseline = max device compatibility (mobile, streaming, older hardware). main = balanced (default). high = best quality for modern playback."
112
+ }
113
+ }
114
+ }
115
+ },
116
+ "required": [
117
+ "source_url",
118
+ "output_format"
119
+ ]
120
+ },
121
+ "outputSchema": {
122
+ "type": "object",
123
+ "properties": {
124
+ "job_id": {
125
+ "type": "string",
126
+ "description": "Unique identifier for this job. Pass to get_job_status and get_download_url."
127
+ },
128
+ "status": {
129
+ "type": "string",
130
+ "enum": [
131
+ "queued",
132
+ "processing"
133
+ ],
134
+ "description": "Initial job state — always queued or processing immediately after submission."
135
+ },
136
+ "estimated_seconds": {
137
+ "type": "number",
138
+ "description": "Rough estimated processing time in seconds. Actual time may vary."
139
+ }
140
+ },
141
+ "required": [
142
+ "job_id",
143
+ "status"
144
+ ]
145
+ },
146
+ "annotations": {
147
+ "readOnlyHint": false,
148
+ "destructiveHint": false,
149
+ "idempotentHint": false,
150
+ "openWorldHint": true
151
+ }
152
+ },
153
+ {
154
+ "name": "transcode_video",
155
+ "description": "Offload a video transcode to Botverse — encoding runs server-side so you can continue with other tasks. Returns a job_id immediately. Source must be ≤ 10 minutes and ≤ 5 GB. Poll get_job_status every 5 seconds until 'complete', then get_download_url. Wallet debited on completion.",
156
+ "inputSchema": {
157
+ "type": "object",
158
+ "properties": {
159
+ "object_key": {
160
+ "type": "string",
161
+ "description": "storage object key returned by get_upload_url."
162
+ },
163
+ "output_format": {
164
+ "type": "string",
165
+ "enum": [
166
+ "mp4",
167
+ "webm",
168
+ "mov_prores",
169
+ "mp3",
170
+ "gif"
171
+ ],
172
+ "description": "Target output format. One of: mp4 (H.264), webm (VP9), mov_prores (ProRes 422), mp3 (audio extraction), gif."
173
+ },
174
+ "options": {
175
+ "type": "object",
176
+ "description": "Optional encoding parameters. All are optional.",
177
+ "properties": {
178
+ "start_time": {
179
+ "type": "string",
180
+ "description": "Seek to this position before encoding, e.g. '00:01:30' or '90'."
181
+ },
182
+ "duration": {
183
+ "type": "number",
184
+ "description": "Encode only this many seconds of content after start_time."
185
+ },
186
+ "width": {
187
+ "type": "number",
188
+ "description": "Output width in pixels."
189
+ },
190
+ "height": {
191
+ "type": "number",
192
+ "description": "Output height in pixels."
193
+ },
194
+ "bitrate": {
195
+ "type": "string",
196
+ "description": "Video bitrate, e.g. '2M'."
197
+ },
198
+ "framerate": {
199
+ "type": "number",
200
+ "description": "Output frame rate, e.g. 24 or 30."
201
+ },
202
+ "audio_bitrate": {
203
+ "type": "string",
204
+ "description": "Audio bitrate for mp3 output, e.g. '128k'."
205
+ },
206
+ "h264_profile": {
207
+ "type": "string",
208
+ "enum": [
209
+ "baseline",
210
+ "main",
211
+ "high"
212
+ ],
213
+ "description": "H.264 encoding profile for mp4 output. baseline = max device compatibility. main = balanced (default). high = best quality for modern playback."
214
+ }
215
+ }
216
+ }
217
+ },
218
+ "required": [
219
+ "object_key",
220
+ "output_format"
221
+ ]
222
+ },
223
+ "outputSchema": {
224
+ "type": "object",
225
+ "properties": {
226
+ "job_id": {
227
+ "type": "string",
228
+ "description": "Unique identifier for this job. Pass to get_job_status and get_download_url."
229
+ },
230
+ "status": {
231
+ "type": "string",
232
+ "enum": [
233
+ "queued",
234
+ "processing"
235
+ ],
236
+ "description": "Initial job state — always queued or processing immediately after submission."
237
+ },
238
+ "estimated_seconds": {
239
+ "type": "number",
240
+ "description": "Rough estimated processing time in seconds. Actual time may vary."
241
+ }
242
+ },
243
+ "required": [
244
+ "job_id",
245
+ "status"
246
+ ]
247
+ },
248
+ "annotations": {
249
+ "readOnlyHint": false,
250
+ "destructiveHint": false,
251
+ "idempotentHint": false,
252
+ "openWorldHint": true
253
+ }
254
+ },
255
+ {
256
+ "name": "get_job_status",
257
+ "description": "Poll the status of a transcode or convert job. Call every 5 seconds until status is 'complete' or 'failed'. Status 'queued' or 'processing' is normal — large files take 5–15 minutes. Keep polling indefinitely until a terminal status is reached. Do not stop polling after a fixed number of attempts.",
258
+ "inputSchema": {
259
+ "type": "object",
260
+ "properties": {
261
+ "job_id": {
262
+ "type": "string",
263
+ "description": "Job ID returned by transcode_video, transcode_from_url, convert_file, convert_from_url, or convert_content."
264
+ }
265
+ },
266
+ "required": [
267
+ "job_id"
268
+ ]
269
+ },
270
+ "outputSchema": {
271
+ "type": "object",
272
+ "properties": {
273
+ "job_id": {
274
+ "type": "string",
275
+ "description": "The job identifier."
276
+ },
277
+ "status": {
278
+ "type": "string",
279
+ "enum": [
280
+ "queued",
281
+ "processing",
282
+ "complete",
283
+ "failed"
284
+ ],
285
+ "description": "Current job state."
286
+ },
287
+ "progress_pct": {
288
+ "type": "number",
289
+ "description": "Encoding progress 0–100. Only present while status is 'processing'."
290
+ },
291
+ "output_key": {
292
+ "type": "string",
293
+ "description": "storage object key of the completed output. Present when status is 'complete'. Pass to get_download_url."
294
+ },
295
+ "cost_usd": {
296
+ "type": "number",
297
+ "description": "Amount debited from the wallet on completion, in USD."
298
+ },
299
+ "error_message": {
300
+ "type": "string",
301
+ "description": "Human-readable error description. Present when status is 'failed'."
302
+ }
303
+ },
304
+ "required": [
305
+ "job_id",
306
+ "status"
307
+ ]
308
+ },
309
+ "annotations": {
310
+ "readOnlyHint": true,
311
+ "destructiveHint": false,
312
+ "idempotentHint": true,
313
+ "openWorldHint": true
314
+ }
315
+ },
316
+ {
317
+ "name": "get_download_url",
318
+ "description": "Get a presigned HTTPS URL to download the completed output file. Call after get_job_status returns 'complete'. URL expires in 24 hours.",
319
+ "inputSchema": {
320
+ "type": "object",
321
+ "properties": {
322
+ "job_id": {
323
+ "type": "string",
324
+ "description": "Job ID from transcode_video, transcode_from_url, or any convert tool."
325
+ }
326
+ },
327
+ "required": [
328
+ "job_id"
329
+ ]
330
+ },
331
+ "outputSchema": {
332
+ "type": "object",
333
+ "properties": {
334
+ "download_url": {
335
+ "type": "string",
336
+ "description": "Presigned HTTPS GET URL for the output file. Valid for 24 hours."
337
+ },
338
+ "filename": {
339
+ "type": "string",
340
+ "description": "Suggested filename for the downloaded file including extension."
341
+ },
342
+ "expires_at": {
343
+ "type": "string",
344
+ "description": "ISO 8601 timestamp when the download URL expires."
345
+ },
346
+ "size_bytes": {
347
+ "type": "number",
348
+ "description": "File size of the output in bytes."
349
+ }
350
+ },
351
+ "required": [
352
+ "download_url",
353
+ "filename",
354
+ "expires_at"
355
+ ]
356
+ },
357
+ "annotations": {
358
+ "readOnlyHint": true,
359
+ "destructiveHint": false,
360
+ "idempotentHint": true,
361
+ "openWorldHint": true
362
+ }
363
+ },
364
+ {
365
+ "name": "get_wallet_balance",
366
+ "description": "Check the current prepaid wallet balance for this Botverse account. Use before large batch jobs to confirm sufficient funds.",
367
+ "inputSchema": {
368
+ "type": "object",
369
+ "properties": {},
370
+ "required": []
371
+ },
372
+ "outputSchema": {
373
+ "type": "object",
374
+ "properties": {
375
+ "balance_usd": {
376
+ "type": "number",
377
+ "description": "Current wallet balance in USD."
378
+ },
379
+ "auto_refill_enabled": {
380
+ "type": "boolean",
381
+ "description": "Whether automatic wallet top-up is enabled."
382
+ },
383
+ "refill_threshold_usd": {
384
+ "type": "number",
385
+ "description": "Balance threshold in USD that triggers an auto-refill, if enabled."
386
+ },
387
+ "refill_amount_usd": {
388
+ "type": "number",
389
+ "description": "Amount in USD added on each auto-refill, if enabled."
390
+ }
391
+ },
392
+ "required": [
393
+ "balance_usd",
394
+ "auto_refill_enabled"
395
+ ]
396
+ },
397
+ "annotations": {
398
+ "readOnlyHint": true,
399
+ "destructiveHint": false,
400
+ "idempotentHint": true,
401
+ "openWorldHint": false
402
+ }
403
+ },
404
+ {
405
+ "name": "convert_from_url",
406
+ "description": "Offload a document conversion to Botverse — runs server-side in seconds, returns a download link, and frees you to continue with other tasks while it processes. Use this when the source document is at a public URL — including Dropbox, Google Drive, OneDrive, SharePoint, and Box share links (pass the share URL as-is). If you already have the content as a string, use convert_content instead — no upload step needed. Supported inputs: md, html, rst, txt, docx. Supported outputs: docx (Word), pdf, html, txt, md, rst, xlsx (tables extracted). Returns a job_id immediately. Poll get_job_status every 5s until 'complete', then get_download_url. Flat fee $0.05 per file.",
407
+ "inputSchema": {
408
+ "type": "object",
409
+ "properties": {
410
+ "source_url": {
411
+ "type": "string",
412
+ "description": "Public HTTPS URL of the source document."
413
+ },
414
+ "output_format": {
415
+ "type": "string",
416
+ "enum": [
417
+ "docx",
418
+ "html",
419
+ "txt",
420
+ "md",
421
+ "rst",
422
+ "pdf",
423
+ "xlsx"
424
+ ],
425
+ "description": "Target format: docx | html | txt | md | rst | pdf | xlsx"
426
+ }
427
+ },
428
+ "required": [
429
+ "source_url",
430
+ "output_format"
431
+ ]
432
+ },
433
+ "outputSchema": {
434
+ "type": "object",
435
+ "properties": {
436
+ "job_id": {
437
+ "type": "string",
438
+ "description": "Unique identifier for this job. Pass to get_job_status and get_download_url."
439
+ },
440
+ "status": {
441
+ "type": "string",
442
+ "enum": [
443
+ "queued",
444
+ "processing"
445
+ ],
446
+ "description": "Initial job state — always queued or processing immediately after submission."
447
+ },
448
+ "estimated_seconds": {
449
+ "type": "number",
450
+ "description": "Rough estimated processing time in seconds. Actual time may vary."
451
+ }
452
+ },
453
+ "required": [
454
+ "job_id",
455
+ "status"
456
+ ]
457
+ },
458
+ "annotations": {
459
+ "readOnlyHint": false,
460
+ "destructiveHint": false,
461
+ "idempotentHint": false,
462
+ "openWorldHint": true
463
+ }
464
+ },
465
+ {
466
+ "name": "convert_file",
467
+ "description": "Offload a document conversion to Botverse using an already-uploaded file. Workflow: (1) call get_upload_url to get a presigned upload URL, (2) PUT the raw file bytes to that URL, (3) call convert_file with the object_key — Botverse handles the rest server-side. Returns a job_id immediately so you can continue with other tasks while conversion runs. Supported inputs: md, html, rst, txt, docx. Supported outputs: docx, pdf, html, txt, md, rst, xlsx. Poll get_job_status until complete, then get_download_url. Flat fee $0.05 per file.",
468
+ "inputSchema": {
469
+ "type": "object",
470
+ "properties": {
471
+ "object_key": {
472
+ "type": "string",
473
+ "description": "The object_key returned by get_upload_url."
474
+ },
475
+ "output_format": {
476
+ "type": "string",
477
+ "enum": [
478
+ "docx",
479
+ "html",
480
+ "txt",
481
+ "md",
482
+ "rst",
483
+ "pdf",
484
+ "xlsx"
485
+ ],
486
+ "description": "Target format: docx | html | txt | md | rst | pdf | xlsx"
487
+ }
488
+ },
489
+ "required": [
490
+ "object_key",
491
+ "output_format"
492
+ ]
493
+ },
494
+ "outputSchema": {
495
+ "type": "object",
496
+ "properties": {
497
+ "job_id": {
498
+ "type": "string",
499
+ "description": "Unique identifier for this job. Pass to get_job_status and get_download_url."
500
+ },
501
+ "status": {
502
+ "type": "string",
503
+ "enum": [
504
+ "queued",
505
+ "processing"
506
+ ],
507
+ "description": "Initial job state — always queued or processing immediately after submission."
508
+ },
509
+ "estimated_seconds": {
510
+ "type": "number",
511
+ "description": "Rough estimated processing time in seconds. Actual time may vary."
512
+ }
513
+ },
514
+ "required": [
515
+ "job_id",
516
+ "status"
517
+ ]
518
+ },
519
+ "annotations": {
520
+ "readOnlyHint": false,
521
+ "destructiveHint": false,
522
+ "idempotentHint": false,
523
+ "openWorldHint": true
524
+ }
525
+ },
526
+ {
527
+ "name": "submit_workflow",
528
+ "description": "Submit a multi-step workflow to the Botverse workflow engine. Steps execute in dependency order; parallel branches (multiple steps with the same depends_on) run simultaneously. Returns a workflow_id immediately — poll get_workflow_status every 5–10 seconds until terminal. Requires auto-refill to be enabled at botverse.cloud/dashboard/billing to prevent mid-workflow balance failures. Workflow definition uses BWDL (Botverse Workflow Definition Language) — schema at botverse.cloud/schemas/workflow/v1.json.",
529
+ "inputSchema": {
530
+ "type": "object",
531
+ "properties": {
532
+ "definition": {
533
+ "type": "object",
534
+ "description": "BWDL workflow definition. Must include workflow_id (string) and steps (array). Each step needs id, tool, and inputs."
535
+ }
536
+ },
537
+ "required": [
538
+ "definition"
539
+ ]
540
+ },
541
+ "outputSchema": {
542
+ "type": "object",
543
+ "properties": {
544
+ "workflow_id": {
545
+ "type": "string",
546
+ "description": "Unique workflow identifier. Pass to get_workflow_status."
547
+ },
548
+ "status": {
549
+ "type": "string",
550
+ "description": "Initial status: QUEUED or PROCESSING."
551
+ },
552
+ "step_count": {
553
+ "type": "number",
554
+ "description": "Number of steps in the workflow."
555
+ },
556
+ "already_exists": {
557
+ "type": "boolean",
558
+ "description": "True if this workflow_id was already submitted — idempotent resubmit."
559
+ }
560
+ },
561
+ "required": [
562
+ "workflow_id",
563
+ "status"
564
+ ]
565
+ },
566
+ "annotations": {
567
+ "readOnlyHint": false,
568
+ "destructiveHint": false,
569
+ "idempotentHint": true,
570
+ "openWorldHint": true
571
+ }
572
+ },
573
+ {
574
+ "name": "get_workflow_status",
575
+ "description": "Get the current status of a workflow and all its steps. Each call may advance the workflow by dispatching steps whose dependencies have completed. Poll every 5–10 seconds until status is COMPLETED, FAILED, PARTIALLY_FAILED, or CANCELLED.",
576
+ "inputSchema": {
577
+ "type": "object",
578
+ "properties": {
579
+ "workflow_id": {
580
+ "type": "string",
581
+ "description": "Workflow ID returned by submit_workflow."
582
+ }
583
+ },
584
+ "required": [
585
+ "workflow_id"
586
+ ]
587
+ },
588
+ "outputSchema": {
589
+ "type": "object",
590
+ "properties": {
591
+ "workflow_id": {
592
+ "type": "string"
593
+ },
594
+ "status": {
595
+ "type": "string",
596
+ "enum": [
597
+ "QUEUED",
598
+ "PROCESSING",
599
+ "COMPLETED",
600
+ "PARTIALLY_FAILED",
601
+ "FAILED",
602
+ "CANCELLED"
603
+ ]
604
+ },
605
+ "total_cost_usd": {
606
+ "type": "number"
607
+ },
608
+ "steps": {
609
+ "type": "array",
610
+ "description": "Array of step status objects."
611
+ },
612
+ "completed_at": {
613
+ "type": "string",
614
+ "description": "ISO timestamp when workflow reached terminal state."
615
+ }
616
+ },
617
+ "required": [
618
+ "workflow_id",
619
+ "status"
620
+ ]
621
+ },
622
+ "annotations": {
623
+ "readOnlyHint": true,
624
+ "destructiveHint": false,
625
+ "idempotentHint": true,
626
+ "openWorldHint": true
627
+ }
628
+ },
629
+ {
630
+ "name": "cancel_workflow",
631
+ "description": "Cancel an in-progress workflow. All queued and dispatched steps are marked CANCELLED. Completed steps are not reversed. You are only billed for steps that completed before cancellation.",
632
+ "inputSchema": {
633
+ "type": "object",
634
+ "properties": {
635
+ "workflow_id": {
636
+ "type": "string",
637
+ "description": "Workflow ID to cancel."
638
+ }
639
+ },
640
+ "required": [
641
+ "workflow_id"
642
+ ]
643
+ },
644
+ "outputSchema": {
645
+ "type": "object",
646
+ "properties": {
647
+ "workflow_id": {
648
+ "type": "string"
649
+ },
650
+ "cancelled": {
651
+ "type": "boolean"
652
+ },
653
+ "status": {
654
+ "type": "string"
655
+ }
656
+ },
657
+ "required": [
658
+ "workflow_id",
659
+ "cancelled"
660
+ ]
661
+ },
662
+ "annotations": {
663
+ "readOnlyHint": false,
664
+ "destructiveHint": false,
665
+ "idempotentHint": true,
666
+ "openWorldHint": false
667
+ }
668
+ },
669
+ {
670
+ "name": "convert_content",
671
+ "description": "Offload an inline document conversion to Botverse — pass the content directly as a string. ONLY use this tool for content you generated yourself (e.g. Markdown you just wrote). HARD LIMIT: content must be under 10,000 characters. If the content is longer than 10,000 characters, or came from an uploaded or external file, DO NOT use this tool — tell the user to make the file available at a public URL (Google Drive share link, Dropbox, object storage, etc.) and use convert_from_url instead. Supported inputs: md, html, rst, txt (plain text), docx (base64). Supported outputs: docx (Word), pdf, html, txt, md, rst, xlsx. Flat fee $0.05 per file.",
672
+ "inputSchema": {
673
+ "type": "object",
674
+ "properties": {
675
+ "content": {
676
+ "type": "string",
677
+ "description": "The file content as a plain text string (for md, html, rst, txt) or base64-encoded bytes (for docx)."
678
+ },
679
+ "input_format": {
680
+ "type": "string",
681
+ "enum": [
682
+ "md",
683
+ "html",
684
+ "rst",
685
+ "txt",
686
+ "docx"
687
+ ],
688
+ "description": "Source format of the content."
689
+ },
690
+ "output_format": {
691
+ "type": "string",
692
+ "enum": [
693
+ "docx",
694
+ "html",
695
+ "txt",
696
+ "md",
697
+ "rst",
698
+ "pdf",
699
+ "xlsx"
700
+ ],
701
+ "description": "Target format: docx | html | txt | md | rst | pdf | xlsx"
702
+ },
703
+ "encoding": {
704
+ "type": "string",
705
+ "enum": [
706
+ "text",
707
+ "base64"
708
+ ],
709
+ "description": "Encoding of the content field. Defaults to \"text\". Use \"base64\" for binary inputs like .docx."
710
+ }
711
+ },
712
+ "required": [
713
+ "content",
714
+ "input_format",
715
+ "output_format"
716
+ ]
717
+ },
718
+ "outputSchema": {
719
+ "type": "object",
720
+ "properties": {
721
+ "job_id": {
722
+ "type": "string",
723
+ "description": "Unique identifier for this job. Pass to get_job_status and get_download_url."
724
+ },
725
+ "status": {
726
+ "type": "string",
727
+ "enum": [
728
+ "queued",
729
+ "processing"
730
+ ],
731
+ "description": "Initial job state — always queued or processing immediately after submission."
732
+ },
733
+ "estimated_seconds": {
734
+ "type": "number",
735
+ "description": "Rough estimated processing time in seconds. Actual time may vary."
736
+ }
737
+ },
738
+ "required": [
739
+ "job_id",
740
+ "status"
741
+ ]
742
+ },
743
+ "annotations": {
744
+ "readOnlyHint": false,
745
+ "destructiveHint": false,
746
+ "idempotentHint": false,
747
+ "openWorldHint": true
748
+ }
749
+ }
750
+ ]