ask-mcp 0.5.0 → 0.6.1
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 +21 -0
- data/README.md +28 -0
- data/lib/ask/mcp/auth/connect.rb +241 -0
- data/lib/ask/mcp/runtime_executor.rb +208 -0
- data/lib/ask/mcp/tool_discovery.rb +29 -0
- data/lib/ask/mcp/version.rb +3 -1
- data/lib/ask/mcp.rb +3 -0
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2b34899f61be9226b77f6e212d5756555c86e52e17ba301fcf3eaa24f1d3a62
|
|
4
|
+
data.tar.gz: 51656c01a274744d51bb328d11f0f9dc84ac6f20fbe7fa5bb0b90125e670a2d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baec4cee8a4bdd42174107b545e0c7a144dd7a646f64e37f3ed09d3f57826a417a1a801ec5f06fc8aed7119a9357c41f524549e62b5c0446e756ebbd5df0171c
|
|
7
|
+
data.tar.gz: dfaddecd4733e04fa5fbac5ee68ae95031cf4e3111a9db7237170ac7601f261ddb25f7f82eab41f478a2aa010aa9c31c92ca385b2b41d4e615cf628363e43578
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
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
|
+
- **`Ask::MCP::RuntimeExecutor`** — bridges MCP clients into the
|
|
11
|
+
`Ask::Runtime::ToolExecutor` interface. Wraps an `Ask::MCP::Client`,
|
|
12
|
+
implements `#execute(tool_call, context:)`, normalizes MCP content/result
|
|
13
|
+
shapes (array, hash, string, nil) into `ToolResult.success` or `failure`,
|
|
14
|
+
honors context cancellation before calling the client, and emits
|
|
15
|
+
`ToolStarted` / `ToolCompleted` / `ToolFailed` / `ToolCancelled` lifecycle
|
|
16
|
+
events via `context.event_sink` with terminal snapshots.
|
|
17
|
+
|
|
18
|
+
- **`Ask::MCP::ToolDiscovery`** module — helper for discovering MCP tools
|
|
19
|
+
from a client and wrapping them as `Adapters::AskTool` or
|
|
20
|
+
`Ask::Tools::Tool` instances.
|
|
21
|
+
|
|
1
22
|
## [0.5.0] - 2026-09-17
|
|
2
23
|
|
|
3
24
|
### Added
|
data/README.md
CHANGED
|
@@ -161,6 +161,8 @@ annotations are excluded from `tools/list` on HTTP transports.
|
|
|
161
161
|
| `server.notify_*_list_changed` | Emit change notifications to clients |
|
|
162
162
|
| `Ask::MCP::Adapters::AskTool.wrap(tools_hash)` | Adapter from MCP tools to `Ask::Tool` instances for ask-agent |
|
|
163
163
|
| `Ask::MCP::Adapters::ToolServer` | Adapter from duck-typed tools to MCP server tools |
|
|
164
|
+
| `Ask::MCP::RuntimeExecutor.new(client)` | Bridge MCP client into `Ask::Runtime::ToolExecutor` |
|
|
165
|
+
| `Ask::MCP::ToolDiscovery` | Discover MCP tools and wrap them as `AskTool` adapters |
|
|
164
166
|
| `Ask::MCP::Auth::Token.new(token)` | Token-based auth (`apply(headers)`) |
|
|
165
167
|
| `Ask::MCP::Auth::OAuth.new(client_id:, ...)` | OAuth for MCP; `discover!` (OIDC), `authenticate!`, `validate_iss!`, `apply(headers)` |
|
|
166
168
|
| `Ask::MCP::Auth::ClientIdMetadataDocument` | Build/validate Client ID Metadata Documents (2026-07-28 client registration) |
|
|
@@ -188,6 +190,32 @@ wrapped.each { |name, adapter| agent.register_tool(adapter.to_ask_tool) }
|
|
|
188
190
|
|
|
189
191
|
Expose `Ask::Tool` subclasses as an MCP server with `Ask::MCP::Server.start_stdio(name:, tools:, capabilities: { tools: {} })`; the `ToolServer` adapter handles them.
|
|
190
192
|
|
|
193
|
+
### With ask-runtime
|
|
194
|
+
|
|
195
|
+
Bridge an MCP client into `Ask::Runtime` tool-call pipelines:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
require "ask/mcp"
|
|
199
|
+
require "ask/runtime"
|
|
200
|
+
|
|
201
|
+
client = Ask::MCP.from_stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
|
|
202
|
+
client.start
|
|
203
|
+
|
|
204
|
+
executor = Ask::MCP::RuntimeExecutor.new(client)
|
|
205
|
+
|
|
206
|
+
# Build a tool call and context
|
|
207
|
+
call = Ask::Runtime::ToolCall.new(tool_name: "read_file", input: { path: "/tmp/test.txt" })
|
|
208
|
+
ctx = Ask::Runtime::ExecutionContext.new(session_id: "s1", turn: 1)
|
|
209
|
+
|
|
210
|
+
result = executor.execute(call, context: ctx)
|
|
211
|
+
result.success? # => true
|
|
212
|
+
result.output # => file contents
|
|
213
|
+
|
|
214
|
+
# Discover and wrap tools for integration
|
|
215
|
+
include Ask::MCP::ToolDiscovery
|
|
216
|
+
tools = mcp_tools(client) # => Hash{String => Adapters::AskTool}
|
|
217
|
+
```
|
|
218
|
+
|
|
191
219
|
## Full documentation
|
|
192
220
|
|
|
193
221
|
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
|
@@ -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
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask/runtime"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module MCP
|
|
7
|
+
# Bridges an MCP Client into the Ask::Runtime::ToolExecutor interface.
|
|
8
|
+
#
|
|
9
|
+
# Wraps an Ask::MCP::Client and implements the execute() contract so that
|
|
10
|
+
# MCP tools can participate in Ask::Runtime tool-call pipelines.
|
|
11
|
+
#
|
|
12
|
+
# executor = Ask::MCP::RuntimeExecutor.new(client)
|
|
13
|
+
# result = executor.execute(tool_call, context: ctx)
|
|
14
|
+
#
|
|
15
|
+
# Responsibilities:
|
|
16
|
+
# - Delegates to client.call_tool(tool_call.tool_name, tool_call.input)
|
|
17
|
+
# - Normalizes MCP content/result shapes into ToolResult.success or failure
|
|
18
|
+
# - Honors context cancellation before calling the client
|
|
19
|
+
# - Emits ToolStarted / ToolCompleted / ToolFailed / ToolCancelled events
|
|
20
|
+
# via context.event_sink with terminal snapshots
|
|
21
|
+
#
|
|
22
|
+
# Error handling:
|
|
23
|
+
# - MCP isError responses → ToolResult.failure
|
|
24
|
+
# - Client exceptions (ProtocolError, ConnectionError) → ToolResult.failure
|
|
25
|
+
# - Malformed/empty results → ToolResult.success with nil output
|
|
26
|
+
#
|
|
27
|
+
class RuntimeExecutor
|
|
28
|
+
include Ask::Runtime::ToolExecutor
|
|
29
|
+
|
|
30
|
+
# @param client [Ask::MCP::Client] an initialized MCP client
|
|
31
|
+
def initialize(client)
|
|
32
|
+
@client = client
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Ask::MCP::Client] the underlying MCP client
|
|
36
|
+
attr_reader :client
|
|
37
|
+
|
|
38
|
+
# Create a RuntimeExecutor from a client.
|
|
39
|
+
#
|
|
40
|
+
# @param client [Ask::MCP::Client] an initialized MCP client
|
|
41
|
+
# @return [RuntimeExecutor]
|
|
42
|
+
def self.from_client(client)
|
|
43
|
+
new(client)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Execute a tool call via the MCP client.
|
|
47
|
+
#
|
|
48
|
+
# @param tool_call [Ask::Runtime::ToolCall] the tool-call request
|
|
49
|
+
# @param context [Ask::Runtime::ExecutionContext, nil] execution context
|
|
50
|
+
# @return [Ask::Runtime::ToolResult] normalized result
|
|
51
|
+
def execute(tool_call, context: nil)
|
|
52
|
+
context ||= Ask::Runtime::ExecutionContext.new
|
|
53
|
+
|
|
54
|
+
started_call = transition(tool_call, :running, started_at: Time.now)
|
|
55
|
+
emit_event(Ask::Runtime::Events::ToolStarted,
|
|
56
|
+
tool_call: started_call, execution_context: context)
|
|
57
|
+
|
|
58
|
+
return cancel(tool_call, context, "Cancelled before execution") if context.cancelled?
|
|
59
|
+
|
|
60
|
+
result = call_tool(tool_call)
|
|
61
|
+
return cancel(tool_call, context, "Cancelled during execution") if context.cancelled?
|
|
62
|
+
|
|
63
|
+
finished_at = Time.now
|
|
64
|
+
duration = finished_at - started_call.started_at
|
|
65
|
+
|
|
66
|
+
terminal_call = started_call.with(
|
|
67
|
+
state: result.success? ? :completed : :failed,
|
|
68
|
+
tool_result: result,
|
|
69
|
+
finished_at: finished_at
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
event_class = result.success? ? Ask::Runtime::Events::ToolCompleted : Ask::Runtime::Events::ToolFailed
|
|
73
|
+
emit_event(event_class,
|
|
74
|
+
tool_call: terminal_call, tool_result: result,
|
|
75
|
+
execution_context: context, duration: duration)
|
|
76
|
+
|
|
77
|
+
result_with_duration(result, duration)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def call_tool(tool_call)
|
|
83
|
+
response = @client.call_tool(tool_call.tool_name, tool_call.input)
|
|
84
|
+
normalize_result(response)
|
|
85
|
+
rescue Ask::MCP::Error, Ask::MCP::ConnectionError => e
|
|
86
|
+
Ask::Runtime::ToolResult.failure(e.message)
|
|
87
|
+
rescue => e
|
|
88
|
+
Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Normalize MCP tools/call response shapes into a ToolResult.
|
|
92
|
+
#
|
|
93
|
+
# MCP responses are one of:
|
|
94
|
+
# Array — [{ type: "text", text: "..." }, ...]
|
|
95
|
+
# Hash — { content: [...], isError: bool, ... }
|
|
96
|
+
# String — plain text (some servers)
|
|
97
|
+
# nil — empty response
|
|
98
|
+
def normalize_result(response)
|
|
99
|
+
error = error?(response)
|
|
100
|
+
output = extract_output(response)
|
|
101
|
+
|
|
102
|
+
if error
|
|
103
|
+
msg = extract_error_message(response) ||
|
|
104
|
+
(output.is_a?(String) ? output : nil) ||
|
|
105
|
+
"MCP tool error"
|
|
106
|
+
Ask::Runtime::ToolResult.failure(msg)
|
|
107
|
+
else
|
|
108
|
+
Ask::Runtime::ToolResult.success(data: output)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_output(response)
|
|
113
|
+
case response
|
|
114
|
+
when Array
|
|
115
|
+
extract_from_content_array(response)
|
|
116
|
+
when Hash
|
|
117
|
+
if response[:content] || response["content"]
|
|
118
|
+
extract_from_content_array(response[:content] || response["content"])
|
|
119
|
+
else
|
|
120
|
+
response
|
|
121
|
+
end
|
|
122
|
+
when String
|
|
123
|
+
response
|
|
124
|
+
when nil
|
|
125
|
+
nil
|
|
126
|
+
else
|
|
127
|
+
response.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract_from_content_array(array)
|
|
132
|
+
return nil if array.nil? || array.empty?
|
|
133
|
+
|
|
134
|
+
if array.length == 1
|
|
135
|
+
item = array.first
|
|
136
|
+
if item.is_a?(Hash) && (item[:text] || item["text"])
|
|
137
|
+
return item[:text] || item["text"]
|
|
138
|
+
end
|
|
139
|
+
return array
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
texts = array.filter_map do |item|
|
|
143
|
+
item.is_a?(Hash) ? (item[:text] || item["text"]) : item&.to_s
|
|
144
|
+
end
|
|
145
|
+
texts.length == array.length ? texts.join("\n") : array
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def error?(response)
|
|
149
|
+
return false unless response.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
response[:isError] || response["isError"]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_error_message(response)
|
|
155
|
+
return unless response.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
content = response[:content] || response["content"]
|
|
158
|
+
return unless content.is_a?(Array) && content.any?
|
|
159
|
+
|
|
160
|
+
first = content.first
|
|
161
|
+
first[:text] || first["text"] if first.is_a?(Hash)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def cancel(tool_call, context, reason)
|
|
165
|
+
result = Ask::Runtime::ToolResult.cancelled(reason)
|
|
166
|
+
terminal_call = tool_call.with(
|
|
167
|
+
state: :cancelled,
|
|
168
|
+
tool_result: result,
|
|
169
|
+
finished_at: Time.now
|
|
170
|
+
)
|
|
171
|
+
duration = terminal_call.finished_at - terminal_call.created_at
|
|
172
|
+
|
|
173
|
+
emit_event(Ask::Runtime::Events::ToolCancelled,
|
|
174
|
+
tool_call: terminal_call, tool_result: result,
|
|
175
|
+
execution_context: context, duration: duration)
|
|
176
|
+
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def transition(tool_call, state, **attrs)
|
|
181
|
+
tool_call.with(state: state, **attrs)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def result_with_duration(result, duration)
|
|
185
|
+
return result if result.duration == duration
|
|
186
|
+
|
|
187
|
+
Ask::Runtime::ToolResult.new(
|
|
188
|
+
result: result.result,
|
|
189
|
+
outcome: result.outcome,
|
|
190
|
+
duration: duration
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def emit_event(event_class, **args)
|
|
195
|
+
timestamp = Time.now
|
|
196
|
+
event = event_class.new(**args, timestamp: timestamp)
|
|
197
|
+
sink = args[:execution_context].event_sink
|
|
198
|
+
event_type = event_class.name.split("::").last
|
|
199
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
200
|
+
.downcase
|
|
201
|
+
.to_sym
|
|
202
|
+
sink.emit(event_type, event: event)
|
|
203
|
+
rescue => e
|
|
204
|
+
warn "[ask-mcp][runtime_executor] event emission failed: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module MCP
|
|
5
|
+
# Helpers for discovering MCP tools and exposing them as Ask::Tool wrappers.
|
|
6
|
+
#
|
|
7
|
+
# include Ask::MCP::ToolDiscovery
|
|
8
|
+
# tools = mcp_tools(client) # => Hash{String => Adapters::AskTool}
|
|
9
|
+
#
|
|
10
|
+
module ToolDiscovery
|
|
11
|
+
# Discover tools from an MCP client and wrap them as AskTool adapters.
|
|
12
|
+
#
|
|
13
|
+
# @param client [Ask::MCP::Client] an initialized MCP client
|
|
14
|
+
# @return [Hash{String => Adapters::AskTool}] tools keyed by name
|
|
15
|
+
def mcp_tools(client)
|
|
16
|
+
tools = client.tools
|
|
17
|
+
Adapters::AskTool.wrap(tools)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Discover tools and convert them to Ask::Tools::Tool instances.
|
|
21
|
+
#
|
|
22
|
+
# @param client [Ask::MCP::Client] an initialized MCP client
|
|
23
|
+
# @return [Hash{String => Ask::Tools::Tool}] tools keyed by name
|
|
24
|
+
def mcp_ask_tools(client)
|
|
25
|
+
mcp_tools(client).transform_values(&:to_ask_tool)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/ask/mcp/version.rb
CHANGED
data/lib/ask/mcp.rb
CHANGED
|
@@ -24,6 +24,8 @@ module Ask
|
|
|
24
24
|
autoload :Validator, "ask/mcp/validator"
|
|
25
25
|
autoload :XMcpHeader, "ask/mcp/x_mcp_header"
|
|
26
26
|
autoload :TraceContext, "ask/mcp/trace_context"
|
|
27
|
+
autoload :RuntimeExecutor, "ask/mcp/runtime_executor"
|
|
28
|
+
autoload :ToolDiscovery, "ask/mcp/tool_discovery"
|
|
27
29
|
|
|
28
30
|
module Native
|
|
29
31
|
autoload :Messages, "ask/mcp/native/messages"
|
|
@@ -37,6 +39,7 @@ module Ask
|
|
|
37
39
|
|
|
38
40
|
module Auth
|
|
39
41
|
autoload :OAuth, "ask/mcp/auth/oauth"
|
|
42
|
+
autoload :Connect, "ask/mcp/auth/connect"
|
|
40
43
|
autoload :Token, "ask/mcp/auth/token"
|
|
41
44
|
autoload :ClientIdMetadataDocument, "ask/mcp/auth/client_id_metadata_document"
|
|
42
45
|
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.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -93,6 +93,34 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '13.0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: ask-core
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 0.12.0
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: 0.12.0
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: ask-runtime
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: 0.1.0
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: 0.1.0
|
|
96
124
|
description: Connect to MCP servers via stdio, SSE, and Streamable HTTP transports.
|
|
97
125
|
Run as an MCP server exposing any Ruby object as a tool. Discover tools, resources,
|
|
98
126
|
and prompts. OAuth 2.1 authentication.
|
|
@@ -112,17 +140,20 @@ files:
|
|
|
112
140
|
- lib/ask/mcp/adapters/ask_tool.rb
|
|
113
141
|
- lib/ask/mcp/adapters/tool_server.rb
|
|
114
142
|
- lib/ask/mcp/auth/client_id_metadata_document.rb
|
|
143
|
+
- lib/ask/mcp/auth/connect.rb
|
|
115
144
|
- lib/ask/mcp/auth/oauth.rb
|
|
116
145
|
- lib/ask/mcp/auth/token.rb
|
|
117
146
|
- lib/ask/mcp/client.rb
|
|
118
147
|
- lib/ask/mcp/native/messages.rb
|
|
119
148
|
- lib/ask/mcp/prompt.rb
|
|
120
149
|
- lib/ask/mcp/resource.rb
|
|
150
|
+
- lib/ask/mcp/runtime_executor.rb
|
|
121
151
|
- lib/ask/mcp/server.rb
|
|
122
152
|
- lib/ask/mcp/server/core.rb
|
|
123
153
|
- lib/ask/mcp/server/http.rb
|
|
124
154
|
- lib/ask/mcp/server/stdio.rb
|
|
125
155
|
- lib/ask/mcp/tool.rb
|
|
156
|
+
- lib/ask/mcp/tool_discovery.rb
|
|
126
157
|
- lib/ask/mcp/trace_context.rb
|
|
127
158
|
- lib/ask/mcp/transport/sse.rb
|
|
128
159
|
- lib/ask/mcp/transport/stdio.rb
|