#!/usr/bin/env node // Klarbild MCP-Server — erlaubt einem KI-Assistenten (z. B. Claude), lokale Bilder // oder anderweitig beschaffte Bilder (iMessage, Ordner XY …) an Klarbild zu übergeben // und dort zu verarbeiten. Der Assistent besorgt die Bilddateien; dieser Server lädt // sie hoch und legt einen Auftrag an. // // Setup: // npm i @modelcontextprotocol/sdk // KLARBILD_URL=https://klarbild.heidrich-digital.de \ // KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs // Den Token erzeugt man in Klarbild unter Admin → „Automatisierung / MCP-Zugriff". import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, ''); const TOKEN = process.env.KLARBILD_TOKEN || ''; if (!BASE || !TOKEN) { console.error('KLARBILD_URL und KLARBILD_TOKEN müssen gesetzt sein.'); process.exit(1); } const authHeaders = { Authorization: `Bearer ${TOKEN}` }; const api = (path) => `${BASE}${path}`; async function uploadFile(pathOrBase64, name) { let buf, filename = name; if (pathOrBase64.startsWith('data:')) { buf = Buffer.from(pathOrBase64.split(',')[1], 'base64'); filename = name || 'bild.png'; } else { buf = await readFile(pathOrBase64); filename = name || basename(pathOrBase64); } const fd = new FormData(); fd.append('files', new Blob([buf]), filename); const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd }); const j = await r.json(); const info = (j.files || [])[0]; if (!info?.source_path) throw new Error(info?.error || 'Upload fehlgeschlagen'); return { source_path: info.source_path, filename }; } const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } }); const TOOLS = [ { name: 'list_recipes', description: 'Verfügbare Presets/Rezepte in Klarbild auflisten.', inputSchema: { type: 'object', properties: {} } }, { name: 'process_images', description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.', inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' }, recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes) — überschreibt die Einzeloptionen' }, mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' }, tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] }, description: 'Nur bei mode=each. Standard: ["format"] wenn output_format gesetzt.' }, prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' }, output_format: { type: 'string', description: 'Fest: 30x40, A4, theframe, hochformat, keep … ODER frei: "25x35" (=25×35 cm) bzw. "sticker5" (=5×5 cm).' }, output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'Dateiformat. Weglassen = globale Standard-Einstellung. JPG nur ohne Transparenz.' }, orientation: { type: 'string', enum: ['portrait', 'landscape'] }, crop_mode: { type: 'string', enum: ['crop', 'extend'] }, contour_mm: { type: 'number', description: 'Stickerrand in mm (nur mit tasks=cutout+contour).' }, picdrop_gallery: { type: 'string', description: 'Ziel-Galerie/Unterordner (bei delivery picdrop/both).' }, delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] }, }, required: ['files'] } }, { name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).', inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } }, ]; server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS })); server.setRequestHandler({ method: 'tools/call' }, async (req) => { const { name, arguments: a = {} } = req.params; try { if (name === 'list_recipes') { const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json(); const list = (j.recipes || []).map((r) => `${r.id} — ${r.name} (${r.mode || 'each'})`).join('\n'); return { content: [{ type: 'text', text: list || 'Keine Presets.' }] }; } if (name === 'process_images') { const files = a.files || []; const mode = a.mode || 'each'; const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f))); const wantsFormat = a.output_format && a.output_format !== 'keep'; const tasks = Array.isArray(a.tasks) && a.tasks.length ? a.tasks : (wantsFormat ? ['format'] : []); const body = a.recipeId ? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text } : { mode, prompt_text: a.prompt_text, sources, recipe: { tasks: mode === 'each' ? tasks : (wantsFormat ? ['format'] : []), output_format: a.output_format || 'keep', output_ext: a.output_ext || null, orientation: a.orientation || 'portrait', crop_mode: a.crop_mode || 'crop', contour_mm: a.contour_mm ?? null, picdrop_gallery: a.picdrop_gallery || null, delivery: a.delivery || 'library', }, delivery: a.delivery || 'library' }; const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen'); return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] }; } if (name === 'job_status') { const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json(); const job = j.job || j; const errs = (j.items || []).map((it) => it.error_message).filter(Boolean); const fin = job.finished_at ? ` · beendet ${job.finished_at}` : ''; const errTxt = errs.length ? `\nFehler: ${errs.join('; ')}` : ''; return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}${fin}${errTxt}` }] }; } return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true }; } catch (e) { return { content: [{ type: 'text', text: `Fehler: ${e?.message || e}` }], isError: true }; } }); await server.connect(new StdioServerTransport()); console.error('[klarbild-mcp] bereit');