From 356b6b9f5525f9a0237d0409a8e29742274acb59 Mon Sep 17 00:00:00 2001 From: garfieldheron Date: Thu, 5 Mar 2026 13:14:30 -0500 Subject: [PATCH] Initial commit: Hermes MCP - Yahoo Mail server for Claude AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 17 ++++ .gitignore | 4 + .idea/.gitignore | 10 +++ DEPLOY.md | 203 +++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 15 ++++ README.md | 131 +++++++++++++++++++++++++++++ docker-compose.yml | 14 ++++ nginx.conf | 27 ++++++ package.json | 26 ++++++ src/imap.ts | 173 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 117 ++++++++++++++++++++++++++ src/smtp.ts | 97 ++++++++++++++++++++++ src/tools.ts | 160 +++++++++++++++++++++++++++++++++++ tsconfig.json | 15 ++++ 14 files changed, 1009 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 src/imap.ts create mode 100644 src/index.ts create mode 100644 src/smtp.ts create mode 100644 src/tools.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d96a9a1 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# ── Yahoo Mail ─────────────────────────────────────────────────────────────── +# Generate an App Password at: https://myaccount.yahoo.com/security β†’ App passwords +YAHOO_EMAIL=you@yahoo.com +YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx + +# ── FetcherPay self-hosted mail (Dovecot / Poste.io) ───────────────────────── +# IMAP/SMTP are exposed as Kubernetes NodePorts on the server. +# Use the direct server IP (not hostname) to avoid K8s internal DNS flakiness. +FETCHERPAY_EMAIL=you@fetcherpay.com +FETCHERPAY_PASSWORD=yourpassword +FETCHERPAY_IMAP_HOST=23.120.207.35 # direct IP avoids EAI_AGAIN; or: mail.fetcherpay.com +FETCHERPAY_IMAP_PORT=30993 # K8s NodePort β€” IMAPS (TLS, self-signed cert) +FETCHERPAY_SMTP_HOST=23.120.207.35 +FETCHERPAY_SMTP_PORT=30587 # K8s NodePort β€” SMTP + STARTTLS + +# ── Server ─────────────────────────────────────────────────────────────────── +PORT=3456 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11ffbb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +README.private.md diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..36c1475 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,203 @@ +# Hermes MCP β€” Setup & Deployment + +Hermes is a multi-account email MCP server for Claude AI. +It supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (Dovecot / Poste.io). + +--- + +## Local development + +```bash +# 1. Install dependencies +npm install + +# 2. Copy and fill in credentials +cp .env.example .env +# edit .env with your email credentials + +# 3. Run in dev mode (hot-reload) +npm run dev + +# 4. Test health +curl http://localhost:3456/health +# β†’ {"status":"ok","service":"hermes-mcp"} +``` + +--- + +## Production deployment (MicroK8s β€” current setup) + +The server runs as a Kubernetes Deployment on `23.120.207.35` (MicroK8s single-node cluster). +SSL is handled by `cert-manager` with a Let's Encrypt cert for `hermes.fetcherpay.com`. + +### Prerequisites on the server +- MicroK8s with addons: `dns`, `ingress`, `registry`, `cert-manager` +- Local registry at `localhost:32000` +- A `ClusterIssuer` named `letsencrypt-prod` already configured + +### One-time: create K8s namespace secret +```bash +microk8s kubectl create namespace fetcherpay # if it doesn't exist + +microk8s kubectl create secret generic hermes-mcp-env -n fetcherpay \ + --from-literal=YAHOO_EMAIL=gheron01@yahoo.com \ + --from-literal=YAHOO_APP_PASSWORD=lzlleytmslxocxae \ + --from-literal=FETCHERPAY_EMAIL=garfield.heron@fetcherpay.com \ + --from-literal=FETCHERPAY_PASSWORD=onelove \ + --from-literal=FETCHERPAY_IMAP_HOST=23.120.207.35 \ + --from-literal=FETCHERPAY_IMAP_PORT=30993 \ + --from-literal=FETCHERPAY_SMTP_HOST=23.120.207.35 \ + --from-literal=FETCHERPAY_SMTP_PORT=30587 \ + --from-literal=PORT=3456 +``` + +### Build & push image +```bash +# From your local machine β€” SCP src to server first, then SSH in: +scp -P 2222 -r src/ package*.json tsconfig.json Dockerfile garfield@23.120.207.35:~/hermes-mcp/ + +ssh -p 2222 garfield@23.120.207.35 +cd ~/hermes-mcp +docker build -t localhost:32000/hermes-mcp:latest . +docker push localhost:32000/hermes-mcp:latest +``` + +### Apply K8s manifests (hermes-k8s.yaml on the server) +```yaml +# ~/hermes-mcp/hermes-k8s.yaml (already applied β€” shown for reference) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hermes-mcp + namespace: fetcherpay +spec: + replicas: 1 + selector: + matchLabels: + app: hermes-mcp + template: + metadata: + labels: + app: hermes-mcp + spec: + containers: + - name: hermes-mcp + image: localhost:32000/hermes-mcp:latest + ports: + - containerPort: 3456 + envFrom: + - secretRef: + name: hermes-mcp-env + readinessProbe: + httpGet: + path: /health + port: 3456 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: hermes-mcp + namespace: fetcherpay +spec: + selector: + app: hermes-mcp + ports: + - port: 3456 + targetPort: 3456 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hermes-ingress + namespace: fetcherpay + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: nginx + rules: + - host: hermes.fetcherpay.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hermes-mcp + port: + number: 3456 + tls: + - hosts: + - hermes.fetcherpay.com + secretName: hermes-fetcherpay-tls +``` + +### Redeploy after code changes +```bash +# On your local machine: +scp -P 2222 src/imap.ts src/smtp.ts src/tools.ts src/index.ts \ + garfield@23.120.207.35:~/hermes-mcp/src/ + +ssh -p 2222 garfield@23.120.207.35 " + cd ~/hermes-mcp && + docker build -t localhost:32000/hermes-mcp:latest . && + docker push localhost:32000/hermes-mcp:latest && + microk8s kubectl rollout restart deployment/hermes-mcp -n fetcherpay && + microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay +" +``` + +--- + +## Useful commands + +```bash +# Logs +microk8s kubectl logs -n fetcherpay -l app=hermes-mcp --tail=100 -f + +# Pod status +microk8s kubectl get pods -n fetcherpay -l app=hermes-mcp + +# Update a single env var without rebuild (takes effect on next rollout) +microk8s kubectl set env deployment/hermes-mcp -n fetcherpay KEY=value +microk8s kubectl rollout restart deployment/hermes-mcp -n fetcherpay + +# Health check +curl https://hermes.fetcherpay.com/health +``` + +--- + +## Add to Claude.ai + +1. Go to **Claude.ai β†’ Settings β†’ Connectors β†’ Add custom connector** +2. Enter URL: `https://hermes.fetcherpay.com/mcp` +3. Click Connect + +### Available tools + +| Tool | Description | Key params | +|------|-------------|------------| +| `get_profile` | Get email address for an account | `account` | +| `search_messages` | Search INBOX by keyword/sender/subject | `q`, `maxResults`, `account` | +| `read_message` | Read full message body by UID | `uid`, `account` | +| `list_folders` | List all mailbox folders | `account` | +| `create_draft` | Save a draft to the Drafts folder | `to`, `subject`, `body`, `account` | +| `send_email` | Send an email | `to`, `subject`, `body`, `account` | + +`account` is always optional β€” defaults to `"yahoo"`. Set to `"fetcherpay"` for the FetcherPay mailbox. + +--- + +## Known issues & fixes + +| Issue | Cause | Fix | +|-------|-------|-----| +| `yahoo_read_message` 5-min timeout | `source: true` downloads full raw RFC822 | Use `bodyParts: ['TEXT']` | +| `messageFlagsAdd` deadlock | Called inside `for await` loop while FETCH active | Moved to after the loop | +| Stale session after pod restart | `!sessionId` guard blocked re-initialize | Accept initialize regardless of session ID | +| FetcherPay `EAI_AGAIN` DNS | K8s internal DNS cold-start for hostname | Use direct IP `23.120.207.35` | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0523174 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npx tsc + +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 3456 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..88445a4 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Hermes MCP + +A multi-account email MCP server for [Claude AI](https://claude.ai). +Supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (Dovecot / Poste.io). + +--- + +## Features + +- Read, search, and send email from Claude via MCP +- Multi-account: connect Yahoo and a custom IMAP/SMTP server simultaneously +- Streamable HTTP transport (MCP 1.x) + legacy SSE endpoint +- Automatic session recovery after server restarts +- Deployable to Kubernetes (MicroK8s example included) + +--- + +## Tools + +| Tool | Description | Key params | +|------|-------------|------------| +| `get_profile` | Get email address for an account | `account` | +| `search_messages` | Search INBOX by keyword / sender / subject | `q`, `maxResults`, `account` | +| `read_message` | Read full message body by UID | `uid`, `account` | +| `list_folders` | List all mailbox folders | `account` | +| `create_draft` | Save a draft to the Drafts folder | `to`, `subject`, `body`, `account` | +| `send_email` | Send an email | `to`, `subject`, `body`, `account` | + +`account` defaults to `"yahoo"`. Set to `"fetcherpay"` (or whatever you name your second account) for the custom mailbox. + +--- + +## Quick Start (local dev) + +```bash +# 1. Install dependencies +npm install + +# 2. Configure credentials +cp .env.example .env +# Edit .env with your email credentials + +# 3. Run in dev mode +npm run dev + +# 4. Verify +curl http://localhost:3456/health +# β†’ {"status":"ok","service":"hermes-mcp"} +``` + +--- + +## Configuration + +Copy `.env.example` to `.env` and fill in your values: + +```env +# Yahoo Mail β€” generate an App Password at: +# https://myaccount.yahoo.com/security β†’ App passwords +YAHOO_EMAIL=you@yahoo.com +YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx + +# Self-hosted mail (Dovecot / Poste.io / any IMAP server) +FETCHERPAY_EMAIL=you@yourdomain.com +FETCHERPAY_PASSWORD=yourpassword +FETCHERPAY_IMAP_HOST=your-mail-server-ip +FETCHERPAY_IMAP_PORT=993 +FETCHERPAY_SMTP_HOST=your-mail-server-ip +FETCHERPAY_SMTP_PORT=587 + +PORT=3456 +``` + +> **Tip:** Use the server's direct IP for `FETCHERPAY_IMAP_HOST` / `FETCHERPAY_SMTP_HOST` to avoid DNS resolution issues in Kubernetes. + +--- + +## Connecting to Claude.ai + +1. Go to **Claude.ai β†’ Settings β†’ Connectors β†’ Add custom connector** +2. Enter your server URL: `https://your-domain.com/mcp` +3. Click **Connect** + +--- + +## Production Deployment (Kubernetes) + +See [`DEPLOY.md`](./DEPLOY.md) for full instructions covering: + +- MicroK8s setup with nginx-ingress and cert-manager +- Building and pushing a Docker image to the local registry +- Applying the Kubernetes Deployment / Service / Ingress manifests +- Zero-downtime redeploys after code changes + +--- + +## Architecture + +``` +Claude.ai ──POST /mcp──► StreamableHTTPServerTransport + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ MCP Server (SDK) β”‚ + β”‚ tools / handlers β”‚ + β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜ + β”‚ β”‚ + imapflow (IMAP) nodemailer (SMTP) + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” + β”‚ Yahoo Mail β”‚ β”‚ Self-hosted β”‚ + β”‚ imap.mail β”‚ β”‚ Dovecot / β”‚ + β”‚ .yahoo.com β”‚ β”‚ Poste.io β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Tech Stack + +- **Runtime:** Node.js + TypeScript +- **MCP SDK:** `@modelcontextprotocol/sdk` +- **IMAP:** `imapflow` +- **SMTP:** `nodemailer` +- **HTTP:** `express` +- **Deployment:** Docker + MicroK8s + +--- + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80d3318 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + hermes: + build: . + container_name: hermes-mcp + restart: unless-stopped + env_file: .env + ports: + - "3456:3456" + networks: + - hermes-net + +networks: + hermes-net: + driver: bridge diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f31029d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name hermes.fetcherpay.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name hermes.fetcherpay.com; + + ssl_certificate /etc/letsencrypt/live/hermes.fetcherpay.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hermes.fetcherpay.com/privkey.pem; + + # Required for SSE β€” disable buffering + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + + location / { + proxy_pass http://localhost:3456; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Connection ''; + proxy_read_timeout 3600s; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f8e110f --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "hermes-mcp", + "version": "1.0.0", + "description": "Yahoo Mail MCP server for Claude AI", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.18.0", + "imapflow": "^1.0.0", + "nodemailer": "^6.9.0", + "dotenv": "^16.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/nodemailer": "^6.4.0", + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/src/imap.ts b/src/imap.ts new file mode 100644 index 0000000..c07362f --- /dev/null +++ b/src/imap.ts @@ -0,0 +1,173 @@ +import { ImapFlow } from 'imapflow'; + +export type Account = 'yahoo' | 'fetcherpay'; + +function getConfig(account: Account = 'yahoo') { + if (account === 'fetcherpay') { + return { + host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', + port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), + secure: true, + auth: { + user: process.env['FETCHERPAY_EMAIL'] as string, + pass: process.env['FETCHERPAY_PASSWORD'] as string, + }, + tls: { rejectUnauthorized: false }, // self-signed cert on self-hosted server + }; + } + return { + host: 'imap.mail.yahoo.com', + port: 993, + secure: true, + auth: { + user: process.env['YAHOO_EMAIL'] as string, + pass: process.env['YAHOO_APP_PASSWORD'] as string, + }, + }; +} + +async function withClient(account: Account, fn: (client: ImapFlow) => Promise): Promise { + const client = new ImapFlow(getConfig(account)); + await client.connect(); + try { + return await fn(client); + } finally { + await client.logout(); + } +} + +export interface MessageSummary { + uid: number; + messageId: string; + subject: string; + from: string; + date: string; + seen: boolean; + size: number; +} + +export interface FullMessage { + uid: number; + messageId: string; + subject: string; + from: string; + to: string; + date: string; + body: string; + seen: boolean; +} + +export async function searchMessages(query: string, maxResults = 20, account: Account = 'yahoo'): Promise { + return withClient(account, async (client) => { + await client.mailboxOpen('INBOX'); + + const criteria = query + ? { or: [{ subject: query }, { from: query }] } + : { all: true }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const uids = await client.search(criteria as any, { uid: true }); + const uidList: number[] = Array.isArray(uids) ? uids : []; + const recentUids = uidList.slice(-maxResults).reverse(); + + if (recentUids.length === 0) return []; + + const messages: MessageSummary[] = []; + for await (const msg of client.fetch(recentUids, { + envelope: true, + flags: true, + size: true, + }, { uid: true })) { + const env = msg.envelope; + messages.push({ + uid: msg.uid, + messageId: env?.messageId ?? '', + subject: env?.subject ?? '(no subject)', + from: env?.from?.[0] + ? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim() + : '', + date: env?.date?.toISOString() ?? '', + seen: msg.flags?.has('\\Seen') ?? false, + size: msg.size ?? 0, + }); + } + return messages; + }); +} + +export async function readMessage(uid: number, account: Account = 'yahoo'): Promise { + return withClient(account, async (client) => { + console.log(`[imap] readMessage uid=${uid} account=${account}`); + await client.mailboxOpen('INBOX'); + console.log(`[imap] mailbox opened, fetching uid=${uid}`); + + let result: FullMessage | null = null; + + for await (const msg of client.fetch([uid], { + envelope: true, + flags: true, + bodyParts: ['TEXT'], + }, { uid: true })) { + const env = msg.envelope; + console.log(`[imap] got msg uid=${msg.uid} subject="${env?.subject}"`); + + const bpKeys = msg.bodyParts ? [...msg.bodyParts.keys()] : []; + console.log(`[imap] bodyParts keys:`, JSON.stringify(bpKeys)); + + const textBuf = + msg.bodyParts?.get('text') ?? + msg.bodyParts?.get('TEXT') ?? + msg.bodyParts?.get('1'); + console.log(`[imap] textBuf length=${textBuf ? textBuf.length : 'null'}`); + + const rawBody = textBuf ? textBuf.toString('utf-8') : ''; + + const body = rawBody + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 10000); + + console.log(`[imap] body length after strip=${body.length}`); + + result = { + uid: msg.uid, + messageId: env?.messageId ?? '', + subject: env?.subject ?? '(no subject)', + from: env?.from?.[0] + ? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim() + : '', + to: env?.to?.[0]?.address ?? '', + date: env?.date?.toISOString() ?? '', + body, + seen: true, + }; + } + + if (!result) throw new Error(`Message UID ${uid} not found`); + + // Mark as seen AFTER the fetch loop fully completes β€” calling messageFlagsAdd + // inside the for-await loop deadlocks because the FETCH command is still active. + console.log(`[imap] marking uid=${uid} as seen`); + await client.messageFlagsAdd([uid], ['\\Seen'], { uid: true }); + + console.log(`[imap] readMessage done uid=${uid}`); + return result; + }); +} + +export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> { + const email = account === 'fetcherpay' + ? (process.env['FETCHERPAY_EMAIL'] ?? '') + : (process.env['YAHOO_EMAIL'] ?? ''); + return { email, name: email.split('@')[0], account }; +} + +export async function listFolders(account: Account = 'yahoo'): Promise { + return withClient(account, async (client) => { + const mailboxes = await client.list(); + return mailboxes.map((m) => m.path); + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5a56fe4 --- /dev/null +++ b/src/index.ts @@ -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 + ); + }); + return server; +} + +// ── NEW: Streamable HTTP transport (MCP 1.x standard) ────────────────────── +const httpTransports = new Map(); + +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(); + +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`); +}); diff --git a/src/smtp.ts b/src/smtp.ts new file mode 100644 index 0000000..cad0678 --- /dev/null +++ b/src/smtp.ts @@ -0,0 +1,97 @@ +import nodemailer from 'nodemailer'; +import type { Account } from './imap.js'; + +function getSmtpTransport(account: Account = 'yahoo') { + if (account === 'fetcherpay') { + return nodemailer.createTransport({ + host: process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com', + port: parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587'), + secure: false, // STARTTLS + auth: { + user: process.env['FETCHERPAY_EMAIL']!, + pass: process.env['FETCHERPAY_PASSWORD']!, + }, + tls: { rejectUnauthorized: false }, // self-signed cert + }); + } + return nodemailer.createTransport({ + host: 'smtp.mail.yahoo.com', + port: 587, + secure: false, + auth: { + user: process.env['YAHOO_EMAIL']!, + pass: process.env['YAHOO_APP_PASSWORD']!, + }, + }); +} + +function getSenderEmail(account: Account = 'yahoo'): string { + return account === 'fetcherpay' + ? process.env['FETCHERPAY_EMAIL']! + : process.env['YAHOO_EMAIL']!; +} + +export async function sendEmail( + to: string, + subject: string, + body: string, + account: Account = 'yahoo', +): Promise { + const transporter = getSmtpTransport(account); + const info = await transporter.sendMail({ + from: getSenderEmail(account), + to, + subject, + text: body, + }); + return info.messageId; +} + +export async function createDraft( + to: string, + subject: string, + body: string, + account: Account = 'yahoo', +): Promise { + const { ImapFlow } = await import('imapflow'); + + const imapConfig = account === 'fetcherpay' + ? { + host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', + port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), + secure: true, + auth: { + user: process.env['FETCHERPAY_EMAIL']!, + pass: process.env['FETCHERPAY_PASSWORD']!, + }, + tls: { rejectUnauthorized: false }, + } + : { + host: 'imap.mail.yahoo.com', + port: 993, + secure: true, + auth: { + user: process.env['YAHOO_EMAIL']!, + pass: process.env['YAHOO_APP_PASSWORD']!, + }, + }; + + const client = new ImapFlow(imapConfig); + await client.connect(); + + const from = getSenderEmail(account); + const rawMessage = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=UTF-8`, + ``, + body, + ].join('\r\n'); + + await client.append('Drafts', Buffer.from(rawMessage), ['\\Draft', '\\Seen']); + await client.logout(); + + return `Draft created: "${subject}" to ${to}`; +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..39e1831 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,160 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js'; +import { sendEmail, createDraft } from './smtp.js'; + +const ACCOUNT_PARAM = { + account: { + type: 'string', + enum: ['yahoo', 'fetcherpay'], + description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com) or "fetcherpay" (garfield.heron@fetcherpay.com). Defaults to "yahoo".', + }, +}; + +export const tools: Tool[] = [ + { + name: 'get_profile', + description: 'Get the email account profile (email address and name)', + inputSchema: { + type: 'object', + properties: { ...ACCOUNT_PARAM }, + }, + }, + { + name: 'search_messages', + description: 'Search email messages by keyword, sender, or subject', + inputSchema: { + type: 'object', + properties: { + q: { type: 'string', description: 'Search query (keyword, from:email, subject:text)' }, + maxResults: { type: 'number', description: 'Max messages to return (default 20)' }, + ...ACCOUNT_PARAM, + }, + required: ['q'], + }, + }, + { + name: 'read_message', + description: 'Read a full email message by UID', + inputSchema: { + type: 'object', + properties: { + uid: { type: 'number', description: 'Message UID from search results' }, + ...ACCOUNT_PARAM, + }, + required: ['uid'], + }, + }, + { + name: 'list_folders', + description: 'List all email folders/mailboxes', + inputSchema: { + type: 'object', + properties: { ...ACCOUNT_PARAM }, + }, + }, + { + name: 'create_draft', + description: 'Create a draft email', + inputSchema: { + type: 'object', + properties: { + to: { type: 'string', description: 'Recipient email address' }, + subject: { type: 'string', description: 'Email subject' }, + body: { type: 'string', description: 'Email body (plain text)' }, + ...ACCOUNT_PARAM, + }, + required: ['to', 'subject', 'body'], + }, + }, + { + name: 'send_email', + description: 'Send an email', + inputSchema: { + type: 'object', + properties: { + to: { type: 'string', description: 'Recipient email address' }, + subject: { type: 'string', description: 'Email subject' }, + body: { type: 'string', description: 'Email body (plain text)' }, + ...ACCOUNT_PARAM, + }, + required: ['to', 'subject', 'body'], + }, + }, +]; + +function acct(args: Record): Account { + return (args.account as Account) ?? 'yahoo'; +} + +export async function handleToolCall( + name: string, + args: Record +): Promise<{ content: Array<{ type: string; text: string }> }> { + console.log(`[tool] ${name}`, JSON.stringify(args)); + const t0 = Date.now(); + try { + let result: unknown; + + switch (name) { + case 'get_profile': + result = await getProfile(acct(args)); + break; + + case 'search_messages': + result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args)); + break; + + case 'read_message': + result = await readMessage(args.uid as number, acct(args)); + break; + + case 'list_folders': + result = await listFolders(acct(args)); + break; + + case 'create_draft': + result = await createDraft(args.to as string, args.subject as string, args.body as string, acct(args)); + break; + + case 'send_email': + result = await sendEmail(args.to as string, args.subject as string, args.body as string, acct(args)); + break; + + // Legacy Yahoo-prefixed names β€” keep working for any cached Claude sessions + case 'yahoo_get_profile': + result = await getProfile('yahoo'); + break; + case 'yahoo_search_messages': + result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, 'yahoo'); + break; + case 'yahoo_read_message': + result = await readMessage(args.uid as number, 'yahoo'); + break; + case 'yahoo_list_folders': + result = await listFolders('yahoo'); + break; + case 'yahoo_create_draft': + result = await createDraft(args.to as string, args.subject as string, args.body as string, 'yahoo'); + break; + case 'yahoo_send_email': + result = await sendEmail(args.to as string, args.subject as string, args.body as string, 'yahoo'); + break; + + default: + throw new Error(`Unknown tool: ${name}`); + } + + console.log(`[tool] ${name} OK (${Date.now() - t0}ms)`); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const msg = (error as Error).message; + const stack = (error as Error).stack ?? ''; + console.error(`[tool] ${name} ERROR (${Date.now() - t0}ms):`, msg); + console.error(stack); + return { + content: [{ type: 'text', text: `Error: ${msg}` }], + }; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e2fc10b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}