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 +4 -4
- data/CHANGELOG.md +78 -0
- data/lib/ask/mcp/auth/connect.rb +241 -0
- data/lib/ask/mcp/client.rb +46 -3
- data/lib/ask/mcp/server/core.rb +372 -0
- data/lib/ask/mcp/server/http.rb +344 -0
- data/lib/ask/mcp/server/stdio.rb +16 -292
- data/lib/ask/mcp/server.rb +18 -0
- data/lib/ask/mcp/transport/streamable_http.rb +27 -15
- data/lib/ask/mcp/version.rb +3 -1
- data/lib/ask/mcp.rb +1 -0
- metadata +4 -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,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
|
data/lib/ask/mcp/client.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =>
|
|
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
|
}
|