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:
117
src/index.ts
Normal file
117
src/index.ts
Normal 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`);
|
||||
});
|
||||
Reference in New Issue
Block a user