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 +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/ask/mcp/auth/connect.rb +241 -0
- data/lib/ask/mcp/version.rb +3 -1
- data/lib/ask/mcp.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d29dde6a951c940e97933e666e97f5df75483b9de280be3fcabcb184fd5e6091
|
|
4
|
+
data.tar.gz: 27cb99a853ae2daabb8f38910e8f14373d0af7d0c3f99ff79f054ed8a88c38bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/mcp/version.rb
CHANGED
data/lib/ask/mcp.rb
CHANGED
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.
|
|
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
|