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.
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module GMCP
2
+ VERSION = '0.5.0'
3
+ end