Files
klarbild/mcp/klarbild-mcp.mjs
T
till ed22a76fda feat: deliver in chosen format (not forced JPEG); wire output_ext through jobs/Telegram/MCP
- delivery.ts: upload the stored result in its chosen format (output_ext,
  global or per-recipe) instead of always converting non-alpha to JPEG.
- jobs POST froze the snapshot without output_ext -> per-conversion/per-recipe
  format choice was silently dropped. Now carried through.
- telegram.ts: recipe snapshots carry output_ext + delivery_target_id
  (custom formats already flow via output_format).
- mcp/klarbild-mcp.mjs: process_images gains tasks, output_ext, orientation,
  crop_mode, contour_mm, picdrop_gallery; job_status shows finished_at + errors.
- Docs: llms.txt, mcp/README, changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 09:12:07 +00:00

119 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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');