ask-mcp 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01bf6655c874ece6be6c44d5864e19c844f7edd1fa88c8f90b60b836092bcd90
4
- data.tar.gz: 2cebafa8dd9d36901b5e78e81350c235ef837f1d4a010792f6c7d23982729969
3
+ metadata.gz: ae155e99332cb2a3fcec6b65c6d0ac20aa9b2b81b27f1e3aa3c667dc0c7a42fb
4
+ data.tar.gz: af84c5f5aae82c640e2c12316cde40444fe7733d6ad5aaddd6780242b98e1a1c
5
5
  SHA512:
6
- metadata.gz: 292a26a5c625cd6e1b06d214ce5b3cd9e38a7562f8e388b8d2817ee4fc915d6f743301c23466c03d2f5e5f3399607a02ca07c2c32bc179696cb1c71386609d5c
7
- data.tar.gz: 9c6395fbae61b1013fb72013dfd3d069f76956ad25efd88c62444b3f6c33823462a01c4cb91b129f1a7001a5fef3d7580b91986391128aea41b33c253221afda
6
+ metadata.gz: 62f473ff0fdc35b25adcdadf3aeb2a65c330767f7b73cc85bed204a32bff7fcc28f0348c2ee3f8e8ae1e3d7eaf3977714af67d67038c1be990d9feca2199d81f
7
+ data.tar.gz: e511364526282fcb429740dbd65bbce3e51d0bde06a06cb672e33a0c30b1b09e1971c663145f1b272d7e01015060349447bb7d53081b8dd9813dea02f19591e9
data/CHANGELOG.md CHANGED
@@ -1,40 +1,45 @@
1
- ## [0.2.0] - 2026-06-26
1
+ ## [0.4.0] - 2026-06-26
2
2
 
3
3
  ### Added
4
- - Server Runtime: `Ask::MCP::Server::Stdio`run as an MCP server over stdio
5
- with full initialize handshake, tool discovery, and tool call dispatch.
6
- - `Ask::MCP::Adapters::AskToolServer`converts `Ask::Tool` instances to MCP
7
- tool definitions and dispatches calls, enabling any Ask::Tool to be exposed
8
- via MCP without modification.
9
- - `Ask::MCP::Server.start_stdio` entry point for easy one-line server setup.
10
- - `examples/` reference usage in README "Running as an MCP Server" section.
4
+ - Request ID deduplicationretried `tools/call` with the same ID returns
5
+ cached result instead of re-executing the tool.
6
+ - SIGTERM/SIGHUP graceful shutdown closes stdin to unblock the read loop
7
+ so the current tool call can finish before the process exits.
8
+ - Configurable `tool_timeout` option — prevents hung tool calls from
9
+ blocking the server indefinitely.
10
+ - `ping` handler for MCP keepalive support.
11
+ - 10 new tests: request dedup (3), tool timeout (2), SIGTERM (1),
12
+ multiline JSON safety (3), ping (1) — 180 total.
13
+
14
+ ### Changed
15
+ - `Server::Stdio` now echoes back the client's requested protocol version
16
+ instead of hardcoding a value.
17
+
18
+ ## [0.3.0] - 2026-06-26
11
19
 
12
20
  ### Changed
13
- - Server Runtime adds 56 new tests across adapter, server stdio, integration,
14
- and start_stdio entry point (170 total, up from 114).
15
- - `ask-mcp` gem description updated to reflect client + server capabilities.
16
- - Architecture section updated to include server/ and adapters/ask_tool_server.rb.
21
+ - Renamed `Adapters::AskToolServer` `Adapters::ToolServer` duck-typed,
22
+ works with any Ruby object, not just Ask::Tool instances.
23
+ - README rewritten to lead with general-purpose duck-typed server example.
24
+ - ask-rb integration is now a documented sub-section.
25
+
26
+ ## [0.2.0] - 2026-06-26
27
+
28
+ ### Added
29
+ - Server Runtime: `Ask::MCP::Server::Stdio` with full initialize handshake.
30
+ - `Server.start_stdio` entry point for one-line server setup.
31
+ - 56 new tests (170 total at time of release).
17
32
 
18
33
  ## [0.1.1] - 2026-06-25
19
34
 
20
35
  ### Changed
21
- - Major test expansion: Transport tests (SSE/Stdio/StreamableHTTP), MessagesParser, OAuth, Client(17t with cache invalidation), Server(7t), Tool(8t), Messages(17t with serialization), integration tests(7t with mock server). Infrastructure: rubocop, overcommit, CI matrix, gemspec, SimpleCov.
36
+ - Major test expansion across all modules.
22
37
 
23
38
  ## [0.1.0] - 2026-06-10
24
39
 
25
40
  ### Added
26
- - Core MCP client with full JSON-RPC 2.0 message layer
27
- - stdio transport for local process MCP servers
28
- - SSE transport for remote Server-Sent Events MCP servers
29
- - Streamable HTTP transport for remote HTTP MCP servers
30
- - Tool, Resource, and Prompt data models with `from_h`/`to_h` serialization
31
- - Client lifecycle: initialize, capabilities discovery, session management
32
- - Tool calling, resource reading, and prompt retrieval
33
- - Token-based authentication (Bearer/Basic)
34
- - OAuth 2.1 authentication (client credentials + authorization code flows)
35
- - Ask::Tool adapter for integration with ask-agent
36
- - Thread-safe request/response matching with configurable timeouts
37
- - Server-side notifications handling (tools/resources/prompts list changed)
38
- - Comprehensive test suite with mock MCP server
39
- - Factory methods: `from_stdio`, `from_sse`, `from_http`, `connect`
40
- - Ability to cache or bypass caching for tools/resources/prompts
41
+ - Core MCP client with JSON-RPC 2.0 message layer.
42
+ - stdio, SSE, and Streamable HTTP transports.
43
+ - Tool, Resource, Prompt data models.
44
+ - OAuth 2.1 and token-based authentication.
45
+ - ask-agent integration via AskTool adapter.
data/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/ask-mcp.svg)](https://badge.fury.io/rb/ask-mcp)
4
4
 
5
- **Model Context Protocol (MCP) client for Ruby.** Connect to MCP servers via
6
- stdio, SSE, or Streamable HTTP transports. Discover tools, resources, and
7
- prompts. Supports the full MCP protocol with OAuth 2.1 authentication.
5
+ **Model Context Protocol (MCP) client and server for Ruby.** Connect to MCP
6
+ servers via stdio, SSE, or Streamable HTTP transports. Run as an MCP server
7
+ to expose your own tools to any MCP client. No framework lock-in — just
8
+ implement a couple of duck-typed methods and you're done.
8
9
 
9
10
  MCP is the industry standard for LLM tool discovery — the same protocol used by
10
11
  Claude Code, Codex, Cursor, and GitHub Copilot.
@@ -21,44 +22,49 @@ Or add to your Gemfile:
21
22
  gem "ask-mcp", "~> 0.1.0"
22
23
  ```
23
24
 
24
- ## Quick Start
25
+ ## Quick Start — Client
26
+
27
+ Connect to any MCP server and call its tools:
25
28
 
26
29
  ```ruby
27
30
  require "ask/mcp"
28
31
 
29
- # Connect to a local MCP server via stdio
30
32
  client = Ask::MCP.from_stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
31
33
  client.start
32
34
 
33
- # List available tools
34
35
  client.tools.each { |name, tool| puts "#{name}: #{tool.description}" }
35
36
 
36
- # Call a tool
37
37
  result = client.call_tool("read_file", path: "/tmp/test.txt")
38
38
  puts result
39
39
 
40
- # Clean up
41
40
  client.stop
42
41
  ```
43
42
 
43
+ ## Quick Start — Server
44
44
 
45
- ## Running as an MCP Server
46
-
47
- `ask-mcp` can run as a standalone MCP server over stdio, exposing any
48
- `Ask::Tool` instances to MCP-compatible clients (Codex Desktop, Claude Code, etc.).
45
+ Run as a standalone MCP server exposing your own tools to any MCP client
46
+ (Codex, Claude Code, Cursor, etc.). Any object that responds to `name`,
47
+ `description`, `params_schema`, and `call(args)` will work:
49
48
 
50
49
  ```ruby
51
50
  require "ask/mcp"
52
- require "ask-tools-shell"
53
- require "ask-web-search"
54
51
 
55
- tools = Ask::Tools::Shell::TOOLS.map(&:new)
56
- tools << Ask::Tools::WebSearch.new
52
+ # Define your tools no base class needed, just duck typing
53
+ class Greeter
54
+ def name; "greet" end
55
+ def description; "Greets someone by name" end
56
+ def params_schema
57
+ { type: "object", properties: { "name" => { "type" => "string" } }, required: ["name"] }
58
+ end
59
+ def call(args = {})
60
+ "Hello, #{args['name']}!"
61
+ end
62
+ end
57
63
 
64
+ # Start the server (blocking — runs until stdin closes)
58
65
  Ask::MCP::Server.start_stdio(
59
66
  name: "my-server",
60
- tools: tools,
61
- capabilities: { tools: {} }
67
+ tools: [Greeter.new]
62
68
  )
63
69
  ```
64
70
 
@@ -69,14 +75,58 @@ Configure your MCP client:
69
75
  "mcpServers": {
70
76
  "my-server": {
71
77
  "command": "ruby",
72
- "args": ["path/to/your/script.rb"]
78
+ "args": ["/path/to/your/server.rb"]
73
79
  }
74
80
  }
75
81
  }
76
82
  ```
77
83
 
78
- For a production example, see [llm-proxy](https://github.com/ask-rb/llm-proxy)
79
- which embeds the same server and adds rate limiting, auth, and monitoring.
84
+ Tools can return any value the client can use. The server automatically wraps
85
+ the result into MCP's `content` array format.
86
+
87
+ ### What about complex tools?
88
+
89
+ The result object from `call(args)` should respond to `ok?` (or `ok`) and
90
+ `output` / `error_message`. Use `OpenStruct` for simple cases:
91
+
92
+ ```ruby
93
+ require "ostruct"
94
+
95
+ class BashTool
96
+ def name; "bash" end
97
+ def description; "Run a shell command" end
98
+ def params_schema
99
+ { type: "object", properties: { "command" => { "type" => "string" } }, required: ["command"] }
100
+ end
101
+ def call(args = {})
102
+ output = `#{args['command']} 2>&1`
103
+ OpenStruct.new(ok?: true, output: output)
104
+ rescue => e
105
+ OpenStruct.new(ok?: false, error_message: e.message)
106
+ end
107
+ end
108
+ ```
109
+
110
+ For a production example with shell tools, file ops, and web search,
111
+ see [llm-proxy](https://github.com/ask-rb/llm-proxy).
112
+
113
+ ### Using with ask-tools
114
+
115
+ If you use the ask-rb ecosystem, you can expose Ask::Tool subclasses directly:
116
+
117
+ ```ruby
118
+ require "ask/mcp"
119
+ require "ask-tools-shell"
120
+ require "ask-web-search"
121
+
122
+ tools = Ask::Tools::Shell::TOOLS.map(&:new) + [Ask::Tools::WebSearch.new]
123
+
124
+ Ask::MCP::Server.start_stdio(
125
+ name: "my-server",
126
+ tools: tools,
127
+ capabilities: { tools: {} }
128
+ )
129
+ ```
80
130
 
81
131
  ## Transports
82
132
 
@@ -183,7 +233,6 @@ wrapped = Ask::MCP::Adapters::AskTool.wrap(client.tools)
183
233
  wrapped.each { |name, adapter| agent.register_tool(adapter.to_ask_tool) }
184
234
  ```
185
235
 
186
-
187
236
  ## Architecture
188
237
 
189
238
  ```
@@ -205,8 +254,9 @@ ask-mcp/
205
254
  │ └── token.rb # Token-based auth
206
255
  └── lib/ask/mcp/adapters/
207
256
  ├── ask_tool.rb # MCP::Tool → Ask::Tool adapter
208
- └── ask_tool_server.rb # Ask::Tool → MCP server adapter (reverse)
257
+ └── tool_server.rb # Duck-typed tools → MCP server adapter
209
258
  ```
259
+
210
260
  ## Development
211
261
 
212
262
  ```bash
@@ -3,19 +3,26 @@
3
3
  module Ask
4
4
  module MCP
5
5
  module Adapters
6
- # Converts Ask::Tool instances into MCP tool definitions and dispatches
7
- # calls. This is the server-direction adapter — it takes Ask::Tool objects
8
- # (from tool packs like ask-tools-shell) and makes them available over MCP.
6
+ # Converts duck-typed tool objects into MCP tool definitions and dispatches
7
+ # calls. This is the server-direction adapter — it takes any objects that
8
+ # respond to +name+, +description+, +params_schema+, and +call(args)+ and
9
+ # exposes them over MCP.
9
10
  #
10
11
  # Usage:
11
- # adapter = AskToolServer.new(Ask::Tools::Shell.all)
12
- # adapter.definitions # => [{ name: "bash", description: "...", inputSchema: {...} }]
13
- # adapter.call("bash", { command: "echo hi" }) # => { content: [...], isError: ... }
14
- class AskToolServer
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 }
22
+ class ToolServer
15
23
  attr_reader :tools
16
24
 
17
- # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose.
18
- # Each tool must respond to name, description, params_schema, and call(args).
25
+ # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
19
26
  def initialize(tools = [])
20
27
  @tools = tools
21
28
  @tool_map = tools.each_with_object({}) { |t, h| h[t.name] = t }
@@ -44,12 +51,10 @@ module Ask
44
51
  return error_result("Tool not found: #{name}")
45
52
  end
46
53
 
47
- # Stringify keys — Ask::Tool subclasses expect string keys
48
54
  normalized = deep_stringify_keys(arguments)
49
55
  result = tool.call(normalized)
50
56
  wrap_result(result)
51
57
  rescue StandardError => e
52
- # If the tool raised Ask::Tool::Halt, treat it as a success
53
58
  if defined?(Ask::Tool::Halt) && e.is_a?(Ask::Tool::Halt)
54
59
  return { content: [{ type: "text", text: e.content.to_s }], isError: false }
55
60
  end
@@ -1,70 +1,80 @@
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
19
12
 
20
13
  attr_reader :name, :tools, :capabilities, :resources, :prompts
21
14
 
22
- def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {}, debug: false)
15
+ def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {},
16
+ debug: false, tool_timeout: nil)
23
17
  @name = name
24
18
  @capabilities = capabilities
25
19
  @resources = resources
26
20
  @prompts = prompts
27
21
  @debug = debug
22
+ @tool_timeout = tool_timeout
28
23
 
29
- @adapter = Adapters::AskToolServer.new(tools || [])
24
+ @adapter = Adapters::ToolServer.new(tools || [])
30
25
  @initialized = false
31
26
  @running = false
27
+ @shutdown_requested = false
28
+ @result_cache = {}
32
29
  end
33
30
 
34
- # Start the server (blocking). Processes stdin until EOF.
35
31
  def start
36
32
  @running = true
37
33
  $stdout.sync = true
38
34
 
35
+ # Graceful shutdown: on TERM/HUP, set flag and close stdin to
36
+ # unblock the read loop so the current tool call can finish.
37
+ trap("TERM") { graceful_shutdown }
38
+ trap("HUP") { graceful_shutdown }
39
+
39
40
  debug_log "Server starting: #{@name} (PID #{Process.pid})"
40
41
  debug_log "Tools: #{@adapter.definitions.map { |d| d[:name] }.join(', ')}"
41
42
 
42
- while @running && (line = $stdin.gets)
43
+ while @running && !@shutdown_requested && (line = $stdin.gets)
43
44
  line = line.strip
44
45
  next if line.empty?
45
-
46
46
  process_line(line)
47
47
  end
48
48
 
49
49
  debug_log "stdin closed — exiting"
50
50
  rescue Errno::EBADF, IOError
51
- # stdin closed externally
51
+ # stdin closed externally (e.g. from trap handler)
52
+ rescue SignalException
53
+ # SIGTERM or SIGHUP during blocked read
52
54
  ensure
53
55
  @running = false
56
+ @shutdown_requested = false
57
+ trap("TERM", "DEFAULT")
58
+ trap("HUP", "DEFAULT")
54
59
  end
55
60
 
56
- # Stop the server (non-blocking, closes stdin to unblock the read loop).
57
61
  def stop
58
62
  @running = false
59
63
  end
60
64
 
61
- # Is the server still running?
62
65
  def running?
63
66
  @running
64
67
  end
65
68
 
66
69
  private
67
70
 
71
+ def graceful_shutdown
72
+ @shutdown_requested = true
73
+ # Close stdin to unblock $stdin.gets so the signal handler
74
+ # returns promptly and the process exits cleanly.
75
+ $stdin.close rescue nil
76
+ end
77
+
68
78
  def process_line(line)
69
79
  msg = JSON.parse(line, symbolize_names: true)
70
80
  handle_message(msg)
@@ -90,17 +100,20 @@ module Ask
90
100
  when "tools/call"
91
101
  return send_error(id, -32000, "Server not initialized") unless @initialized
92
102
  handle_tool_call(id, params)
103
+ when "ping"
104
+ send_result(id, {}) if has_id
93
105
  else
94
106
  debug_log "Unknown method: #{method}"
95
107
  send_error(id, -32601, "Method not found: #{method}") if has_id
96
108
  end
97
109
  end
98
110
 
99
- def handle_initialize(id, _params)
111
+ def handle_initialize(id, params)
100
112
  @initialized = true
101
- debug_log "Handling initialize (id=#{id.inspect})"
113
+ client_version = params[:protocolVersion] || PROTOCOL_VERSION
114
+ debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
102
115
  send_result(id, {
103
- protocolVersion: PROTOCOL_VERSION,
116
+ protocolVersion: client_version,
104
117
  capabilities: @capabilities,
105
118
  serverInfo: {
106
119
  name: @name,
@@ -111,20 +124,41 @@ module Ask
111
124
  end
112
125
 
113
126
  def handle_tools_list(id)
114
- debug_log "Handling tools/list (id=#{id.inspect})"
115
127
  defs = @adapter.definitions
116
128
  debug_log "tools/list returning #{defs.length} tool definitions"
117
129
  send_result(id, { tools: defs })
118
130
  end
119
131
 
120
132
  def handle_tool_call(id, params)
133
+ cache_key = id.to_s
134
+
135
+ # Return cached result for retried requests (same ID, already processed)
136
+ if @result_cache.key?(cache_key)
137
+ debug_log "Returning cached result for id=#{id}"
138
+ return send_result(id, @result_cache[cache_key])
139
+ end
140
+
121
141
  tool_name = params[:name].to_s
122
142
  arguments = params[:arguments] || {}
123
143
 
124
144
  debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
125
145
 
126
- result = @adapter.call(tool_name, arguments)
146
+ result = if @tool_timeout
147
+ Timeout.timeout(@tool_timeout) { @adapter.call(tool_name, arguments) }
148
+ else
149
+ @adapter.call(tool_name, arguments)
150
+ end
151
+
152
+ @result_cache[cache_key] = result
153
+ trim_cache
154
+
127
155
  send_result(id, result)
156
+ rescue Timeout::Error
157
+ debug_log "Tool call timed out: #{tool_name}"
158
+ send_result(id, {
159
+ content: [{ type: "text", text: "Tool call timed out: #{tool_name}" }],
160
+ isError: true
161
+ })
128
162
  end
129
163
 
130
164
  def send_result(id, result)
@@ -140,6 +174,11 @@ module Ask
140
174
  ts = Time.now.strftime("%H:%M:%S.%L")
141
175
  $stderr.puts "[#{ts}] [ask-mcp] #{msg}"
142
176
  end
177
+
178
+ def trim_cache
179
+ return if @result_cache.size <= MAX_RESULT_CACHE
180
+ @result_cache.shift(@result_cache.size - MAX_RESULT_CACHE)
181
+ end
143
182
  end
144
183
  end
145
184
  end
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module MCP
3
- VERSION = "0.2.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
data/lib/ask/mcp.rb CHANGED
@@ -28,7 +28,7 @@ module Ask
28
28
 
29
29
  module Adapters
30
30
  autoload :AskTool, "ask/mcp/adapters/ask_tool"
31
- autoload :AskToolServer, "ask/mcp/adapters/ask_tool_server"
31
+ autoload :ToolServer, "ask/mcp/adapters/tool_server"
32
32
  end
33
33
 
34
34
  class Error < StandardError; 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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -80,8 +80,8 @@ dependencies:
80
80
  - !ruby/object:Gem::Version
81
81
  version: '13.0'
82
82
  description: Connect to MCP servers via stdio, SSE, and Streamable HTTP transports.
83
- Run as an MCP server to expose Ask::Tool instances. Discover tools, resources, and
84
- prompts. OAuth 2.1 authentication.
83
+ Run as an MCP server exposing any Ruby object as a tool. Discover tools, resources,
84
+ and prompts. OAuth 2.1 authentication.
85
85
  email:
86
86
  - kaka@myrrlabs.com
87
87
  executables: []
@@ -95,7 +95,7 @@ files:
95
95
  - lib/ask-mcp.rb
96
96
  - lib/ask/mcp.rb
97
97
  - lib/ask/mcp/adapters/ask_tool.rb
98
- - lib/ask/mcp/adapters/ask_tool_server.rb
98
+ - lib/ask/mcp/adapters/tool_server.rb
99
99
  - lib/ask/mcp/auth/oauth.rb
100
100
  - lib/ask/mcp/auth/token.rb
101
101
  - lib/ask/mcp/client.rb