// MCP-Postfach für die Claude-Sitzungen von Pascal und Lara. // Läuft nur im internen Docker-Netz "claude" (kein Host-Port, kein Caddy). // Wer schreibt, ergibt sich aus dem Header "x-mailbox-user", den die // MCP-Konfiguration im jeweiligen Claude-Container fest setzt. import { randomUUID } from 'node:crypto'; import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import express from 'express'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; const USERS = ['pascal', 'lara']; const PROJECTS = ['larpaso', 'youtube', 'allgemein']; const DATA_DIR = process.env.DATA_DIR || '/data'; const FILE = path.join(DATA_DIR, 'messages.json'); const PORT = Number(process.env.PORT || 8080); mkdirSync(DATA_DIR, { recursive: true }); function load() { try { return JSON.parse(readFileSync(FILE, 'utf8')); } catch { return []; } } function save(messages) { const tmp = FILE + '.tmp'; writeFileSync(tmp, JSON.stringify(messages, null, 2)); renameSync(tmp, FILE); } function isForMe(m, me) { return m.from !== me && (m.to === me || m.to === 'alle'); } function unreadFor(messages, me) { return messages.filter((m) => isForMe(m, me) && !m.readBy.includes(me)); } function format(m) { return [ `[${m.id}] ${m.createdAt} · ${m.from} → ${m.to} · Projekt: ${m.project}`, `Betreff: ${m.subject}`, m.replyTo ? `Antwort auf: ${m.replyTo}` : null, '', m.body, ] .filter((l) => l !== null) .join('\n'); } function text(t) { return { content: [{ type: 'text', text: t }] }; } function buildServer(me) { const server = new McpServer({ name: 'postfach', version: '1.0.0' }); server.registerTool( 'send_message', { description: 'Nachricht an die Claude-Sitzungen der anderen Person (oder an alle) schicken. ' + 'Für Übergaben, Entscheidungen, Hinweise auf Änderungen, Fragen.', inputSchema: { to: z.enum([...USERS, 'alle']).describe('Empfänger: pascal, lara oder alle'), project: z.enum(PROJECTS).describe('Projekt: larpaso, youtube oder allgemein'), subject: z.string().min(1).max(200), body: z.string().min(1).max(20000), reply_to: z.string().optional().describe('ID der Nachricht, auf die geantwortet wird'), }, }, async ({ to, project, subject, body, reply_to }) => { const messages = load(); const m = { id: randomUUID().slice(0, 8), createdAt: new Date().toISOString(), from: me, to, project, subject, body, replyTo: reply_to || null, readBy: [], }; messages.push(m); save(messages); return text(`Gesendet (ID ${m.id}) an ${to}.`); } ); server.registerTool( 'read_messages', { description: 'Nachrichten an mich lesen. Standard: nur ungelesene, die danach als gelesen markiert werden.', inputSchema: { only_unread: z.boolean().default(true), project: z.enum(PROJECTS).optional(), mark_read: z.boolean().default(true), limit: z.number().int().min(1).max(100).default(20), }, }, async ({ only_unread, project, mark_read, limit }) => { const messages = load(); let list = only_unread ? unreadFor(messages, me) : messages.filter((m) => isForMe(m, me)); if (project) list = list.filter((m) => m.project === project); list = list.slice(-limit); if (mark_read && list.length) { const ids = new Set(list.map((m) => m.id)); for (const m of messages) if (ids.has(m.id) && !m.readBy.includes(me)) m.readBy.push(me); save(messages); } if (!list.length) return text('Keine Nachrichten.'); return text(list.map(format).join('\n\n---\n\n')); } ); server.registerTool( 'list_messages', { description: 'Verlauf aller Nachrichten (gesendet und empfangen) ansehen, ohne etwas als gelesen zu markieren.', inputSchema: { project: z.enum(PROJECTS).optional(), limit: z.number().int().min(1).max(100).default(20), }, }, async ({ project, limit }) => { let list = load().filter((m) => m.from === me || isForMe(m, me)); if (project) list = list.filter((m) => m.project === project); list = list.slice(-limit); if (!list.length) return text('Noch keine Nachrichten.'); return text(list.map(format).join('\n\n---\n\n')); } ); return server; } const app = express(); app.use(express.json({ limit: '1mb' })); function userFrom(req, res) { const me = String(req.get('x-mailbox-user') || req.query.user || '').toLowerCase(); if (!USERS.includes(me)) { res.status(403).json({ error: 'unbekannter Benutzer' }); return null; } return me; } // Kurzinfo für den Hook in den Claude-Containern. app.get('/unread', (req, res) => { const me = userFrom(req, res); if (!me) return; const list = unreadFor(load(), me); res.json({ count: list.length, messages: list.slice(-5).map((m) => ({ id: m.id, from: m.from, project: m.project, subject: m.subject })), }); }); app.get('/health', (_req, res) => res.json({ ok: true })); // Zustandsloses MCP über Streamable HTTP: pro Anfrage ein frischer Server. app.post('/mcp', async (req, res) => { const me = userFrom(req, res); if (!me) return; const server = buildServer(me); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => { transport.close(); server.close(); }); try { await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (err) { console.error(err); if (!res.headersSent) res.status(500).json({ error: 'interner Fehler' }); } }); app.all('/mcp', (_req, res) => res.status(405).set('Allow', 'POST').end()); app.listen(PORT, () => console.log(`postfach läuft auf :${PORT}`));