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,148 @@
1
+ # Capability manifest for the GMCP MCP server.
2
+ #
3
+ # SOURCE OF TRUTH for what GMCP can do. GMCP::Capabilities loads from here, and
4
+ # spec/gmcp/capability_manifest_spec.rb asserts that every capability declared
5
+ # here is claimed by exactly the tools listed and vice versa — so the manifest
6
+ # cannot drift from the code without the suite going red. Capabilities should
7
+ # be declared by the connector, never inferred; a declaration that can drift is
8
+ # worse than an inference, because it reads as authoritative while being
9
+ # wrong. The drift spec is what makes this file a fact about the connector
10
+ # rather than a claim by it.
11
+ #
12
+ # A static file (rather than a tool) also lets a registration UI read what a
13
+ # connector COULD do without spawning it first.
14
+ #
15
+ # credential_source says where a connector's credential lives:
16
+ # local_file | keychain | delegated | none. GMCP's OAuth tokens are local_file,
17
+ # at ~/.config/gmcp/<account>/token.yaml. Voice is `delegated`: its session is
18
+ # held by an application GMCP does not run (Chrome), though GMCP picks the
19
+ # Chrome profile signed in as the requested account, so the principal is still
20
+ # constrained.
21
+ #
22
+ # ── account_scoping ────────────────────────────────────────────────────────
23
+ #
24
+ # One GMCP process serves several connectors, and they do not share account
25
+ # semantics. Each declares which it has:
26
+ #
27
+ # enforced — GMCP_ACCOUNTS constrains which principal this connector reaches.
28
+ # A grant's connector_account means what it says.
29
+ #
30
+ # ignored — GMCP_ACCOUNTS does NOT constrain this connector. The principal is
31
+ # resolved at call time by something outside GMCP. A grant's
32
+ # connector_account is DECORATIVE for it: it names an account that
33
+ # constrains nothing.
34
+ #
35
+ # No connector is `ignored` today; the value stays so that one which cannot be
36
+ # scoped is forced to say so rather than look normal. The safety property of the grant model is that the account column always
37
+ # constrains; a connector that quietly violates that has to say so in a field a
38
+ # UI can read, not in a comment. Render an `ignored` grant as the principal it
39
+ # actually reaches, never as the account named on the row.
40
+
41
+ provider: gmcp
42
+
43
+ connectors:
44
+ # ── the server itself ────────────────────────────────────────────────────
45
+ - name: gmcp
46
+ account_scoping: enforced
47
+ credential_source: local_file
48
+ description: GMCP's own administrative surface.
49
+ capabilities:
50
+ - name: gmcp.authorize
51
+ description: >-
52
+ Start the OAuth consent flow for a configured Google account. Opens a
53
+ browser window on the machine running GMCP.
54
+ tools: [gmcp_authorize]
55
+
56
+ # ── Gmail ────────────────────────────────────────────────────────────────
57
+ - name: gmail
58
+ account_scoping: enforced
59
+ credential_source: local_file
60
+ description: Google Mail, per authorized account.
61
+ capabilities:
62
+ - name: gmail.read
63
+ description: Search and read mail, and list labels. No mutation of any kind.
64
+ tools: [gmail_search, gmail_get_message, gmail_list_labels,
65
+ gmail_list_attachments, gmail_unsubscribe_info]
66
+
67
+ - name: gmail.modify_labels
68
+ description: >-
69
+ Add and remove labels on a message. Archiving is included because in
70
+ Gmail archiving IS removing the INBOX label — these tools reach labels
71
+ and nothing else.
72
+ tools: [gmail_archive_message, gmail_label_message,
73
+ gmail_batch_archive, gmail_batch_modify]
74
+
75
+ - name: gmail.trash
76
+ description: Move a message to the trash. Separate from modify_labels because it is destructive.
77
+ tools: [gmail_trash_message, gmail_batch_trash]
78
+
79
+ - name: gmail.send
80
+ description: >-
81
+ Send mail out into the world, or stage it as a draft. The highest-risk
82
+ capability here: the only one whose effects reach third parties.
83
+ tools: [gmail_send, gmail_create_draft, gmail_reply]
84
+
85
+ - name: gmail.download
86
+ description: >-
87
+ Write attachment bytes to a local directory. Separate from gmail.read
88
+ because reading headers into a response is not the same act as
89
+ creating files on the machine GMCP runs on.
90
+ tools: [gmail_download_attachment]
91
+
92
+ - name: gmail.unsubscribe
93
+ description: >-
94
+ Send an RFC 8058 one-click unsubscribe to the URL in a message's own
95
+ List-Unsubscribe header. Separate because it leaves Google entirely
96
+ and tells a third party the address is live.
97
+ tools: [gmail_unsubscribe]
98
+
99
+ # ── Calendar ─────────────────────────────────────────────────────────────
100
+ - name: google_calendar
101
+ account_scoping: enforced
102
+ credential_source: local_file
103
+ description: Google Calendar, per authorized account.
104
+ capabilities:
105
+ - name: calendar.read
106
+ description: List calendars and read events. No mutation.
107
+ tools: [calendar_list_calendars, calendar_list_events, calendar_get_event]
108
+
109
+ - name: calendar.write
110
+ description: >-
111
+ Create and update events, and respond to invitations. RSVP is here
112
+ rather than under read because it is visible to other attendees.
113
+ tools: [calendar_create_event, calendar_update_event, calendar_rsvp]
114
+
115
+ - name: calendar.delete
116
+ description: Delete an event. Separate from write because it is destructive.
117
+ tools: [calendar_delete_event]
118
+
119
+ # ── Drive ────────────────────────────────────────────────────────────────
120
+ - name: drive
121
+ account_scoping: enforced
122
+ credential_source: local_file
123
+ description: Google Drive, per authorized account. Read-only; GMCP exposes no Drive writes.
124
+ capabilities:
125
+ - name: drive.read
126
+ description: Search Drive, list folders, and read file contents.
127
+ tools: [drive_search, drive_list_folder, drive_read_file]
128
+
129
+ # ── Voice ────────────────────────────────────────────────────────────────
130
+ - name: voice
131
+ account_scoping: enforced
132
+ credential_source: delegated
133
+ description: >-
134
+ Google Voice, per account. There is no usable OAuth path, so the session
135
+ is read from the Chrome profile signed in as the requested account —
136
+ found by email in Chrome's profile list, never by guessing — and an
137
+ account with no such profile has no Voice. GMCP_ACCOUNTS constrains it
138
+ like any other connector.
139
+ capabilities:
140
+ - name: voice.read
141
+ description: >-
142
+ List and search Voice messages, calls, and voicemail, and report an
143
+ account's Voice number.
144
+ tools: [voice_account, voice_list, voice_search]
145
+
146
+ - name: voice.modify
147
+ description: Mark a Voice thread read. Touches the read flag and nothing else.
148
+ tools: [voice_mark_read]
data/config/scopes.yml ADDED
@@ -0,0 +1,14 @@
1
+ # OAuth scope definitions per service.
2
+ # Used by GMCP::Auth when requesting consent.
3
+
4
+ gmail:
5
+ - https://www.googleapis.com/auth/gmail.modify
6
+
7
+ calendar:
8
+ - https://www.googleapis.com/auth/calendar.events
9
+
10
+ drive:
11
+ - https://www.googleapis.com/auth/drive.readonly
12
+
13
+ voice:
14
+ - https://www.googleapis.com/auth/userinfo.email
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GMCP
4
+ class AccountRegistry
5
+ attr_reader :accounts
6
+
7
+ def initialize(accounts:)
8
+ @accounts = accounts
9
+ @apis = {}
10
+ ApiBinding.install!
11
+ end
12
+
13
+ def load_authorized_accounts!
14
+ accounts.each { |account| load_account(account) }
15
+ end
16
+
17
+ def reinitialize!(account:)
18
+ @apis[account] = Apis.build_for_account(account:)
19
+ end
20
+
21
+ def default_account
22
+ accounts.first
23
+ end
24
+
25
+ def account_description
26
+ accounts.length == 1 ? default_account : accounts.join(', ')
27
+ end
28
+
29
+ # The API set for an account, or nil if it is unknown or unauthorized.
30
+ def apis_for(account = nil)
31
+ account ||= default_account
32
+ account && @apis[account]
33
+ end
34
+
35
+ # Runs the block with this account bound, restoring whatever was bound
36
+ # before. Returns [error_message, nil] or [nil, block_result] so the caller
37
+ # can tell an auth failure from a legitimately nil result.
38
+ def scoped(account = nil)
39
+ apis = apis_for(account)
40
+ return [authorization_message(account || default_account), nil] unless apis
41
+
42
+ [nil, ApiBinding.with(apis) { yield }]
43
+ end
44
+
45
+ def authorization_message(account)
46
+ if account && accounts.include?(account)
47
+ "Not authorized for #{account}. Call gmcp_authorize(account: \"#{account}\")."
48
+ elsif account
49
+ "Unknown account #{account.inspect}. Configured: #{accounts.join(', ')}."
50
+ else
51
+ 'Not authorized. Call gmcp_authorize.'
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def load_account(account)
58
+ @apis[account] = Apis.build_for_account(account:)
59
+ rescue Auth::AuthRequired, Errno::ENOENT
60
+ nil # not authorized yet, or no credentials.json; gmcp_authorize reports which
61
+ rescue Signet::AuthorizationError, Google::Auth::AuthorizationError => e
62
+ # Refresh token revoked or expired. Don't crash the whole server —
63
+ # leave this account unbound and let the user re-authorize via gmcp_authorize.
64
+ warn "GMCP: stored token for #{account} is no longer valid (#{e.message[0, 120]}); call gmcp_authorize to reconnect"
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GMCP
4
+ # Per-fiber binding of models to a set of account APIs.
5
+ #
6
+ # him resolves `use_api` at request time and calls it if it responds to
7
+ # :call (him/lib/him/model/http.rb). Binding a *resolver* once therefore lets
8
+ # the API in effect change per fiber without ever mutating class state again.
9
+ #
10
+ # This is what makes it safe for one process to serve more than one account:
11
+ # writing the API onto the model class instead would let two concurrent
12
+ # requests for different accounts race on process-global state.
13
+ #
14
+ # Binding is fiber-local (Thread.current[]) rather than thread-local, so it
15
+ # holds under fiber schedulers as well as threads.
16
+ module ApiBinding
17
+ KEY = :gmcp_current_apis
18
+
19
+ # Deferred so autoload is not forced at require time.
20
+ MODELS = {
21
+ gmail: -> { [Gmail::Message, Gmail::Label, Gmail::Draft] },
22
+ calendar: -> { [Calendar::Event, Calendar::Calendar] },
23
+ drive: -> { [Drive::File] }
24
+ }.freeze
25
+
26
+ # Raised when a model issues a request with no account bound. Better than
27
+ # him's NoMethodError on nil, and better than silently reusing whatever the
28
+ # previous caller left behind.
29
+ class NotBound < StandardError; end
30
+
31
+ class << self
32
+ # Idempotent — the resolvers only need installing once per process.
33
+ def install!
34
+ return if @installed
35
+
36
+ MODELS.each do |service, models|
37
+ resolver = -> { fetch(service) }
38
+ models.call.each { |model| model.use_api(resolver) }
39
+ end
40
+ @installed = true
41
+ end
42
+
43
+ def current
44
+ Thread.current[KEY]
45
+ end
46
+
47
+ def current=(apis)
48
+ Thread.current[KEY] = apis
49
+ end
50
+
51
+ # Scopes a binding to a block and restores whatever was in effect before,
52
+ # so a nested call cannot leak its account to its caller.
53
+ def with(apis)
54
+ previous = current
55
+ self.current = apis
56
+ yield
57
+ ensure
58
+ self.current = previous
59
+ end
60
+
61
+ def fetch(service)
62
+ apis = current
63
+ raise NotBound, "no account is bound on this fiber (wanted #{service})" if apis.nil?
64
+
65
+ apis[service] || raise(NotBound, "bound account has no #{service} API")
66
+ end
67
+
68
+ # Test seam.
69
+ def reset!
70
+ @installed = false
71
+ self.current = nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,33 @@
1
+ require 'json'
2
+
3
+ module GMCP
4
+ # A Google API request that came back 4xx/5xx. Without this, him hands an
5
+ # error body back as if it were the resource, so a failed trash or send
6
+ # reported success and a missing message surfaced as NoMethodError on nil.
7
+ class ApiError < StandardError
8
+ attr_reader :status
9
+
10
+ def initialize(status, message)
11
+ @status = status
12
+ super("Google API error #{status}: #{message}")
13
+ end
14
+
15
+ # Sits next to the adapter so it sees the raw body before any JSON parsing.
16
+ class Middleware < Faraday::Middleware
17
+ def on_complete(env)
18
+ return if env[:status] < 400
19
+
20
+ raise ApiError.new(env[:status], message_from(env[:body]) || env[:reason_phrase] || 'request failed')
21
+ end
22
+
23
+ private
24
+
25
+ def message_from(body)
26
+ error = JSON.parse(body.to_s)['error']
27
+ error.is_a?(Hash) ? error['message'] : error
28
+ rescue JSON::ParserError
29
+ body.to_s[0, 200] unless body.to_s.empty?
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/gmcp/apis.rb ADDED
@@ -0,0 +1,46 @@
1
+ require 'erb'
2
+
3
+ module GMCP
4
+ module Apis
5
+ GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me/'
6
+ CALENDAR_BASE = 'https://www.googleapis.com/calendar/v3/'
7
+ DRIVE_BASE = 'https://www.googleapis.com/drive/v3/'
8
+
9
+ # Fails fast (AuthRequired, or a refresh error) when the account has no
10
+ # usable token, then resolves the access token per request so it is
11
+ # refreshed as it nears expiry.
12
+ # Joins path segments, escaping each. Calendar ids are email-like and
13
+ # holiday and contact calendars contain '#', which would otherwise end the
14
+ # path and send the request to the wrong resource.
15
+ def self.path(*segments)
16
+ segments.map { |s| ERB::Util.url_encode(s.to_s) }.join('/')
17
+ end
18
+
19
+ def self.build_for_account(account:)
20
+ Auth.credentials(account:)
21
+ build(token: -> { Auth.access_token(account:) })
22
+ end
23
+
24
+ # The adapter is a seam for specs, which run the real middleware stack
25
+ # against Faraday's :test adapter.
26
+ def self.build(token:, adapter: [Faraday.default_adapter])
27
+ {
28
+ gmail: build_api(GMAIL_BASE, token, adapter),
29
+ calendar: build_api(CALENDAR_BASE, token, adapter),
30
+ drive: build_api(DRIVE_BASE, token, adapter)
31
+ }
32
+ end
33
+
34
+ def self.build_api(base_url, token, adapter)
35
+ Him::API.new(url: base_url) do |conn|
36
+ conn.options.params_encoder = Faraday::FlatParamsEncoder # labelIds=a&labelIds=b, as Google expects
37
+ conn.request :json
38
+ conn.use BearerMiddleware, token: token
39
+ conn.use Him::Middleware::DefaultParseJSON
40
+ conn.use ApiError::Middleware
41
+ conn.adapter(*adapter)
42
+ end
43
+ end
44
+ private_class_method :build_api
45
+ end
46
+ end
data/lib/gmcp/auth.rb ADDED
@@ -0,0 +1,189 @@
1
+ require 'googleauth'
2
+ require 'googleauth/token_store'
3
+ require 'securerandom'
4
+ require 'webrick'
5
+ require 'yaml'
6
+
7
+ module GMCP
8
+ module Auth
9
+ CONFIG_DIR = File.expand_path('~/.config/gmcp').freeze
10
+ DEFAULT_CREDENTIALS_PATH = File.join(CONFIG_DIR, 'credentials.json').freeze
11
+ CALLBACK_PATH = '/oauth2callback'
12
+ AUTH_TIMEOUT = 900
13
+ REFRESH_MARGIN = 60
14
+ # An email address, and nothing that could climb out of CONFIG_DIR.
15
+ ACCOUNT_FORMAT = /\A[^\s\/\\@]+@[^\s\/\\@]+\z/
16
+ AUTH_SUCCESS_HTML = '<html><body><h2>Authorized! You can close this tab.</h2></body></html>'
17
+ AUTH_FAILURE_HTML = '<html><body><h2>Authorization failed — no code returned.</h2></body></html>'
18
+
19
+ class AuthRequired < StandardError; end
20
+
21
+ # Refresh tokens are long-lived secrets: written owner-only, atomically, and
22
+ # never created as a side effect of a read. googleauth's FileTokenStore
23
+ # wraps YAML::Store, whose read transaction creates an empty file under the
24
+ # process umask, which left 0644 token.yaml files for accounts that were
25
+ # never authorized. The on-disk format is the same, so existing tokens load.
26
+ class TokenStore < Google::Auth::TokenStore
27
+ def initialize(path)
28
+ super()
29
+ @path = path
30
+ end
31
+
32
+ def load(id)
33
+ read[id]
34
+ end
35
+
36
+ def store(id, token)
37
+ write(read.merge(id => token))
38
+ end
39
+
40
+ def delete(id)
41
+ write(read.except(id))
42
+ end
43
+
44
+ private
45
+
46
+ def read
47
+ return {} unless File.size?(@path)
48
+
49
+ File.chmod(0o600, @path) unless (File.stat(@path).mode & 0o077).zero? # tighten older files
50
+ YAML.safe_load_file(@path) || {}
51
+ end
52
+
53
+ def write(data)
54
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
55
+ tmp = "#{@path}.#{Process.pid}.tmp"
56
+ File.write(tmp, YAML.dump(data), perm: 0o600)
57
+ File.rename(tmp, @path)
58
+ end
59
+ end
60
+
61
+ class << self
62
+ def credentials_path
63
+ ENV.fetch('GMCP_CREDENTIALS_FILE', DEFAULT_CREDENTIALS_PATH)
64
+ end
65
+
66
+ def token_path(account)
67
+ raise ArgumentError, "not an email address: #{account.inspect}" unless account.to_s.match?(ACCOUNT_FORMAT)
68
+
69
+ File.join(CONFIG_DIR, account, 'token.yaml')
70
+ end
71
+
72
+ def scopes
73
+ @scopes ||= YAML.load_file(GMCP.root('config', 'scopes.yml')).values.flatten.freeze
74
+ end
75
+
76
+ # Live credentials for an account, refreshed when within REFRESH_MARGIN
77
+ # of expiry. Held per account so a refresh is shared by every request,
78
+ # and serialized so concurrent requests do not all refresh at once.
79
+ def credentials(account:)
80
+ lock.synchronize do
81
+ creds = (cache[account] ||= load_credentials(account))
82
+ creds.refresh! if creds.expires_within?(REFRESH_MARGIN)
83
+ creds
84
+ end
85
+ end
86
+
87
+ def access_token(account:)
88
+ credentials(account:).access_token
89
+ end
90
+
91
+ # Opens Google's consent page in a browser and returns its URL. The code
92
+ # exchange completes on a background thread when Google redirects to the
93
+ # loopback callback; exactly one of on_success / on_failure is called,
94
+ # unless the timeout passes first.
95
+ def authorize_interactive!(account:, on_success:, on_failure: nil, timeout: AUTH_TIMEOUT)
96
+ token_path(account) # validates before anything touches disk or network
97
+ state = SecureRandom.urlsafe_base64(24)
98
+ server = WEBrick::HTTPServer.new(
99
+ BindAddress: '127.0.0.1', Port: 0,
100
+ Logger: WEBrick::Log.new(File::NULL), AccessLog: []
101
+ )
102
+ base_url = "http://127.0.0.1:#{server.config[:Port]}"
103
+ on_failure ||= ->(_acct, error) { warn "GMCP auth error: #{error.class}: #{error.message}" }
104
+
105
+ server.mount_proc(CALLBACK_PATH) do |req, res|
106
+ handle_callback(req, res, server:, state:, account:, base_url:, on_success:, on_failure:)
107
+ end
108
+ serve(server, timeout)
109
+
110
+ url = authorizer.get_authorization_url(base_url:, state:, login_hint: account)
111
+ open_browser(url)
112
+ url
113
+ end
114
+
115
+ # Test seam.
116
+ def reset!
117
+ @cache = @authorizer = @client_id = nil
118
+ end
119
+
120
+ private
121
+
122
+ def lock
123
+ @lock ||= Mutex.new
124
+ end
125
+
126
+ def cache
127
+ @cache ||= {}
128
+ end
129
+
130
+ def load_credentials(account)
131
+ raise AuthRequired, "No stored token for #{account.inspect}" unless File.size?(token_path(account))
132
+
133
+ authorizer(account).get_credentials(account) ||
134
+ raise(AuthRequired, "Stored token for #{account.inspect} lacks the required scopes")
135
+ end
136
+
137
+ def client_id
138
+ @client_id ||= Google::Auth::ClientId.from_file(credentials_path)
139
+ end
140
+
141
+ # One authorizer per token file; with no account, one for building the
142
+ # consent URL, which never reads or writes a token.
143
+ def authorizer(account = nil)
144
+ store = account ? TokenStore.new(token_path(account)) : TokenStore.new(File::NULL)
145
+ Google::Auth::UserAuthorizer.new(client_id, scopes, store)
146
+ end
147
+
148
+ # Requests that do not carry our state are ignored rather than ending the
149
+ # flow, so a stray or forged hit cannot cancel or hijack a sign-in.
150
+ def handle_callback(req, res, server:, state:, account:, base_url:, on_success:, on_failure:)
151
+ res['Content-Type'] = 'text/html'
152
+ unless req.query['state'] == state
153
+ res.status = 400
154
+ res.body = AUTH_FAILURE_HTML
155
+ return
156
+ end
157
+
158
+ code = req.query['code']
159
+ res.body = code ? AUTH_SUCCESS_HTML : AUTH_FAILURE_HTML
160
+ server.shutdown
161
+ return on_failure.call(account, AuthRequired.new(req.query['error'] || 'no code returned')) unless code
162
+
163
+ Thread.new do
164
+ authorizer(account).get_and_store_credentials_from_code(user_id: account, code:, base_url:)
165
+ lock.synchronize { cache.delete(account) }
166
+ on_success.call(account)
167
+ rescue StandardError => e
168
+ on_failure.call(account, e)
169
+ end
170
+ end
171
+
172
+ def serve(server, timeout)
173
+ Thread.new { server.start }
174
+ Thread.new do
175
+ sleep timeout
176
+ server.shutdown
177
+ end
178
+ end
179
+
180
+ def open_browser(url)
181
+ case RbConfig::CONFIG['host_os']
182
+ when /darwin/ then system('open', url)
183
+ when /linux/ then system('xdg-open', url)
184
+ when /mswin|mingw/ then system('start', url)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,17 @@
1
+ module GMCP
2
+ # Sets the Authorization header from a token provider called per request, so
3
+ # a long-running server picks up refreshed access tokens instead of sending
4
+ # the one it started with until Google rejects it an hour later.
5
+ class BearerMiddleware < Faraday::Middleware
6
+ def initialize(app, token:)
7
+ super(app)
8
+ @token = token
9
+ end
10
+
11
+ def call(env)
12
+ token = @token.respond_to?(:call) ? @token.call : @token
13
+ env[:request_headers]['Authorization'] = "Bearer #{token}"
14
+ @app.call(env)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module GMCP
2
+ module Calendar
3
+ class Calendar
4
+ include Him::Model
5
+
6
+ collection_path 'users/me/calendarList'
7
+ primary_key :id
8
+
9
+ parse_root_in_json true
10
+ root_element :items
11
+
12
+ attributes :id, :summary, :description, :timeZone, :primary, :accessRole,
13
+ :backgroundColor, :foregroundColor
14
+ end
15
+ end
16
+ end