ask-mcp 0.4.6 → 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: 8719aff67887e47eec36f7e8b8b7c822261cbf6e1120380580747691c4d2ef2b
4
- data.tar.gz: a5c2006a33be93158838aa8fc0f0ae69c8f3a1ec2aa07fd1478cf88c50f2aa62
3
+ metadata.gz: d29dde6a951c940e97933e666e97f5df75483b9de280be3fcabcb184fd5e6091
4
+ data.tar.gz: 27cb99a853ae2daabb8f38910e8f14373d0af7d0c3f99ff79f054ed8a88c38bd
5
5
  SHA512:
6
- metadata.gz: d5a909234ffdc34be3ee993118ae8145b971f120516e7eef66be8c1bf95b5a9b18e767a366e0abca51f5fd1d573186e60496404afeebcc2d66cc3d371a907a74
7
- data.tar.gz: 8c058d280dd5da3aac82fcebe7aacddb3a7c5e8861375e987835294d99cfd6f4f79e12589f47b53773397267fbf7992a44f9548321b65cc6d6a108175fe800af
6
+ metadata.gz: ccfaa454b590f534bf0b91aa73c6a13894d4b9fd029a95ac4239ef00bcc29d0a0bf56cee54f2a1f1c941104d7f95b4d60e8d2c3ac235d0b3c1b4949b34a1428c
7
+ data.tar.gz: 40da8189f319185ea90e8e1a8cf8bed64aee140ce182fa439c8985f548878a44289f1ee95d48ca043928f97a85c24d460815e0deff8d7188f37e2b089de02237
data/CHANGELOG.md CHANGED
@@ -1,3 +1,81 @@
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
+
10
+ ## [0.5.0] - 2026-09-17
11
+
12
+ ### Added
13
+
14
+ - **`Ask::MCP::Server::HTTP`, a Rack application serving MCP over the
15
+ stateless Streamable HTTP transport (2026-07-28)** — mount it wherever Rack
16
+ runs: `mount Ask::MCP::Server.rack_app(name: "anychat", tools: [...]) => "/mcp"`.
17
+ Every POST carries one JSON-RPC message and is answered with a single
18
+ `application/json` object; there are no sessions, no GET stream and no SSE
19
+ resumability, which is what the revision requires. The transport enforces
20
+ what the spec makes a server's job: `Origin` validation (403 for an origin
21
+ the host has not allowed), the required `MCP-Protocol-Version`, `Mcp-Method`
22
+ and `Mcp-Name` request headers validated against the body (400 with
23
+ `HeaderMismatch` -32020), the base64 sentinel form for header values that are
24
+ not plain ASCII, `UnsupportedProtocolVersionError` (-32022) listing the
25
+ versions it does support, 404 with -32601 for a method it does not implement,
26
+ 202 for a notification, and 401 with `WWW-Authenticate` when `authenticate`
27
+ rejects a caller. `tools` may be a callable of the request context, which is
28
+ what lets a host expose a different tool set per caller.
29
+
30
+ - **`Ask::MCP::Server::Core`, transport-agnostic message handling** — the
31
+ dispatch that used to live inside `Server::Stdio`, extracted so more than one
32
+ transport can share it. A Core turns one parsed message into the messages the
33
+ server wants to send and collects them in `#outbox`; `Stdio` overrides
34
+ `#deliver` to write to stdout as they are produced. Protocol state is
35
+ per-instance, so a stateless transport builds a Core per request.
36
+
37
+ - **`serverInfo` in every stateless result's `_meta`** — servers SHOULD
38
+ identify themselves on each result so a client can attribute an answer
39
+ without a separate discovery round trip.
40
+
41
+ ### Changed
42
+
43
+ - **`Ask::MCP::Server::Stdio` is now a thin subclass of `Core`** — the read
44
+ loop, the stdout writer and the `notify_*` methods remain. Wire behaviour is
45
+ unchanged; the init gate is now stated once instead of on each gated method.
46
+
47
+ - **The client negotiates stateless servers correctly** — `server/discover` is
48
+ now sent as a *modern* request, carrying `MCP-Protocol-Version` and `_meta`,
49
+ because a compliant 2026-07-28 server refuses anything else. A version the
50
+ server does not implement comes back as `UnsupportedProtocolVersionError`;
51
+ the client retries once with the best version the server advertises instead
52
+ of falling back to the `initialize` handshake the revision removed.
53
+
54
+ - **A JSON-RPC error on a non-200 response is delivered as a response, not
55
+ raised as a transport failure** — the spec has servers carry errors on 400
56
+ (header mismatch, unsupported version) and 404 (unknown method), so
57
+ `Transport::StreamableHTTP` surfaces those to the message handlers where the
58
+ pending request can pick them up. `ConnectionError` is reserved for responses
59
+ that are not JSON-RPC at all.
60
+
61
+ ### Fixed
62
+
63
+ - **A server rejection now echoes the request id** — request-level errors
64
+ (header mismatch, unsupported version) carry the id of the request that
65
+ caused them, so a client can correlate a failure with what it sent. Errors
66
+ raised before the body is understood, and the 403 for an untrusted `Origin`,
67
+ carry no id, which the spec allows.
68
+
69
+ - **A base64 `Mcp-Name` decodes as UTF-8** — base64 carries bytes and knows
70
+ nothing of encodings, so a decoded name was compared as ASCII-8BIT and never
71
+ matched the UTF-8 value in the body. The spec defines the payload as the
72
+ UTF-8 representation, so the decoded string is tagged as such.
73
+
74
+ - **A stateless request naming an unspoken revision is refused** — version
75
+ mismatches are answered with `UnsupportedProtocolVersionError` (-32022) and
76
+ the list of versions the server does speak, replacing the generic
77
+ "not initialized" the legacy gate would have produced.
78
+
1
79
  ## [0.4.5] - 2026-08-12
2
80
 
3
81
  ### 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
@@ -161,8 +161,23 @@ module Ask
161
161
  @transport.protocol_version = @protocol_version if @transport.respond_to?(:protocol_version=)
162
162
  end
163
163
 
164
+ # Probe the server as a *modern* request. A compliant 2026-07-28 server
165
+ # requires MCP-Protocol-Version and `_meta` on every request and refuses
166
+ # anything else, so the probe has to name a version before negotiation
167
+ # has happened. A version the server does not implement comes back as an
168
+ # UnsupportedProtocolVersionError naming the ones it does; retry once
169
+ # with the best of those. Answers false for a server that is not modern,
170
+ # which is the caller's cue to fall back to `initialize`.
164
171
  def discover_server
165
- response = send_request_raw("server/discover", {}, timeout: 5)
172
+ response = probe_discover(Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.last)
173
+
174
+ if unsupported_version?(response)
175
+ chosen = advertised_protocol_version(response)
176
+ return false unless chosen
177
+
178
+ response = probe_discover(chosen)
179
+ end
180
+
166
181
  return false unless response.success?
167
182
 
168
183
  result = response.result
@@ -179,15 +194,43 @@ module Ask
179
194
  false
180
195
  end
181
196
 
197
+ def probe_discover(version)
198
+ @transport.protocol_version = version if @transport.respond_to?(:protocol_version=)
199
+ request = Native::Messages::Request.new(
200
+ method: "server/discover", params: meta_params(version), id: next_id
201
+ )
202
+ wait_for_response(request, timeout: 5)
203
+ end
204
+
205
+ def unsupported_version?(response)
206
+ return false if response.success?
207
+
208
+ error_of(response)[:code] == Native::Messages::ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION
209
+ end
210
+
211
+ def advertised_protocol_version(response)
212
+ data = error_of(response)[:data]
213
+ supported = Array(data[:supported] || data["supported"])
214
+ Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.reverse.find { |version| supported.include?(version) }
215
+ end
216
+
217
+ def error_of(response)
218
+ error = response.error
219
+ { code: error[:code] || error["code"], data: error[:data] || error["data"] || {} }
220
+ end
221
+
182
222
  def reset_caches
183
223
  @tools_cache = nil
184
224
  @resources_cache = nil
185
225
  @prompts_cache = nil
186
226
  end
187
227
 
188
- def meta_params
228
+ # The `_meta` envelope every stateless request carries. `version` is
229
+ # overridable so the discover probe can name a version before any
230
+ # negotiation has happened.
231
+ def meta_params(version = @protocol_version)
189
232
  meta = {
190
- Native::Messages::Meta::PROTOCOL_VERSION_KEY => @protocol_version,
233
+ Native::Messages::Meta::PROTOCOL_VERSION_KEY => version,
191
234
  Native::Messages::Meta::CLIENT_CAPABILITIES_KEY => @options[:client_capabilities] || {},
192
235
  Native::Messages::Meta::CLIENT_INFO_KEY => { name: "ask-mcp", version: Ask::MCP::VERSION }
193
236
  }