Module: Mailmate::MCP

Extended by:
MCP
Included in:
MCP
Defined in:
lib/mailmate/mcp.rb

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

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 call_list_tags(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 call_message(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

  message_id = Mailmate::HeaderReader.message_id(path)
  mailbox    = path.sub("#{Mailmate.config.imap_root}/", "")
                   .sub(%r{/Messages/[^/]+\.eml\z}, "")

  payload = {
    eml_id:      eml_id,
    message_id:  message_id,
    message_url: message_id ? Mailmate::MidUrl.message_url_for(message_id) : nil,
    mid_url:     message_id ? Mailmate::MidUrl.for(message_id) : 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 call_message(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 call_list_tags(args)
  when "resolve_id"     then call_resolve(args)
  else                       text_error("Unknown tool: #{name}")
  end
rescue StandardError => e
  text_error("#{e.class}: #{e.message}\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, message)
  { jsonrpc: "2.0", id: id, error: { code: code, message: 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.message}"))
      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_ioObject



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