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"- 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.
347 348 349 350 351 |
# File 'lib/mailmate/mcp.rb', line 347 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.
324 325 326 327 |
# File 'lib/mailmate/mcp.rb', line 324 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
359 360 361 362 363 364 |
# File 'lib/mailmate/mcp.rb', line 359 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
366 367 368 369 370 |
# File 'lib/mailmate/mcp.rb', line 366 def (args) argv = [] argv << "--defined" if args["defined"] run_cli(Mailmate::CLI::Tags, argv) end |
#call_message(args) ⇒ Object
301 302 303 304 305 306 |
# File 'lib/mailmate/mcp.rb', line 301 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
308 309 310 311 312 313 314 |
# File 'lib/mailmate/mcp.rb', line 308 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
353 354 355 356 357 |
# File 'lib/mailmate/mcp.rb', line 353 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
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 |
# File 'lib/mailmate/mcp.rb', line 372 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 —————————————————-
286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/mailmate/mcp.rb', line 286 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
316 317 318 319 320 |
# File 'lib/mailmate/mcp.rb', line 316 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`).
331 332 333 334 335 336 337 338 339 340 341 342 |
# File 'lib/mailmate/mcp.rb', line 331 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
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/mailmate/mcp.rb', line 267 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
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/mailmate/mcp.rb', line 241 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
435 436 437 |
# File 'lib/mailmate/mcp.rb', line 435 def jsonrpc_error(id, code, ) { jsonrpc: "2.0", id: id, error: { code: code, message: } } end |
#jsonrpc_result(id, result) ⇒ Object
431 432 433 |
# File 'lib/mailmate/mcp.rb', line 431 def jsonrpc_result(id, result) { jsonrpc: "2.0", id: id, result: result } end |
#run(stdin: $stdin, stdout: $stdout) ⇒ Object
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/mailmate/mcp.rb', line 221 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 ————————————————-
396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/mailmate/mcp.rb', line 396 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
427 428 429 |
# File 'lib/mailmate/mcp.rb', line 427 def text_error(msg) { content: [{ type: "text", text: msg }], isError: true } end |
#with_captured_io ⇒ Object
408 409 410 411 412 413 414 415 416 417 |
# File 'lib/mailmate/mcp.rb', line 408 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
419 420 421 422 423 424 425 |
# File 'lib/mailmate/mcp.rb', line 419 def with_stdin(text) old = $stdin $stdin = StringIO.new(text) yield ensure $stdin = old end |
#write(stdout, obj) ⇒ Object
439 440 441 442 |
# File 'lib/mailmate/mcp.rb', line 439 def write(stdout, obj) stdout.write(JSON.generate(obj) + "\n") stdout.flush end |