ask-mcp 0.1.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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ask
6
+ module MCP
7
+ module Transport
8
+ class Stdio
9
+ attr_reader :command, :args, :pid
10
+
11
+ def initialize(command, args = [], options = {})
12
+ @command = command
13
+ @args = args
14
+ @options = options
15
+ @pid = nil
16
+ @stdin = nil
17
+ @stdout = nil
18
+ @stderr = nil
19
+ @wait_thr = nil
20
+ @buffer = +""
21
+ @message_handlers = []
22
+ @running = false
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def on_message(&block)
27
+ @message_handlers << block
28
+ end
29
+
30
+ def start
31
+ env = @options[:env] || {}
32
+ workdir = @options[:workdir]
33
+
34
+ cmd = build_command
35
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(env, *cmd, chdir: workdir || Dir.pwd)
36
+ @pid = @wait_thr.pid
37
+ @running = true
38
+
39
+ start_reader
40
+ self
41
+ end
42
+
43
+ def stop
44
+ @running = false
45
+ @stdin&.close unless @stdin&.closed?
46
+ @stdout&.close unless @stdout&.closed?
47
+ @stderr&.close unless @stderr&.closed?
48
+ @wait_thr&.value
49
+ rescue Errno::EPIPE, Errno::ECHILD
50
+ # Process already exited
51
+ end
52
+
53
+ def send(message)
54
+ data = message.is_a?(String) ? message : message.to_json
55
+ @mutex.synchronize do
56
+ @stdin&.puts(data)
57
+ @stdin&.flush
58
+ end
59
+ rescue Errno::EPIPE, IOError => e
60
+ raise ConnectionError, "Failed to send message: #{e.message}"
61
+ end
62
+
63
+ def running?
64
+ @running && @wait_thr&.alive?
65
+ end
66
+
67
+ def shutdown
68
+ stop
69
+ end
70
+
71
+ private
72
+
73
+ def build_command
74
+ if @command.is_a?(Array)
75
+ @command
76
+ else
77
+ [@command] + @args
78
+ end
79
+ end
80
+
81
+ def start_reader
82
+ @reader_thread = Thread.new do
83
+ read_partial_line(@stdout)
84
+ rescue IOError, Errno::EPIPE, Errno::EBADF => e
85
+ @running = false
86
+ notify_error(e)
87
+ end
88
+ end
89
+
90
+ def read_partial_line(io)
91
+ while @running && (char = io.getc)
92
+ @buffer << char
93
+ if @buffer.end_with?("\n")
94
+ line = @buffer.strip
95
+ @buffer = +""
96
+ next if line.empty?
97
+
98
+ process_line(line)
99
+ end
100
+ end
101
+ rescue IOError, Errno::EPIPE, Errno::EBADF
102
+ @running = false
103
+ end
104
+
105
+ def process_line(line)
106
+ message = Native::Messages::Parser.parse(line)
107
+ @message_handlers.each { |handler| handler.call(message) }
108
+ rescue JSON::ParserError => e
109
+ # Ignore non-JSON output (e.g., stderr mixed in)
110
+ rescue ProtocolError => e
111
+ notify_error(e)
112
+ end
113
+
114
+ def notify_error(error)
115
+ @message_handlers.each do |handler|
116
+ handler.call(error) if error.is_a?(Exception)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ module Transport
6
+ class StreamableHTTP
7
+ attr_reader :url
8
+
9
+ def initialize(url, options = {})
10
+ @url = url
11
+ @options = options
12
+ @running = false
13
+ @message_handlers = []
14
+ @http = nil
15
+ @session_id = nil
16
+ end
17
+
18
+ def on_message(&block)
19
+ @message_handlers << block
20
+ end
21
+
22
+ def start
23
+ require "httpx"
24
+
25
+ headers = { "Content-Type" => "application/json" }
26
+ headers["Accept"] = "text/event-stream" if @options[:stream]
27
+ headers.merge!(@options[:headers]) if @options[:headers]
28
+
29
+ @http = HTTPX.with(
30
+ headers:,
31
+ timeout: { request_timeout: @options[:timeout] || 30 }
32
+ )
33
+ @running = true
34
+ self
35
+ end
36
+
37
+ def stop
38
+ @running = false
39
+ @http&.close
40
+ end
41
+
42
+ def send(message)
43
+ data = message.is_a?(String) ? message : message.to_json
44
+
45
+ if @options[:stream]
46
+ send_streaming(data)
47
+ else
48
+ send_request_response(data)
49
+ end
50
+ rescue HTTPX::Error => e
51
+ raise ConnectionError, "HTTP error: #{e.message}"
52
+ end
53
+
54
+ def running?
55
+ @running
56
+ end
57
+
58
+ def shutdown
59
+ stop
60
+ end
61
+
62
+ private
63
+
64
+ def send_request_response(data)
65
+ response = @http.post(@url, body: data)
66
+ status = response.status
67
+
68
+ if status == 200 || status == 202
69
+ body = response.body.to_s
70
+ if body && !body.empty?
71
+ message = Native::Messages::Parser.parse(body)
72
+ @message_handlers.each { |handler| handler.call(message) }
73
+ end
74
+ elsif status == 204
75
+ # No content — nothing to process
76
+ else
77
+ raise ConnectionError, "HTTP #{status}: #{response.body.to_s[0..200]}"
78
+ end
79
+
80
+ response
81
+ end
82
+
83
+ def send_streaming(data)
84
+ response = @http.post(@url, body: data)
85
+
86
+ unless response.status == 200
87
+ raise ConnectionError, "HTTP #{response.status}: #{response.body.to_s[0..200]}"
88
+ end
89
+
90
+ buffer = +""
91
+ response.body.each do |chunk|
92
+ buffer << chunk
93
+ while (line = buffer.slice!(/\A.*\n/))
94
+ line = line.strip
95
+ next if line.empty?
96
+
97
+ if line.start_with?("data: ")
98
+ data_line = line[6..]
99
+ begin
100
+ message = Native::Messages::Parser.parse(data_line)
101
+ @message_handlers.each { |handler| handler.call(message) }
102
+ rescue JSON::ParserError
103
+ # Skip non-JSON data lines
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ # Validates tool call arguments against JSON Schema input schemas.
6
+ # Uses the json-schema gem to validate arguments before sending to a server.
7
+ class Validator
8
+ class ValidationError < Error; end
9
+
10
+ def initialize(schema)
11
+ @schema = schema
12
+ end
13
+
14
+ def validate!(arguments)
15
+ return true if @schema.nil? || @schema.empty?
16
+
17
+ require "json-schema"
18
+
19
+ string_schema = deep_stringify_keys(@schema)
20
+ data = arguments.is_a?(Hash) ? deep_stringify_keys(arguments) : arguments
21
+
22
+ errors = JSON::Validator.fully_validate(string_schema, data)
23
+ if errors.any?
24
+ raise ValidationError, "Validation failed: #{errors.join(", ")}"
25
+ end
26
+
27
+ true
28
+ end
29
+
30
+ def valid?(arguments)
31
+ validate!(arguments)
32
+ true
33
+ rescue ValidationError
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ def deep_stringify_keys(obj)
40
+ case obj
41
+ when Hash
42
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
43
+ when Array
44
+ obj.map { |v| deep_stringify_keys(v) }
45
+ else
46
+ obj
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ module Ask
2
+ module MCP
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
data/lib/ask/mcp.rb ADDED
@@ -0,0 +1,65 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require_relative "mcp/version"
4
+
5
+ module Ask
6
+ module MCP
7
+ autoload :Client, "ask/mcp/client"
8
+ autoload :Server, "ask/mcp/server"
9
+ autoload :Tool, "ask/mcp/tool"
10
+ autoload :Resource, "ask/mcp/resource"
11
+ autoload :Prompt, "ask/mcp/prompt"
12
+ autoload :Validator, "ask/mcp/validator"
13
+
14
+ module Native
15
+ autoload :Messages, "ask/mcp/native/messages"
16
+ end
17
+
18
+ module Transport
19
+ autoload :Stdio, "ask/mcp/transport/stdio"
20
+ autoload :SSE, "ask/mcp/transport/sse"
21
+ autoload :StreamableHTTP, "ask/mcp/transport/streamable_http"
22
+ end
23
+
24
+ module Auth
25
+ autoload :OAuth, "ask/mcp/auth/oauth"
26
+ autoload :Token, "ask/mcp/auth/token"
27
+ end
28
+
29
+ module Adapters
30
+ autoload :AskTool, "ask/mcp/adapters/ask_tool"
31
+ end
32
+
33
+ class Error < StandardError; end
34
+ class ConnectionError < Error; end
35
+ class ProtocolError < Error; end
36
+ class AuthError < Error; end
37
+ class ValidationError < Error; end
38
+
39
+ class << self
40
+ def connect(transport, options = {})
41
+ Client.new(transport, options)
42
+ end
43
+
44
+ def from_stdio(command, args = [], options = {})
45
+ transport = Transport::Stdio.new(command, args, options)
46
+ Client.new(transport)
47
+ end
48
+
49
+ def from_sse(url, options = {})
50
+ transport = Transport::SSE.new(url, options)
51
+ Client.new(transport)
52
+ end
53
+
54
+ def from_http(url, options = {})
55
+ transport = Transport::StreamableHTTP.new(url, options)
56
+ Client.new(transport)
57
+ end
58
+
59
+ # Validate tool arguments against a JSON Schema input schema
60
+ def validate!(schema, arguments)
61
+ Validator.new(schema).validate!(arguments)
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/ask-mcp.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative "ask/mcp"
metadata ADDED
@@ -0,0 +1,135 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: httpx
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json-schema
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.25'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.25'
54
+ - !ruby/object:Gem::Dependency
55
+ name: mocha
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.1'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
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.
85
+ email:
86
+ - kaka@myrrlabs.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE
93
+ - README.md
94
+ - docs/auth-setup.md
95
+ - lib/ask-mcp.rb
96
+ - lib/ask/mcp.rb
97
+ - lib/ask/mcp/adapters/ask_tool.rb
98
+ - lib/ask/mcp/auth/oauth.rb
99
+ - lib/ask/mcp/auth/token.rb
100
+ - lib/ask/mcp/client.rb
101
+ - lib/ask/mcp/native/messages.rb
102
+ - lib/ask/mcp/prompt.rb
103
+ - lib/ask/mcp/resource.rb
104
+ - lib/ask/mcp/server.rb
105
+ - lib/ask/mcp/tool.rb
106
+ - lib/ask/mcp/transport/sse.rb
107
+ - lib/ask/mcp/transport/stdio.rb
108
+ - lib/ask/mcp/transport/streamable_http.rb
109
+ - lib/ask/mcp/validator.rb
110
+ - lib/ask/mcp/version.rb
111
+ homepage: https://github.com/ask-rb/ask-mcp
112
+ licenses:
113
+ - MIT
114
+ metadata:
115
+ homepage_uri: https://github.com/ask-rb/ask-mcp
116
+ source_code_uri: https://github.com/ask-rb/ask-mcp
117
+ changelog_uri: https://github.com/ask-rb/ask-mcp/blob/master/CHANGELOG.md
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '3.2'
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 4.0.3
133
+ specification_version: 4
134
+ summary: Model Context Protocol (MCP) client for Ruby
135
+ test_files: []