ask-mcp 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 207c163681c8ee59bc9c869627cb6c053ca5e3824bb2e7eafd4669152c0a86cf
4
- data.tar.gz: '029f07e479189995bb8b9aa7b4e09eb7e70308851789a4a9e05ab09a80e56568'
3
+ metadata.gz: d29dde6a951c940e97933e666e97f5df75483b9de280be3fcabcb184fd5e6091
4
+ data.tar.gz: 27cb99a853ae2daabb8f38910e8f14373d0af7d0c3f99ff79f054ed8a88c38bd
5
5
  SHA512:
6
- metadata.gz: 823681d47e782fa73574987dfdb65f7a0ef53ff68d8dfb46d3537995d1bfa04a231bac6ec53b6b4fa03f7c34276ee6181d339644b821a391dffbcc9642a42303
7
- data.tar.gz: 4118870bcb52e14efccb0d1fec411c01af5d68e5b62111a5c6818d952004f63a8198c35f7264997f7922a41a7418fb6851a31bd3a20e182ead6fbd685011ef8f
6
+ metadata.gz: ccfaa454b590f534bf0b91aa73c6a13894d4b9fd029a95ac4239ef00bcc29d0a0bf56cee54f2a1f1c941104d7f95b4d60e8d2c3ac235d0b3c1b4949b34a1428c
7
+ data.tar.gz: 40da8189f319185ea90e8e1a8cf8bed64aee140ce182fa439c8985f548878a44289f1ee95d48ca043928f97a85c24d460815e0deff8d7188f37e2b089de02237
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.6.0
2
+
3
+ - `Auth::Connect`: the browser OAuth flow for MCP servers, as a reusable
4
+ class. Discovers the authorization server behind a protected resource
5
+ (RFC 9728) and its endpoints (RFC 8414 / OIDC), registers the client on
6
+ the spot when the server allows it, builds the authorization URL with
7
+ PKCE, and redeems the redirected code — or a refresh token — for tokens.
8
+ HTTP rides a small injected seam, so hosts can bring their own client.
9
+
1
10
  ## [0.5.0] - 2026-09-17
2
11
 
3
12
  ### Added
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "uri"
6
+
7
+ module Ask
8
+ module MCP
9
+ module Auth
10
+ # The browser dance that gives one person a credential for an MCP
11
+ # server: discover the authorization server behind the protected
12
+ # resource, register this client when the server allows it, hand the
13
+ # host a URL to send the browser to, and redeem the redirected code
14
+ # for tokens.
15
+ #
16
+ # Built for hosts that drive a real browser — a web action redirects
17
+ # to #authorization_url and its callback redeems the code — so the
18
+ # class keeps no browser state of its own. The caller carries the
19
+ # state and the PKCE verifier between the two steps, and persists
20
+ # #registered_client when dynamic registration produced one.
21
+ class Connect
22
+ class Error < StandardError; end
23
+
24
+ attr_reader :endpoint, :registered_client
25
+
26
+ def initialize(endpoint:, client_id: nil, client_secret: nil, http: Http)
27
+ @endpoint = endpoint
28
+ @client_id = client_id
29
+ @client_secret = client_secret
30
+ @http = http
31
+ @metadata = nil
32
+ @registered_client = nil
33
+ end
34
+
35
+ # The URL to send the browser to, with the PKCE verifier the caller
36
+ # must keep until the callback. The scope comes from the server's
37
+ # own advertised scopes unless the caller names one.
38
+ def authorization_url(redirect_uri:, state:, scope: nil, code_verifier: nil)
39
+ metadata = discover!
40
+ client = ensure_client!(redirect_uri)
41
+ verifier = code_verifier || self.class.generate_verifier
42
+
43
+ params = {
44
+ response_type: "code",
45
+ client_id: client[:client_id],
46
+ redirect_uri: redirect_uri,
47
+ state: state,
48
+ code_challenge: pkce_challenge(verifier),
49
+ code_challenge_method: "S256"
50
+ }
51
+ params[:scope] = scope || metadata[:scopes_supported]&.join(" ") || "mcp"
52
+
53
+ {url: "#{metadata[:authorization_endpoint]}?#{URI.encode_www_form(params)}", code_verifier: verifier}
54
+ end
55
+
56
+ # Redeem the redirected code. The same verifier the authorization
57
+ # step handed back comes along; the answer is the caller's token to
58
+ # store against whoever consented.
59
+ def exchange(code:, verifier:, redirect_uri:)
60
+ metadata = discover!
61
+ client = ensure_client!(redirect_uri)
62
+
63
+ params = {
64
+ grant_type: "authorization_code",
65
+ code: code,
66
+ redirect_uri: redirect_uri,
67
+ client_id: client[:client_id],
68
+ code_verifier: verifier
69
+ }
70
+ params[:client_secret] = client[:client_secret] if client[:client_secret]
71
+
72
+ token_response(@http.post_json(metadata[:token_endpoint], params))
73
+ end
74
+
75
+ # A fresh access token from the refresh token the server handed out.
76
+ def refresh(refresh_token:)
77
+ metadata = discover!
78
+ client = ensure_client!
79
+
80
+ params = {
81
+ grant_type: "refresh_token",
82
+ refresh_token: refresh_token,
83
+ client_id: client[:client_id]
84
+ }
85
+ params[:client_secret] = client[:client_secret] if client[:client_secret]
86
+
87
+ token_response(@http.post_json(metadata[:token_endpoint], params))
88
+ end
89
+
90
+ # Walk the chain MCP defines: the resource server names its
91
+ # authorization servers, and one of them names its endpoints. A
92
+ # server that publishes nothing is its own authorization server.
93
+ def discover!
94
+ return @metadata if @metadata
95
+
96
+ issuer = authorization_server_for(endpoint)
97
+ document = authorization_server_metadata(issuer)
98
+ unless document && document[:authorization_endpoint] && document[:token_endpoint]
99
+ raise Error, "no authorization server metadata at #{issuer}"
100
+ end
101
+
102
+ @metadata = {
103
+ issuer: issuer,
104
+ authorization_endpoint: document[:authorization_endpoint],
105
+ token_endpoint: document[:token_endpoint],
106
+ registration_endpoint: document[:registration_endpoint],
107
+ scopes_supported: document[:scopes_supported]
108
+ }
109
+ end
110
+
111
+ # The client this server knows us by: pre-registered credentials
112
+ # when the host has them, otherwise a registration the server
113
+ # created on the spot. The registered client is exposed for the
114
+ # host to persist — a registration that is remembered is one that
115
+ # is never asked for twice.
116
+ def ensure_client!(redirect_uri = nil)
117
+ return {client_id: @client_id, client_secret: @client_secret}.compact if @client_id
118
+
119
+ registration = @metadata && @metadata[:registration_endpoint]
120
+ raise Error, "no client credentials and #{endpoint} does not allow registration" unless registration
121
+ raise Error, "dynamic registration needs a redirect_uri" unless redirect_uri
122
+
123
+ @registered_client ||= begin
124
+ document = @http.post_json(registration, registration_params(redirect_uri))
125
+ {client_id: document[:client_id], client_secret: document[:client_secret]}.compact
126
+ end
127
+ end
128
+
129
+ def self.generate_verifier
130
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(64)).delete("=")
131
+ end
132
+
133
+ def self.pkce_challenge(verifier)
134
+ Base64.urlsafe_encode64(Digest::SHA256.digest(verifier)).delete("=")
135
+ end
136
+
137
+ private
138
+
139
+ attr_reader :http
140
+
141
+ def registration_params(redirect_uri)
142
+ {
143
+ client_name: "Anychat",
144
+ redirect_uris: [redirect_uri],
145
+ grant_types: %w[authorization_code refresh_token],
146
+ response_types: %w[code],
147
+ token_endpoint_auth_method: "none",
148
+ application_type: "web"
149
+ }
150
+ end
151
+
152
+ def pkce_challenge(verifier)
153
+ self.class.pkce_challenge(verifier)
154
+ end
155
+
156
+ # RFC 9728: the resource server publishes its metadata under its own
157
+ # well-known path. The RFC allows the root form and a path-suffixed
158
+ # form; a server that publishes neither is treated as its own
159
+ # authorization server, which is how single-server setups behave.
160
+ def authorization_server_for(url)
161
+ uri = URI.parse(url.to_s)
162
+ candidates = [
163
+ well_known_uri(uri, "oauth-protected-resource"),
164
+ well_known_uri(uri, "oauth-protected-resource", suffix_path: uri.path)
165
+ ].compact
166
+
167
+ document = candidates.filter_map { |candidate| http.get_json(candidate) rescue nil }.first
168
+ servers = document && (document[:authorization_servers] || document["authorization_servers"])
169
+ Array(servers).first || "#{uri.scheme}://#{uri.host}#{uri.port == uri.default_port ? "" : ":#{uri.port}"}"
170
+ rescue URI::InvalidURIError
171
+ raise Error, "not a usable endpoint: #{endpoint.inspect}"
172
+ end
173
+
174
+ # RFC 8414 and OIDC discovery, same bargain: try the standard
175
+ # placements and take the first that answers.
176
+ def authorization_server_metadata(issuer)
177
+ issuer_uri = URI.parse(issuer.to_s)
178
+ [
179
+ well_known_uri(issuer_uri, "oauth-authorization-server"),
180
+ well_known_uri(issuer_uri, "oauth-authorization-server", suffix_path: issuer_uri.path),
181
+ well_known_uri(issuer_uri, "openid-configuration")
182
+ ].compact.filter_map { |candidate| http.get_json(candidate) rescue nil }.first
183
+ end
184
+
185
+ def well_known_uri(uri, name, suffix_path: nil)
186
+ path = suffix_path.to_s.sub(%r{/\z}, "")
187
+ base = "#{uri.scheme}://#{uri.host}#{uri.port == uri.default_port ? "" : ":#{uri.port}"}"
188
+ if path.empty?
189
+ "#{base}/.well-known/#{name}"
190
+ else
191
+ "#{base}/.well-known/#{name}#{path}"
192
+ end
193
+ end
194
+
195
+ def token_response(response)
196
+ unless response[:access_token]
197
+ raise Error, "token request did not return an access_token"
198
+ end
199
+
200
+ {
201
+ access_token: response[:access_token],
202
+ refresh_token: response[:refresh_token],
203
+ expires_at: response[:expires_in] ? Time.now + response[:expires_in].to_i : nil,
204
+ scope: response[:scope]
205
+ }
206
+ end
207
+
208
+ # A thin HTTP seam, so a host can bring its own client and tests can
209
+ # stay off the network. Form-encoded bodies, because that is what
210
+ # authorization servers speak at these endpoints.
211
+ module Http
212
+ module_function
213
+
214
+ def get_json(url)
215
+ require "httpx"
216
+
217
+ response = HTTPX.get(url)
218
+ return nil unless response.status == 200
219
+
220
+ JSON.parse(response.body.to_s, symbolize_names: true)
221
+ rescue JSON::ParserError
222
+ nil
223
+ end
224
+
225
+ def post_json(url, params)
226
+ require "httpx"
227
+
228
+ response = HTTPX.post(url, form: params)
229
+ unless response.status == 200 || response.status == 201
230
+ raise Error, "request to #{url} failed: #{response.status} #{response.body.to_s[0, 200]}"
231
+ end
232
+
233
+ JSON.parse(response.body.to_s, symbolize_names: true)
234
+ rescue JSON::ParserError
235
+ raise Error, "invalid JSON from #{url}"
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ask
2
4
  module MCP
3
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
4
6
  end
5
7
  end
data/lib/ask/mcp.rb CHANGED
@@ -37,6 +37,7 @@ module Ask
37
37
 
38
38
  module Auth
39
39
  autoload :OAuth, "ask/mcp/auth/oauth"
40
+ autoload :Connect, "ask/mcp/auth/connect"
40
41
  autoload :Token, "ask/mcp/auth/token"
41
42
  autoload :ClientIdMetadataDocument, "ask/mcp/auth/client_id_metadata_document"
42
43
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -112,6 +112,7 @@ files:
112
112
  - lib/ask/mcp/adapters/ask_tool.rb
113
113
  - lib/ask/mcp/adapters/tool_server.rb
114
114
  - lib/ask/mcp/auth/client_id_metadata_document.rb
115
+ - lib/ask/mcp/auth/connect.rb
115
116
  - lib/ask/mcp/auth/oauth.rb
116
117
  - lib/ask/mcp/auth/token.rb
117
118
  - lib/ask/mcp/client.rb