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.
@@ -0,0 +1,151 @@
1
+ require "net/imap"
2
+
3
+ module MailMCP
4
+ class ImapClient
5
+ class AuthError < StandardError; end
6
+ class ConnectionError < StandardError; end
7
+
8
+ attr_reader :imap
9
+
10
+ def initialize(imap)
11
+ @imap = imap
12
+ end
13
+
14
+ def self.validate!(config)
15
+ conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
16
+ conn.login(config[:username], config[:password])
17
+ conn.logout
18
+ conn.disconnect
19
+ rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
20
+ raise AuthError, "IMAP authentication failed: #{e.message}"
21
+ rescue StandardError => e
22
+ raise ConnectionError, "IMAP connection failed: #{e.message}"
23
+ end
24
+
25
+ def self.connect(config)
26
+ conn = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
27
+ conn.login(config[:username], config[:password])
28
+ client = new(conn)
29
+ yield client
30
+ rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
31
+ raise AuthError, "IMAP authentication failed: #{e.message}"
32
+ rescue StandardError => e
33
+ raise ConnectionError, "IMAP connection failed: #{e.message}"
34
+ ensure
35
+ begin
36
+ conn&.logout
37
+ rescue StandardError
38
+ nil
39
+ end
40
+ begin
41
+ conn&.disconnect
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+
47
+ def list_mailboxes
48
+ @imap.list("", "*").map(&:name)
49
+ end
50
+
51
+ def list_messages(folder:, page: 1, per_page: 20)
52
+ @imap.examine(folder)
53
+ uids = @imap.uid_search(["ALL"]).reverse
54
+ total = uids.length
55
+ offset = (page - 1) * per_page
56
+ page_uids = uids[offset, per_page] || []
57
+ return { messages: [], total: total } if page_uids.empty?
58
+
59
+ envelopes = @imap.uid_fetch(page_uids, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
60
+ messages = (envelopes || []).map { |msg| format_envelope(msg) }
61
+ { messages: messages, total: total, page: page, per_page: per_page }
62
+ end
63
+
64
+ def get_message(folder:, uid:)
65
+ @imap.examine(folder)
66
+ data = @imap.uid_fetch([uid.to_i], %w[RFC822 FLAGS]).first
67
+ return nil unless data
68
+
69
+ raw = data.attr["RFC822"]
70
+ flags = data.attr["FLAGS"]
71
+ parsed = Mail.new(raw)
72
+ attachments = extract_attachments(parsed)
73
+ {
74
+ uid: uid,
75
+ subject: parsed.subject,
76
+ from: parsed.from,
77
+ to: parsed.to,
78
+ cc: parsed.cc,
79
+ date: parsed.date&.iso8601,
80
+ text_body: parsed.text_part&.decoded,
81
+ html_body: parsed.html_part&.decoded,
82
+ flags: flags,
83
+ attachments: attachments
84
+ }
85
+ end
86
+
87
+ def search_messages(folder:, query:)
88
+ @imap.examine(folder)
89
+ @imap.search(query.split)
90
+ end
91
+
92
+ def delete_message(folder:, uid:)
93
+ @imap.select(folder)
94
+ @imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
95
+ @imap.expunge
96
+ end
97
+
98
+ def move_message(folder:, uid:, destination:)
99
+ @imap.select(folder)
100
+ if @imap.capability.include?("MOVE")
101
+ @imap.uid_move(uid.to_i, destination)
102
+ else
103
+ @imap.uid_copy(uid.to_i, destination)
104
+ @imap.uid_store(uid.to_i, "+FLAGS", [:Deleted])
105
+ @imap.expunge
106
+ end
107
+ end
108
+
109
+ def update_flags(folder:, uid:, add: [], remove: [])
110
+ @imap.select(folder)
111
+ @imap.uid_store(uid.to_i, "+FLAGS", add) unless add.empty?
112
+ @imap.uid_store(uid.to_i, "-FLAGS", remove) unless remove.empty?
113
+ end
114
+
115
+ def append_message(folder:, raw_message:)
116
+ @imap.append(folder, raw_message, [:Draft], Time.now)
117
+ end
118
+
119
+ private
120
+
121
+ def format_envelope(msg)
122
+ env = msg.attr["ENVELOPE"]
123
+ {
124
+ uid: msg.attr["UID"],
125
+ subject: env.subject,
126
+ from: format_addresses(env.from),
127
+ to: format_addresses(env.to),
128
+ date: env.date,
129
+ size: msg.attr["RFC822.SIZE"],
130
+ flags: msg.attr["FLAGS"]
131
+ }
132
+ end
133
+
134
+ def format_addresses(addrs)
135
+ return [] unless addrs
136
+
137
+ addrs.map { |a| "#{a.name} <#{a.mailbox}@#{a.host}>" }
138
+ end
139
+
140
+ def extract_attachments(mail)
141
+ mail.attachments.map do |att|
142
+ url = AttachmentStore.upload(
143
+ content: att.decoded,
144
+ filename: att.filename || "attachment",
145
+ content_type: att.content_type
146
+ )
147
+ { filename: att.filename, content_type: att.content_type, size: att.decoded.bytesize, url: url }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,126 @@
1
+ require "json/jwt"
2
+
3
+ module MailMCP
4
+ module JwtService
5
+ class Error < StandardError; end
6
+
7
+ DEFAULT_EXPIRY = 8 * 3600
8
+ DEFAULT_REFRESH_EXPIRY = 30 * 24 * 3600
9
+
10
+ CRED_KEYS = %w[
11
+ imap_host imap_port imap_ssl imap_username imap_password
12
+ smtp_host smtp_port smtp_ssl smtp_username smtp_password
13
+ ].freeze
14
+
15
+ # Access token — JWE (dir/A256GCM), credentials embedded directly in payload
16
+ def self.issue(creds, expires_in: DEFAULT_EXPIRY)
17
+ issue_jwe(creds.merge("typ" => "access"), expires_in: expires_in)
18
+ end
19
+
20
+ def self.verify(token)
21
+ payload = decode_jwe(token)
22
+ raise Error, "Not an access token" unless payload["typ"] == "access"
23
+
24
+ payload.slice(*CRED_KEYS)
25
+ end
26
+
27
+ # Refresh token — JWE, longer-lived, carries same credential payload
28
+ def self.issue_refresh(creds, expires_in: DEFAULT_REFRESH_EXPIRY)
29
+ issue_jwe(creds.merge("typ" => "refresh"), expires_in: expires_in)
30
+ end
31
+
32
+ def self.verify_refresh(token)
33
+ payload = decode_jwe(token)
34
+ raise Error, "Not a refresh token" unless payload["typ"] == "refresh"
35
+
36
+ payload.slice(*CRED_KEYS)
37
+ end
38
+
39
+ # Authorization code — short-lived JWE carrying creds + PKCE state (stateless PKCE)
40
+ CODE_EXPIRY = 300 # 5 minutes
41
+
42
+ def self.issue_code(creds:, code_challenge:, redirect_uri:, client_id:)
43
+ issue_jwe(
44
+ creds.merge(
45
+ "typ" => "code",
46
+ "code_challenge" => code_challenge,
47
+ "redirect_uri" => redirect_uri,
48
+ "client_id" => client_id
49
+ ),
50
+ expires_in: CODE_EXPIRY
51
+ )
52
+ end
53
+
54
+ def self.verify_code(token)
55
+ payload = decode_jwe(token)
56
+ raise Error, "Not an authorization code" unless payload["typ"] == "code"
57
+
58
+ payload
59
+ end
60
+
61
+ # Client ID token — JWE, no expiry, carries imap/smtp config + client_secret
62
+ def self.issue_client_id(imap_host:, imap_port:, imap_ssl:, smtp_host:, smtp_port:, smtp_ssl:, client_secret:)
63
+ payload = JSON.generate(
64
+ iss: ENV.fetch("BASE_URL"),
65
+ aud: ENV.fetch("BASE_URL"),
66
+ typ: "client_id",
67
+ imap_host: imap_host,
68
+ imap_port: imap_port.to_i,
69
+ imap_ssl: imap_ssl,
70
+ smtp_host: smtp_host,
71
+ smtp_port: smtp_port.to_i,
72
+ smtp_ssl: smtp_ssl,
73
+ cs: client_secret
74
+ )
75
+ encrypt_jwe(payload)
76
+ end
77
+
78
+ def self.decode_client_id(token)
79
+ payload = decode_jwe(token, verify_exp: false)
80
+ raise Error, "Not a client_id token" unless payload["typ"] == "client_id"
81
+
82
+ payload
83
+ end
84
+
85
+ def self.issue_jwe(payload_hash, expires_in:)
86
+ now = Time.now.to_i
87
+ payload = JSON.generate(
88
+ payload_hash.merge(
89
+ "iss" => ENV.fetch("BASE_URL"),
90
+ "aud" => ENV.fetch("BASE_URL"),
91
+ "iat" => now,
92
+ "exp" => now + expires_in
93
+ )
94
+ )
95
+ encrypt_jwe(payload)
96
+ end
97
+ private_class_method :issue_jwe
98
+
99
+ def self.encrypt_jwe(payload_json)
100
+ jwe = JSON::JWE.new(payload_json)
101
+ jwe.alg = :dir
102
+ jwe.enc = :A256GCM
103
+ jwe.encrypt!(encryption_key)
104
+ jwe.to_s
105
+ end
106
+ private_class_method :encrypt_jwe
107
+
108
+ def self.decode_jwe(token, verify_exp: true)
109
+ jwe = JSON::JWE.decode(token, encryption_key)
110
+ payload = JSON.parse(jwe.plain_text)
111
+ raise Error, "Token expired" if verify_exp && payload["exp"].to_i < Time.now.to_i
112
+ raise Error, "Invalid issuer" if payload["iss"] != ENV.fetch("BASE_URL")
113
+ raise Error, "Invalid audience" if payload["aud"] != ENV.fetch("BASE_URL")
114
+
115
+ payload
116
+ rescue JSON::JWT::Exception => e
117
+ raise Error, e.message
118
+ end
119
+ private_class_method :decode_jwe
120
+
121
+ def self.encryption_key
122
+ Base64.strict_decode64(ENV.fetch("ENCRYPTION_KEY"))
123
+ end
124
+ private_class_method :encryption_key
125
+ end
126
+ end
@@ -0,0 +1,42 @@
1
+ require "mail"
2
+ require "open-uri"
3
+
4
+ module MailMCP
5
+ module MailBuilder
6
+ module_function
7
+
8
+ def build(from:, to:, subject:, text_body:, cc: nil, bcc: nil, html_body: nil, attachment_urls: [])
9
+ mail = Mail.new
10
+ mail.from = from
11
+ mail.to = to
12
+ mail.subject = subject
13
+ mail.cc = cc if cc
14
+ mail.bcc = bcc if bcc
15
+ if html_body
16
+ mail.html_part = Mail::Part.new do
17
+ content_type "text/html
18
+ charset=UTF-8"
19
+ body html_body
20
+ end
21
+ mail.text_part = Mail::Part.new do
22
+ content_type "text/plain
23
+ charset=UTF-8"
24
+ body text_body
25
+ end
26
+ else
27
+ mail.body = text_body
28
+ end
29
+ attachment_urls.each { |url| attach_from_url(mail, url) }
30
+ mail
31
+ end
32
+
33
+ def attach_from_url(mail, url)
34
+ URI.open(url) do |f|
35
+ filename = File.basename(URI.parse(url).path)
36
+ mail.attachments[filename] = { content: f.read, mime_type: f.content_type }
37
+ end
38
+ rescue StandardError => e
39
+ raise "Failed to fetch attachment from #{url}: #{e.message}"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ require "digest"
2
+ require "securerandom"
3
+
4
+ module MailMCP
5
+ module Pkce
6
+ def self.challenge(verifier)
7
+ Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
8
+ end
9
+
10
+ def self.valid?(verifier:, challenge:)
11
+ expected = challenge(verifier)
12
+ begin
13
+ ActiveSupport::SecurityUtils.secure_compare(expected, challenge)
14
+ rescue StandardError
15
+ (expected == challenge)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ require "net/smtp"
2
+
3
+ module MailMCP
4
+ module SmtpClient
5
+ class ConnectionError < StandardError; end
6
+
7
+ def self.validate!(config)
8
+ smtp_open(config) { nil }
9
+ end
10
+
11
+ def self.send(config, mail)
12
+ smtp_open(config) do |s|
13
+ s.send_message(mail.to_s, mail.from.first, mail.to)
14
+ end
15
+ rescue Net::SMTPError, SocketError => e
16
+ raise ConnectionError, "SMTP send failed: #{e.message}"
17
+ end
18
+
19
+ def self.smtp_open(config, &)
20
+ smtp = Net::SMTP.new(config[:host], config[:port])
21
+ smtp.enable_starttls_auto unless config[:ssl]
22
+ smtp.start(config[:host], config[:username], config[:password], :login, &)
23
+ rescue StandardError => e
24
+ raise ConnectionError, "SMTP connection failed: #{e.message}"
25
+ end
26
+ private_class_method :smtp_open
27
+ end
28
+ end
@@ -0,0 +1,6 @@
1
+ require "mcp"
2
+
3
+ module MailMCP
4
+ class Tool < MCP::Tool
5
+ end
6
+ end
@@ -0,0 +1,42 @@
1
+ module MailMCP
2
+ class CreateDraftMailMessageTool < Tool
3
+ tool_name "create_draft_mail_message"
4
+ description "Save an email as a draft to the Drafts folder via IMAP APPEND"
5
+ annotations(
6
+ title: "Create Draft Mail Message",
7
+ read_only_hint: false,
8
+ destructive_hint: false,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ to: { type: "string" },
17
+ subject: { type: "string" },
18
+ text_body: { type: "string" },
19
+ cc: { type: "string" },
20
+ bcc: { type: "string" },
21
+ html_body: { type: "string" },
22
+ attachment_urls: { type: "array", items: { type: "string" },
23
+ description: "S3 presigned URLs to attach" },
24
+ folder: { type: "string", description: "Target folder (default: Drafts)", default: "Drafts" }
25
+ },
26
+ required: %w[to subject text_body]
27
+ )
28
+
29
+ def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
30
+ attachment_urls: [], folder: "Drafts")
31
+ imap_config = server_context.imap_config
32
+ mail = MailBuilder.build(
33
+ from: imap_config[:username],
34
+ to: to, subject: subject, text_body: text_body,
35
+ cc: cc, bcc: bcc, html_body: html_body,
36
+ attachment_urls: attachment_urls
37
+ )
38
+ ImapClient.connect(imap_config) { |c| c.append_message(folder: folder, raw_message: mail.to_s) }
39
+ MCP::Tool::Response.new([{ type: "text", text: "Draft saved to #{folder}" }])
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ module MailMCP
2
+ class DeleteMailMessageTool < Tool
3
+ tool_name "delete_mail_message"
4
+ description "Delete a message by UID (marks as deleted and expunges)"
5
+ annotations(
6
+ title: "Delete Mail Message",
7
+ read_only_hint: false,
8
+ destructive_hint: true,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string" },
17
+ uid: { type: "integer" }
18
+ },
19
+ required: %w[folder uid]
20
+ )
21
+
22
+ def self.call(folder:, uid:, server_context:)
23
+ ImapClient.connect(server_context.imap_config) { |c| c.delete_message(folder: folder, uid: uid) }
24
+ MCP::Tool::Response.new([{ type: "text", text: "Message #{uid} deleted from #{folder}" }])
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module MailMCP
2
+ class GetMailMessageTool < Tool
3
+ tool_name "get_mail_message"
4
+ description "Fetch a full email message including body and attachment URLs"
5
+ annotations(
6
+ title: "Get Mail Message",
7
+ read_only_hint: true,
8
+ destructive_hint: false,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string", description: "Mailbox folder name" },
17
+ uid: { type: "integer", description: "Message UID" }
18
+ },
19
+ required: %w[folder uid]
20
+ )
21
+
22
+ def self.call(folder:, uid:, server_context:)
23
+ msg = ImapClient.connect(server_context.imap_config) { |c| c.get_message(folder: folder, uid: uid) }
24
+ return MCP::Tool::Response.new([{ type: "text", text: "Message not found" }], error: true) unless msg
25
+
26
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(msg) }])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ module MailMCP
2
+ class ListMailMessagesTool < Tool
3
+ tool_name "list_mail_messages"
4
+ description "List messages in an IMAP folder with pagination"
5
+ annotations(
6
+ title: "List Mail Messages",
7
+ read_only_hint: true,
8
+ destructive_hint: false,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string", description: "Mailbox folder name" },
17
+ page: { type: "integer", description: "Page number (default 1)", default: 1 },
18
+ per_page: { type: "integer", description: "Messages per page (default 20)", default: 20 }
19
+ },
20
+ required: ["folder"]
21
+ )
22
+
23
+ def self.call(folder:, server_context:, page: 1, per_page: 20)
24
+ result = ImapClient.connect(server_context.imap_config) do |c|
25
+ c.list_messages(folder: folder, page: page, per_page: per_page)
26
+ end
27
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(result) }])
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ module MailMCP
2
+ class ListMailboxesTool < Tool
3
+ tool_name "list_mailboxes"
4
+ description "List all IMAP mailboxes/folders"
5
+ annotations(
6
+ title: "List Mailboxes",
7
+ read_only_hint: true,
8
+ destructive_hint: false,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ def self.call(server_context:)
14
+ mailboxes = ImapClient.connect(server_context.imap_config, &:list_mailboxes)
15
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(mailboxes) }])
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ module MailMCP
2
+ class MoveMailMessageTool < Tool
3
+ tool_name "move_mail_message"
4
+ description "Move a message to another folder"
5
+ annotations(
6
+ title: "Move Mail Message",
7
+ read_only_hint: false,
8
+ destructive_hint: true,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string", description: "Source folder" },
17
+ uid: { type: "integer" },
18
+ destination: { type: "string", description: "Destination folder" }
19
+ },
20
+ required: %w[folder uid destination]
21
+ )
22
+
23
+ def self.call(folder:, uid:, destination:, server_context:)
24
+ ImapClient.connect(server_context.imap_config) do |c|
25
+ c.move_message(folder: folder, uid: uid, destination: destination)
26
+ end
27
+ MCP::Tool::Response.new([{ type: "text", text: "Message #{uid} moved from #{folder} to #{destination}" }])
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ module MailMCP
2
+ class SearchMailMessagesTool < Tool
3
+ tool_name "search_mail_messages"
4
+ description "Search messages in an IMAP folder using raw IMAP SEARCH criteria"
5
+ annotations(
6
+ title: "Search Mail Messages",
7
+ read_only_hint: true,
8
+ destructive_hint: false,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string", description: "Mailbox folder name, e.g. INBOX" },
17
+ query: { type: "string",
18
+ description: "Raw IMAP SEARCH criteria, e.g. 'UNSEEN' or " \
19
+ "'FROM alice@example.com SINCE 01-Jan-2025'" }
20
+ },
21
+ required: %w[folder query]
22
+ )
23
+
24
+ def self.call(folder:, query:, server_context:)
25
+ uids = ImapClient.connect(server_context.imap_config) { |c| c.search_messages(folder: folder, query: query) }
26
+ MCP::Tool::Response.new([{ type: "text",
27
+ text: JSON.generate({ folder: folder, query: query, uids: uids,
28
+ count: uids.length }) }])
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ module MailMCP
2
+ class SendMailMessageTool < Tool
3
+ tool_name "send_mail_message"
4
+ description "Send an email via SMTP"
5
+ annotations(
6
+ title: "Send Mail Message",
7
+ read_only_hint: false,
8
+ destructive_hint: true,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ to: { type: "string", description: "Recipient address(es), comma-separated" },
17
+ subject: { type: "string" },
18
+ text_body: { type: "string", description: "Plain-text body" },
19
+ cc: { type: "string" },
20
+ bcc: { type: "string" },
21
+ html_body: { type: "string", description: "HTML body (optional)" },
22
+ attachment_urls: { type: "array", items: { type: "string" },
23
+ description: "S3 presigned URLs to attach" },
24
+ folder: { type: "string", description: "IMAP folder to save the sent message (default: Sent)",
25
+ default: "Sent" }
26
+ },
27
+ required: %w[to subject text_body]
28
+ )
29
+
30
+ def self.call(to:, subject:, text_body:, server_context:, cc: nil, bcc: nil, html_body: nil,
31
+ attachment_urls: [], folder: "Sent")
32
+ mail = MailBuilder.build(
33
+ from: server_context.imap_config[:username],
34
+ to: to, subject: subject, text_body: text_body,
35
+ cc: cc, bcc: bcc, html_body: html_body,
36
+ attachment_urls: attachment_urls
37
+ )
38
+ SmtpClient.send(server_context.smtp_config, mail)
39
+ ImapClient.connect(server_context.imap_config) { |c| c.append_message(folder: folder, raw_message: mail.to_s) }
40
+ MCP::Tool::Response.new([{ type: "text", text: "Email sent successfully to #{to} and saved to #{folder}" }])
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ module MailMCP
2
+ class UpdateMailMessageFlagsTool < Tool
3
+ tool_name "update_mail_message_flags"
4
+ description "Update IMAP flags on a message (mark as read, flagged, etc.)"
5
+ annotations(
6
+ title: "Update Mail Message Flags",
7
+ read_only_hint: false,
8
+ destructive_hint: true,
9
+ idempotent_hint: false,
10
+ open_world_hint: true
11
+ )
12
+
13
+ input_schema(
14
+ type: "object",
15
+ properties: {
16
+ folder: { type: "string" },
17
+ uid: { type: "integer" },
18
+ add: { type: "array", items: { type: "string" },
19
+ description: "Flags to add, e.g. ['\\\\Seen', '\\\\Flagged']" },
20
+ remove: { type: "array", items: { type: "string" }, description: "Flags to remove" }
21
+ },
22
+ required: %w[folder uid]
23
+ )
24
+
25
+ def self.call(folder:, uid:, server_context:, add: [], remove: [])
26
+ add_flags = add.map(&:to_sym)
27
+ remove_flags = remove.map(&:to_sym)
28
+ ImapClient.connect(server_context.imap_config) do |c|
29
+ c.update_flags(folder: folder, uid: uid, add: add_flags, remove: remove_flags)
30
+ end
31
+ MCP::Tool::Response.new([{ type: "text", text: "Flags updated for message #{uid}" }])
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module MailMCP
2
+ VERSION = "0.1.0".freeze
3
+ end