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>
189 lines
5.9 KiB
JavaScript
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}`));
|