Module: Mailmate::MCP
Overview
Stdio MCP server (JSON-RPC 2.0, line-delimited). Exposes the gem’s CLIs —search, message, modify, send — plus a resolve_id helper that round-trips between local eml-id, RFC Message-ID, and the cross-machine message:// URL.
In-process: each tool call runs the corresponding ‘Mailmate::CLI::*.run` method with a synthesized argv, capturing stdout/stderr from the existing CLI rather than re-implementing each command.
Constant Summary collapse
- PROTOCOL_VERSION =
"2024-11-05"- SERVER_NAME =
"mailmate"- TOOLS =
[ { name: "search", description: <<~DESC.strip, inputSchema: { type: "object", properties: { query: { type: "string", description: "Quicksearch expression. Empty string disables filtering. Default: 'd 1d' (today).", }, fields: { type: "string", description: "Space-separated columns. Prefix with '+' to add to defaults. Available: id path mailbox from to cc bcc reply-to subject date time message-id references in-reply-to direction party flags read archive tags keywords.", }, mailbox: { type: "string", description: "Account, mailbox path, or smart-mailbox name. Default: all.", }, limit: { type: "integer", description: "Stop after N matches." }, headers_only: { type: "boolean", description: "Skip body matching (much faster on text searches)." }, sort: { type: "string", enum: %w[asc desc none], description: "Sort by date+time. Default: asc." }, }, additionalProperties: false, }, }, { name: "message", description: "Read one MailMate message. Accepts either local eml-id (digits) or RFC Message-ID (with or without angle brackets). Default output: headers block + plain-text body.", inputSchema: { type: "object", properties: { id: { type: "string", description: "eml-id (e.g. '183715') or RFC Message-ID (e.g. '<abc@example.com>')." }, raw: { type: "boolean", description: "Return raw .eml bytes." }, text_only: { type: "boolean", description: "Body only, no headers block." }, }, required: ["id"], additionalProperties: false, }, }, { name: "modify", description: <<~DESC.strip, inputSchema: { type: "object", properties: { id: { type: "string", description: "eml-id or RFC Message-ID." }, actions: { type: "array", items: { type: "string" }, description: "Flat list of action tokens; arg-taking actions consume the following item.", }, dry_run: { type: "boolean", description: "Print plan, don't execute." }, verify: { type: "boolean", description: "Re-read flags after acting to confirm." }, keep_window: { type: "boolean", description: "Skip the close-window keystroke at the end." }, }, required: %w[id actions], additionalProperties: false, }, }, { name: "send", description: "Send mail via MailMate's `emate` (markdown body). Recipients and subject via fields; body is the markdown source.", inputSchema: { type: "object", properties: { to: { type: "string", description: "Recipient(s), comma-separated." }, cc: { type: "string", description: "CC recipient(s), comma-separated." }, bcc: { type: "string", description: "BCC recipient(s), comma-separated." }, subject: { type: "string", description: "Subject line." }, body: { type: "string", description: "Markdown body." }, attachments: { type: "array", items: { type: "string" }, description: "Absolute paths to files to attach." }, send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." }, }, required: %w[to subject body], additionalProperties: false, }, }, { name: "open", description: "Open one MailMate message in MailMate's UI (activates the window). Accepts any of the id forms `resolve_id` takes. Read-side semantically, but does shift focus to MailMate.", inputSchema: { type: "object", properties: { id: { type: "string", description: "eml-id, RFC Message-ID, message://… URL, or mid:… URL." }, print_only: { type: "boolean", description: "Return the mid: URL without invoking `open`." }, }, required: ["id"], additionalProperties: false, }, }, { name: "list_mailboxes", description: "Enumerate accounts, IMAP mailboxes (with optional message counts), and smart mailboxes MailMate has defined. Account names are decoded for display (`%40` → `@`).", inputSchema: { type: "object", properties: { count: { type: "boolean", description: "Include .eml counts per IMAP mailbox (default true; pass false to skip for speed)." }, csv: { type: "boolean", description: "Flat CSV output (one row per mailbox); default is grouped by account." }, }, additionalProperties: false, }, }, { name: "list_tags", description: "List user tags. Default: tags actually applied to messages, with usage counts (from MailMate's #flags index; system flags excluded). `defined: true`: tags MailMate has registered in Preferences → Tags (from Tags.plist).", inputSchema: { type: "object", properties: { defined: { type: "boolean", description: "Read from Tags.plist (defined tags) instead of scanning #flags (used tags)." }, }, additionalProperties: false, }, }, { name: "resolve_id", description: <<~DESC.strip, inputSchema: { type: "object", properties: { id: { type: "string", description: "eml-id, Message-ID, or message:// URL." }, }, required: ["id"], additionalProperties: false, }, }, ].freeze
Instance Method Summary collapse
- #call_list_mailboxes(args) ⇒ Object
- #call_list_tags(args) ⇒ Object
- #call_message(args) ⇒ Object
- #call_modify(args) ⇒ Object
- #call_open(args) ⇒ Object
- #call_resolve(args) ⇒ Object
-
#call_search(args) ⇒ Object
—- tool handlers —————————————————-.
- #call_send(args) ⇒ Object
- #dispatch(name, args) ⇒ Object
- #handle(msg, stdout) ⇒ Object
- #jsonrpc_error(id, code, message) ⇒ Object
- #jsonrpc_result(id, result) ⇒ Object
- #run(stdin: $stdin, stdout: $stdout) ⇒ Object
-
#run_cli(mod, argv) ⇒ Object
—- protocol helpers ————————————————-.
- #text_error(msg) ⇒ Object
- #with_captured_io ⇒ Object
- #with_stdin(text) ⇒ Object
- #write(stdout, obj) ⇒ Object
Instance Method Details
#call_list_mailboxes(args) ⇒ Object
308 309 310 311 312 313 |
# File 'lib/mailmate/mcp.rb', line 308 def call_list_mailboxes(args) argv = [] argv << "--no-count" if args.key?("count") && !args["count"] argv << "--csv" if args["csv"] run_cli(Mailmate::CLI::Mailboxes, argv) end |
#call_list_tags(args) ⇒ Object
315 316 317 318 319 |
# File 'lib/mailmate/mcp.rb', line 315 def (args) argv = [] argv << "--defined" if args["defined"] run_cli(Mailmate::CLI::Tags, argv) end |
#call_message(args) ⇒ Object
276 277 278 279 280 281 |
# File 'lib/mailmate/mcp.rb', line 276 def (args) argv = [args["id"].to_s] argv << "--raw" if args["raw"] argv << "--text-only" if args["text_only"] run_cli(Mailmate::CLI::Message, argv) end |
#call_modify(args) ⇒ Object
283 284 285 286 287 288 289 |
# File 'lib/mailmate/mcp.rb', line 283 def call_modify(args) argv = [args["id"].to_s] + Array(args["actions"]).map(&:to_s) argv << "--dry-run" if args["dry_run"] argv << "--verify" if args["verify"] argv << "--keep-window" if args["keep_window"] run_cli(Mailmate::CLI::Modify, argv) end |
#call_open(args) ⇒ Object
302 303 304 305 306 |
# File 'lib/mailmate/mcp.rb', line 302 def call_open(args) argv = [args["id"].to_s] argv << "--print" if args["print_only"] run_cli(Mailmate::CLI::Open, argv) end |
#call_resolve(args) ⇒ Object
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 |
# File 'lib/mailmate/mcp.rb', line 321 def call_resolve(args) eml_id = Mailmate::EmlLookup.resolve_id(args["id"].to_s) return text_error("Not found: #{args["id"].inspect}") if eml_id.nil? || eml_id.zero? path = Mailmate::EmlLookup.path_for(eml_id) return text_error("Not found: #{eml_id}.eml") unless path = Mailmate::HeaderReader.(path) mailbox = path.sub("#{Mailmate.config.imap_root}/", "") .sub(%r{/Messages/[^/]+\.eml\z}, "") payload = { eml_id: eml_id, message_id: , message_url: ? Mailmate::MidUrl.() : nil, mid_url: ? Mailmate::MidUrl.for() : nil, path: path, mailbox: mailbox, } { content: [{ type: "text", text: JSON.pretty_generate(payload) }] } end |
#call_search(args) ⇒ Object
—- tool handlers —————————————————-
261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/mailmate/mcp.rb', line 261 def call_search(args) argv = [] argv.push("--mailbox", args["mailbox"].to_s) if args["mailbox"] argv.push("--limit", args["limit"].to_i.to_s) if args["limit"] argv.push("--headers-only") if args["headers_only"] argv.push("--sort", args["sort"].to_s) if args["sort"] # Positionals: search-string then fields. Only include if the caller # gave us either — otherwise let the CLI apply its defaults. if args.key?("query") || args["fields"] argv << (args["query"] || "") argv << args["fields"].to_s if args["fields"] end run_cli(Mailmate::CLI::Search, argv) end |
#call_send(args) ⇒ Object
291 292 293 294 295 296 297 298 299 300 |
# File 'lib/mailmate/mcp.rb', line 291 def call_send(args) argv = [] argv.push("-t", args["to"].to_s) if args["to"] argv.push("-c", args["cc"].to_s) if args["cc"] argv.push("-b", args["bcc"].to_s) if args["bcc"] argv.push("-s", args["subject"].to_s) if args["subject"] argv << "--send-now" if args["send_now"] Array(args["attachments"]).each { |p| argv << p.to_s } with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) } end |
#dispatch(name, args) ⇒ Object
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/mailmate/mcp.rb', line 243 def dispatch(name, args) case name when "search" then call_search(args) when "message" then (args) when "modify" then call_modify(args) when "send" then call_send(args) when "open" then call_open(args) when "list_mailboxes" then call_list_mailboxes(args) when "list_tags" then (args) when "resolve_id" then call_resolve(args) else text_error("Unknown tool: #{name}") end rescue StandardError => e text_error("#{e.class}: #{e.}\n#{e.backtrace.first(8).join("\n")}") end |
#handle(msg, stdout) ⇒ Object
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'lib/mailmate/mcp.rb', line 217 def handle(msg, stdout) method = msg["method"] id = msg["id"] case method when "initialize" write(stdout, jsonrpc_result(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: Mailmate::VERSION }, })) when "notifications/initialized", "notifications/cancelled" # notifications — no response when "tools/list" write(stdout, jsonrpc_result(id, { tools: TOOLS })) when "tools/call" params = msg["params"] || {} result = dispatch(params["name"], params["arguments"] || {}) write(stdout, jsonrpc_result(id, result)) when "ping" write(stdout, jsonrpc_result(id, {})) else # Unknown method — error if it has an id (request), drop if not. write(stdout, jsonrpc_error(id, -32601, "Method not found: #{method}")) unless id.nil? end end |
#jsonrpc_error(id, code, message) ⇒ Object
384 385 386 |
# File 'lib/mailmate/mcp.rb', line 384 def jsonrpc_error(id, code, ) { jsonrpc: "2.0", id: id, error: { code: code, message: } } end |
#jsonrpc_result(id, result) ⇒ Object
380 381 382 |
# File 'lib/mailmate/mcp.rb', line 380 def jsonrpc_result(id, result) { jsonrpc: "2.0", id: id, result: result } end |
#run(stdin: $stdin, stdout: $stdout) ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/mailmate/mcp.rb', line 197 def run(stdin: $stdin, stdout: $stdout) stdin.binmode stdout.binmode stdout.sync = true loop do line = stdin.gets break if line.nil? line = line.strip next if line.empty? begin msg = JSON.parse(line) rescue JSON::ParserError => e write(stdout, jsonrpc_error(nil, -32700, "Parse error: #{e.}")) next end handle(msg, stdout) end 0 end |
#run_cli(mod, argv) ⇒ Object
—- protocol helpers ————————————————-
345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/mailmate/mcp.rb', line 345 def run_cli(mod, argv) out, err, code = with_captured_io { mod.run(argv) } text = +"" text << out unless out.empty? unless err.empty? text << "\n" unless text.empty? text << "[stderr]\n" << err end text = "(no output)" if text.empty? { content: [{ type: "text", text: text }], isError: code != 0 } end |
#text_error(msg) ⇒ Object
376 377 378 |
# File 'lib/mailmate/mcp.rb', line 376 def text_error(msg) { content: [{ type: "text", text: msg }], isError: true } end |
#with_captured_io ⇒ Object
357 358 359 360 361 362 363 364 365 366 |
# File 'lib/mailmate/mcp.rb', line 357 def with_captured_io old_out, old_err = $stdout, $stderr $stdout = StringIO.new $stderr = StringIO.new code = yield [$stdout.string, $stderr.string, code.is_a?(Integer) ? code : 0] ensure $stdout = old_out $stderr = old_err end |
#with_stdin(text) ⇒ Object
368 369 370 371 372 373 374 |
# File 'lib/mailmate/mcp.rb', line 368 def with_stdin(text) old = $stdin $stdin = StringIO.new(text) yield ensure $stdin = old end |
#write(stdout, obj) ⇒ Object
388 389 390 391 |
# File 'lib/mailmate/mcp.rb', line 388 def write(stdout, obj) stdout.write(JSON.generate(obj) + "\n") stdout.flush end |