A hosted Model Context Protocol server for IMAP and SMTP email, built in Ruby. It acts as both an OAuth 2.1 Authorization Server and an MCP Resource Server.
Client (Claude Desktop / MCP Inspector)
│
├─ OAuth 2.1 flow
│ GET /.well-known/oauth-protected-resource RFC 9728 metadata
│ GET /.well-known/oauth-authorization-server RFC 8414 metadata
│ GET /oauth/authorize Login UI
│ POST /oauth/authorize Validate IMAP/SMTP → issue code
│ POST /oauth/token Code + PKCE + client_secret → tokens
│ (also: refresh_token grant)
│
└─ MCP calls (all HTTP methods)
/mcp Bearer access token → decrypt creds → IMAP/SMTP operations
Clients are provisioned once via the bin/mail_mcp generate CLI, which produces:
client_id— a JWE token (encrypted, opaque) encoding the IMAP/SMTP server configuration and theclient_secret. Only the server can decrypt it.client_secret— a random secret used to authenticate the client at the token endpoint.
The OAuth flow:
- Client redirects the user to
/oauth/authorize?client_id=<jwe>&... - Server decrypts the
client_idJWE to learn which IMAP/SMTP servers to connect to - User enters their IMAP/SMTP username and password in the login form
- Server validates both connections live; shows an error banner on failure
- On success: credentials are encrypted directly in a JWE access token and a JWE refresh token
- Client exchanges the authorization code (
POST /oauth/token) withclient_id+client_secret; receivesaccess_token+refresh_token - Every MCP request carries
Authorization: Bearer <access_token>; the server decrypts credentials per-request
All tokens are 5-part JWE (dir / A256GCM), encrypted with ENCRYPTION_KEY. There is no separate signing key.
| Token | typ claim |
Expiry | Contents |
|---|---|---|---|
client_id |
client_id |
none | imap/smtp host+port, client_secret |
| Access token | access |
8 hours | IMAP + SMTP credentials |
| Refresh token | refresh |
30 days | IMAP + SMTP credentials |
mail_mcp/
├── Gemfile
├── .env.sample # Environment variable template
├── bin/
│ └── mail_mcp # CLI: `generate` (client_id) and `server` (puma)
├── config.ru # Rack entry point — run MailMCP::App.new
├── config/
│ └── puma.rb # Puma config (single worker, 5 threads)
├── lib/
│ ├── mail_mcp.rb # Module root + requires
│ └── mail_mcp/
│ ├── jwt_service.rb # All JWE tokens (access, refresh, client_id)
│ ├── pkce.rb # PKCE S256 challenge/verify
│ ├── credential_context.rb # Struct passed as MCP server_context per request
│ ├── imap_client.rb # net-imap wrapper
│ ├── smtp_client.rb # net-smtp wrapper
│ ├── attachment_store.rb # S3 upload + presigned URLs (7 days)
│ ├── tool.rb # MailMCP::Tool base class
│ ├── app.rb # Sinatra: OAuth + MCP /mcp route (all methods)
│ └── tools/*.rb # MCP tool classes
├── views/
│ └── login.erb # Login form (username + password only)
├── spec/ # RSpec test suite
└── Dockerfile
Copy .env.sample to .env and fill in the values:
| Variable | Description |
|---|---|
BASE_URL |
Public URL of this server, e.g. https://mail.mcp.example.com |
ENCRYPTION_KEY |
AES-256 key (base64-encoded 32 bytes) — used for all JWE tokens |
AWS_ACCESS_KEY_ID |
AWS credentials for S3 attachment storage |
AWS_SECRET_ACCESS_KEY |
AWS credentials for S3 attachment storage |
AWS_REGION |
S3 bucket region, e.g. us-east-1 |
AWS_S3_BUCKET |
S3 bucket name for attachments |
PORT |
HTTP port (default 3000) |
RACK_ENV |
development or production |
MAIL_MCP_LOG_LEVEL |
Log level (DEBUG, INFO, WARN, ERROR, default INFO) |
Generate ENCRYPTION_KEY:
ruby -e "require 'base64','securerandom'; puts Base64.strict_encode64(SecureRandom.bytes(32))"IMAP/SMTP host and port are embedded in the client_id JWE and are never passed as headers or query parameters.
# Install dependencies
bundle install
# Copy and edit environment variables
cp .env.sample .env
$EDITOR .env
# Run tests
bundle exec rspec
# Start the server
bundle exec puma -C config/puma.rbRun bin/mail_mcp generate once per mail server configuration. The resulting client_id and client_secret are configured in the MCP client (e.g. Claude Desktop).
bundle exec bin/mail_mcp generate \
--imap-host=imap.gmail.com \
--imap-port=993 \
--smtp-host=smtp.gmail.com \
--smtp-port=465
# Client ID: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0...<encrypted>
# Client Secret: 713576e2f94802b9d9abfd755e38e29b63e491df...
#
# IMAP: imap.gmail.com:993 (ssl=true)
# SMTP: smtp.gmail.com:465 (ssl=true)| Flag | Default | Description |
|---|---|---|
--imap-host=HOST |
required | IMAP server hostname |
--imap-port=PORT |
993 |
IMAP port |
--[no-]imap-ssl |
true when port 993 |
Enable SSL/TLS for IMAP |
--smtp-host=HOST |
required | SMTP server hostname |
--smtp-port=PORT |
465 |
SMTP port |
--[no-]smtp-ssl |
true when port 465 |
Enable SSL/TLS for SMTP |
- Discovery — client fetches
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server - Authorization — client redirects user to
/oauth/authorize?client_id=<jwe>&code_challenge=<s256>&... - Login — server decrypts
client_idJWE to get IMAP/SMTP hosts; user enters credentials; server validates both connections live - Code exchange —
POST /oauth/tokenwithgrant_type=authorization_code,code,code_verifier,client_id,client_secret; server issuesaccess_token+refresh_token - Token refresh —
POST /oauth/tokenwithgrant_type=refresh_token,refresh_token,client_id,client_secret; server issues a newaccess_token+refresh_token - MCP calls — client sends
Authorization: Bearer <access_token>on every request; server decrypts credentials per-request via a stateless per-request MCP server
| Tool | Parameters | Description |
|---|---|---|
list_mailboxes |
— | List all IMAP folders |
list_mail_messages |
folder, page, per_page |
List messages with pagination |
get_mail_message |
folder, uid |
Fetch full message; attachments uploaded to S3 and returned as presigned URLs |
search_mail_messages |
folder, query |
Raw IMAP SEARCH criteria, e.g. UNSEEN or FROM alice@example.com SINCE 01-Jan-2025 |
send_mail_message |
to, subject, text_body, cc, bcc, html_body, attachment_urls, folder |
Send via SMTP and append to the Sent folder via IMAP; attachments fetched from S3 presigned URLs |
create_draft_mail_message |
to, subject, text_body, cc, bcc, html_body, attachment_urls, folder |
Append to Drafts via IMAP APPEND; attachments fetched from S3 presigned URLs |
delete_mail_message |
folder, uid |
Mark \Deleted + EXPUNGE |
move_mail_message |
folder, uid, destination |
IMAP MOVE (or COPY+DELETE fallback) |
update_mail_message_flags |
folder, uid, add, remove |
Add/remove IMAP flags, e.g. \Seen, \Flagged |
Attachments are never returned as binary data — they are uploaded to S3 on first access and returned as presigned URLs valid for 7 days.
docker build -t mail_mcp .
docker run -p 3000:3000 --env-file .env mail_mcp