gmcp 0.5.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +96 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/bin/gmail_survey +167 -0
- data/bin/gmcp +33 -0
- data/bin/gmcp-auth +68 -0
- data/config/capabilities.yml +148 -0
- data/config/scopes.yml +14 -0
- data/lib/gmcp/account_registry.rb +68 -0
- data/lib/gmcp/api_binding.rb +75 -0
- data/lib/gmcp/api_error.rb +33 -0
- data/lib/gmcp/apis.rb +46 -0
- data/lib/gmcp/auth.rb +189 -0
- data/lib/gmcp/bearer_middleware.rb +17 -0
- data/lib/gmcp/calendar/calendar.rb +16 -0
- data/lib/gmcp/calendar/event.rb +95 -0
- data/lib/gmcp/calendar/tools.rb +150 -0
- data/lib/gmcp/capabilities.rb +133 -0
- data/lib/gmcp/drive/file.rb +145 -0
- data/lib/gmcp/drive/tools.rb +64 -0
- data/lib/gmcp/gmail/draft.rb +21 -0
- data/lib/gmcp/gmail/label.rb +16 -0
- data/lib/gmcp/gmail/message.rb +407 -0
- data/lib/gmcp/gmail/mime.rb +108 -0
- data/lib/gmcp/gmail/tools.rb +364 -0
- data/lib/gmcp/gmail/unsubscribe.rb +96 -0
- data/lib/gmcp/server.rb +90 -0
- data/lib/gmcp/tool_helpers.rb +105 -0
- data/lib/gmcp/version.rb +3 -0
- data/lib/gmcp/voice/chrome.rb +111 -0
- data/lib/gmcp/voice/conversation.rb +65 -0
- data/lib/gmcp/voice/folder.rb +67 -0
- data/lib/gmcp/voice/message.rb +81 -0
- data/lib/gmcp/voice/session.rb +126 -0
- data/lib/gmcp/voice/tools.rb +178 -0
- data/lib/gmcp/voice.rb +34 -0
- data/lib/gmcp.rb +43 -0
- metadata +226 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
require 'mcp'
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Gmail
|
|
5
|
+
module Tools
|
|
6
|
+
def self.register(server)
|
|
7
|
+
register_read(server)
|
|
8
|
+
register_write(server)
|
|
9
|
+
register_bulk(server)
|
|
10
|
+
register_attachments(server)
|
|
11
|
+
register_unsubscribe(server)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.register_read(server)
|
|
15
|
+
ToolHelpers.account_tool(
|
|
16
|
+
server,
|
|
17
|
+
name: 'gmail_search',
|
|
18
|
+
capability: 'gmail.read',
|
|
19
|
+
description: 'Search Gmail messages using a query string (same syntax as Gmail search box). ' \
|
|
20
|
+
'Returns one line per message: id, date, from, subject. ' \
|
|
21
|
+
'Returns a page_token when more results exist; pass it back to get the next page.',
|
|
22
|
+
properties: {
|
|
23
|
+
query: { type: 'string', description: 'Gmail search query, e.g. "from:alice subject:report"' },
|
|
24
|
+
max_results: { type: 'integer', description: 'Max messages per page (default 20, Gmail caps at 500)' },
|
|
25
|
+
page_token: { type: 'string', description: 'Cursor from a previous call. Omit for the first page.' }
|
|
26
|
+
},
|
|
27
|
+
required: ['query']
|
|
28
|
+
) do |query:, max_results: 20, page_token: nil|
|
|
29
|
+
Tools.search(query: query, max_results: max_results, page_token: page_token)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ToolHelpers.account_tool(
|
|
33
|
+
server,
|
|
34
|
+
name: 'gmail_get_message',
|
|
35
|
+
capability: 'gmail.read',
|
|
36
|
+
description: 'Get a Gmail message by ID, with decoded headers and body text',
|
|
37
|
+
properties: {
|
|
38
|
+
message_id: { type: 'string' }
|
|
39
|
+
},
|
|
40
|
+
required: ['message_id']
|
|
41
|
+
) do |message_id:|
|
|
42
|
+
ToolHelpers.json_response(Message.find(message_id).to_summary)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ToolHelpers.account_tool(
|
|
46
|
+
server,
|
|
47
|
+
name: 'gmail_list_labels',
|
|
48
|
+
capability: 'gmail.read',
|
|
49
|
+
description: 'List all Gmail labels',
|
|
50
|
+
properties: {}
|
|
51
|
+
) do
|
|
52
|
+
ToolHelpers.list_response(Label.all, empty_message: 'No labels found.') { |l| "#{l.id}: #{l.name}" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.register_write(server)
|
|
57
|
+
ToolHelpers.account_tool(
|
|
58
|
+
server,
|
|
59
|
+
name: 'gmail_trash_message',
|
|
60
|
+
capability: 'gmail.trash',
|
|
61
|
+
description: 'Move a Gmail message to trash',
|
|
62
|
+
properties: {
|
|
63
|
+
message_id: { type: 'string' }
|
|
64
|
+
},
|
|
65
|
+
required: ['message_id']
|
|
66
|
+
) do |message_id:|
|
|
67
|
+
Message.find(message_id).trash!
|
|
68
|
+
ToolHelpers.text_response("Message #{message_id} moved to trash.")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
ToolHelpers.account_tool(
|
|
72
|
+
server,
|
|
73
|
+
name: 'gmail_archive_message',
|
|
74
|
+
capability: 'gmail.modify_labels',
|
|
75
|
+
description: 'Archive a Gmail message (remove from INBOX)',
|
|
76
|
+
properties: {
|
|
77
|
+
message_id: { type: 'string' }
|
|
78
|
+
},
|
|
79
|
+
required: ['message_id']
|
|
80
|
+
) do |message_id:|
|
|
81
|
+
Message.find(message_id).archive!
|
|
82
|
+
ToolHelpers.text_response("Message #{message_id} archived.")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
ToolHelpers.account_tool(
|
|
86
|
+
server,
|
|
87
|
+
name: 'gmail_label_message',
|
|
88
|
+
capability: 'gmail.modify_labels',
|
|
89
|
+
description: 'Add and/or remove labels on a Gmail message',
|
|
90
|
+
properties: {
|
|
91
|
+
message_id: { type: 'string' },
|
|
92
|
+
add_label_ids: { type: 'array', items: { type: 'string' }, description: 'Label IDs to add' },
|
|
93
|
+
remove_label_ids: { type: 'array', items: { type: 'string' }, description: 'Label IDs to remove' }
|
|
94
|
+
},
|
|
95
|
+
required: ['message_id']
|
|
96
|
+
) do |message_id:, add_label_ids: [], remove_label_ids: []|
|
|
97
|
+
Message.find(message_id).modify!(addLabelIds: add_label_ids, removeLabelIds: remove_label_ids)
|
|
98
|
+
ToolHelpers.text_response("Labels updated on #{message_id}.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
ToolHelpers.account_tool(
|
|
102
|
+
server,
|
|
103
|
+
name: 'gmail_send',
|
|
104
|
+
capability: 'gmail.send',
|
|
105
|
+
description: 'Send a new email',
|
|
106
|
+
properties: {
|
|
107
|
+
to: { type: 'string', description: 'Recipient email address' },
|
|
108
|
+
subject: { type: 'string' },
|
|
109
|
+
body: { type: 'string', description: 'Plain-text message body' }
|
|
110
|
+
},
|
|
111
|
+
required: ['to', 'subject', 'body']
|
|
112
|
+
) do |to:, subject:, body:|
|
|
113
|
+
Message.send_message(to: to, subject: subject, body: body)
|
|
114
|
+
ToolHelpers.text_response("Message sent to #{to}.")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
ToolHelpers.account_tool(
|
|
118
|
+
server,
|
|
119
|
+
name: 'gmail_create_draft',
|
|
120
|
+
capability: 'gmail.send',
|
|
121
|
+
description: 'Create a Gmail draft',
|
|
122
|
+
properties: {
|
|
123
|
+
to: { type: 'string' },
|
|
124
|
+
subject: { type: 'string' },
|
|
125
|
+
body: { type: 'string' }
|
|
126
|
+
},
|
|
127
|
+
required: ['to', 'subject', 'body']
|
|
128
|
+
) do |to:, subject:, body:|
|
|
129
|
+
Draft.create_draft(to: to, subject: subject, body: body)
|
|
130
|
+
ToolHelpers.text_response('Draft created.')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ToolHelpers.account_tool(
|
|
134
|
+
server,
|
|
135
|
+
name: 'gmail_reply',
|
|
136
|
+
capability: 'gmail.send',
|
|
137
|
+
description: 'Reply to a Gmail message',
|
|
138
|
+
properties: {
|
|
139
|
+
message_id: { type: 'string' },
|
|
140
|
+
body: { type: 'string', description: 'Reply body (plain text)' }
|
|
141
|
+
},
|
|
142
|
+
required: ['message_id', 'body']
|
|
143
|
+
) do |message_id:, body:|
|
|
144
|
+
Message.metadata(message_id, headers: Message::REPLY_HEADERS).reply!(body: body)
|
|
145
|
+
ToolHelpers.text_response('Reply sent.')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── bulk operations ─────────────────────────────────────────────────
|
|
150
|
+
#
|
|
151
|
+
# One request for up to 1000 messages. Without these, acting on a
|
|
152
|
+
# triage result means one round trip per message.
|
|
153
|
+
def self.register_bulk(server)
|
|
154
|
+
ToolHelpers.account_tool(
|
|
155
|
+
server,
|
|
156
|
+
name: 'gmail_batch_archive',
|
|
157
|
+
capability: 'gmail.modify_labels',
|
|
158
|
+
description: 'Archive many messages at once (removes INBOX). Up to 1000 ids per call. Reversible.',
|
|
159
|
+
properties: {
|
|
160
|
+
message_ids: { type: 'array', items: { type: 'string' }, description: 'Message IDs to archive' }
|
|
161
|
+
},
|
|
162
|
+
required: ['message_ids']
|
|
163
|
+
) do |message_ids:|
|
|
164
|
+
Tools.batching(message_ids) do |ids|
|
|
165
|
+
Message.batch_archive(ids: ids)
|
|
166
|
+
"Archived #{ids.length} message(s)."
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
ToolHelpers.account_tool(
|
|
171
|
+
server,
|
|
172
|
+
name: 'gmail_batch_trash',
|
|
173
|
+
capability: 'gmail.trash',
|
|
174
|
+
description: 'Move many messages to trash at once. Up to 1000 ids per call. ' \
|
|
175
|
+
'Recoverable for 30 days, after which Gmail deletes them permanently.',
|
|
176
|
+
properties: {
|
|
177
|
+
message_ids: { type: 'array', items: { type: 'string' }, description: 'Message IDs to trash' }
|
|
178
|
+
},
|
|
179
|
+
required: ['message_ids']
|
|
180
|
+
) do |message_ids:|
|
|
181
|
+
Tools.batching(message_ids) do |ids|
|
|
182
|
+
Message.batch_trash(ids: ids)
|
|
183
|
+
"Moved #{ids.length} message(s) to trash. Recoverable for 30 days."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
ToolHelpers.account_tool(
|
|
188
|
+
server,
|
|
189
|
+
name: 'gmail_batch_modify',
|
|
190
|
+
capability: 'gmail.modify_labels',
|
|
191
|
+
description: 'Add and/or remove labels on many messages at once. Up to 1000 ids per call.',
|
|
192
|
+
properties: {
|
|
193
|
+
message_ids: { type: 'array', items: { type: 'string' } },
|
|
194
|
+
add_label_ids: { type: 'array', items: { type: 'string' }, description: 'Label IDs to add' },
|
|
195
|
+
remove_label_ids: { type: 'array', items: { type: 'string' }, description: 'Label IDs to remove' }
|
|
196
|
+
},
|
|
197
|
+
required: ['message_ids']
|
|
198
|
+
) do |message_ids:, add_label_ids: [], remove_label_ids: []|
|
|
199
|
+
if add_label_ids.empty? && remove_label_ids.empty?
|
|
200
|
+
next ToolHelpers.error_response('Nothing to do: no labels to add or remove.')
|
|
201
|
+
end
|
|
202
|
+
Tools.batching(message_ids) do |ids|
|
|
203
|
+
Message.batch_modify(ids: ids, add_label_ids: add_label_ids, remove_label_ids: remove_label_ids)
|
|
204
|
+
"Updated labels on #{ids.length} message(s)."
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ── attachments ─────────────────────────────────────────────────────
|
|
210
|
+
def self.register_attachments(server)
|
|
211
|
+
ToolHelpers.account_tool(
|
|
212
|
+
server,
|
|
213
|
+
name: 'gmail_list_attachments',
|
|
214
|
+
capability: 'gmail.read',
|
|
215
|
+
description: 'List the attachments on a message: part_id (needed to download it), filename, type and size.',
|
|
216
|
+
properties: {
|
|
217
|
+
message_id: { type: 'string' }
|
|
218
|
+
},
|
|
219
|
+
required: ['message_id']
|
|
220
|
+
) do |message_id:|
|
|
221
|
+
found = Message.attachments(message_id)
|
|
222
|
+
ToolHelpers.list_response(found, empty_message: 'No attachments on this message.') do |a|
|
|
223
|
+
"part_id #{a[:part_id]} #{a[:filename]} (#{a[:mime_type]}, #{a[:size]} bytes)"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
ToolHelpers.account_tool(
|
|
228
|
+
server,
|
|
229
|
+
name: 'gmail_download_attachment',
|
|
230
|
+
capability: 'gmail.download',
|
|
231
|
+
description: 'Download an attachment to a local directory. Get the part_id from gmail_list_attachments.',
|
|
232
|
+
properties: {
|
|
233
|
+
message_id: { type: 'string' },
|
|
234
|
+
part_id: { type: 'string', description: 'From gmail_list_attachments. Names the file after the attachment.' },
|
|
235
|
+
attachment_id: { type: 'string', description: 'Raw Gmail attachment id, instead of part_id. ' \
|
|
236
|
+
'Carries no filename, so pass filename too.' },
|
|
237
|
+
dest_dir: { type: 'string', description: 'Existing directory to write into. Required — there is no default.' },
|
|
238
|
+
filename: { type: 'string', description: 'Override the filename. Defaults to the name on the attachment.' }
|
|
239
|
+
},
|
|
240
|
+
required: %w[message_id dest_dir]
|
|
241
|
+
) do |message_id:, dest_dir:, part_id: nil, attachment_id: nil, filename: nil|
|
|
242
|
+
Tools.download_attachment_to(
|
|
243
|
+
message_id: message_id, part_id: part_id, attachment_id: attachment_id,
|
|
244
|
+
dest_dir: dest_dir, filename: filename
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# ── unsubscribe ─────────────────────────────────────────────────────
|
|
250
|
+
def self.register_unsubscribe(server)
|
|
251
|
+
ToolHelpers.account_tool(
|
|
252
|
+
server,
|
|
253
|
+
name: 'gmail_unsubscribe_info',
|
|
254
|
+
capability: 'gmail.read',
|
|
255
|
+
description: 'Show the List-Unsubscribe options a message offers, and whether one-click (RFC 8058) is supported.',
|
|
256
|
+
properties: {
|
|
257
|
+
message_id: { type: 'string' }
|
|
258
|
+
},
|
|
259
|
+
required: ['message_id']
|
|
260
|
+
) do |message_id:|
|
|
261
|
+
details = Unsubscribe.info(message_id)
|
|
262
|
+
next ToolHelpers.text_response('This message offers no List-Unsubscribe header.') if details.nil?
|
|
263
|
+
|
|
264
|
+
ToolHelpers.text_response([
|
|
265
|
+
"one-click (RFC 8058): #{details[:one_click] ? 'yes' : 'no'}",
|
|
266
|
+
"https: #{details[:https] || '(none)'}",
|
|
267
|
+
"http: #{details[:http] || '(none)'}",
|
|
268
|
+
"mailto: #{details[:mailto] || '(none)'}"
|
|
269
|
+
].join("\n"))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
ToolHelpers.account_tool(
|
|
273
|
+
server,
|
|
274
|
+
name: 'gmail_unsubscribe',
|
|
275
|
+
capability: 'gmail.unsubscribe',
|
|
276
|
+
description: 'Unsubscribe from a sender via RFC 8058 one-click, using the URL in the message\'s own ' \
|
|
277
|
+
'List-Unsubscribe header. https only. Note this confirms to the sender that the address is live.',
|
|
278
|
+
properties: {
|
|
279
|
+
message_id: { type: 'string', description: 'A message from the sender you want to stop hearing from' }
|
|
280
|
+
},
|
|
281
|
+
required: ['message_id']
|
|
282
|
+
) do |message_id:|
|
|
283
|
+
code = Unsubscribe.one_click!(message_id)
|
|
284
|
+
ToolHelpers.text_response("Unsubscribe request accepted (HTTP #{code}).")
|
|
285
|
+
# Expected outcomes, not faults: say what happened in the user's terms
|
|
286
|
+
# instead of guarded's class name and a backtrace on stderr.
|
|
287
|
+
rescue Unsubscribe::NotSupported => e
|
|
288
|
+
ToolHelpers.error_response("Cannot one-click unsubscribe: #{e.message}")
|
|
289
|
+
rescue Unsubscribe::Failed => e
|
|
290
|
+
ToolHelpers.error_response("Unsubscribe failed: #{e.message}")
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
class << self
|
|
295
|
+
# One line per message — id, date, sender, subject — so a caller can
|
|
296
|
+
# triage a page without a follow-up fetch per result.
|
|
297
|
+
def search(query:, max_results: 20, page_token: nil)
|
|
298
|
+
page = Message.search_page(query, max_results: max_results, page_token: page_token)
|
|
299
|
+
messages = Message.metadata_for(page[:messages].map(&:id), headers: Message::SEARCH_HEADERS)
|
|
300
|
+
|
|
301
|
+
lines = messages.map do |m|
|
|
302
|
+
[m.id, m.header('Date'), m.header('From'), m.header('Subject')].compact.join(' ')
|
|
303
|
+
end
|
|
304
|
+
body = lines.empty? ? 'No messages found.' : lines.join("\n")
|
|
305
|
+
if page[:next_page_token]
|
|
306
|
+
body += "\n\nMore results available. next page_token: #{page[:next_page_token]}"
|
|
307
|
+
end
|
|
308
|
+
body += "\n(Gmail estimates ~#{page[:estimate]} total matches; the estimate is approximate.)" if page[:estimate]
|
|
309
|
+
ToolHelpers.text_response(body)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Shared shape for the batch tools: clean the id set and refuse an
|
|
313
|
+
# empty one before any request. Gmail's own limit is enforced, not
|
|
314
|
+
# truncated, by Message.batch_modify.
|
|
315
|
+
def batching(ids)
|
|
316
|
+
ids = Array(ids).map { |i| i.to_s.strip }.reject(&:empty?).uniq
|
|
317
|
+
return ToolHelpers.error_response('No message ids given.') if ids.empty?
|
|
318
|
+
|
|
319
|
+
ToolHelpers.text_response(yield(ids))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# part_id is the normal route: one listing yields both the filename and
|
|
323
|
+
# a current attachmentId. A bare attachment_id cannot be named from a
|
|
324
|
+
# listing, because Gmail reissues the id on every fetch.
|
|
325
|
+
def download_attachment_to(message_id:, dest_dir:, part_id: nil, attachment_id: nil, filename: nil)
|
|
326
|
+
dir = ::File.expand_path(dest_dir)
|
|
327
|
+
return ToolHelpers.error_response("Not a directory: #{dir}") unless ::File.directory?(dir)
|
|
328
|
+
if blank?(part_id) && blank?(attachment_id)
|
|
329
|
+
return ToolHelpers.error_response('Pass part_id (from gmail_list_attachments) or attachment_id.')
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
if blank?(part_id)
|
|
333
|
+
fallback = "#{message_id}-#{attachment_id[0, 12]}.bin"
|
|
334
|
+
else
|
|
335
|
+
found = Message.attachment(message_id, part_id: part_id)
|
|
336
|
+
attachment_id = found[:attachment_id]
|
|
337
|
+
fallback = found[:filename]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
name = ::File.basename((blank?(filename) ? fallback : filename).to_s)
|
|
341
|
+
return ToolHelpers.error_response('Refusing to write: unusable filename.') if name.empty? || name == '.' || name == '..'
|
|
342
|
+
|
|
343
|
+
path = ::File.expand_path(::File.join(dir, name))
|
|
344
|
+
# basename should make this unreachable; belt and braces against a
|
|
345
|
+
# filename that escapes the destination.
|
|
346
|
+
unless path.start_with?(dir + ::File::SEPARATOR)
|
|
347
|
+
return ToolHelpers.error_response('Refusing to write outside the destination directory.')
|
|
348
|
+
end
|
|
349
|
+
return ToolHelpers.error_response("Refusing to overwrite existing file: #{path}") if ::File.exist?(path)
|
|
350
|
+
|
|
351
|
+
bytes = Message.download_attachment(message_id: message_id, attachment_id: attachment_id)
|
|
352
|
+
::File.binwrite(path, bytes)
|
|
353
|
+
ToolHelpers.text_response("Wrote #{bytes.bytesize} bytes to #{path}")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
def blank?(value)
|
|
359
|
+
value.nil? || value.to_s.strip.empty?
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module GMCP
|
|
7
|
+
module Gmail
|
|
8
|
+
# RFC 8058 one-click unsubscribe.
|
|
9
|
+
#
|
|
10
|
+
# This is the one place GMCP talks to a host that is not Google, so the
|
|
11
|
+
# rules are deliberately narrow:
|
|
12
|
+
#
|
|
13
|
+
# * The target URL is read from the message's own List-Unsubscribe header.
|
|
14
|
+
# A caller cannot pass a URL in — otherwise this would be a general
|
|
15
|
+
# purpose "POST anywhere" tool wearing a Gmail costume.
|
|
16
|
+
# * https only. A mailto: entry is reported but never acted on, and a
|
|
17
|
+
# plaintext http:// entry is refused rather than downgraded silently.
|
|
18
|
+
# * One-click is only attempted when the sender advertises it via
|
|
19
|
+
# List-Unsubscribe-Post, which is what RFC 8058 requires. Without that
|
|
20
|
+
# header the link is a web page needing a human, not an endpoint.
|
|
21
|
+
#
|
|
22
|
+
# Note that unsubscribing confirms to the sender that the address is live.
|
|
23
|
+
# That is fine for a legitimate business and a bad idea for a spammer; the
|
|
24
|
+
# judgement of which is which is the caller's, not this module's.
|
|
25
|
+
module Unsubscribe
|
|
26
|
+
ONE_CLICK_BODY = 'List-Unsubscribe=One-Click'
|
|
27
|
+
USER_AGENT = 'GMCP'
|
|
28
|
+
OPEN_TIMEOUT = 10
|
|
29
|
+
READ_TIMEOUT = 20
|
|
30
|
+
HEADERS = %w[List-Unsubscribe List-Unsubscribe-Post].freeze
|
|
31
|
+
|
|
32
|
+
class NotSupported < StandardError; end
|
|
33
|
+
class Failed < StandardError; end
|
|
34
|
+
|
|
35
|
+
# Returns { https:, mailto:, one_click:, raw: } — or nil if the message
|
|
36
|
+
# carries no List-Unsubscribe header at all.
|
|
37
|
+
def self.info(message_id)
|
|
38
|
+
message = Message.metadata(message_id, headers: HEADERS)
|
|
39
|
+
raw = message.header('List-Unsubscribe')
|
|
40
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
41
|
+
|
|
42
|
+
uris = raw.to_s.scan(/<([^>]+)>/).flatten
|
|
43
|
+
uris = raw.to_s.split(',').map(&:strip) if uris.empty?
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
https: uris.find { |u| u.start_with?('https://') },
|
|
47
|
+
http: uris.find { |u| u.start_with?('http://') },
|
|
48
|
+
mailto: uris.find { |u| u.start_with?('mailto:') },
|
|
49
|
+
one_click: message.header('List-Unsubscribe-Post').to_s.downcase.include?('one-click'),
|
|
50
|
+
raw: raw
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Performs the unsubscribe. Returns the final HTTP status.
|
|
55
|
+
def self.one_click!(message_id)
|
|
56
|
+
details = info(message_id)
|
|
57
|
+
raise NotSupported, 'message has no List-Unsubscribe header' if details.nil?
|
|
58
|
+
unless details[:one_click]
|
|
59
|
+
raise NotSupported,
|
|
60
|
+
'sender does not advertise List-Unsubscribe-Post; the link needs a human'
|
|
61
|
+
end
|
|
62
|
+
if details[:https].nil?
|
|
63
|
+
raise NotSupported,
|
|
64
|
+
details[:http] ? 'only a plaintext http:// unsubscribe URL is offered; refusing to use it'
|
|
65
|
+
: 'no https unsubscribe URL offered (mailto only)'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
post(details[:https])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.post(url, redirects_left: 3)
|
|
72
|
+
uri = URI.parse(url)
|
|
73
|
+
raise NotSupported, "refusing non-https unsubscribe URL: #{uri.scheme}" unless uri.is_a?(URI::HTTPS)
|
|
74
|
+
|
|
75
|
+
req = Net::HTTP::Post.new(uri)
|
|
76
|
+
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
77
|
+
req['User-Agent'] = USER_AGENT
|
|
78
|
+
req.body = ONE_CLICK_BODY
|
|
79
|
+
|
|
80
|
+
resp = Net::HTTP.start(uri.host, uri.port,
|
|
81
|
+
use_ssl: true,
|
|
82
|
+
open_timeout: OPEN_TIMEOUT,
|
|
83
|
+
read_timeout: READ_TIMEOUT) { |h| h.request(req) }
|
|
84
|
+
|
|
85
|
+
if resp.is_a?(Net::HTTPRedirection) && resp['location'] && redirects_left.positive?
|
|
86
|
+
return post(URI.join(url, resp['location']).to_s, redirects_left: redirects_left - 1)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
raise Failed, "unsubscribe endpoint returned HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
90
|
+
|
|
91
|
+
resp.code
|
|
92
|
+
end
|
|
93
|
+
private_class_method :post
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/gmcp/server.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require 'mcp'
|
|
2
|
+
|
|
3
|
+
# mcp validates schemas with json-schema, which warns on every start unless
|
|
4
|
+
# told to use the stdlib JSON it already has.
|
|
5
|
+
require 'json-schema'
|
|
6
|
+
JSON::Validator.use_multi_json = false
|
|
7
|
+
|
|
8
|
+
module GMCP
|
|
9
|
+
module Server
|
|
10
|
+
SERVER_NAME = 'gmcp'
|
|
11
|
+
SERVER_VERSION = GMCP::VERSION
|
|
12
|
+
|
|
13
|
+
def self.run!(accounts:)
|
|
14
|
+
@registry = AccountRegistry.new(accounts: accounts)
|
|
15
|
+
@registry.load_authorized_accounts!
|
|
16
|
+
MCP::Server::Transports::StdioTransport.new(build_server).open
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.reinitialize!(account:)
|
|
20
|
+
registry.reinitialize!(account: account)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Runs the block with `account` bound for the duration, restoring whatever
|
|
24
|
+
# was bound before. The binding is fiber-local, so two concurrent tool calls
|
|
25
|
+
# for different accounts cannot see each other's APIs.
|
|
26
|
+
def self.with_account(account, &block)
|
|
27
|
+
error, result = registry.scoped(account, &block)
|
|
28
|
+
error ? ToolHelpers.error_response(error) : result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The account a tool call names, or the default one; refuses an account
|
|
32
|
+
# outside GMCP_ACCOUNTS. For tools that do not bind Google APIs (Voice).
|
|
33
|
+
def self.configured_account(account)
|
|
34
|
+
account ||= registry.default_account
|
|
35
|
+
return account if account && registry.accounts.include?(account)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, registry.authorization_message(account)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.build_server
|
|
41
|
+
server = MCP::Server.new(name: SERVER_NAME, version: SERVER_VERSION)
|
|
42
|
+
register_auth_tool(server)
|
|
43
|
+
Gmail::Tools.register(server)
|
|
44
|
+
Calendar::Tools.register(server)
|
|
45
|
+
Drive::Tools.register(server)
|
|
46
|
+
Voice::Tools.register(server)
|
|
47
|
+
server
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.register_auth_tool(server)
|
|
51
|
+
ToolHelpers.define_tool(
|
|
52
|
+
server,
|
|
53
|
+
name: 'gmcp_authorize',
|
|
54
|
+
capability: 'gmcp.authorize',
|
|
55
|
+
description: 'Connect GMCP to a Google account. Opens a browser window for OAuth and captures the callback automatically.',
|
|
56
|
+
properties: {
|
|
57
|
+
account: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: "Google account to authorize (configured: #{registry.account_description})"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
) do |account: nil|
|
|
63
|
+
account ||= registry.default_account
|
|
64
|
+
|
|
65
|
+
next ToolHelpers.error_response(registry.authorization_message(account)) unless registry.accounts.include?(account)
|
|
66
|
+
|
|
67
|
+
unless ::File.exist?(Auth.credentials_path)
|
|
68
|
+
next ToolHelpers.error_response(
|
|
69
|
+
"credentials.json not found at #{Auth.credentials_path}.\n\n" \
|
|
70
|
+
"1. Go to https://console.cloud.google.com/ → APIs & Services → Credentials\n" \
|
|
71
|
+
"2. Create an OAuth 2.0 Client ID (Desktop app)\n" \
|
|
72
|
+
"3. Download the JSON and save it to #{Auth.credentials_path}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
url = Auth.authorize_interactive!(account: account, on_success: ->(acct) { Server.reinitialize!(account: acct) })
|
|
77
|
+
ToolHelpers.text_response(
|
|
78
|
+
"A browser window has opened for #{account}. Complete sign-in there — tools will become available automatically once you authorize.\n\n" \
|
|
79
|
+
"If the browser did not open, use this URL manually:\n#{url}"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.registry
|
|
85
|
+
@registry ||= AccountRegistry.new(accounts: [])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private_class_method :build_server, :register_auth_tool, :registry
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module GMCP
|
|
7
|
+
module ToolHelpers
|
|
8
|
+
ACCOUNT_PARAM = {
|
|
9
|
+
account: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
description: 'Google account to use (optional if only one is configured)'
|
|
12
|
+
}
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.text_response(text)
|
|
16
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Marked isError, so the client knows the call failed rather than having to
|
|
20
|
+
# guess from the wording.
|
|
21
|
+
def self.error_response(text)
|
|
22
|
+
MCP::Tool::Response.new([{ type: 'text', text: text }], error: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Him models do not include ActiveModel::Serializers::JSON, so `to_json` on
|
|
26
|
+
# one resolves to Object#to_json and yields the inspect string —
|
|
27
|
+
# "#<GMCP::Gmail::Message:0x...>". That is valid JSON and completely empty
|
|
28
|
+
# of the record, so a caller cannot tell it from success. Serialize by
|
|
29
|
+
# attributes instead, recursing so a collection of models does not hit the
|
|
30
|
+
# same wall one level down.
|
|
31
|
+
def self.json_response(object)
|
|
32
|
+
text_response(JSON.pretty_generate(serializable(object)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.serializable(object)
|
|
36
|
+
case object
|
|
37
|
+
when Array then object.map { |item| serializable(item) }
|
|
38
|
+
when Hash then object.to_h { |key, value| [key, serializable(value)] }
|
|
39
|
+
else
|
|
40
|
+
object.respond_to?(:attributes) ? serializable(object.attributes) : object
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Registers a tool, unless `capability:` names a capability this process was
|
|
45
|
+
# not granted — in which case the tool is never defined and therefore never
|
|
46
|
+
# appears in tools/list. See GMCP::Capabilities.
|
|
47
|
+
#
|
|
48
|
+
# Returns the tool on registration, nil when gated out.
|
|
49
|
+
#
|
|
50
|
+
# Every failure becomes an isError response here, once, instead of each
|
|
51
|
+
# tool rescuing its own way or letting the exception escape as a JSON-RPC
|
|
52
|
+
# internal error that hides the cause.
|
|
53
|
+
def self.define_tool(server, name:, description:, properties:, required: nil, capability: nil, &block)
|
|
54
|
+
declarations[name] = capability
|
|
55
|
+
return nil unless Capabilities.enabled?(capability)
|
|
56
|
+
|
|
57
|
+
schema = { properties: properties }
|
|
58
|
+
schema[:required] = required if required
|
|
59
|
+
# Declared with **args, so MCP passes server_context; the tool blocks do
|
|
60
|
+
# not take it. MCP installs this as the tool's own `call`, so self is the
|
|
61
|
+
# tool there: name the module explicitly.
|
|
62
|
+
handler = lambda do |**args|
|
|
63
|
+
args.delete(:server_context)
|
|
64
|
+
ToolHelpers.guarded { block.call(**args) }
|
|
65
|
+
end
|
|
66
|
+
server.define_tool(name: name, description: description, input_schema: schema, &handler)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A tool that acts on a Google account: adds the `account` parameter and
|
|
70
|
+
# runs the block with that account bound.
|
|
71
|
+
def self.account_tool(server, properties:, **options, &block)
|
|
72
|
+
define_tool(server, properties: properties.merge(ACCOUNT_PARAM), **options) do |account: nil, **args|
|
|
73
|
+
GMCP::Server.with_account(account) { block.call(**args) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.guarded
|
|
78
|
+
yield
|
|
79
|
+
rescue ApiError, Auth::AuthRequired, ApiBinding::NotBound, ArgumentError => e
|
|
80
|
+
error_response(e.message)
|
|
81
|
+
rescue Signet::AuthorizationError => e
|
|
82
|
+
error_response("Google rejected the stored token (#{e.message.lines.first&.strip}). Call gmcp_authorize to reconnect.")
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
warn "GMCP tool error: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
85
|
+
error_response("#{e.class}: #{e.message}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.list_response(items, empty_message:, &formatter)
|
|
89
|
+
lines = items.map(&formatter).join("\n")
|
|
90
|
+
text_response(lines.empty? ? empty_message : lines)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Every (tool name => capability) pair this process has attempted to
|
|
94
|
+
# register, granted or not. Populated as a side effect of define_tool, so it
|
|
95
|
+
# cannot drift from what the tools actually declare — which is what makes
|
|
96
|
+
# the manifest consistency spec exact rather than a regex over source.
|
|
97
|
+
def self.declarations
|
|
98
|
+
@declarations ||= {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.reset_declarations!
|
|
102
|
+
@declarations = {}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/gmcp/version.rb
ADDED