larpaso-website/claude-remote/mailbox/server.js
Pascal Scholz 30418ea841 Add Claude remote-control stack with shared MCP mailbox
Containers for Pascal and Lara run claude remote-control per project
(larpaso, youtube) on the VPS, isolated from the web stack, and exchange
messages through a small MCP mailbox server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:25:54 +01:00

189 lines
5.9 KiB
JavaScript

// 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}`));