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,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
|