diff --git a/mcp/README.md b/mcp/README.md
new file mode 100644
index 0000000..27c0f4d
--- /dev/null
+++ b/mcp/README.md
@@ -0,0 +1,41 @@
+# Klarbild MCP-Server
+
+Erlaubt einem KI-Assistenten (z. B. Claude), Bilder an Klarbild zu übergeben und dort zu
+verarbeiten — etwa „nimm die Bilder aus Ordner XY" oder „verarbeite die Fotos, die mir jemand
+per iMessage geschickt hat". Der Assistent besorgt die Bilddateien (mit seinen eigenen
+Werkzeugen) und ruft dann die Klarbild-Tools auf.
+
+## Einrichtung
+
+1. In Klarbild einen Token erzeugen: **Admin → „Automatisierung / MCP-Zugriff" → Token erzeugen**.
+2. Abhängigkeit installieren: `npm i @modelcontextprotocol/sdk`
+3. Server starten (bzw. im MCP-Client eintragen):
+
+```json
+{
+ "mcpServers": {
+ "klarbild": {
+ "command": "node",
+ "args": ["/pfad/zu/mcp/klarbild-mcp.mjs"],
+ "env": {
+ "KLARBILD_URL": "https://klarbild.heidrich-digital.de",
+ "KLARBILD_TOKEN": "klb_…"
+ }
+ }
+ }
+}
+```
+
+## Tools
+
+- **list_recipes** — verfügbare Presets/Rezepte auflisten.
+- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten.
+ Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen
+ (`prompt_text`, `output_format`, `delivery`). Liefert die Auftrags-ID.
+- **job_status** — Status eines Auftrags abfragen.
+
+## Sicherheit
+
+Der Token gibt vollen programmatischen Zugriff auf Upload/Verarbeitung (nicht auf den
+Admin-Bereich). Wie ein Passwort behandeln; bei Verdacht neuen Token erzeugen (der alte wird
+dadurch ungültig).
diff --git a/mcp/klarbild-mcp.mjs b/mcp/klarbild-mcp.mjs
new file mode 100644
index 0000000..57c4169
--- /dev/null
+++ b/mcp/klarbild-mcp.mjs
@@ -0,0 +1,98 @@
+#!/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)' },
+ mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' },
+ prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' },
+ output_format: { type: 'string', description: 'z. B. 30x40, theframe, keep' },
+ delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] },
+ }, required: ['files'] } },
+ { name: 'job_status', description: 'Status eines Auftrags abfragen.',
+ 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 body = a.recipeId
+ ? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
+ : { mode, prompt_text: a.prompt_text, sources,
+ recipe: { tasks: a.output_format && a.output_format !== 'keep' ? ['format'] : [],
+ output_format: a.output_format || 'keep', 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;
+ return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}` }] };
+ }
+ 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');
diff --git a/migrations/008_backup_metadata_apitoken.sql b/migrations/008_backup_metadata_apitoken.sql
new file mode 100644
index 0000000..b1233eb
--- /dev/null
+++ b/migrations/008_backup_metadata_apitoken.sql
@@ -0,0 +1,19 @@
+-- Backup für beliebige Ziele, Metadaten-Beileger, API-Token für externen Zugriff (MCP).
+
+-- Jedes Zusatzziel kann als Backup-Ziel markiert werden (erhält ALLE Ergebnisse).
+ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
+
+-- Metadaten als begleitende .md-Datei mitschicken (Prompt, Dateiname, Modell …).
+ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
+
+-- API-Token für programmatischen Zugriff (Klarbild-MCP / Automationen).
+ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
+
+-- Backup-Status je Ziel und Position (für „auf mehreren Zielen gesichert").
+CREATE TABLE IF NOT EXISTS item_backups (
+ item_id uuid REFERENCES items(id) ON DELETE CASCADE,
+ target text NOT NULL, -- 'nas' | delivery_target-UUID
+ status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
+ at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (item_id, target)
+);
diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx
index ea39a02..d108b54 100644
--- a/src/components/AdminApp.tsx
+++ b/src/components/AdminApp.tsx
@@ -33,6 +33,21 @@ export default function AdminApp() {
setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load();
};
const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
+ const toggleBackup = async (t: any) => {
+ await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) });
+ load();
+ };
+ const backupAllTargets = async () => {
+ if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
+ notify('Sichere … (kann dauern)');
+ const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
+ notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK'));
+ load();
+ };
+ const genToken = async () => {
+ await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
+ notify('API-Token erzeugt.'); load();
+ };
const testTarget = async (id: string) => {
notify('Teste Ziel …');
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
@@ -59,6 +74,7 @@ export default function AdminApp() {
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
+ metadata_sidecar: !!s.metadata_sidecar,
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
};
@@ -201,6 +217,8 @@ export default function AdminApp() {
Vorschaubilder erzeugenKleine, sparsame Bilder für die Bibliotheksvorschau.
+
field('retention_days', e.target.value)} />
Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.
@@ -237,15 +255,18 @@ export default function AdminApp() {
-
Weitere Ausgabeziele (FTP/SFTP)
+
Weitere Ausgabeziele (FTP/SFTP)
+
Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server.
- Ein Preset (Rezept) oder Ordner kann fest auf ein Ziel zeigen; sonst gilt das Standard-Picdrop oben.
+ Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS).
+ „Backup"-Ziele erhalten automatisch alle Ergebnisse (wie das NAS).
@@ -268,10 +289,23 @@ export default function AdminApp() {
setNt({ ...nt, password: e.target.value })} />
setNt({ ...nt, base_path: e.target.value })} />
+
+
+
Automatisierung / MCP-Zugriff
+
+
API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als Authorization: Bearer <Token> an /api/uploads und /api/jobs.