#!/usr/bin/env python3 """MCPBytes REST client for agents without MCP (Python 3.9+, standard library only). mcpbytes.py tools mcpbytes.py run [--filename NAME] [-o KEY=VALUE ...] [--out DIR] [--no-wait] mcpbytes.py job mcpbytes.py read [--offset N] [--limit N] The API key comes from the MCPBYTES_API_KEY environment variable (create one at https://console.mcpbytes.com/keys). Results are printed as JSON on stdout, progress on stderr. Contract: https://api.mcpbytes.com/openapi.json """ from __future__ import annotations import argparse import json import os import sys import time import urllib.error import urllib.parse import urllib.request import uuid from pathlib import Path API = os.environ.get("MCPBYTES_API_URL", "https://api.mcpbytes.com").rstrip("/") FINAL = {"succeeded", "failed", "canceled", "expired"} def fail(message: str) -> "NoReturn": # noqa: F821 print(f"mcpbytes: {message}", file=sys.stderr) sys.exit(1) def call(method: str, path: str, body: bytes | None = None, headers: dict | None = None) -> dict: key = os.environ.get("MCPBYTES_API_KEY") if not key: fail("set MCPBYTES_API_KEY (create a key at https://console.mcpbytes.com/keys)") request = urllib.request.Request( API + path, data=body, method=method, headers={"Authorization": f"Bearer {key}", "User-Agent": "mcpbytes-skill/1", **(headers or {})} ) try: with urllib.request.urlopen(request, timeout=300) as response: return json.load(response) except urllib.error.HTTPError as e: try: error = json.load(e).get("error", {}) except ValueError: error = {} retry = e.headers.get("Retry-After") fail(f"HTTP {e.code} {error.get('code', '')}: {error.get('message', e.reason)}" + (f" (retry after {retry} s)" if retry else "")) except urllib.error.URLError as e: fail(f"cannot reach {API}: {e.reason}") def option(text: str) -> tuple[str, object]: key, sep, value = text.partition("=") if not sep: raise argparse.ArgumentTypeError("options are KEY=VALUE, e.g. detail=high") try: return key, json.loads(value) # numbers, true/false, null except ValueError: return key, value def wait(job: dict) -> dict: delay = 1.0 while job["status"] not in FINAL: print(f"job {job['id']}: {job['status']}", file=sys.stderr) time.sleep(delay) delay = min(delay * 1.5, 5.0) job = call("GET", f"/v1/jobs/{job['id']}") return job def download(job: dict, out: Path) -> None: for file in (job.get("result") or {}).get("files", []): target = out / file["name"] # names are 1-2 safe segments (the API validates them) target.parent.mkdir(parents=True, exist_ok=True) with urllib.request.urlopen(urllib.request.Request(file["url"], headers={"User-Agent": "mcpbytes-skill/1"}), timeout=600) as r: target.write_bytes(r.read()) print(f"saved {target}", file=sys.stderr) def run(args: argparse.Namespace) -> dict: options = dict(args.option or []) jobs = f"/v1/tools/{urllib.parse.quote(args.tool)}/jobs" retry = {"Idempotency-Key": str(uuid.uuid4())} # a retried request returns the same job if args.input.startswith("https://"): body = {"url": args.input, "options": options, **({"filename": args.filename} if args.filename else {})} job = call("POST", jobs, json.dumps(body).encode(), {"Content-Type": "application/json", **retry}) else: path = Path(args.input) if not path.is_file(): fail(f"no such file: {path}") text = lambda v: v if isinstance(v, str) else "" if v is None else json.dumps(v) # noqa: E731 (null is an empty value) query = {"filename": args.filename or path.name, **{k: text(v) for k, v in options.items()}} job = call("POST", f"{jobs}?{urllib.parse.urlencode(query)}", path.read_bytes(), {"Content-Type": "application/octet-stream", **retry}) if not args.no_wait: job = wait(job) if job["status"] == "succeeded" and args.out: download(job, Path(args.out)) return job def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("tools", help="the tools that exist now, with their options and your limits") p = commands.add_parser("run", help="run a tool on a local file or a public https URL") p.add_argument("tool") p.add_argument("input", help="a file path, or an https URL") p.add_argument("--filename", help="file name for its extension, if the URL or path does not end in one") p.add_argument("-o", "--option", action="append", type=option, metavar="KEY=VALUE", help="a tool option (see `tools`); repeatable") p.add_argument("--out", metavar="DIR", help="download every output file into DIR") p.add_argument("--no-wait", action="store_true", help="return as soon as the job is created") p = commands.add_parser("job", help="status and result of a job") p.add_argument("job_id") p = commands.add_parser("read", help="a chunk of a text output (.txt .json .md .csv); continue with next_offset") p.add_argument("job_id") p.add_argument("name") p.add_argument("--offset", type=int, default=0) p.add_argument("--limit", type=int, default=20000) args = parser.parse_args() sys.stdout.reconfigure(encoding="utf-8") # text outputs are UTF-8, whatever the console's code page if args.command == "tools": result = call("GET", "/v1/tools") elif args.command == "run": result = run(args) elif args.command == "job": result = call("GET", f"/v1/jobs/{urllib.parse.quote(args.job_id)}") else: result = call("GET", f"/v1/jobs/{urllib.parse.quote(args.job_id)}/files/{urllib.parse.quote(args.name)}?offset={args.offset}&limit={args.limit}") json.dump(result, sys.stdout, indent=2, ensure_ascii=False) print() if result.get("status") in FINAL - {"succeeded"}: sys.exit(2) if __name__ == "__main__": main()