@bigapi/mcp 0.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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artoption GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @bigapi/mcp
2
+
3
+ MCP server for **[bigapi.dev](https://bigapi.dev)** – *the output layer for AI agents.*
4
+
5
+ Gives Claude Desktop, Cursor, Cline, Windsurf and any MCP-capable agent the file operations an LLM cannot do itself:
6
+
7
+ | Tool | What it does |
8
+ |---|---|
9
+ | `render` | HTML / Markdown / URL → **PDF** or **PNG** (reports, invoices, offers, screenshots) |
10
+ | `pdf_merge` · `pdf_split` · `pdf_rotate` · `pdf_compress` | The PDF basics |
11
+ | `pdf_to_images` | PDF pages → JPEG/PNG, e.g. to look at a document with a vision model |
12
+ | `image_process` | Resize, crop, rotate, convert (webp/avif/…), compress, strip EXIF, watermark – one call |
13
+ | `image_info` | Format, dimensions, color space, EXIF/ICC presence |
14
+ | `get_access` | Free API key, instantly, no signup – 100 free operations |
15
+ | `get_balance` · `get_usage` · `set_monthly_cap` · `get_pricing` | Account |
16
+
17
+ **1 cent per operation. 100 free. Balance never expires. Failed calls are free.** Servers in Germany, files deleted after delivery.
18
+
19
+ ## Install
20
+
21
+ Requires Node 18+. No API key needed up front – the agent can call `get_access` itself.
22
+
23
+ ### Claude Desktop
24
+
25
+ `claude_desktop_config.json` (Settings → Developer → Edit Config):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "bigapi": {
31
+ "command": "npx",
32
+ "args": ["-y", "@bigapi/mcp"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Restart Claude Desktop. Then: *"Get bigapi access and render this text as a PDF on my Desktop."*
39
+
40
+ ### Cursor / Windsurf / Cline
41
+
42
+ Same block in the respective MCP settings (`.cursor/mcp.json`, `~/.codeium/windsurf/mcp_config.json`, Cline → MCP Servers → Configure).
43
+
44
+ ### With an existing key
45
+
46
+ ```json
47
+ "bigapi": { "command": "npx", "args": ["-y", "@bigapi/mcp"], "env": { "BIGAPI_KEY": "bigapi_..." } }
48
+ ```
49
+
50
+ ## How files work
51
+
52
+ Inputs are local paths (`/Users/me/report.pdf`, `C:\Users\me\scan.pdf`). Outputs are written to `output_path` if given, otherwise to a temp folder (`BIGAPI_OUTPUT_DIR` to change). Every result includes the cost, what it was charged from, and the remaining balance.
53
+
54
+ ## Environment
55
+
56
+ | Variable | Default | Purpose |
57
+ |---|---|---|
58
+ | `BIGAPI_KEY` | – | Use this key instead of the stored one |
59
+ | `BIGAPI_CONFIG_DIR` | `~/.bigapi` | Where `get_access` stores the key (`config.json`, mode 600) |
60
+ | `BIGAPI_OUTPUT_DIR` | OS temp dir | Default output folder |
61
+ | `BIGAPI_URL` | `https://api.bigapi.dev` | API base (for self-hosting / testing) |
62
+
63
+ ## Without MCP
64
+
65
+ Plain HTTP works everywhere (n8n, Make, Zapier, LangChain, your code):
66
+
67
+ ```bash
68
+ curl -X POST https://api.bigapi.dev/v1/keys # → key
69
+ curl -o out.pdf https://api.bigapi.dev/v1/render \
70
+ -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
71
+ -d '{"markdown":"# Hello from an agent"}'
72
+ ```
73
+
74
+ OpenAPI: https://api.bigapi.dev/openapi.json · Docs: https://api.bigapi.dev/docs · llms.txt: https://api.bigapi.dev/llms.txt
75
+
76
+ ## License
77
+
78
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@bigapi/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for bigapi.dev – the output layer for AI agents: render HTML/Markdown to PDF, merge/split/compress PDFs, convert images. One cent per operation.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "node src/index.js",
8
+ "test": "node test/smoke.js"
9
+ },
10
+ "keywords": [
11
+ "mcp",
12
+ "model-context-protocol",
13
+ "pdf",
14
+ "render",
15
+ "html-to-pdf",
16
+ "markdown-to-pdf",
17
+ "image",
18
+ "agent",
19
+ "tools",
20
+ "bigapi"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.30.0",
26
+ "zod": "^4.4.3"
27
+ },
28
+ "type": "module",
29
+ "bin": {
30
+ "bigapi-mcp": "src/index.js"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "README.md"
35
+ ],
36
+ "homepage": "https://bigapi.dev",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/BiGapi-2026/bigapi-mcp"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ }
package/src/client.js ADDED
@@ -0,0 +1,86 @@
1
+ // Client für api.bigapi.dev: Key-Verwaltung (Datei oder Umgebungsvariable), Upload, Download.
2
+ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join, basename, extname, resolve } from 'node:path';
5
+ import { openAsBlob } from 'node:fs';
6
+
7
+ export const BASE_URL = (process.env.BIGAPI_URL || 'https://api.bigapi.dev').replace(/\/$/, '');
8
+ const CONFIG_DIR = process.env.BIGAPI_CONFIG_DIR || join(homedir(), '.bigapi');
9
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
10
+ const OUT_DIR = process.env.BIGAPI_OUTPUT_DIR || join(tmpdir(), 'bigapi');
11
+
12
+ async function readConfig() { try { return JSON.parse(await readFile(CONFIG_FILE, 'utf8')); } catch { return {}; } }
13
+ async function writeConfig(c) { await mkdir(CONFIG_DIR, { recursive: true }); await writeFile(CONFIG_FILE, JSON.stringify(c, null, 2), { mode: 0o600 }); }
14
+
15
+ export async function getKey() {
16
+ if (process.env.BIGAPI_KEY) return process.env.BIGAPI_KEY;
17
+ return (await readConfig()).key || null;
18
+ }
19
+ export async function saveKey(key) { const c = await readConfig(); c.key = key; c.saved_at = new Date().toISOString(); await writeConfig(c); return CONFIG_FILE; }
20
+ export function configPath() { return CONFIG_FILE; }
21
+
22
+ export class BigapiError extends Error {
23
+ constructor(status, body) { super(body?.error || `http_${status}`); this.status = status; this.body = body; }
24
+ }
25
+
26
+ async function authHeaders() {
27
+ const key = await getKey();
28
+ if (!key) throw new BigapiError(401, { error: 'no_api_key', hint: 'Rufe zuerst das Tool get_access auf (kostenlos, ohne Anmeldung) oder setze BIGAPI_KEY.' });
29
+ return { Authorization: `Bearer ${key}` };
30
+ }
31
+
32
+ function billing(res) {
33
+ const n = (h) => (res.headers.get(h) != null ? Number(res.headers.get(h)) : undefined);
34
+ return { cost_cents: n('x-bigapi-cost'), charged_from: res.headers.get('x-bigapi-charged-from') || undefined,
35
+ balance_cents: n('x-bigapi-balance'), free_operations_remaining: n('x-bigapi-free-ops'), duration_ms: n('x-bigapi-duration-ms'), pages: n('x-bigapi-pages') };
36
+ }
37
+
38
+ async function parseError(res) {
39
+ let body; try { body = await res.json(); } catch { body = { error: `http_${res.status}` }; }
40
+ return new BigapiError(res.status, body);
41
+ }
42
+
43
+ export async function apiJson(method, path, body, { auth = true } = {}) {
44
+ const headers = { 'content-type': 'application/json', ...(auth ? await authHeaders() : {}) };
45
+ const res = await fetch(BASE_URL + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
46
+ if (!res.ok) throw await parseError(res);
47
+ return res.json();
48
+ }
49
+
50
+ // Operation mit JSON-Body (render) → Datei
51
+ export async function opJson(path, body, outName, idem) {
52
+ const headers = { 'content-type': 'application/json', ...(await authHeaders()) };
53
+ if (idem) headers['Idempotency-Key'] = idem;
54
+ const res = await fetch(BASE_URL + path, { method: 'POST', headers, body: JSON.stringify(body) });
55
+ if (!res.ok) throw await parseError(res);
56
+ return saveResponse(res, outName);
57
+ }
58
+
59
+ // Operation mit Datei-Upload(s) (multipart) → Datei oder JSON
60
+ export async function opFiles(path, files, fields = {}, outName, idem) {
61
+ const fd = new FormData();
62
+ for (const [field, p] of files) {
63
+ const abs = resolve(p);
64
+ await stat(abs).catch(() => { throw new BigapiError(400, { error: 'file_not_found', path: abs }); });
65
+ fd.append(field, await openAsBlob(abs), basename(abs));
66
+ }
67
+ for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, typeof v === 'string' ? v : JSON.stringify(v));
68
+ const headers = { ...(await authHeaders()) };
69
+ if (idem) headers['Idempotency-Key'] = idem;
70
+ const res = await fetch(BASE_URL + path, { method: 'POST', headers, body: fd });
71
+ if (!res.ok) throw await parseError(res);
72
+ if ((res.headers.get('content-type') || '').includes('application/json')) return { json: await res.json(), ...billing(res) };
73
+ return saveResponse(res, outName);
74
+ }
75
+
76
+ async function saveResponse(res, outName) {
77
+ const ct = res.headers.get('content-type') || 'application/octet-stream';
78
+ const ext = ct.includes('pdf') ? '.pdf' : ct.includes('png') ? '.png' : ct.includes('jpeg') ? '.jpg' : ct.includes('webp') ? '.webp' : ct.includes('avif') ? '.avif' : ct.includes('zip') ? '.zip' : ct.includes('tiff') ? '.tiff' : ct.includes('gif') ? '.gif' : '';
79
+ let out;
80
+ if (outName) { out = resolve(outName); if (!extname(out)) out += ext; }
81
+ else { await mkdir(OUT_DIR, { recursive: true }); out = join(OUT_DIR, `bigapi-${Date.now()}${ext}`); }
82
+ await mkdir(join(out, '..'), { recursive: true });
83
+ const buf = Buffer.from(await res.arrayBuffer());
84
+ await writeFile(out, buf);
85
+ return { output_path: out, bytes: buf.length, content_type: ct, ...billing(res) };
86
+ }
package/src/index.js ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // bigapi MCP-Server – macht api.bigapi.dev als Werkzeuge für Claude Desktop, Cursor, Cline & Co. verfügbar.
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { apiJson, opJson, opFiles, getKey, saveKey, configPath, BigapiError, BASE_URL } from './client.js';
7
+
8
+ const server = new McpServer({ name: 'bigapi', version: '0.1.0' }, {
9
+ instructions: `bigapi.dev – the output layer for AI agents. Deterministic file operations an LLM cannot do itself:
10
+ render HTML/Markdown/URLs to PDF or PNG, merge/split/rotate/compress PDFs, turn PDF pages into images, resize/convert/watermark images.
11
+ Pricing: 1 cent per operation, 100 free operations per new key (no signup), balance never expires, failed calls are free.
12
+ If no API key is configured, call get_access first – it is free and instant. Files are given and returned as local paths.`,
13
+ });
14
+
15
+ // Einheitliche Ergebnis-/Fehlerdarstellung
16
+ function ok(obj, text) {
17
+ return { content: [{ type: 'text', text: text ? `${text}\n\n${JSON.stringify(obj, null, 2)}` : JSON.stringify(obj, null, 2) }] };
18
+ }
19
+ function fail(e) {
20
+ if (e instanceof BigapiError) {
21
+ const b = e.body || {};
22
+ let hint = '';
23
+ if (b.error === 'balance_empty') hint = `\nGuthaben leer. Aufladen (ab 5 €, verfällt nie): ${b.upgrade_url}`;
24
+ if (b.error === 'cap_reached') hint = `\nMonatsobergrenze dieses Keys erreicht (${b.monthly_cap_cents} ct). Mit set_monthly_cap anheben.`;
25
+ if (b.error === 'rate_limited') hint = `\nZu viele Anfragen. In ${b.retry_after ?? 1} s erneut versuchen.`;
26
+ if (b.error === 'no_api_key' || e.status === 401) hint = `\nKein gültiger Key. Tool get_access aufrufen (kostenlos).`;
27
+ return { isError: true, content: [{ type: 'text', text: `bigapi error ${e.status}: ${b.error}${hint}\n${JSON.stringify(b)}` }] };
28
+ }
29
+ return { isError: true, content: [{ type: 'text', text: `error: ${e.message}` }] };
30
+ }
31
+ const run = (fn) => async (args) => { try { return await fn(args); } catch (e) { return fail(e); } };
32
+
33
+ // ---- Zugang -----------------------------------------------------------------
34
+ server.tool('get_access',
35
+ 'Get a free bigapi API key instantly – no signup, no credit card. 100 free operations for 7 days, then 1 cent per operation. The key is stored locally and used by all other tools. Call this once if no key is configured.',
36
+ { name: z.string().optional().describe('Optional label for the key, e.g. "claude-desktop"') },
37
+ run(async ({ name }) => {
38
+ const existing = await getKey();
39
+ if (existing) return ok({ status: 'already_configured', config: configPath(), hint: 'Use get_balance to see credit, or force_new=true is not supported – revoke via console.' }, 'A key is already configured.');
40
+ const r = await apiJson('POST', '/v1/keys', { name: name || 'mcp' }, { auth: false });
41
+ const path = await saveKey(r.key);
42
+ return ok({ key_id: r.key_id, free_operations: r.free_operations, free_until: r.free_until, monthly_cap_cents: r.monthly_cap_cents,
43
+ price_per_operation_cents: r.price_per_operation_cents, upgrade_url: r.upgrade_url, stored_at: path },
44
+ 'Key created and stored. Keep the upgrade_url – it is where credit is added when the free operations run out.');
45
+ }));
46
+
47
+ server.tool('get_balance',
48
+ 'Show credit, free operations remaining, monthly cap and spend of the configured key.',
49
+ {}, run(async () => ok(await apiJson('GET', '/v1/balance'))));
50
+
51
+ server.tool('get_usage', 'Operations and cost this month, grouped by operation.', {}, run(async () => ok(await apiJson('GET', '/v1/usage'))));
52
+
53
+ server.tool('set_monthly_cap',
54
+ 'Raise or lower the monthly spending cap (in cents) of the configured key. Default is 1000 (10 €). Protects against runaway loops.',
55
+ { monthly_cap_cents: z.number().int().min(0).max(1_000_000) },
56
+ run(async ({ monthly_cap_cents }) => {
57
+ const bal = await apiJson('GET', '/v1/balance');
58
+ return ok(await apiJson('PATCH', `/v1/keys/${bal.key.id}`, { monthly_cap_cents }));
59
+ }));
60
+
61
+ server.tool('get_pricing', 'Current price list of bigapi.dev (machine-readable).', {}, run(async () => ok(await apiJson('GET', '/v1/pricing', null, { auth: false }))));
62
+
63
+ // ---- Render -------------------------------------------------------------------
64
+ server.tool('render',
65
+ 'Render HTML, Markdown or a URL to a PDF (default) or PNG file. Use for reports, invoices, offers, documentation, screenshots. Returns the local output path. 1 cent.',
66
+ {
67
+ markdown: z.string().optional().describe('Markdown source (a clean print stylesheet is applied)'),
68
+ html: z.string().optional().describe('Full or partial HTML'),
69
+ url: z.string().url().optional().describe('Public URL to render'),
70
+ format: z.enum(['pdf', 'png']).default('pdf'),
71
+ output_path: z.string().optional().describe('Where to save the result (extension optional). Default: temp dir'),
72
+ css: z.string().optional().describe('Extra CSS'),
73
+ page_format: z.enum(['A4', 'A3', 'Letter', 'Legal']).default('A4'),
74
+ landscape: z.boolean().default(false),
75
+ margin_mm: z.number().min(0).max(60).optional().describe('Uniform page margin in mm (default 20/18)'),
76
+ footer_page_numbers: z.boolean().default(false).describe('Add "Seite X/Y" footer'),
77
+ png_width: z.number().int().min(200).max(4000).optional(),
78
+ png_height: z.number().int().min(200).max(4000).optional(),
79
+ png_full_page: z.boolean().default(true),
80
+ idempotency_key: z.string().optional(),
81
+ },
82
+ run(async (a) => {
83
+ if (!a.markdown && !a.html && !a.url) throw new BigapiError(400, { error: 'missing_source', hint: 'Provide markdown, html or url' });
84
+ const body = { markdown: a.markdown, html: a.html, url: a.url, format: a.format, css: a.css,
85
+ pdf: { format: a.page_format, landscape: a.landscape,
86
+ margin: a.margin_mm != null ? { top: `${a.margin_mm}mm`, right: `${a.margin_mm}mm`, bottom: `${a.margin_mm}mm`, left: `${a.margin_mm}mm` } : undefined,
87
+ footerTemplate: a.footer_page_numbers ? '<div style="font-size:8px;width:100%;text-align:center;color:#666">Seite <span class="pageNumber"></span>/<span class="totalPages"></span></div>' : undefined },
88
+ png: { width: a.png_width, height: a.png_height, fullPage: a.png_full_page } };
89
+ return ok(await opJson('/v1/render', body, a.output_path, a.idempotency_key), 'Rendered.');
90
+ }));
91
+
92
+ // ---- PDF ------------------------------------------------------------------------
93
+ server.tool('pdf_merge', 'Merge two or more PDF files (local paths, in order) into one. 1 cent.',
94
+ { files: z.array(z.string()).min(2).describe('Local PDF paths in order'), output_path: z.string().optional(), idempotency_key: z.string().optional() },
95
+ run(async (a) => ok(await opFiles('/v1/pdf/merge', a.files.map(f => ['files', f]), {}, a.output_path, a.idempotency_key), 'Merged.')));
96
+
97
+ server.tool('pdf_split', 'Extract pages from a PDF. Page ranges like "1-3,7,9-z" (z = last page). 1 cent.',
98
+ { file: z.string(), pages: z.string().default('1-z'), output_path: z.string().optional(), idempotency_key: z.string().optional() },
99
+ run(async (a) => ok(await opFiles('/v1/pdf/split', [['file', a.file]], { pages: a.pages }, a.output_path, a.idempotency_key), 'Split.')));
100
+
101
+ server.tool('pdf_rotate', 'Rotate PDF pages by 90, 180 or 270 degrees. 1 cent.',
102
+ { file: z.string(), angle: z.enum(['90', '180', '270']).default('90'), pages: z.string().default('1-z'), output_path: z.string().optional(), idempotency_key: z.string().optional() },
103
+ run(async (a) => ok(await opFiles('/v1/pdf/rotate', [['file', a.file]], { angle: a.angle, pages: a.pages }, a.output_path, a.idempotency_key), 'Rotated.')));
104
+
105
+ server.tool('pdf_compress', 'Shrink a PDF. Levels: screen (smallest), ebook (default, good for sharing), printer, prepress (largest, best quality). 1 cent.',
106
+ { file: z.string(), level: z.enum(['screen', 'ebook', 'printer', 'prepress']).default('ebook'), output_path: z.string().optional(), idempotency_key: z.string().optional() },
107
+ run(async (a) => ok(await opFiles('/v1/pdf/compress', [['file', a.file]], { level: a.level }, a.output_path, a.idempotency_key), 'Compressed.')));
108
+
109
+ server.tool('pdf_to_images', 'Render PDF pages as JPEG (default) or PNG images – e.g. to look at a document with a vision model. Single page → image file, multiple pages → ZIP. 1 cent.',
110
+ { file: z.string(), dpi: z.number().int().min(36).max(600).default(150), first_page: z.number().int().min(1).optional(), last_page: z.number().int().min(1).optional(),
111
+ format: z.enum(['jpeg', 'png']).default('jpeg'), quality: z.number().int().min(30).max(100).default(85), output_path: z.string().optional(), idempotency_key: z.string().optional() },
112
+ run(async (a) => ok(await opFiles('/v1/pdf/pages', [['file', a.file]], { dpi: String(a.dpi), first: a.first_page && String(a.first_page), last: a.last_page && String(a.last_page), format: a.format, quality: String(a.quality) }, a.output_path, a.idempotency_key), 'Pages rendered.')));
113
+
114
+ // ---- Images --------------------------------------------------------------------
115
+ server.tool('image_process', 'Resize, crop, rotate, convert (jpeg/png/webp/avif/tiff), compress, strip EXIF and/or watermark an image in one call. 1 cent.',
116
+ { file: z.string(),
117
+ resize_width: z.number().int().min(1).max(10000).optional(), resize_height: z.number().int().min(1).max(10000).optional(),
118
+ fit: z.enum(['inside', 'cover', 'contain', 'outside', 'fill']).default('inside'),
119
+ crop: z.object({ left: z.number().int(), top: z.number().int(), width: z.number().int(), height: z.number().int() }).optional(),
120
+ rotate: z.union([z.literal('auto'), z.number()]).optional().describe('"auto" = fix EXIF orientation, or degrees'),
121
+ format: z.enum(['jpeg', 'png', 'webp', 'avif', 'tiff', 'gif']).optional(), quality: z.number().int().min(1).max(100).optional(),
122
+ keep_metadata: z.boolean().default(false).describe('Keep EXIF/ICC (default: stripped)'),
123
+ watermark_text: z.string().optional(), watermark_gravity: z.enum(['southeast', 'southwest', 'northeast', 'northwest', 'center']).default('southeast'), watermark_opacity: z.number().min(0).max(1).default(0.55),
124
+ output_path: z.string().optional(), idempotency_key: z.string().optional() },
125
+ run(async (a) => {
126
+ const ops = {};
127
+ if (a.resize_width || a.resize_height) ops.resize = { width: a.resize_width, height: a.resize_height, fit: a.fit };
128
+ if (a.crop) ops.crop = a.crop;
129
+ if (a.rotate !== undefined) ops.rotate = a.rotate;
130
+ if (a.format) ops.format = a.format;
131
+ if (a.quality) ops.quality = a.quality;
132
+ if (a.keep_metadata) ops.keepMetadata = true;
133
+ if (a.watermark_text) ops.watermark = { text: a.watermark_text, gravity: a.watermark_gravity, opacity: a.watermark_opacity };
134
+ return ok(await opFiles('/v1/image', [['file', a.file]], { ops }, a.output_path, a.idempotency_key), 'Image processed.');
135
+ }));
136
+
137
+ server.tool('image_info', 'Read format, dimensions, color space, EXIF/ICC presence of an image. 1 cent.',
138
+ { file: z.string() }, run(async (a) => ok(await opFiles('/v1/image/info', [['file', a.file]]))));
139
+
140
+ // ---- Start ---------------------------------------------------------------------
141
+ const transport = new StdioServerTransport();
142
+ await server.connect(transport);
143
+ process.stderr.write(`bigapi MCP server ready (${BASE_URL})\n`);