Initial commit: Hermes MCP - Yahoo Mail server for Claude AI

- Multi-account email support (Yahoo + self-hosted IMAP/SMTP)
- MCP tools: get_profile, search_messages, read_message, list_folders, create_draft, send_email
- Streamable HTTP transport with session recovery
- Docker + Kubernetes deployment configuration
- Express server with health endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
garfieldheron
2026-03-05 13:14:30 -05:00
commit 356b6b9f55
14 changed files with 1009 additions and 0 deletions

117
src/index.ts Normal file
View File

@@ -0,0 +1,117 @@
import 'dotenv/config';
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
isInitializeRequest,
} from '@modelcontextprotocol/sdk/types.js';
import { tools, handleToolCall } from './tools.js';
const app = express();
app.use(express.json());
function createMcpServer() {
const server = new Server(
{ name: 'hermes', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handleToolCall(
request.params.name,
(request.params.arguments ?? {}) as Record<string, unknown>
);
});
return server;
}
// ── NEW: Streamable HTTP transport (MCP 1.x standard) ──────────────────────
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && httpTransports.has(sessionId)) {
// Known active session — reuse it
transport = httpTransports.get(sessionId)!;
} else if (isInitializeRequest(req.body)) {
// Initialize request: create a new session.
// Handles both first-connect (no sessionId) and re-connect after pod restart
// (stale sessionId present but not in map — we simply ignore it and issue a fresh one).
if (sessionId) {
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
}
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => { httpTransports.set(id, transport); },
});
transport.onclose = () => {
if (transport.sessionId) httpTransports.delete(transport.sessionId);
};
const server = createMcpServer();
await server.connect(transport);
} else {
// Unknown session + non-initialize request: session expired (e.g. pod restarted).
// Return 404 so MCP clients know to re-initialize rather than keep retrying.
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — returning 404`);
res.status(404).json({ error: 'Session expired — please re-initialize' });
return;
}
await transport.handleRequest(req, res, req.body);
});
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !httpTransports.has(sessionId)) {
res.status(400).json({ error: 'No active session' });
return;
}
await httpTransports.get(sessionId)!.handleRequest(req, res);
});
app.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && httpTransports.has(sessionId)) {
httpTransports.delete(sessionId);
}
res.status(200).end();
});
// ── LEGACY: SSE transport (kept for compatibility) ──────────────────────────
const sseTransports = new Map<string, SSEServerTransport>();
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
const server = createMcpServer();
await server.connect(transport);
});
app.post('/messages', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = sseTransports.get(sessionId);
if (!transport) {
res.status(400).json({ error: 'No active SSE session' });
return;
}
await transport.handlePostMessage(req, res);
});
// ── Health ──────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'hermes-mcp' });
});
const PORT = process.env.PORT ?? 3456;
app.listen(PORT, () => {
console.log(`Hermes MCP server running on port ${PORT}`);
console.log(` Streamable HTTP: http://localhost:${PORT}/mcp`);
console.log(` SSE (legacy): http://localhost:${PORT}/sse`);
});