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 +4 -4
- data/CHANGELOG.md +44 -22
- data/lib/ask/mcp/adapters/tool_server.rb +25 -26
- data/lib/ask/mcp/server/stdio.rb +61 -21
- data/lib/ask/mcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 909c171336e0497c2377c2f4106e1ec0314adbbbf15791819caf6be0c9d04035
|
|
4
|
+
data.tar.gz: 5a5e95508499f38e310cce0052fcd4c9dfd2c95f8a4e965621c37d35c392293d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52526b46e9574fb4289ddde3b7ff340945eead680763ad7ece3903a8bea89a5a535cb3b9951b0236f6749808b22254c9f4b691133eff56e24a7438eb68ae8743
|
|
7
|
+
data.tar.gz: be8c62053892b8aa52e5216fcb58c7857ecfdff12b025173d9cd1be1a384cced8dd41ef4f9e17ca531fe6d869743dbc63caad275fbccb82014ddd4d94d49c3a2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,37 +1,59 @@
|
|
|
1
|
-
## [0.
|
|
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
|
-
|
|
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
|
-
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
18
|
+
- Request ID deduplication — retried `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
|
-
-
|
|
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
|
|
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
|
|
30
|
-
- stdio, SSE, and Streamable HTTP transports
|
|
31
|
-
- Tool, Resource,
|
|
32
|
-
-
|
|
33
|
-
-
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
data/lib/ask/mcp/server/stdio.rb
CHANGED
|
@@ -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
|
-
|
|
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: {},
|
|
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,
|
|
112
|
+
def handle_initialize(id, params)
|
|
100
113
|
@initialized = true
|
|
101
|
-
|
|
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:
|
|
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 = @
|
|
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
|
data/lib/ask/mcp/version.rb
CHANGED