ask-mcp 0.3.0 → 0.4.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: 90e04370e4d5b1136abb513220b68beb46a5ca2c74d8f7ca694fa59297f8f32e
4
- data.tar.gz: 17f2accea77cefc0ea51fedcc3879e219fff47760999b0ba39e0e168d30dbfe1
3
+ metadata.gz: 909c171336e0497c2377c2f4106e1ec0314adbbbf15791819caf6be0c9d04035
4
+ data.tar.gz: 5a5e95508499f38e310cce0052fcd4c9dfd2c95f8a4e965621c37d35c392293d
5
5
  SHA512:
6
- metadata.gz: d8a481b9467e8f4d6d4d305da61346855f4d27ac94de77f109ac528e704599c18aee5a9c5deb64f7edb71a71b4e446cc1801149df21891d15eefdcb441eb1d09
7
- data.tar.gz: b78ccc9a910fbfbeaf2e273ce6169279e4195624871f89356d6564ab6cd46380dd876951544e90b2f3b3b57328ae00180c6d218e1d66afa5416090c5703e1560
6
+ metadata.gz: 52526b46e9574fb4289ddde3b7ff340945eead680763ad7ece3903a8bea89a5a535cb3b9951b0236f6749808b22254c9f4b691133eff56e24a7438eb68ae8743
7
+ data.tar.gz: be8c62053892b8aa52e5216fcb58c7857ecfdff12b025173d9cd1be1a384cced8dd41ef4f9e17ca531fe6d869743dbc63caad275fbccb82014ddd4d94d49c3a2
data/CHANGELOG.md CHANGED
@@ -1,37 +1,59 @@
1
- ## [0.3.0] - 2026-06-26
1
+ ## [0.4.1] - 2026-07-31
2
2
 
3
3
  ### Changed
4
- - Renamed `Adapters::AskToolServer` → `Adapters::ToolServer` — the adapter is
5
- now duck-typed and works with any Ruby object, not just Ask::Tool instances.
6
- - README fully rewritten: leads with a general-purpose duck-typed server example
7
- instead of ask-tools-specific code. ask-rb integration is now a sub-section.
8
- - Gemspec description updated to reflect general-purpose positioning.
9
4
 
10
- ## [0.2.0] - 2026-06-26
5
+ - **`ToolServer` result wrapping** — plain `String` results are always treated
6
+ as success; result-like objects (`Ask::Result`, OpenStruct, etc.) use
7
+ `ok?`/`ok` and `output`/`error_message`; any other value is treated as
8
+ success with `to_s` used for the response.
9
+
10
+ ### Fixed
11
+
12
+ - **Stdio server protocol version** — pins `PROTOCOL_VERSION` to
13
+ `2025-06-18` and echoes it back in the `initialize` response.
14
+
15
+ ## [0.4.0] - 2026-06-26
11
16
 
12
17
  ### Added
13
- - Server Runtime: `Ask::MCP::Server::Stdio`run as an MCP server over stdio
14
- with full initialize handshake, tool discovery, and tool call dispatch.
15
- - `Adapters::AskToolServer`converts tools to MCP definitions and dispatches calls.
16
- - `Ask::MCP::Server.start_stdio` entry point for easy one-line server setup.
18
+ - Request ID deduplicationretried `tools/call` with the same ID returns
19
+ cached result instead of re-executing the tool.
20
+ - SIGTERM/SIGHUP graceful shutdown closes stdin to unblock the read loop
21
+ so the current tool call can finish before the process exits.
22
+ - Configurable `tool_timeout` option — prevents hung tool calls from
23
+ blocking the server indefinitely.
24
+ - `ping` handler for MCP keepalive support.
25
+ - 10 new tests: request dedup (3), tool timeout (2), SIGTERM (1),
26
+ multiline JSON safety (3), ping (1) — 180 total.
17
27
 
18
28
  ### Changed
19
- - 56 new tests across adapter, server stdio, integration, and start_stdio (170 total).
29
+ - `Server::Stdio` now echoes back the client's requested protocol version
30
+ instead of hardcoding a value.
31
+
32
+ ## [0.3.0] - 2026-06-26
33
+
34
+ ### Changed
35
+ - Renamed `Adapters::AskToolServer` → `Adapters::ToolServer` — duck-typed,
36
+ works with any Ruby object, not just Ask::Tool instances.
37
+ - README rewritten to lead with general-purpose duck-typed server example.
38
+ - ask-rb integration is now a documented sub-section.
39
+
40
+ ## [0.2.0] - 2026-06-26
41
+
42
+ ### Added
43
+ - Server Runtime: `Ask::MCP::Server::Stdio` with full initialize handshake.
44
+ - `Server.start_stdio` entry point for one-line server setup.
45
+ - 56 new tests (170 total at time of release).
20
46
 
21
47
  ## [0.1.1] - 2026-06-25
22
48
 
23
49
  ### Changed
24
- - Major test expansion: Transport tests, MessagesParser, OAuth, Client, Server, Tool, Messages, integration. Rubocop, overcommit, CI matrix, SimpleCov.
50
+ - Major test expansion across all modules.
25
51
 
26
52
  ## [0.1.0] - 2026-06-10
27
53
 
28
54
  ### Added
29
- - Core MCP client with full JSON-RPC 2.0 message layer
30
- - stdio, SSE, and Streamable HTTP transports
31
- - Tool, Resource, and Prompt data models
32
- - Client lifecycle, tool calling, resource reading, prompt retrieval
33
- - Token-based and OAuth 2.1 authentication
34
- - Ask::Tool adapter for ask-agent integration
35
- - Thread-safe request/response matching with configurable timeouts
36
- - Server-side notifications handling
37
- - Factory methods: `from_stdio`, `from_sse`, `from_http`, `connect`
55
+ - Core MCP client with JSON-RPC 2.0 message layer.
56
+ - stdio, SSE, and Streamable HTTP transports.
57
+ - Tool, Resource, Prompt data models.
58
+ - OAuth 2.1 and token-based authentication.
59
+ - ask-agent integration via AskTool adapter.
@@ -8,28 +8,18 @@ module Ask
8
8
  # respond to +name+, +description+, +params_schema+, and +call(args)+ and
9
9
  # exposes them over MCP.
10
10
  #
11
- # Usage:
12
- # class MyTool
13
- # def name; "hello" end
14
- # def description; "Says hello" end
15
- # def params_schema; nil end
16
- # def call(args = {}); OpenStruct.new(ok?: true, output: "Hello!") end
17
- # end
18
- #
19
- # adapter = ToolServer.new([MyTool.new])
20
- # adapter.definitions # => [{ name: "hello", ... }]
21
- # adapter.call("hello", {}) # => { content: [...], isError: false }
11
+ # Tools can return:
12
+ # - An object responding to #ok?/#ok and #output/#error_message (Ask::Result style)
13
+ # - A plain String (treated as success)
14
+ # - Any other value (treated as success, .to_s is used for the response)
22
15
  class ToolServer
23
16
  attr_reader :tools
24
17
 
25
- # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
26
18
  def initialize(tools = [])
27
19
  @tools = tools
28
20
  @tool_map = tools.each_with_object({}) { |t, h| h[t.name] = t }
29
21
  end
30
22
 
31
- # MCP tool definitions for tools/list
32
- # @return [Array<Hash>]
33
23
  def definitions
34
24
  @tools.map do |tool|
35
25
  schema = tool.params_schema || { type: "object", properties: {}, required: [] }
@@ -41,10 +31,6 @@ module Ask
41
31
  end
42
32
  end
43
33
 
44
- # Call a tool and wrap the result in MCP format
45
- # @param name [String] tool name
46
- # @param arguments [Hash] arguments (may have symbol or string keys)
47
- # @return [Hash] { content: [...], isError: true/false }
48
34
  def call(name, arguments = {})
49
35
  tool = @tool_map[name]
50
36
  unless tool
@@ -64,18 +50,31 @@ module Ask
64
50
  private
65
51
 
66
52
  def wrap_result(result)
67
- if result.respond_to?(:ok?) ? result.ok? : result.ok
68
- output = result.respond_to?(:output) ? result.output : result.to_s
69
- text = output.is_a?(Hash) ? (output[:summary] || output.to_s) : output.to_s
70
- { content: [{ type: "text", text: text }], isError: false }
71
- else
72
- msg = result.respond_to?(:error_message) ? result.error_message : result.to_s
73
- { content: [{ type: "text", text: "Error: #{msg}" }], isError: true }
53
+ # Plain strings are always a success
54
+ if result.is_a?(String)
55
+ return { content: [{ type: "text", text: result }], isError: false }
56
+ end
57
+
58
+ # Result-like objects (Ask::Result, OpenStruct, etc.)
59
+ if result.respond_to?(:ok?) || result.respond_to?(:ok)
60
+ ok = result.respond_to?(:ok?) ? result.ok? : result.ok
61
+ return success_result(result, ok) if ok
62
+ return error_result(result.respond_to?(:error_message) ? result.error_message : result.to_s)
74
63
  end
64
+
65
+ # Everything else — treat as success
66
+ text = result.is_a?(Hash) ? (result[:summary] || result.to_s) : result.to_s
67
+ { content: [{ type: "text", text: text }], isError: false }
68
+ end
69
+
70
+ def success_result(result, _ok)
71
+ output = result.respond_to?(:output) ? result.output : result.to_s
72
+ text = output.is_a?(Hash) ? (output[:summary] || output.to_s) : output.to_s
73
+ { content: [{ type: "text", text: text }], isError: false }
75
74
  end
76
75
 
77
76
  def error_result(message)
78
- { content: [{ type: "text", text: message }], isError: true }
77
+ { content: [{ type: "text", text: "Error: #{message}" }], isError: true }
79
78
  end
80
79
 
81
80
  def deep_stringify_keys(obj)
@@ -1,70 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "timeout"
4
5
 
5
6
  module Ask
6
7
  module MCP
7
8
  class Server
8
9
  # MCP server over stdio transport.
9
- #
10
- # Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout.
11
- # Designed to be spawned as a child process by MCP clients (Codex Desktop,
12
- # Claude Code, etc.) with command/args config.
13
- #
14
- # Usage:
15
- # Server::Stdio.new(name: "my-server", tools: my_tools).start
16
- #
17
10
  class Stdio
18
- PROTOCOL_VERSION = "0.1.0"
11
+ MAX_RESULT_CACHE = 100
12
+ PROTOCOL_VERSION = "2025-06-18"
19
13
 
20
14
  attr_reader :name, :tools, :capabilities, :resources, :prompts
21
15
 
22
- def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {}, debug: false)
16
+ def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {},
17
+ debug: false, tool_timeout: nil)
23
18
  @name = name
24
19
  @capabilities = capabilities
25
20
  @resources = resources
26
21
  @prompts = prompts
27
22
  @debug = debug
23
+ @tool_timeout = tool_timeout
28
24
 
29
25
  @adapter = Adapters::ToolServer.new(tools || [])
30
26
  @initialized = false
31
27
  @running = false
28
+ @shutdown_requested = false
29
+ @result_cache = {}
32
30
  end
33
31
 
34
- # Start the server (blocking). Processes stdin until EOF.
35
32
  def start
36
33
  @running = true
37
34
  $stdout.sync = true
38
35
 
36
+ # Graceful shutdown: on TERM/HUP, set flag and close stdin to
37
+ # unblock the read loop so the current tool call can finish.
38
+ trap("TERM") { graceful_shutdown }
39
+ trap("HUP") { graceful_shutdown }
40
+
39
41
  debug_log "Server starting: #{@name} (PID #{Process.pid})"
40
42
  debug_log "Tools: #{@adapter.definitions.map { |d| d[:name] }.join(', ')}"
41
43
 
42
- while @running && (line = $stdin.gets)
44
+ while @running && !@shutdown_requested && (line = $stdin.gets)
43
45
  line = line.strip
44
46
  next if line.empty?
45
-
46
47
  process_line(line)
47
48
  end
48
49
 
49
50
  debug_log "stdin closed — exiting"
50
51
  rescue Errno::EBADF, IOError
51
- # stdin closed externally
52
+ # stdin closed externally (e.g. from trap handler)
53
+ rescue SignalException
54
+ # SIGTERM or SIGHUP during blocked read
52
55
  ensure
53
56
  @running = false
57
+ @shutdown_requested = false
58
+ trap("TERM", "DEFAULT")
59
+ trap("HUP", "DEFAULT")
54
60
  end
55
61
 
56
- # Stop the server (non-blocking, closes stdin to unblock the read loop).
57
62
  def stop
58
63
  @running = false
59
64
  end
60
65
 
61
- # Is the server still running?
62
66
  def running?
63
67
  @running
64
68
  end
65
69
 
66
70
  private
67
71
 
72
+ def graceful_shutdown
73
+ @shutdown_requested = true
74
+ # Close stdin to unblock $stdin.gets so the signal handler
75
+ # returns promptly and the process exits cleanly.
76
+ $stdin.close rescue nil
77
+ end
78
+
68
79
  def process_line(line)
69
80
  msg = JSON.parse(line, symbolize_names: true)
70
81
  handle_message(msg)
@@ -90,17 +101,20 @@ module Ask
90
101
  when "tools/call"
91
102
  return send_error(id, -32000, "Server not initialized") unless @initialized
92
103
  handle_tool_call(id, params)
104
+ when "ping"
105
+ send_result(id, {}) if has_id
93
106
  else
94
107
  debug_log "Unknown method: #{method}"
95
108
  send_error(id, -32601, "Method not found: #{method}") if has_id
96
109
  end
97
110
  end
98
111
 
99
- def handle_initialize(id, _params)
112
+ def handle_initialize(id, params)
100
113
  @initialized = true
101
- debug_log "Handling initialize (id=#{id.inspect})"
114
+ client_version = params[:protocolVersion] || PROTOCOL_VERSION
115
+ debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
102
116
  send_result(id, {
103
- protocolVersion: PROTOCOL_VERSION,
117
+ protocolVersion: client_version,
104
118
  capabilities: @capabilities,
105
119
  serverInfo: {
106
120
  name: @name,
@@ -111,20 +125,41 @@ module Ask
111
125
  end
112
126
 
113
127
  def handle_tools_list(id)
114
- debug_log "Handling tools/list (id=#{id.inspect})"
115
128
  defs = @adapter.definitions
116
129
  debug_log "tools/list returning #{defs.length} tool definitions"
117
130
  send_result(id, { tools: defs })
118
131
  end
119
132
 
120
133
  def handle_tool_call(id, params)
134
+ cache_key = id.to_s
135
+
136
+ # Return cached result for retried requests (same ID, already processed)
137
+ if @result_cache.key?(cache_key)
138
+ debug_log "Returning cached result for id=#{id}"
139
+ return send_result(id, @result_cache[cache_key])
140
+ end
141
+
121
142
  tool_name = params[:name].to_s
122
143
  arguments = params[:arguments] || {}
123
144
 
124
145
  debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
125
146
 
126
- result = @adapter.call(tool_name, arguments)
147
+ result = if @tool_timeout
148
+ Timeout.timeout(@tool_timeout) { @adapter.call(tool_name, arguments) }
149
+ else
150
+ @adapter.call(tool_name, arguments)
151
+ end
152
+
153
+ @result_cache[cache_key] = result
154
+ trim_cache
155
+
127
156
  send_result(id, result)
157
+ rescue Timeout::Error
158
+ debug_log "Tool call timed out: #{tool_name}"
159
+ send_result(id, {
160
+ content: [{ type: "text", text: "Tool call timed out: #{tool_name}" }],
161
+ isError: true
162
+ })
128
163
  end
129
164
 
130
165
  def send_result(id, result)
@@ -140,6 +175,11 @@ module Ask
140
175
  ts = Time.now.strftime("%H:%M:%S.%L")
141
176
  $stderr.puts "[#{ts}] [ask-mcp] #{msg}"
142
177
  end
178
+
179
+ def trim_cache
180
+ return if @result_cache.size <= MAX_RESULT_CACHE
181
+ @result_cache.shift(@result_cache.size - MAX_RESULT_CACHE)
182
+ end
143
183
  end
144
184
  end
145
185
  end
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module MCP
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.1"
4
4
  end
5
5
  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.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto