Module: Mailmate::MCP
Overview
Stdio MCP server (JSON-RPC 2.0, line-delimited). Exposes the gem’s CLIs —search, message, modify, send, draft — 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"- INSTRUCTIONS =
Server-level guidance returned from ‘initialize`. This is the gem’s operational doctrine — the things a caller must know to drive MailMate correctly that don’t fit in a single tool’s description. It travels with the gem so the MCP is self-sufficient (no companion skill required).
<<~INSTRUCTIONS.strip Read and act on MailMate's local mail store on macOS. `search`, `message`, `resolve_id`, and `list_*` are read-only; `send`, `draft`, `modify`, and `open` require MailMate to be running (they drive the app). Composing (send / draft) - Bodies are Markdown; MailMate renders them to HTML on the way out. For that to reach recipients, MailMate → Preferences → Composer must have "Preview: Display" = Always and "Replying/Forwarding HTML" = Always embed — otherwise recipients get plain text. These are global, one-time settings. - Set `from` explicitly. When the To: address matches one of the user's own accounts, MailMate may otherwise send from that account. - Prefer `draft` over `send` whenever the user said "don't send" / "just draft it" — `draft` physically cannot send, so it's the safe choice. `send` also opens a draft and waits unless you pass `send_now: true`. - Threading: set BOTH `in_reply_to` and `references`. A "Re:" subject alone does not thread in modern clients. MailMate generates the outgoing Message-ID itself. Modifying (modify) - Drives MailMate's UI via AppleScript: it briefly takes focus, calls are serial per app, and each call costs a few seconds (flag/tag writes are async). Batch multiple actions into ONE call rather than many. - Opening a message marks it \\Seen; read state is auto-preserved unless your action chain itself includes read/unread. - A Message-ID can live in several mailboxes (Sent + Received copies, Gmail label copies). When it does, the action may land on a different copy than the id you targeted. Identifiers - Tools accept a local eml-id (an integer; per-machine, NOT portable) or an RFC Message-ID (portable across machines). Use `resolve_id` to convert between them and to mint a cross-machine message:// URL. INSTRUCTIONS
- 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. For replies, set `in_reply_to` and `references` so recipients' clients thread the message — without them a `Re:` subject alone is not enough. MailMate generates the outgoing Message-ID automatically.", inputSchema: { type: "object", properties: { from: { type: "string", description: "Sender identity (one of MailMate's configured addresses; see mmdiscover). If omitted, MailMate uses its default identity." }, 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." }, in_reply_to: { type: "string", description: "Message-ID of the parent message (with or without angle brackets). Sets the In-Reply-To header on the outgoing message so recipients' clients thread it correctly." }, references: { type: "string", description: "Space-separated chain of Message-IDs (with angle brackets). Conventionally: parent's References header + parent's Message-ID. Required alongside in_reply_to for clean threading in deep chains." }, send_now: { type: "boolean", description: "Send immediately (skip the Drafts pause)." }, }, required: %w[to subject body], additionalProperties: false, }, }, { name: "draft", description: "Compose a draft via MailMate's `emate` (markdown body) — IDENTICAL to `send` but it never sends: the draft opens in MailMate and waits. There is no `send_now` option; use this whenever the instruction is 'write/compose but don't send'. For replies, set `in_reply_to` and `references` so the draft threads correctly. MailMate generates the outgoing Message-ID automatically.", inputSchema: { type: "object", properties: { from: { type: "string", description: "Sender identity (one of MailMate's configured addresses; see mmdiscover). If omitted, MailMate uses its default identity." }, 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." }, in_reply_to: { type: "string", description: "Message-ID of the parent message (with or without angle brackets). Sets the In-Reply-To header so recipients' clients thread it correctly." }, references: { type: "string", description: "Space-separated chain of Message-IDs (with angle brackets). Conventionally: parent's References header + parent's Message-ID. Required alongside in_reply_to for clean threading in deep chains." }, }, 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
-
#bracket_mid(id) ⇒ Object
Wrap a bare Message-ID in ‘<…>` if it doesn’t already have them.
-
#call_draft(args) ⇒ Object
‘draft` mirrors `send` but never sends — it has no send_now option and routes through CLI::Draft, which refuses `–send-now` outright.
- #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
-
#compose_argv(args) ⇒ Object
Shared message-composition argv for both send and draft (everything bar the body, which is piped on stdin, and the send-only ‘–send-now`).
- #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
#bracket_mid(id) ⇒ Object
Wrap a bare Message-ID in ‘<…>` if it doesn’t already have them. Both forms are valid input to the MCP for ergonomics; the header value going on the wire needs the brackets per RFC 5322.
404 405 406 407 408 |
# File 'lib/mailmate/mcp.rb', line 404 def bracket_mid(id) s = id.to_s.strip return s if s.start_with?("<") && s.end_with?(">") "<#{s}>" end |
#call_draft(args) ⇒ Object
‘draft` mirrors `send` but never sends — it has no send_now option and routes through CLI::Draft, which refuses `–send-now` outright.
381 382 383 384 |
# File 'lib/mailmate/mcp.rb', line 381 def call_draft(args) argv = compose_argv(args) with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Draft, argv) } end |
#call_list_mailboxes(args) ⇒ Object
416 417 418 419 420 421 |
# File 'lib/mailmate/mcp.rb', line 416 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
423 424 425 426 427 |
# File 'lib/mailmate/mcp.rb', line 423 def (args) argv = [] argv << "--defined" if args["defined"] run_cli(Mailmate::CLI::Tags, argv) end |
#call_message(args) ⇒ Object
358 359 360 361 362 363 |
# File 'lib/mailmate/mcp.rb', line 358 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
365 366 367 368 369 370 371 |
# File 'lib/mailmate/mcp.rb', line 365 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
410 411 412 413 414 |
# File 'lib/mailmate/mcp.rb', line 410 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
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'lib/mailmate/mcp.rb', line 429 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 —————————————————-
343 344 345 346 347 348 349 350 351 352 353 354 355 356 |
# File 'lib/mailmate/mcp.rb', line 343 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
373 374 375 376 377 |
# File 'lib/mailmate/mcp.rb', line 373 def call_send(args) argv = compose_argv(args) argv << "--send-now" if args["send_now"] with_stdin(args["body"].to_s) { run_cli(Mailmate::CLI::Send, argv) } end |
#compose_argv(args) ⇒ Object
Shared message-composition argv for both send and draft (everything bar the body, which is piped on stdin, and the send-only ‘–send-now`).
388 389 390 391 392 393 394 395 396 397 398 399 |
# File 'lib/mailmate/mcp.rb', line 388 def compose_argv(args) argv = [] argv.push("-f", args["from"].to_s) if args["from"] 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.push("--header", "In-Reply-To: #{bracket_mid(args["in_reply_to"])}") if args["in_reply_to"] argv.push("--header", "References: #{args["references"]}") if args["references"] Array(args["attachments"]).each { |p| argv << p.to_s } argv end |
#dispatch(name, args) ⇒ Object
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 |
# File 'lib/mailmate/mcp.rb', line 324 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 "draft" then call_draft(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
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/mailmate/mcp.rb', line 297 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 }, instructions: INSTRUCTIONS, })) 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
492 493 494 |
# File 'lib/mailmate/mcp.rb', line 492 def jsonrpc_error(id, code, ) { jsonrpc: "2.0", id: id, error: { code: code, message: } } end |
#jsonrpc_result(id, result) ⇒ Object
488 489 490 |
# File 'lib/mailmate/mcp.rb', line 488 def jsonrpc_result(id, result) { jsonrpc: "2.0", id: id, result: result } end |
#run(stdin: $stdin, stdout: $stdout) ⇒ Object
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/mailmate/mcp.rb', line 277 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 ————————————————-
453 454 455 456 457 458 459 460 461 462 463 |
# File 'lib/mailmate/mcp.rb', line 453 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
484 485 486 |
# File 'lib/mailmate/mcp.rb', line 484 def text_error(msg) { content: [{ type: "text", text: msg }], isError: true } end |
#with_captured_io ⇒ Object
465 466 467 468 469 470 471 472 473 474 |
# File 'lib/mailmate/mcp.rb', line 465 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
476 477 478 479 480 481 482 |
# File 'lib/mailmate/mcp.rb', line 476 def with_stdin(text) old = $stdin $stdin = StringIO.new(text) yield ensure $stdin = old end |
#write(stdout, obj) ⇒ Object
496 497 498 499 |
# File 'lib/mailmate/mcp.rb', line 496 def write(stdout, obj) stdout.write(JSON.generate(obj) + "\n") stdout.flush end |