ask-mcp 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d29dde6a951c940e97933e666e97f5df75483b9de280be3fcabcb184fd5e6091
4
- data.tar.gz: 27cb99a853ae2daabb8f38910e8f14373d0af7d0c3f99ff79f054ed8a88c38bd
3
+ metadata.gz: b2b34899f61be9226b77f6e212d5756555c86e52e17ba301fcf3eaa24f1d3a62
4
+ data.tar.gz: 51656c01a274744d51bb328d11f0f9dc84ac6f20fbe7fa5bb0b90125e670a2d3
5
5
  SHA512:
6
- metadata.gz: ccfaa454b590f534bf0b91aa73c6a13894d4b9fd029a95ac4239ef00bcc29d0a0bf56cee54f2a1f1c941104d7f95b4d60e8d2c3ac235d0b3c1b4949b34a1428c
7
- data.tar.gz: 40da8189f319185ea90e8e1a8cf8bed64aee140ce182fa439c8985f548878a44289f1ee95d48ca043928f97a85c24d460815e0deff8d7188f37e2b089de02237
6
+ metadata.gz: baec4cee8a4bdd42174107b545e0c7a144dd7a646f64e37f3ed09d3f57826a417a1a801ec5f06fc8aed7119a9357c41f524549e62b5c0446e756ebbd5df0171c
7
+ data.tar.gz: dfaddecd4733e04fa5fbac5ee68ae95031cf4e3111a9db7237170ac7601f261ddb25f7f82eab41f478a2aa010aa9c31c92ca385b2b41d4e615cf628363e43578
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@
7
7
  PKCE, and redeems the redirected code — or a refresh token — for tokens.
8
8
  HTTP rides a small injected seam, so hosts can bring their own client.
9
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
+
10
22
  ## [0.5.0] - 2026-09-17
11
23
 
12
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,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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module MCP
5
- VERSION = "0.6.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
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"
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.6.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.
@@ -119,11 +147,13 @@ files:
119
147
  - lib/ask/mcp/native/messages.rb
120
148
  - lib/ask/mcp/prompt.rb
121
149
  - lib/ask/mcp/resource.rb
150
+ - lib/ask/mcp/runtime_executor.rb
122
151
  - lib/ask/mcp/server.rb
123
152
  - lib/ask/mcp/server/core.rb
124
153
  - lib/ask/mcp/server/http.rb
125
154
  - lib/ask/mcp/server/stdio.rb
126
155
  - lib/ask/mcp/tool.rb
156
+ - lib/ask/mcp/tool_discovery.rb
127
157
  - lib/ask/mcp/trace_context.rb
128
158
  - lib/ask/mcp/transport/sse.rb
129
159
  - lib/ask/mcp/transport/stdio.rb