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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/docs/auth-setup.md +141 -0
- data/lib/ask/mcp/adapters/ask_tool.rb +43 -0
- data/lib/ask/mcp/auth/oauth.rb +105 -0
- data/lib/ask/mcp/auth/token.rb +24 -0
- data/lib/ask/mcp/client.rb +219 -0
- data/lib/ask/mcp/native/messages.rb +161 -0
- data/lib/ask/mcp/prompt.rb +30 -0
- data/lib/ask/mcp/resource.rb +32 -0
- data/lib/ask/mcp/server.rb +41 -0
- data/lib/ask/mcp/tool.rb +41 -0
- data/lib/ask/mcp/transport/sse.rb +152 -0
- data/lib/ask/mcp/transport/stdio.rb +122 -0
- data/lib/ask/mcp/transport/streamable_http.rb +112 -0
- data/lib/ask/mcp/validator.rb +51 -0
- data/lib/ask/mcp/version.rb +5 -0
- data/lib/ask/mcp.rb +65 -0
- data/lib/ask-mcp.rb +1 -0
- metadata +135 -0
|
@@ -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
|
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: []
|