ask-mcp 0.1.1 → 0.3.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: 0f8dc795c9584e225ffb354888e3c2694ec2a5f2378498fa6f1046023aa8b8a6
4
- data.tar.gz: aa580507f8d318f224a09a57e1d8cbca372ff95a05a1fb8c54463be8dd21c483
3
+ metadata.gz: 90e04370e4d5b1136abb513220b68beb46a5ca2c74d8f7ca694fa59297f8f32e
4
+ data.tar.gz: 17f2accea77cefc0ea51fedcc3879e219fff47760999b0ba39e0e168d30dbfe1
5
5
  SHA512:
6
- metadata.gz: ed50f3c8de7c5902afd35c29073480b6a34c7f97af24ff5942a2ad38cdd2e0adf9db53698a35ec0c22b985f9957aa9fd5bc3b92cba37afbb4f30b802077aaeb7
7
- data.tar.gz: 367d7ff2f4523bfa42af5a261dcdbb2e6157753da05b430652439b4f4f9468a94cf517872563d1509100ebdfa51eb2b41b47c0fa21bde502dbc9372af4cd9a15
6
+ metadata.gz: d8a481b9467e8f4d6d4d305da61346855f4d27ac94de77f109ac528e704599c18aee5a9c5deb64f7edb71a71b4e446cc1801149df21891d15eefdcb441eb1d09
7
+ data.tar.gz: b78ccc9a910fbfbeaf2e273ce6169279e4195624871f89356d6564ab6cd46380dd876951544e90b2f3b3b57328ae00180c6d218e1d66afa5416090c5703e1560
data/CHANGELOG.md CHANGED
@@ -1,24 +1,37 @@
1
+ ## [0.3.0] - 2026-06-26
2
+
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
+
10
+ ## [0.2.0] - 2026-06-26
11
+
12
+ ### 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.
17
+
18
+ ### Changed
19
+ - 56 new tests across adapter, server stdio, integration, and start_stdio (170 total).
20
+
1
21
  ## [0.1.1] - 2026-06-25
2
22
 
3
23
  ### Changed
4
- - 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.
5
- # Changelog
24
+ - Major test expansion: Transport tests, MessagesParser, OAuth, Client, Server, Tool, Messages, integration. Rubocop, overcommit, CI matrix, SimpleCov.
6
25
 
7
26
  ## [0.1.0] - 2026-06-10
8
27
 
9
28
  ### Added
10
29
  - Core MCP client with full JSON-RPC 2.0 message layer
11
- - stdio transport for local process MCP servers
12
- - SSE transport for remote Server-Sent Events MCP servers
13
- - Streamable HTTP transport for remote HTTP MCP servers
14
- - Tool, Resource, and Prompt data models with `from_h`/`to_h` serialization
15
- - Client lifecycle: initialize, capabilities discovery, session management
16
- - Tool calling, resource reading, and prompt retrieval
17
- - Token-based authentication (Bearer/Basic)
18
- - OAuth 2.1 authentication (client credentials + authorization code flows)
19
- - Ask::Tool adapter for integration with ask-agent
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
20
35
  - Thread-safe request/response matching with configurable timeouts
21
- - Server-side notifications handling (tools/resources/prompts list changed)
22
- - Comprehensive test suite with mock MCP server
36
+ - Server-side notifications handling
23
37
  - Factory methods: `from_stdio`, `from_sse`, `from_http`, `connect`
24
- - Ability to cache or bypass caching for tools/resources/prompts
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,26 +22,112 @@ 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
+
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:
48
+
49
+ ```ruby
50
+ require "ask/mcp"
51
+
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
63
+
64
+ # Start the server (blocking — runs until stdin closes)
65
+ Ask::MCP::Server.start_stdio(
66
+ name: "my-server",
67
+ tools: [Greeter.new]
68
+ )
69
+ ```
70
+
71
+ Configure your MCP client:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "my-server": {
77
+ "command": "ruby",
78
+ "args": ["/path/to/your/server.rb"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
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
+ ```
130
+
44
131
  ## Transports
45
132
 
46
133
  ```ruby
@@ -152,20 +239,22 @@ wrapped.each { |name, adapter| agent.register_tool(adapter.to_ask_tool) }
152
239
  ask-mcp/
153
240
  ├── lib/ask/mcp.rb # Entry point, factory methods
154
241
  ├── lib/ask/mcp/client.rb # MCP client (connect, call_tool, etc.)
155
- ├── lib/ask/mcp/server.rb # MCP server representation
242
+ ├── lib/ask/mcp/server.rb # MCP server representation + Server.start_stdio entry point
243
+ ├── lib/ask/mcp/server/stdio.rb # MCP server stdio runtime (run as server)
156
244
  ├── lib/ask/mcp/tool.rb # MCP tool representation
157
245
  ├── lib/ask/mcp/resource.rb # MCP resource representation
158
246
  ├── lib/ask/mcp/prompt.rb # MCP prompt representation
159
247
  ├── lib/ask/mcp/native/messages.rb # JSON-RPC message layer
160
248
  ├── lib/ask/mcp/transport/
161
- │ ├── stdio.rb # stdio transport
249
+ │ ├── stdio.rb # stdio transport (client direction)
162
250
  │ ├── sse.rb # Server-Sent Events transport
163
251
  │ └── streamable_http.rb # Streamable HTTP transport
164
252
  ├── lib/ask/mcp/auth/
165
253
  │ ├── oauth.rb # OAuth 2.1 for MCP
166
254
  │ └── token.rb # Token-based auth
167
255
  └── lib/ask/mcp/adapters/
168
- └── ask_tool.rb # MCP::Tool → Ask::Tool adapter
256
+ ├── ask_tool.rb # MCP::Tool → Ask::Tool adapter
257
+ └── tool_server.rb # Duck-typed tools → MCP server adapter
169
258
  ```
170
259
 
171
260
  ## Development
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ module Adapters
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.
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 }
22
+ class ToolServer
23
+ attr_reader :tools
24
+
25
+ # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
26
+ def initialize(tools = [])
27
+ @tools = tools
28
+ @tool_map = tools.each_with_object({}) { |t, h| h[t.name] = t }
29
+ end
30
+
31
+ # MCP tool definitions for tools/list
32
+ # @return [Array<Hash>]
33
+ def definitions
34
+ @tools.map do |tool|
35
+ schema = tool.params_schema || { type: "object", properties: {}, required: [] }
36
+ {
37
+ name: tool.name,
38
+ description: tool.description || "",
39
+ inputSchema: schema
40
+ }
41
+ end
42
+ end
43
+
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
+ def call(name, arguments = {})
49
+ tool = @tool_map[name]
50
+ unless tool
51
+ return error_result("Tool not found: #{name}")
52
+ end
53
+
54
+ normalized = deep_stringify_keys(arguments)
55
+ result = tool.call(normalized)
56
+ wrap_result(result)
57
+ rescue StandardError => e
58
+ if defined?(Ask::Tool::Halt) && e.is_a?(Ask::Tool::Halt)
59
+ return { content: [{ type: "text", text: e.content.to_s }], isError: false }
60
+ end
61
+ error_result("#{e.class}: #{e.message}")
62
+ end
63
+
64
+ private
65
+
66
+ 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 }
74
+ end
75
+ end
76
+
77
+ def error_result(message)
78
+ { content: [{ type: "text", text: message }], isError: true }
79
+ end
80
+
81
+ def deep_stringify_keys(obj)
82
+ case obj
83
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
84
+ when Array then obj.map { |v| deep_stringify_keys(v) }
85
+ else obj
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module MCP
7
+ class Server
8
+ # 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
+ class Stdio
18
+ PROTOCOL_VERSION = "0.1.0"
19
+
20
+ attr_reader :name, :tools, :capabilities, :resources, :prompts
21
+
22
+ def initialize(name:, tools: [], capabilities: {}, resources: {}, prompts: {}, debug: false)
23
+ @name = name
24
+ @capabilities = capabilities
25
+ @resources = resources
26
+ @prompts = prompts
27
+ @debug = debug
28
+
29
+ @adapter = Adapters::ToolServer.new(tools || [])
30
+ @initialized = false
31
+ @running = false
32
+ end
33
+
34
+ # Start the server (blocking). Processes stdin until EOF.
35
+ def start
36
+ @running = true
37
+ $stdout.sync = true
38
+
39
+ debug_log "Server starting: #{@name} (PID #{Process.pid})"
40
+ debug_log "Tools: #{@adapter.definitions.map { |d| d[:name] }.join(', ')}"
41
+
42
+ while @running && (line = $stdin.gets)
43
+ line = line.strip
44
+ next if line.empty?
45
+
46
+ process_line(line)
47
+ end
48
+
49
+ debug_log "stdin closed — exiting"
50
+ rescue Errno::EBADF, IOError
51
+ # stdin closed externally
52
+ ensure
53
+ @running = false
54
+ end
55
+
56
+ # Stop the server (non-blocking, closes stdin to unblock the read loop).
57
+ def stop
58
+ @running = false
59
+ end
60
+
61
+ # Is the server still running?
62
+ def running?
63
+ @running
64
+ end
65
+
66
+ private
67
+
68
+ def process_line(line)
69
+ msg = JSON.parse(line, symbolize_names: true)
70
+ handle_message(msg)
71
+ rescue JSON::ParserError => e
72
+ send_error(nil, -32700, "Parse error: #{e.message}")
73
+ end
74
+
75
+ def handle_message(msg)
76
+ method = msg[:method]
77
+ id = msg[:id]
78
+ params = msg[:params] || {}
79
+ has_id = msg.key?(:id)
80
+
81
+ case method
82
+ when "initialize"
83
+ handle_initialize(id, params)
84
+ when "notifications/initialized"
85
+ @initialized = true
86
+ debug_log "Client initialized"
87
+ when "tools/list"
88
+ return send_error(id, -32000, "Server not initialized") unless @initialized
89
+ handle_tools_list(id)
90
+ when "tools/call"
91
+ return send_error(id, -32000, "Server not initialized") unless @initialized
92
+ handle_tool_call(id, params)
93
+ else
94
+ debug_log "Unknown method: #{method}"
95
+ send_error(id, -32601, "Method not found: #{method}") if has_id
96
+ end
97
+ end
98
+
99
+ def handle_initialize(id, _params)
100
+ @initialized = true
101
+ debug_log "Handling initialize (id=#{id.inspect})"
102
+ send_result(id, {
103
+ protocolVersion: PROTOCOL_VERSION,
104
+ capabilities: @capabilities,
105
+ serverInfo: {
106
+ name: @name,
107
+ version: Ask::MCP::VERSION
108
+ }
109
+ })
110
+ debug_log "Initialize complete"
111
+ end
112
+
113
+ def handle_tools_list(id)
114
+ debug_log "Handling tools/list (id=#{id.inspect})"
115
+ defs = @adapter.definitions
116
+ debug_log "tools/list returning #{defs.length} tool definitions"
117
+ send_result(id, { tools: defs })
118
+ end
119
+
120
+ def handle_tool_call(id, params)
121
+ tool_name = params[:name].to_s
122
+ arguments = params[:arguments] || {}
123
+
124
+ debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
125
+
126
+ result = @adapter.call(tool_name, arguments)
127
+ send_result(id, result)
128
+ end
129
+
130
+ def send_result(id, result)
131
+ $stdout.puts({ jsonrpc: "2.0", id: id, result: result }.to_json)
132
+ end
133
+
134
+ def send_error(id, code, message)
135
+ $stdout.puts({ jsonrpc: "2.0", id: id, error: { code: code, message: message } }.to_json)
136
+ end
137
+
138
+ def debug_log(msg)
139
+ return unless @debug
140
+ ts = Time.now.strftime("%H:%M:%S.%L")
141
+ $stderr.puts "[#{ts}] [ask-mcp] #{msg}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -36,6 +36,20 @@ module Ask
36
36
  prompts: @prompts.values.map(&:to_h)
37
37
  }
38
38
  end
39
+
40
+ # Start an MCP server over stdio.
41
+ # Blocking — designed to be the last line of an entry-point script.
42
+ #
43
+ # @param name [String] server name
44
+ # @param tools [Array<#call, #name, #description, #params_schema>] tool instances to expose
45
+ # @param capabilities [Hash] MCP capabilities (default: { tools: {} })
46
+ # @param debug [Boolean] enable stderr debug logging
47
+ def self.start_stdio(name:, tools: [], capabilities: { tools: {} }, debug: false)
48
+ Stdio.new(name: name, tools: tools, capabilities: capabilities, debug: debug).start
49
+ end
39
50
  end
40
51
  end
41
52
  end
53
+
54
+ # Load Server subclasses after the Server class is defined
55
+ require_relative "server/stdio"
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module MCP
3
- VERSION = "0.1.1"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
data/lib/ask/mcp.rb CHANGED
@@ -28,6 +28,7 @@ module Ask
28
28
 
29
29
  module Adapters
30
30
  autoload :AskTool, "ask/mcp/adapters/ask_tool"
31
+ autoload :ToolServer, "ask/mcp/adapters/tool_server"
31
32
  end
32
33
 
33
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.1.1
4
+ version: 0.3.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
- Discover tools, resources, and prompts. OAuth 2.1 authentication. Convert MCP tools
84
- to Ask::Tool instances. Supports the full MCP spec.
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,6 +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/tool_server.rb
98
99
  - lib/ask/mcp/auth/oauth.rb
99
100
  - lib/ask/mcp/auth/token.rb
100
101
  - lib/ask/mcp/client.rb
@@ -102,6 +103,7 @@ files:
102
103
  - lib/ask/mcp/prompt.rb
103
104
  - lib/ask/mcp/resource.rb
104
105
  - lib/ask/mcp/server.rb
106
+ - lib/ask/mcp/server/stdio.rb
105
107
  - lib/ask/mcp/tool.rb
106
108
  - lib/ask/mcp/transport/sse.rb
107
109
  - lib/ask/mcp/transport/stdio.rb
@@ -131,5 +133,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
133
  requirements: []
132
134
  rubygems_version: 4.0.3
133
135
  specification_version: 4
134
- summary: Model Context Protocol (MCP) client for Ruby
136
+ summary: Model Context Protocol (MCP) client and server for Ruby
135
137
  test_files: []