mail_mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 52c052a557e253241811bc0b5342a6980846ab1475761ea9b337410ed5488ed3
4
+ data.tar.gz: 4e8ab0e13a25ba3ab8eea26228a55d284bcd2dc4ca403a42e695585e56253643
5
+ SHA512:
6
+ metadata.gz: 5f47ac7a7114328a2cd8b10febe15fcd1d742f0d3ebdbbcb48556ce9a71ff7ca65af36ce5edeafc719bc8a279dbc4366132d1e56fe514347d7d16dbd952d30c0
7
+ data.tar.gz: 03f3ae4b06d27920d667f767757eaa6f2bcaf7bc0955f8774f4f6fe457b29247760ea65203e078979025c3420b4ce5ca27f055a73e629e1807582e1791aba555
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Mitigate Mail MCP
2
+
3
+ A hosted [Model Context Protocol](https://modelcontextprotocol.io/) server for IMAP and SMTP email, built in Ruby. It acts as both an OAuth 2.1 Authorization Server and an MCP Resource Server.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Client (Claude Desktop / MCP Inspector)
9
+
10
+ ├─ OAuth 2.1 flow
11
+ │ GET /.well-known/oauth-protected-resource RFC 9728 metadata
12
+ │ GET /.well-known/oauth-authorization-server RFC 8414 metadata
13
+ │ GET /oauth/authorize Login UI
14
+ │ POST /oauth/authorize Validate IMAP/SMTP → issue code
15
+ │ POST /oauth/token Code + PKCE + client_secret → tokens
16
+ │ (also: refresh_token grant)
17
+
18
+ └─ MCP calls (all HTTP methods)
19
+ /mcp Bearer access token → decrypt creds → IMAP/SMTP operations
20
+ ```
21
+
22
+ ### How authentication works
23
+
24
+ Clients are provisioned once via the `bin/mail_mcp generate` CLI, which produces:
25
+
26
+ - **`client_id`** — a JWE token (encrypted, opaque) encoding the IMAP/SMTP server configuration and the `client_secret`. Only the server can decrypt it.
27
+ - **`client_secret`** — a random secret used to authenticate the client at the token endpoint.
28
+
29
+ The OAuth flow:
30
+
31
+ 1. Client redirects the user to `/oauth/authorize?client_id=<jwe>&...`
32
+ 2. Server decrypts the `client_id` JWE to learn which IMAP/SMTP servers to connect to
33
+ 3. User enters their IMAP/SMTP username and password in the login form
34
+ 4. Server validates both connections live; shows an error banner on failure
35
+ 5. On success: credentials are encrypted directly in a JWE access token and a JWE refresh token
36
+ 6. Client exchanges the authorization code (`POST /oauth/token`) with `client_id` + `client_secret`; receives `access_token` + `refresh_token`
37
+ 7. Every MCP request carries `Authorization: Bearer <access_token>`; the server decrypts credentials per-request
38
+
39
+ ### Token formats
40
+
41
+ All tokens are **5-part JWE** (`dir` / `A256GCM`), encrypted with `ENCRYPTION_KEY`. There is no separate signing key.
42
+
43
+ | Token | `typ` claim | Expiry | Contents |
44
+ |---|---|---|---|
45
+ | `client_id` | `client_id` | none | imap/smtp host+port, `client_secret` |
46
+ | Access token | `access` | 8 hours | IMAP + SMTP credentials |
47
+ | Refresh token | `refresh` | 30 days | IMAP + SMTP credentials |
48
+
49
+ ## Directory Structure
50
+
51
+ ```
52
+ mail_mcp/
53
+ ├── Gemfile
54
+ ├── .env.sample # Environment variable template
55
+ ├── bin/
56
+ │ └── mail_mcp # CLI: `generate` (client_id) and `server` (puma)
57
+ ├── config.ru # Rack entry point — run MailMCP::App.new
58
+ ├── config/
59
+ │ └── puma.rb # Puma config (single worker, 5 threads)
60
+ ├── lib/
61
+ │ ├── mail_mcp.rb # Module root + requires
62
+ │ └── mail_mcp/
63
+ │ ├── jwt_service.rb # All JWE tokens (access, refresh, client_id)
64
+ │ ├── pkce.rb # PKCE S256 challenge/verify
65
+ │ ├── credential_context.rb # Struct passed as MCP server_context per request
66
+ │ ├── imap_client.rb # net-imap wrapper
67
+ │ ├── smtp_client.rb # net-smtp wrapper
68
+ │ ├── attachment_store.rb # S3 upload + presigned URLs (7 days)
69
+ │ ├── tool.rb # MailMCP::Tool base class
70
+ │ ├── app.rb # Sinatra: OAuth + MCP /mcp route (all methods)
71
+ │ └── tools/*.rb # MCP tool classes
72
+ ├── views/
73
+ │ └── login.erb # Login form (username + password only)
74
+ ├── spec/ # RSpec test suite
75
+ └── Dockerfile
76
+ ```
77
+
78
+ ## Configuration
79
+
80
+ Copy `.env.sample` to `.env` and fill in the values:
81
+
82
+ | Variable | Description |
83
+ |---|---|
84
+ | `BASE_URL` | Public URL of this server, e.g. `https://mail.mcp.mitigate.dev` |
85
+ | `ENCRYPTION_KEY` | AES-256 key (base64-encoded 32 bytes) — used for **all** JWE tokens |
86
+ | `AWS_ACCESS_KEY_ID` | AWS credentials for S3 attachment storage |
87
+ | `AWS_SECRET_ACCESS_KEY` | AWS credentials for S3 attachment storage |
88
+ | `AWS_REGION` | S3 bucket region, e.g. `us-east-1` |
89
+ | `AWS_S3_BUCKET` | S3 bucket name for attachments |
90
+ | `PORT` | HTTP port (default `3000`) |
91
+ | `RACK_ENV` | `development` or `production` |
92
+
93
+ Generate `ENCRYPTION_KEY`:
94
+ ```bash
95
+ ruby -e "require 'base64','securerandom'; puts Base64.strict_encode64(SecureRandom.bytes(32))"
96
+ ```
97
+
98
+ IMAP/SMTP host and port are embedded in the `client_id` JWE and are never passed as headers or query parameters.
99
+
100
+ ## Setup
101
+
102
+ ```bash
103
+ # Install dependencies
104
+ bundle install
105
+
106
+ # Copy and edit environment variables
107
+ cp .env.sample .env
108
+ $EDITOR .env
109
+
110
+ # Run tests
111
+ bundle exec rspec
112
+
113
+ # Start the server
114
+ bundle exec puma -C config/puma.rb
115
+ ```
116
+
117
+ ## Provisioning a Client
118
+
119
+ Run `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).
120
+
121
+ ```bash
122
+ bundle exec bin/mail_mcp generate \
123
+ --imap-host=imap.gmail.com \
124
+ --imap-port=993 \
125
+ --smtp-host=smtp.gmail.com \
126
+ --smtp-port=587
127
+
128
+ # Client ID: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0...<encrypted>
129
+ # Client Secret: 713576e2f94802b9d9abfd755e38e29b63e491df...
130
+ #
131
+ # IMAP: imap.gmail.com:993 (ssl=true)
132
+ # SMTP: smtp.gmail.com:587 (ssl=false)
133
+ ```
134
+
135
+ | Flag | Default | Description |
136
+ |---|---|---|
137
+ | `--imap-host=HOST` | required | IMAP server hostname |
138
+ | `--imap-port=PORT` | `993` | IMAP port |
139
+ | `--[no-]imap-ssl` | `true` when port 993 | Enable SSL/TLS for IMAP |
140
+ | `--smtp-host=HOST` | required | SMTP server hostname |
141
+ | `--smtp-port=PORT` | `587` | SMTP port |
142
+ | `--[no-]smtp-ssl` | `true` when port 465 | Enable SSL/TLS for SMTP |
143
+
144
+ ## OAuth 2.1 Flow
145
+
146
+ 1. **Discovery** — client fetches `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`
147
+ 2. **Authorization** — client redirects user to `/oauth/authorize?client_id=<jwe>&code_challenge=<s256>&...`
148
+ 3. **Login** — server decrypts `client_id` JWE to get IMAP/SMTP hosts; user enters credentials; server validates both connections live
149
+ 4. **Code exchange** — `POST /oauth/token` with `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `client_secret`; server issues `access_token` + `refresh_token`
150
+ 5. **Token refresh** — `POST /oauth/token` with `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`; server issues a new `access_token` + `refresh_token`
151
+ 6. **MCP calls** — client sends `Authorization: Bearer <access_token>` on every request; server decrypts credentials per-request via a stateless per-request MCP server
152
+
153
+ ## MCP Tools
154
+
155
+ | Tool | Parameters | Description |
156
+ |---|---|---|
157
+ | `list_mailboxes` | — | List all IMAP folders |
158
+ | `list_mail_messages` | `folder`, `page`, `per_page` | List messages with pagination |
159
+ | `get_mail_message` | `folder`, `uid` | Fetch full message; attachments uploaded to S3 and returned as presigned URLs |
160
+ | `search_mail_messages` | `folder`, `query` | Raw IMAP SEARCH criteria, e.g. `UNSEEN` or `FROM alice@example.com SINCE 01-Jan-2025` |
161
+ | `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 |
162
+ | `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 |
163
+ | `delete_mail_message` | `folder`, `uid` | Mark `\Deleted` + EXPUNGE |
164
+ | `move_mail_message` | `folder`, `uid`, `destination` | IMAP MOVE (or COPY+DELETE fallback) |
165
+ | `update_mail_message_flags` | `folder`, `uid`, `add`, `remove` | Add/remove IMAP flags, e.g. `\Seen`, `\Flagged` |
166
+
167
+ Attachments are never returned as binary data — they are uploaded to S3 on first access and returned as presigned URLs valid for 7 days.
168
+
169
+ ## Docker
170
+
171
+ ```bash
172
+ docker build -t mail_mcp .
173
+ docker run -p 3000:3000 --env-file .env mail_mcp
174
+ ```
data/bin/mail_mcp ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ require "optparse"
3
+
4
+ COMMANDS = {
5
+ "generate" => "Generate a new client_id and client_secret",
6
+ "server" => "Start the MCP server (puma)"
7
+ }.freeze
8
+
9
+ def print_usage
10
+ warn "Usage: mail_mcp <command> [options]"
11
+ warn ""
12
+ warn "Commands:"
13
+ COMMANDS.each { |name, desc| warn format(" %-10<name>s %<desc>s", name: name, desc: desc) }
14
+ warn ""
15
+ warn "Run `mail_mcp <command> --help` for command-specific options."
16
+ end
17
+
18
+ def load_app_env
19
+ if ENV["RACK_ENV"] != "production"
20
+ require "dotenv"
21
+ Dotenv.load(File.expand_path("../.env", __dir__))
22
+ end
23
+ require_relative "../lib/mail_mcp"
24
+ end
25
+
26
+ def generate_option_parser(options)
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: mail_mcp generate [options]"
29
+ opts.separator ""
30
+ opts.separator "Required:"
31
+ opts.on("--imap-host=HOST", "IMAP server hostname, e.g. imap.gmail.com")
32
+ opts.on("--imap-port=PORT", Integer, "IMAP port (default: 993)")
33
+ opts.on("--smtp-host=HOST", "SMTP server hostname, e.g. smtp.gmail.com")
34
+ opts.on("--smtp-port=PORT", Integer, "SMTP port (default: 587)")
35
+ opts.separator ""
36
+ opts.separator "Optional:"
37
+ opts.on("--[no-]imap-ssl", "Enable SSL/TLS for IMAP (default: true when port 993)")
38
+ opts.on("--[no-]smtp-ssl", "Enable SSL/TLS for SMTP (default: true when port 465)")
39
+ opts.separator ""
40
+ opts.on("-h", "--help", "Show this help") do
41
+ puts opts
42
+ exit
43
+ end
44
+ opts.parse!(ARGV, into: options)
45
+ end
46
+ end
47
+
48
+ def normalize_generate_options(options, parser)
49
+ imap_host = options[:"imap-host"]
50
+ smtp_host = options[:"smtp-host"]
51
+ unless imap_host && smtp_host
52
+ warn "Error: --imap-host and --smtp-host are required"
53
+ warn ""
54
+ warn parser
55
+ exit 1
56
+ end
57
+
58
+ imap_port = options[:"imap-port"] || 993
59
+ smtp_port = options[:"smtp-port"] || 587
60
+ {
61
+ imap_host: imap_host,
62
+ imap_port: imap_port,
63
+ imap_ssl: options.fetch(:"imap-ssl", imap_port == 993),
64
+ smtp_host: smtp_host,
65
+ smtp_port: smtp_port,
66
+ smtp_ssl: options.fetch(:"smtp-ssl", smtp_port == 465)
67
+ }
68
+ end
69
+
70
+ def print_generated_client(opts, client_id, client_secret)
71
+ puts ""
72
+ puts " Client ID: #{client_id}"
73
+ puts " Client Secret: #{client_secret}"
74
+ puts ""
75
+ puts " IMAP: #{opts[:imap_host]}:#{opts[:imap_port]} (ssl=#{opts[:imap_ssl]})"
76
+ puts " SMTP: #{opts[:smtp_host]}:#{opts[:smtp_port]} (ssl=#{opts[:smtp_ssl]})"
77
+ puts ""
78
+ end
79
+
80
+ def run_generate
81
+ load_app_env
82
+ options = {}
83
+ parser = generate_option_parser(options)
84
+ opts = normalize_generate_options(options, parser)
85
+
86
+ client_secret = SecureRandom.hex(32)
87
+ client_id = MailMCP::JwtService.issue_client_id(**opts, client_secret: client_secret)
88
+ print_generated_client(opts, client_id, client_secret)
89
+ end
90
+
91
+ def run_server(argv)
92
+ require "puma/cli"
93
+ config = File.expand_path("../config/puma.rb", __dir__)
94
+ rackup = File.expand_path("../config.ru", __dir__)
95
+ Puma::CLI.new(["-C", config, rackup, *argv]).run
96
+ end
97
+
98
+ command = ARGV.shift
99
+
100
+ case command
101
+ when "generate"
102
+ run_generate
103
+ when "server"
104
+ run_server(ARGV)
105
+ when "-h", "--help", nil
106
+ print_usage
107
+ exit(command.nil? ? 1 : 0)
108
+ else
109
+ warn "Unknown command: #{command}"
110
+ warn ""
111
+ print_usage
112
+ exit 1
113
+ end
data/config/puma.rb ADDED
@@ -0,0 +1,8 @@
1
+ port ENV.fetch("PORT", 3000)
2
+ environment ENV.fetch("RACK_ENV", "development")
3
+
4
+ # StreamableHTTPTransport requires a single worker (sessions are in-memory)
5
+ workers 0
6
+ threads 1, 5
7
+
8
+ preload_app!
data/config.ru ADDED
@@ -0,0 +1,8 @@
1
+ if ENV["RACK_ENV"] != "production"
2
+ require "dotenv"
3
+ Dotenv.load
4
+ end
5
+
6
+ require_relative "lib/mail_mcp"
7
+
8
+ run MailMCP::App.new
@@ -0,0 +1,281 @@
1
+ require "sinatra/base"
2
+ require "sinatra/json"
3
+ require "sinatra/multi_route"
4
+ require "mcp"
5
+ require "uri"
6
+
7
+ module MailMCP
8
+ class App < Sinatra::Base
9
+ register Sinatra::MultiRoute
10
+
11
+ set :views, File.expand_path("../../views", __dir__)
12
+ set :public_folder, false
13
+ set :logging, true
14
+
15
+ MCP_TOOLS = [
16
+ ListMailboxesTool,
17
+ ListMailMessagesTool,
18
+ GetMailMessageTool,
19
+ SearchMailMessagesTool,
20
+ SendMailMessageTool,
21
+ CreateDraftMailMessageTool,
22
+ DeleteMailMessageTool,
23
+ MoveMailMessageTool,
24
+ UpdateMailMessageFlagsTool
25
+ ].freeze
26
+
27
+ # ── MCP ──────────────────────────────────────────────────────────────────
28
+
29
+ route :head, :delete, :get, :options, :patch, :post, :put, "/mcp" do
30
+ server_context, error_response = resolve_mcp_context
31
+ halt(*error_response) if error_response
32
+
33
+ mcp_server = MCP::Server.new(
34
+ name: "mail_mcp",
35
+ version: "1.0.0",
36
+ description: "IMAP and SMTP mail server for AI agents",
37
+ tools: MCP_TOOLS,
38
+ server_context: server_context
39
+ )
40
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, stateless: true)
41
+ status_code, resp_headers, body = transport.call(env)
42
+ halt status_code, resp_headers, body
43
+ end
44
+
45
+ # ── OAuth 2.1 Discovery ───────────────────────────────────────────────────
46
+
47
+ get "/.well-known/oauth-protected-resource" do
48
+ content_type :json
49
+ JSON.generate({
50
+ resource: base_url,
51
+ authorization_servers: [base_url]
52
+ })
53
+ end
54
+
55
+ get "/.well-known/oauth-authorization-server" do
56
+ content_type :json
57
+ JSON.generate({
58
+ issuer: base_url,
59
+ authorization_endpoint: "#{base_url}/oauth/authorize",
60
+ token_endpoint: "#{base_url}/oauth/token",
61
+ response_types_supported: ["code"],
62
+ grant_types_supported: ["authorization_code"],
63
+ code_challenge_methods_supported: ["S256"]
64
+ })
65
+ end
66
+
67
+ # ── Authorization (Login UI) ──────────────────────────────────────────────
68
+
69
+ get "/oauth/authorize" do
70
+ client_config = decode_client_id!(params[:client_id])
71
+ return if client_config.nil?
72
+
73
+ @params = {
74
+ client_id: params[:client_id],
75
+ redirect_uri: params[:redirect_uri],
76
+ state: params[:state],
77
+ code_challenge: params[:code_challenge],
78
+ code_challenge_method: params[:code_challenge_method],
79
+ imap_host: client_config["imap_host"],
80
+ smtp_host: client_config["smtp_host"],
81
+ error: nil
82
+ }
83
+ erb :login
84
+ end
85
+
86
+ post "/oauth/authorize" do
87
+ client_id = params[:client_id]
88
+ redirect_uri = params[:redirect_uri]
89
+ state = params[:state]
90
+ code_challenge = params[:code_challenge]
91
+ code_challenge_method = params[:code_challenge_method]
92
+
93
+ client_config = decode_client_id!(client_id)
94
+ return if client_config.nil?
95
+
96
+ imap_host = client_config["imap_host"]
97
+ imap_port = client_config["imap_port"]
98
+ imap_ssl = client_config["imap_ssl"]
99
+ imap_user = params[:imap_username].to_s.strip
100
+ imap_pass = params[:imap_password].to_s
101
+
102
+ use_same = params[:use_same_credentials] == "1"
103
+ smtp_host = client_config["smtp_host"]
104
+ smtp_port = client_config["smtp_port"]
105
+ smtp_ssl = client_config["smtp_ssl"]
106
+ smtp_user = use_same ? imap_user : params[:smtp_username].to_s.strip
107
+ smtp_pass = use_same ? imap_pass : params[:smtp_password].to_s
108
+
109
+ errors = []
110
+
111
+ imap_cfg = { host: imap_host, port: imap_port, ssl: imap_ssl, username: imap_user, password: imap_pass }
112
+ begin
113
+ ImapClient.validate!(imap_cfg)
114
+ rescue ImapClient::AuthError, ImapClient::ConnectionError => e
115
+ errors << "IMAP: #{e.message}"
116
+ end
117
+
118
+ smtp_cfg = { host: smtp_host, port: smtp_port, ssl: smtp_ssl, username: smtp_user, password: smtp_pass }
119
+ begin
120
+ SmtpClient.validate!(smtp_cfg)
121
+ rescue SmtpClient::ConnectionError => e
122
+ errors << "SMTP: #{e.message}"
123
+ end
124
+
125
+ unless errors.empty?
126
+ @params = {
127
+ client_id: client_id, redirect_uri: redirect_uri, state: state,
128
+ code_challenge: code_challenge, code_challenge_method: code_challenge_method,
129
+ imap_host: imap_host, smtp_host: smtp_host,
130
+ error: errors.join(". ")
131
+ }
132
+ return erb :login
133
+ end
134
+
135
+ creds = {
136
+ "imap_host" => imap_host, "imap_port" => imap_port, "imap_ssl" => imap_ssl,
137
+ "imap_username" => imap_user, "imap_password" => imap_pass,
138
+ "smtp_host" => smtp_host, "smtp_port" => smtp_port, "smtp_ssl" => smtp_ssl,
139
+ "smtp_username" => smtp_user, "smtp_password" => smtp_pass
140
+ }
141
+
142
+ code = JwtService.issue_code(
143
+ creds: creds,
144
+ code_challenge: code_challenge,
145
+ redirect_uri: redirect_uri,
146
+ client_id: client_id
147
+ )
148
+
149
+ redirect_to = URI.parse(redirect_uri)
150
+ query = URI.decode_www_form(redirect_to.query.to_s)
151
+ query << ["code", code]
152
+ query << ["state", state] if state
153
+ redirect_to.query = URI.encode_www_form(query)
154
+ redirect redirect_to.to_s
155
+ end
156
+
157
+ # ── Token Exchange ────────────────────────────────────────────────────────
158
+
159
+ post "/oauth/token" do
160
+ content_type :json
161
+
162
+ grant_type = params[:grant_type]
163
+ client_id = params[:client_id]
164
+ client_secret = params[:client_secret]
165
+
166
+ client_config = begin
167
+ JwtService.decode_client_id(client_id.to_s)
168
+ rescue JwtService::Error
169
+ halt 401, JSON.generate({ error: "invalid_client", error_description: "Invalid client_id" })
170
+ end
171
+
172
+ unless client_secret.to_s == client_config["cs"]
173
+ halt 401, JSON.generate({ error: "invalid_client", error_description: "Invalid client_secret" })
174
+ end
175
+
176
+ creds = case grant_type
177
+ when "authorization_code"
178
+ exchange_code(params, client_id)
179
+ when "refresh_token"
180
+ exchange_refresh_token(params[:refresh_token])
181
+ else
182
+ halt 400, JSON.generate({ error: "unsupported_grant_type" })
183
+ end
184
+
185
+ token_response(creds)
186
+ end
187
+
188
+ # ── Health ────────────────────────────────────────────────────────────────
189
+
190
+ get "/health" do
191
+ content_type :json
192
+ JSON.generate({ status: "ok" })
193
+ end
194
+
195
+ not_found do
196
+ content_type :json
197
+ JSON.generate({ error: "not_found" })
198
+ end
199
+
200
+ private
201
+
202
+ def resolve_mcp_context
203
+ auth = request.env["HTTP_AUTHORIZATION"]
204
+ return [nil, nil] unless auth&.start_with?("Bearer ")
205
+
206
+ creds = JwtService.verify(auth[7..])
207
+ context = CredentialContext.new(
208
+ imap_config: {
209
+ host: creds["imap_host"], port: creds["imap_port"].to_i,
210
+ ssl: creds["imap_ssl"], username: creds["imap_username"], password: creds["imap_password"]
211
+ },
212
+ smtp_config: {
213
+ host: creds["smtp_host"], port: creds["smtp_port"].to_i,
214
+ ssl: creds["smtp_ssl"], username: creds["smtp_username"], password: creds["smtp_password"]
215
+ }
216
+ )
217
+ [context, nil]
218
+ rescue JwtService::Error => e
219
+ error = [
220
+ 401,
221
+ { "Content-Type" => "application/json", "WWW-Authenticate" => mcp_www_authenticate },
222
+ [JSON.generate({ error: "invalid_token", error_description: e.message })]
223
+ ]
224
+ [nil, error]
225
+ end
226
+
227
+ def exchange_code(params, client_id)
228
+ code = params[:code]
229
+ code_verifier = params[:code_verifier]
230
+ redirect_uri = params[:redirect_uri]
231
+
232
+ payload = begin
233
+ JwtService.verify_code(code.to_s)
234
+ rescue JwtService::Error => e
235
+ halt 400, JSON.generate({ error: "invalid_grant", error_description: e.message })
236
+ end
237
+
238
+ if payload["redirect_uri"] != redirect_uri
239
+ halt 400,
240
+ JSON.generate({ error: "invalid_grant",
241
+ error_description: "redirect_uri mismatch" })
242
+ end
243
+ halt 400, JSON.generate({ error: "invalid_client" }) if payload["client_id"] != client_id
244
+ unless Pkce.valid?(verifier: code_verifier.to_s, challenge: payload["code_challenge"].to_s)
245
+ halt 400, JSON.generate({ error: "invalid_grant", error_description: "PKCE verification failed" })
246
+ end
247
+
248
+ payload.slice(*JwtService::CRED_KEYS)
249
+ end
250
+
251
+ def exchange_refresh_token(refresh_token)
252
+ JwtService.verify_refresh(refresh_token.to_s)
253
+ rescue JwtService::Error => e
254
+ halt 400, JSON.generate({ error: "invalid_grant", error_description: e.message })
255
+ end
256
+
257
+ def token_response(creds)
258
+ JSON.generate({
259
+ access_token: JwtService.issue(creds),
260
+ refresh_token: JwtService.issue_refresh(creds),
261
+ token_type: "Bearer",
262
+ expires_in: JwtService::DEFAULT_EXPIRY
263
+ })
264
+ end
265
+
266
+ def mcp_www_authenticate
267
+ "Bearer resource_metadata=\"#{base_url}/.well-known/oauth-protected-resource\""
268
+ end
269
+
270
+ def base_url
271
+ ENV.fetch("BASE_URL")
272
+ end
273
+
274
+ def decode_client_id!(client_id)
275
+ JwtService.decode_client_id(client_id.to_s)
276
+ rescue JwtService::Error => e
277
+ halt 400, "Invalid client_id: #{e.message}"
278
+ nil
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,32 @@
1
+ require "aws-sdk-s3"
2
+ require "securerandom"
3
+
4
+ module MailMCP
5
+ module AttachmentStore
6
+ EXPIRY = 7 * 24 * 3600
7
+
8
+ def self.upload(content:, filename:, content_type:)
9
+ key = "attachments/#{SecureRandom.uuid}/#{filename}"
10
+ bucket = ENV.fetch("AWS_S3_BUCKET")
11
+
12
+ s3.put_object(
13
+ bucket: bucket,
14
+ key: key,
15
+ body: content,
16
+ content_type: content_type
17
+ )
18
+
19
+ presigner.presigned_url(:get_object, bucket: bucket, key: key, expires_in: EXPIRY)
20
+ end
21
+
22
+ def self.s3
23
+ Aws::S3::Client.new
24
+ end
25
+ private_class_method :s3
26
+
27
+ def self.presigner
28
+ Aws::S3::Presigner.new(client: s3)
29
+ end
30
+ private_class_method :presigner
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ module MailMCP
2
+ # Immutable credential value object passed as server_context to each per-request MCP server.
3
+ # MCP::ServerContext delegates imap_config / smtp_config to this via method_missing.
4
+ CredentialContext = Struct.new(:imap_config, :smtp_config, keyword_init: true)
5
+ end