32f3b91779
Telegram gains a fully button-driven print flow (photo -> size -> count -> A4 PDF with corner marks), running inline without the queue or a model. MCP gains exact_size and print_sheet so an assistant can produce print-ready files from a local folder. Keeps the rule that every feature works on all three ways in.
189 lines
12 KiB
JavaScript
189 lines
12 KiB
JavaScript
#!/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, writeFile } from 'node:fs/promises';
|
||
import { basename, resolve } 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'] } },
|
||
|
||
{ name: 'exact_size',
|
||
description: 'OHNE KI: ein Bild exakt auf ein physisches Maß bringen (mittiger Ausschnitt im Zielverhältnis, dpi-Metadaten). Ergebnis wird lokal gespeichert.',
|
||
inputSchema: { type: 'object', properties: {
|
||
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||
size: { type: 'string', description: 'Maß: "12x15" (cm), "35x45mm", "5" (=5×5 cm) oder "4:3/15"' },
|
||
landscape: { type: 'boolean', description: 'Querformat statt Hochformat' },
|
||
dpi: { type: 'number', description: 'Standard 300' },
|
||
ext: { type: 'string', enum: ['jpg', 'png'] },
|
||
out: { type: 'string', description: 'Zielpfad für die Datei (Standard: ./klarbild-<maß>.<ext>)' },
|
||
}, required: ['file', 'size'] } },
|
||
|
||
{ name: 'print_sheet',
|
||
description: 'OHNE KI: mehrere Bilder in 100 %-Größe mit Schnittmarken auf einen Druckbogen setzen und als PDF speichern. Für Passbildsätze, Kita-Bilder, Sticker.',
|
||
inputSchema: { type: 'object', properties: {
|
||
images: { type: 'array', description: 'Je Eintrag: Datei + Endmaß + Stückzahl',
|
||
items: { type: 'object', properties: {
|
||
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||
size: { type: 'string', description: 'Endmaß, z. B. "35x45mm", "9x13", "12x15"' },
|
||
count: { type: 'number', description: 'Wie oft auf den Bogen (Standard 1)' },
|
||
landscape: { type: 'boolean' },
|
||
allowRotate: { type: 'boolean', description: 'Darf gedreht platziert werden (Standard true)' },
|
||
}, required: ['file', 'size'] } },
|
||
paper: { type: 'string', description: 'A4 (Standard), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — oder ein freies Maß wie "32,9x48,3"' },
|
||
landscape: { type: 'boolean', description: 'Bogen quer' },
|
||
marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Schnitthilfen (Standard corner)' },
|
||
marginMm: { type: 'number', description: 'Rand zum Papier, Standard 5' },
|
||
gapMm: { type: 'number', description: 'Abstand zwischen den Bildern, Standard passend zu den Marken' },
|
||
bleedMm: { type: 'number', description: 'Beschnittzugabe je Seite, Standard 0' },
|
||
dpi: { type: 'number', description: 'Standard 300' },
|
||
out: { type: 'string', description: 'Zielpfad des PDFs (Standard: ./klarbild-druckbogen.pdf)' },
|
||
}, required: ['images'] } },
|
||
];
|
||
|
||
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 === 'exact_size') {
|
||
const up = await uploadFile(a.file);
|
||
const r = await fetch(api('/api/print/single'), {
|
||
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size,
|
||
landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
const ext = a.ext === 'png' ? 'png' : 'jpg';
|
||
const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`);
|
||
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||
const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi');
|
||
const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Quelle reicht nur für ca. ${real} dpi.` : '';
|
||
return { content: [{ type: 'text', text: `Gespeichert: ${out} (${px} px).${warn}` }] };
|
||
}
|
||
if (name === 'print_sheet') {
|
||
const imgs = a.images || [];
|
||
if (!imgs.length) throw new Error('Keine Bilder angegeben.');
|
||
const cells = [];
|
||
for (const [i, im] of imgs.entries()) {
|
||
const up = await uploadFile(im.file);
|
||
cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size,
|
||
count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false });
|
||
}
|
||
const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' };
|
||
const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5,
|
||
bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true,
|
||
marks: { mode: a.marks || 'corner' }, cells };
|
||
if (a.gapMm != null) body.gapMm = a.gapMm;
|
||
const r = await fetch(api('/api/print/sheet'), {
|
||
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
const out = resolve(a.out || './klarbild-druckbogen.pdf');
|
||
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||
return { content: [{ type: 'text', text:
|
||
`Gespeichert: ${out} — ${r.headers.get('x-klarbild-pages')} Bogen, ${r.headers.get('x-klarbild-per-sheet')} Bilder auf Bogen 1. ` +
|
||
'Beim Drucken „Tatsächliche Größe / 100 %" wählen.' }] };
|
||
}
|
||
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');
|