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,95 @@
1
+ module GMCP
2
+ module Calendar
3
+ class Event
4
+ include Him::Model
5
+
6
+ collection_path 'calendars/primary/events'
7
+ primary_key :id
8
+
9
+ parse_root_in_json true
10
+ root_element :items
11
+
12
+ attributes :id, :summary, :description, :start, :end, :location,
13
+ :attendees, :status, :organizer, :recurringEventId, :htmlLink
14
+
15
+ DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
16
+
17
+ # PATCH, not PUT: PUT replaces the whole event and Google rejects one
18
+ # without start and end. The attendee list is sent whole because Google
19
+ # replaces arrays rather than merging them.
20
+ def rsvp!(response, calendar_id: 'primary')
21
+ list = (attendees || []).map { |a| a.to_h.transform_keys(&:to_s) }
22
+ me = list.find { |a| a['self'] }
23
+ raise ArgumentError, "this account is not an attendee of event #{id}, so it cannot RSVP" unless me
24
+
25
+ me['responseStatus'] = response.to_s
26
+ self.class.patch_raw(Apis.path('calendars', calendar_id, 'events', id), { attendees: list })
27
+ end
28
+
29
+ # The start as Google reports it: dateTime for timed events, date for
30
+ # all-day ones. The raw hash is unreadable in a listing.
31
+ def starts_at
32
+ return nil unless start.is_a?(Hash)
33
+
34
+ start[:dateTime] || start['dateTime'] || start[:date] || start['date']
35
+ end
36
+
37
+ class << self
38
+ # A YYYY-MM-DD value is an all-day event; anything else is a dateTime.
39
+ # Google wants exactly one of date or dateTime.
40
+ def time_field(value, time_zone: nil)
41
+ return { date: value } if value.to_s.match?(DATE_ONLY)
42
+
43
+ { dateTime: value, timeZone: time_zone }.compact
44
+ end
45
+
46
+ def fetch(event_id, calendar_id: 'primary')
47
+ get_resource(Apis.path('calendars', calendar_id, 'events', event_id))
48
+ end
49
+
50
+ def list(**options)
51
+ list_page(**options)[:events]
52
+ end
53
+
54
+ # Returns { events:, next_page_token: }; next_page_token is nil on the
55
+ # last page. get_collection would drop the cursor.
56
+ def list_page(calendar_id: 'primary', time_min: nil, time_max: nil, max_results: 20, page_token: nil)
57
+ params = { maxResults: max_results, singleEvents: true, orderBy: 'startTime' }
58
+ params[:timeMin] = time_min if time_min
59
+ params[:timeMax] = time_max if time_max
60
+ params[:pageToken] = page_token if page_token && !page_token.to_s.empty?
61
+
62
+ get_raw(Apis.path('calendars', calendar_id, 'events'), params) do |parsed, _response|
63
+ data = parsed[:data] || {}
64
+ {
65
+ events: (data[:items] || []).map { |e| new(e) },
66
+ next_page_token: data[:nextPageToken]
67
+ }
68
+ end
69
+ end
70
+
71
+ # sendUpdates is a query parameter, and him sends non-GET params as the
72
+ # body, so it rides on the path. Without it Google adds attendees
73
+ # silently and nobody is invited.
74
+ def create_event(calendar_id: 'primary', **attrs)
75
+ post_raw(with_updates(Apis.path('calendars', calendar_id, 'events'), attrs), attrs)
76
+ end
77
+
78
+ def update_event(event_id:, calendar_id: 'primary', **attrs)
79
+ patch_raw(with_updates(Apis.path('calendars', calendar_id, 'events', event_id), attrs), attrs)
80
+ end
81
+
82
+ def delete_event(event_id:, calendar_id: 'primary')
83
+ delete_raw(Apis.path('calendars', calendar_id, 'events', event_id), {})
84
+ end
85
+
86
+ private
87
+
88
+ def with_updates(base, attrs)
89
+ attendees = attrs[:attendees]
90
+ attendees.nil? || attendees.empty? ? base : "#{base}?sendUpdates=all"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,150 @@
1
+ require 'mcp'
2
+ require 'time'
3
+
4
+ module GMCP
5
+ module Calendar
6
+ module Tools
7
+ def self.register(server)
8
+ ToolHelpers.account_tool(
9
+ server,
10
+ name: 'calendar_list_calendars',
11
+ capability: 'calendar.read',
12
+ description: 'List all calendars on the account',
13
+ properties: {}
14
+ ) do
15
+ ToolHelpers.list_response(Calendar.all, empty_message: 'No calendars found.') do |c|
16
+ "#{c.id}: #{c.summary}#{c.primary ? ' [primary]' : ''}"
17
+ end
18
+ end
19
+
20
+ ToolHelpers.account_tool(
21
+ server,
22
+ name: 'calendar_list_events',
23
+ capability: 'calendar.read',
24
+ description: 'List events from a calendar within an optional time range. ' \
25
+ 'Returns a page_token when more results exist; pass it back to get the next page.',
26
+ properties: {
27
+ calendar_id: { type: 'string', description: 'Calendar ID (default: primary)' },
28
+ time_min: { type: 'string', description: 'RFC3339 start of range (default: now), e.g. 2025-01-01T00:00:00Z' },
29
+ time_max: { type: 'string', description: 'RFC3339 end of range' },
30
+ max_results: { type: 'integer' },
31
+ page_token: { type: 'string', description: 'Cursor from a previous call. Omit for the first page.' }
32
+ }
33
+ ) do |calendar_id: 'primary', time_min: nil, time_max: nil, max_results: 20, page_token: nil|
34
+ # Without a lower bound Google starts at the calendar's first event,
35
+ # which can be decades back; "list events" means upcoming ones.
36
+ time_min ||= Time.now.utc.iso8601
37
+ page = Event.list_page(calendar_id: calendar_id, time_min: time_min, time_max: time_max,
38
+ max_results: max_results, page_token: page_token)
39
+ lines = page[:events].map { |e| "#{e.id}: #{e.summary} (#{e.starts_at})" }
40
+ body = lines.empty? ? 'No events found.' : lines.join("\n")
41
+ body += "\n\nMore results available. next page_token: #{page[:next_page_token]}" if page[:next_page_token]
42
+ ToolHelpers.text_response(body)
43
+ end
44
+
45
+ ToolHelpers.account_tool(
46
+ server,
47
+ name: 'calendar_get_event',
48
+ capability: 'calendar.read',
49
+ description: 'Get a calendar event by ID',
50
+ properties: {
51
+ event_id: { type: 'string' },
52
+ calendar_id: { type: 'string', description: 'Calendar ID (default: primary)' }
53
+ },
54
+ required: ['event_id']
55
+ ) do |event_id:, calendar_id: 'primary'|
56
+ ToolHelpers.json_response(Event.fetch(event_id, calendar_id: calendar_id))
57
+ end
58
+
59
+ ToolHelpers.account_tool(
60
+ server,
61
+ name: 'calendar_create_event',
62
+ capability: 'calendar.write',
63
+ description: 'Create a calendar event. Pass YYYY-MM-DD dates for an all-day event (the end date is ' \
64
+ 'exclusive). Attendees are sent invitations.',
65
+ properties: {
66
+ summary: { type: 'string' },
67
+ start_time: { type: 'string', description: 'RFC3339 datetime, e.g. 2025-06-01T10:00:00-07:00, or YYYY-MM-DD for all-day' },
68
+ end_time: { type: 'string', description: 'RFC3339 datetime, or YYYY-MM-DD (exclusive) for all-day' },
69
+ time_zone: { type: 'string', description: 'IANA time zone for timed events, e.g. America/Denver' },
70
+ description: { type: 'string' },
71
+ location: { type: 'string' },
72
+ attendees: { type: 'array', items: { type: 'string' }, description: 'Email addresses; each is sent an invitation' },
73
+ calendar_id: { type: 'string' }
74
+ },
75
+ required: ['summary', 'start_time', 'end_time']
76
+ ) do |summary:, start_time:, end_time:, time_zone: nil, description: nil, location: nil, attendees: [], calendar_id: 'primary'|
77
+ attrs = {
78
+ summary: summary,
79
+ start: Event.time_field(start_time, time_zone: time_zone),
80
+ end: Event.time_field(end_time, time_zone: time_zone),
81
+ attendees: attendees.map { |e| { email: e } }
82
+ }
83
+ attrs[:description] = description if description
84
+ attrs[:location] = location if location
85
+ Event.create_event(calendar_id: calendar_id, **attrs)
86
+ ToolHelpers.text_response("Event '#{summary}' created.")
87
+ end
88
+
89
+ ToolHelpers.account_tool(
90
+ server,
91
+ name: 'calendar_update_event',
92
+ capability: 'calendar.write',
93
+ description: 'Update fields on an existing calendar event. Pass YYYY-MM-DD dates to make it all-day ' \
94
+ '(the end date is exclusive).',
95
+ properties: {
96
+ event_id: { type: 'string' },
97
+ summary: { type: 'string' },
98
+ start_time: { type: 'string', description: 'RFC3339 datetime, or YYYY-MM-DD for all-day' },
99
+ end_time: { type: 'string', description: 'RFC3339 datetime, or YYYY-MM-DD (exclusive) for all-day' },
100
+ time_zone: { type: 'string', description: 'IANA time zone for timed events, e.g. America/Denver' },
101
+ description: { type: 'string' },
102
+ location: { type: 'string' },
103
+ calendar_id: { type: 'string' }
104
+ },
105
+ required: ['event_id']
106
+ ) do |event_id:, calendar_id: 'primary', summary: nil, start_time: nil, end_time: nil, time_zone: nil, description: nil, location: nil|
107
+ attrs = {}
108
+ attrs[:summary] = summary if summary
109
+ attrs[:start] = Event.time_field(start_time, time_zone: time_zone) if start_time
110
+ attrs[:end] = Event.time_field(end_time, time_zone: time_zone) if end_time
111
+ attrs[:description] = description if description
112
+ attrs[:location] = location if location
113
+ Event.update_event(event_id: event_id, calendar_id: calendar_id, **attrs)
114
+ ToolHelpers.text_response("Event #{event_id} updated.")
115
+ end
116
+
117
+ ToolHelpers.account_tool(
118
+ server,
119
+ name: 'calendar_delete_event',
120
+ capability: 'calendar.delete',
121
+ description: 'Delete a calendar event',
122
+ properties: {
123
+ event_id: { type: 'string' },
124
+ calendar_id: { type: 'string' }
125
+ },
126
+ required: ['event_id']
127
+ ) do |event_id:, calendar_id: 'primary'|
128
+ Event.delete_event(event_id: event_id, calendar_id: calendar_id)
129
+ ToolHelpers.text_response("Event #{event_id} deleted.")
130
+ end
131
+
132
+ ToolHelpers.account_tool(
133
+ server,
134
+ name: 'calendar_rsvp',
135
+ capability: 'calendar.write',
136
+ description: 'RSVP to a calendar event (accepted, declined, tentative). The account must be an attendee.',
137
+ properties: {
138
+ event_id: { type: 'string' },
139
+ response: { type: 'string', enum: %w[accepted declined tentative] },
140
+ calendar_id: { type: 'string', description: 'Calendar ID (default: primary)' }
141
+ },
142
+ required: ['event_id', 'response']
143
+ ) do |event_id:, response:, calendar_id: 'primary'|
144
+ Event.fetch(event_id, calendar_id: calendar_id).rsvp!(response, calendar_id: calendar_id)
145
+ ToolHelpers.text_response("RSVP'd #{response} to event #{event_id}.")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GMCP
6
+ # Capability gating for tool registration.
7
+ #
8
+ # GMCP fits a grant-based permission model in which a grant is
9
+ # `(identity, connector_account, capabilities[])`. GMCP honors the two axes:
10
+ #
11
+ # connector_account → GMCP_ACCOUNTS (which mailboxes this process can reach)
12
+ # capabilities → GMCP_CAPABILITIES (which verbs this process exposes)
13
+ #
14
+ # Gating happens at REGISTRATION, not invocation: a tool whose capability was
15
+ # not granted is never defined on the MCP server, so it never appears in
16
+ # tools/list and cannot be called no matter what the client sends. That is
17
+ # deliberately stronger than a client-side --allowedTools flag, which depends
18
+ # on the launcher assembling it correctly and the client honoring it.
19
+ #
20
+ # ── nil vs empty string is load-bearing ────────────────────────────────────
21
+ #
22
+ # GMCP_CAPABILITIES unset (nil) → every capability. For a human running
23
+ # bin/gmcp directly with no grant system.
24
+ # GMCP_CAPABILITIES="" → NO capabilities. The explicit empty grant
25
+ # set for an identity that was granted
26
+ # nothing.
27
+ #
28
+ # A launcher must therefore ALWAYS set the variable explicitly, including when
29
+ # the grant set is empty. Omitting it conditionally ("no grants, so skip the
30
+ # env var") fails open and hands the agent everything.
31
+ module Capabilities
32
+ # Loaded from config/capabilities.yml — that file is the source of truth,
33
+ # and spec/gmcp/capability_manifest_spec.rb asserts it matches the tools
34
+ # actually registered in lib/.
35
+ MANIFEST_PATH = "config/capabilities.yml"
36
+
37
+ def self.manifest
38
+ @manifest ||= YAML.load_file(GMCP.root(MANIFEST_PATH)).freeze
39
+ end
40
+
41
+ # gmail / google_calendar / drive / voice are separate
42
+ # connectors. One GMCP process serves all of them, and they do NOT share
43
+ # account semantics — see ACCOUNT_SCOPING.
44
+ def self.connectors
45
+ @connectors ||= manifest.fetch("connectors").freeze
46
+ end
47
+
48
+ # Whether GMCP_ACCOUNTS constrains which principal a connector reaches.
49
+ #
50
+ # enforced — it does; a grant's connector_account means what it says.
51
+ # ignored — it does not; the principal is resolved outside GMCP, and a
52
+ # grant's connector_account is decorative for that connector.
53
+ ACCOUNT_SCOPING = %w[enforced ignored].freeze
54
+
55
+ # Where a connector's credential lives. Voice is `delegated`: Chrome holds
56
+ # its session.
57
+ CREDENTIAL_SOURCES = %w[local_file keychain delegated none].freeze
58
+
59
+ def self.declared
60
+ @declared ||= connectors.flat_map { |c| c.fetch("capabilities").map { |x| x.fetch("name") } }.freeze
61
+ end
62
+
63
+ # capability name => the connector hash that declares it
64
+ def self.connector_index
65
+ @connector_index ||= connectors.each_with_object({}) do |connector, index|
66
+ connector.fetch("capabilities").each { |cap| index[cap.fetch("name")] = connector }
67
+ end.freeze
68
+ end
69
+
70
+ def self.connector_for(capability)
71
+ connector_index[capability]
72
+ end
73
+
74
+ def self.account_scoping_for(capability)
75
+ connector_for(capability)&.fetch("account_scoping")
76
+ end
77
+
78
+ # Granted capabilities whose connector ignores GMCP_ACCOUNTS. These reach a
79
+ # principal this process cannot narrow, so bin/gmcp says so on startup
80
+ # rather than letting the account list imply a containment it does not have.
81
+ def self.unscoped_grants
82
+ granted.select { |cap| account_scoping_for(cap) == "ignored" }
83
+ end
84
+
85
+ ALL = declared
86
+
87
+ ENV_VAR = 'GMCP_CAPABILITIES'
88
+
89
+ class << self
90
+ def granted
91
+ @granted ||= parse(ENV.fetch(ENV_VAR, nil))
92
+ end
93
+
94
+ def enabled?(capability)
95
+ return true if capability.nil?
96
+
97
+ granted.include?(capability)
98
+ end
99
+
100
+ # Human-readable summary, used by bin/gmcp on startup so the operator can
101
+ # see what this process is actually able to do.
102
+ def summary
103
+ if unrestricted?
104
+ "all (#{ALL.length} capabilities; #{ENV_VAR} not set)"
105
+ elsif granted.empty?
106
+ "none (#{ENV_VAR} set but empty)"
107
+ else
108
+ granted.join(', ')
109
+ end
110
+ end
111
+
112
+ def unrestricted?
113
+ ENV.fetch(ENV_VAR, nil).nil?
114
+ end
115
+
116
+ # Test seam — capability state is read once and memoized.
117
+ def reset!
118
+ @granted = nil
119
+ end
120
+
121
+ private
122
+
123
+ def parse(raw)
124
+ return ALL.dup if raw.nil?
125
+
126
+ requested = raw.split(',').map(&:strip).reject(&:empty?)
127
+ unknown = requested - ALL
128
+ warn "GMCP: ignoring unknown #{ENV_VAR} entries: #{unknown.join(', ')}" if unknown.any?
129
+ requested & ALL
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,145 @@
1
+ module GMCP
2
+ module Drive
3
+ class File
4
+ include Him::Model
5
+
6
+ collection_path 'files'
7
+ primary_key :id
8
+
9
+ parse_root_in_json true
10
+ root_element :files
11
+
12
+ attributes :id, :name, :mimeType, :description, :parents, :size,
13
+ :webViewLink, :webContentLink, :createdTime, :modifiedTime
14
+
15
+ # Large enough for any document worth reading into a conversation, small
16
+ # enough not to flood one.
17
+ MAX_BYTES = 1_048_576
18
+
19
+ # Google-native files have no bytes of their own; they must be exported.
20
+ EXPORTS = {
21
+ 'application/vnd.google-apps.document' => 'text/plain',
22
+ 'application/vnd.google-apps.spreadsheet' => 'text/csv',
23
+ 'application/vnd.google-apps.presentation' => 'text/plain'
24
+ }.freeze
25
+
26
+ GOOGLE_APPS = 'application/vnd.google-apps.'
27
+
28
+ TEXT_TYPES = %w[
29
+ application/json application/xml application/javascript application/x-javascript
30
+ application/yaml application/x-yaml application/sql application/x-sh
31
+ application/csv application/x-httpd-php application/toml
32
+ ].freeze
33
+
34
+ LIST_FIELDS = 'nextPageToken,files(id,name,mimeType,size,modifiedTime,webViewLink)'
35
+
36
+ class << self
37
+ def search(query, **options)
38
+ search_page(query, **options)[:files]
39
+ end
40
+
41
+ # Returns { files:, next_page_token: }; next_page_token is nil on the
42
+ # last page. get_collection would drop the cursor, and `fields` must
43
+ # name nextPageToken or Google leaves it out.
44
+ #
45
+ # Without the all-drives flags, files on shared drives are invisible.
46
+ def search_page(query, max_results: 20, order_by: 'modifiedTime desc', page_token: nil)
47
+ params = {
48
+ q: query, pageSize: max_results, orderBy: order_by, fields: LIST_FIELDS,
49
+ supportsAllDrives: true, includeItemsFromAllDrives: true
50
+ }
51
+ params[:pageToken] = page_token if page_token && !page_token.to_s.empty?
52
+
53
+ get_raw('files', params) do |parsed, _response|
54
+ data = parsed[:data] || {}
55
+ {
56
+ files: (data[:files] || []).map { |f| new(f) },
57
+ next_page_token: data[:nextPageToken]
58
+ }
59
+ end
60
+ end
61
+
62
+ def list_folder(folder_id, **options)
63
+ search("#{quote(folder_id)} in parents and trashed=false", **options)
64
+ end
65
+
66
+ def list_folder_page(folder_id, **options)
67
+ search_page("#{quote(folder_id)} in parents and trashed=false", **options)
68
+ end
69
+
70
+ # A value as a Drive query string literal. An unescaped quote in an id
71
+ # would end the literal and let the rest rewrite the query.
72
+ def quote(value)
73
+ "'#{value.to_s.gsub(/[\\']/) { |c| "\\#{c}" }}'"
74
+ end
75
+
76
+ # The file's content as text: Google-native files exported, text files
77
+ # downloaded. Raises ArgumentError for anything that is not text or is
78
+ # too large, since there is no useful way to hand either to a model.
79
+ def read_text(file_id)
80
+ meta = metadata(file_id)
81
+ mime = meta[:mimeType].to_s
82
+ name = meta[:name] || file_id
83
+
84
+ body =
85
+ if mime.start_with?(GOOGLE_APPS)
86
+ export = EXPORTS[mime]
87
+ raise ArgumentError, "#{name} is a #{mime}, which cannot be exported as text" unless export
88
+
89
+ raw_get(Apis.path('files', file_id, 'export'), mimeType: export)
90
+ else
91
+ raise ArgumentError, "#{name} is #{mime.empty? ? 'of unknown type' : mime}, not text" unless text?(mime)
92
+ raise ArgumentError, too_large(name, meta[:size].to_i) if meta[:size].to_i > MAX_BYTES
93
+
94
+ raw_get(Apis.path('files', file_id), alt: 'media', supportsAllDrives: true)
95
+ end
96
+
97
+ # Exports report no size up front.
98
+ raise ArgumentError, too_large(name, body.bytesize) if body.bytesize > MAX_BYTES
99
+
100
+ to_utf8(body)
101
+ end
102
+
103
+ def metadata(file_id)
104
+ get_raw(Apis.path('files', file_id), fields: 'id,name,mimeType,size', supportsAllDrives: true) do |parsed, _response|
105
+ parsed[:data] || {}
106
+ end
107
+ end
108
+
109
+ def text?(mime)
110
+ mime.start_with?('text/') || TEXT_TYPES.include?(mime) || mime.end_with?('+json', '+xml')
111
+ end
112
+
113
+ private
114
+
115
+ def too_large(name, bytes)
116
+ "#{name} is #{bytes} bytes; drive_read_file reads at most #{MAX_BYTES}"
117
+ end
118
+
119
+ # GET through the bound API's own middleware (bearer token, ApiError,
120
+ # adapter) minus him's JSON parser, which raises on a text body and
121
+ # turns a JSON file into a parsed hash. Built from the stack rather than
122
+ # a fresh Faraday connection so token refresh and error handling stay
123
+ # in one place.
124
+ def raw_get(request_path, params = {})
125
+ connection = her_api.connection
126
+ builder = connection.builder
127
+ app = builder.handlers
128
+ .reject { |handler| handler.klass <= Him::Middleware::ParseJSON }
129
+ .reverse
130
+ .inject(builder.adapter.build) { |inner, handler| handler.build(inner) }
131
+ request = connection.build_request(:get) { |req| req.url(request_path, params) }
132
+ app.call(builder.build_env(connection, request)).body.to_s
133
+ end
134
+
135
+ # Drive does not report a charset for most text files; UTF-8 is the
136
+ # overwhelming case, and invalid bytes are dropped rather than failing
137
+ # the whole read.
138
+ def to_utf8(body)
139
+ text = body.dup.force_encoding(Encoding::UTF_8)
140
+ text.valid_encoding? ? text : text.scrub('')
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,64 @@
1
+ require 'mcp'
2
+
3
+ module GMCP
4
+ module Drive
5
+ module Tools
6
+ def self.register(server)
7
+ ToolHelpers.account_tool(
8
+ server,
9
+ name: 'drive_search',
10
+ capability: 'drive.read',
11
+ description: 'Search Google Drive files, including shared drives, using a Drive query string. ' \
12
+ 'Returns a page_token when more results exist; pass it back to get the next page.',
13
+ properties: {
14
+ query: { type: 'string', description: "Drive query, e.g. \"name contains 'report' and mimeType='application/pdf'\"" },
15
+ max_results: { type: 'integer' },
16
+ page_token: { type: 'string', description: 'Cursor from a previous call. Omit for the first page.' }
17
+ },
18
+ required: ['query']
19
+ ) do |query:, max_results: 20, page_token: nil|
20
+ page = File.search_page(query, max_results: max_results, page_token: page_token)
21
+ Tools.listing(page, 'No files found.')
22
+ end
23
+
24
+ ToolHelpers.account_tool(
25
+ server,
26
+ name: 'drive_list_folder',
27
+ capability: 'drive.read',
28
+ description: 'List files in a Drive folder. ' \
29
+ 'Returns a page_token when more results exist; pass it back to get the next page.',
30
+ properties: {
31
+ folder_id: { type: 'string', description: 'Drive folder ID or "root"' },
32
+ max_results: { type: 'integer' },
33
+ page_token: { type: 'string', description: 'Cursor from a previous call. Omit for the first page.' }
34
+ },
35
+ required: ['folder_id']
36
+ ) do |folder_id:, max_results: 50, page_token: nil|
37
+ page = File.list_folder_page(folder_id, max_results: max_results, page_token: page_token)
38
+ Tools.listing(page, 'Folder is empty.')
39
+ end
40
+
41
+ ToolHelpers.account_tool(
42
+ server,
43
+ name: 'drive_read_file',
44
+ capability: 'drive.read',
45
+ description: 'Read a Drive file as text. Google Docs and Slides export as plain text, Sheets as CSV ' \
46
+ '(first sheet only). Other files must be text and at most 1 MB; binaries are refused.',
47
+ properties: {
48
+ file_id: { type: 'string' }
49
+ },
50
+ required: ['file_id']
51
+ ) do |file_id:|
52
+ ToolHelpers.text_response(File.read_text(file_id))
53
+ end
54
+ end
55
+
56
+ def self.listing(page, empty_message)
57
+ lines = page[:files].map { |f| "#{f.id}: #{f.name} (#{f.mimeType})" }
58
+ body = lines.empty? ? empty_message : lines.join("\n")
59
+ body += "\n\nMore results available. next page_token: #{page[:next_page_token]}" if page[:next_page_token]
60
+ ToolHelpers.text_response(body)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,21 @@
1
+ module GMCP
2
+ module Gmail
3
+ class Draft
4
+ include Him::Model
5
+
6
+ collection_path 'drafts'
7
+ primary_key :id
8
+
9
+ parse_root_in_json true
10
+ root_element :drafts
11
+
12
+ attributes :id, :message
13
+
14
+ class << self
15
+ def create_draft(to:, subject:, body:)
16
+ post_raw('drafts', { message: { raw: Mime.build(to: to, subject: subject, body: body) } })
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ module GMCP
2
+ module Gmail
3
+ class Label
4
+ include Him::Model
5
+
6
+ collection_path 'labels'
7
+ primary_key :id
8
+
9
+ parse_root_in_json true
10
+ root_element :labels
11
+
12
+ attributes :id, :name, :messageListVisibility, :labelListVisibility, :type,
13
+ :messagesTotal, :messagesUnread, :threadsTotal, :threadsUnread
14
+ end
15
+ end
16
+ end