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:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
README.private.md
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -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/
|
||||||
203
DEPLOY.md
Normal file
203
DEPLOY.md
Normal file
@@ -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` |
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -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"]
|
||||||
131
README.md
Normal file
131
README.md
Normal file
@@ -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
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -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
|
||||||
27
nginx.conf
Normal file
27
nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/imap.ts
Normal file
173
src/imap.ts
Normal file
@@ -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<T>(account: Account, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||||
|
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<MessageSummary[]> {
|
||||||
|
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<FullMessage> {
|
||||||
|
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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<script[^>]*>[\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<string[]> {
|
||||||
|
return withClient(account, async (client) => {
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
return mailboxes.map((m) => m.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
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`);
|
||||||
|
});
|
||||||
97
src/smtp.ts
Normal file
97
src/smtp.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
160
src/tools.ts
Normal file
160
src/tools.ts
Normal file
@@ -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<string, unknown>): Account {
|
||||||
|
return (args.account as Account) ?? 'yahoo';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleToolCall(
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>
|
||||||
|
): 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}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user