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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ class Client
6
+ PROTOCOL_VERSION = "0.1.0"
7
+
8
+ attr_reader :transport, :capabilities, :server_info
9
+
10
+ def initialize(transport, options = {})
11
+ @transport = transport
12
+ @options = options
13
+ @capabilities = {}
14
+ @server_info = {}
15
+ @tools_cache = nil
16
+ @resources_cache = nil
17
+ @prompts_cache = nil
18
+ @pending_requests = {}
19
+ @pending_mutex = Mutex.new
20
+ @pending_condition = ConditionVariable.new
21
+ @next_id = 0
22
+ @initialized = false
23
+ end
24
+
25
+ def start
26
+ @transport.on_message { |message| handle_message(message) }
27
+ @transport.start
28
+ initialize_session
29
+ self
30
+ end
31
+
32
+ def stop
33
+ @transport.stop
34
+ @initialized = false
35
+ end
36
+
37
+ def tools
38
+ return @tools_cache if @tools_cache && !@options[:no_cache]
39
+
40
+ response = send_request("tools/list")
41
+ tools = (response[:tools] || []).map { |t| Tool.from_h(t) }
42
+ @tools_cache = index_by_name(tools)
43
+ end
44
+
45
+ def resources
46
+ return @resources_cache if @resources_cache && !@options[:no_cache]
47
+
48
+ response = send_request("resources/list")
49
+ resources = (response[:resources] || []).map { |r| Resource.from_h(r) }
50
+ @resources_cache = index_by_uri(resources)
51
+ end
52
+
53
+ def prompts
54
+ return @prompts_cache if @prompts_cache && !@options[:no_cache]
55
+
56
+ response = send_request("prompts/list")
57
+ prompts = (response[:prompts] || []).map { |p| Prompt.from_h(p) }
58
+ @prompts_cache = index_by_name(prompts)
59
+ end
60
+
61
+ def call_tool(name, arguments = {})
62
+ if @options[:validate] && @tools_cache
63
+ tool = @tools_cache[name]
64
+ if tool && tool.input_schema && !tool.input_schema.empty?
65
+ Validator.new(tool.input_schema).validate!(arguments)
66
+ end
67
+ end
68
+ response = send_request("tools/call", name: name, arguments: arguments)
69
+ response[:content] || response
70
+ end
71
+
72
+ def read_resource(uri)
73
+ response = send_request("resources/read", uri: uri)
74
+ response[:contents] || response
75
+ end
76
+
77
+ def get_prompt(name, arguments = {})
78
+ response = send_request("prompts/get", name: name, arguments: arguments)
79
+ response[:messages] || response
80
+ end
81
+
82
+ def initialized?
83
+ @initialized
84
+ end
85
+
86
+ private
87
+
88
+ def next_id
89
+ @pending_mutex.synchronize do
90
+ @next_id += 1
91
+ end
92
+ end
93
+
94
+ def initialize_session
95
+ response = send_request_raw("initialize", {
96
+ protocolVersion: PROTOCOL_VERSION,
97
+ capabilities: @options[:client_capabilities] || {},
98
+ clientInfo: {
99
+ name: "ask-mcp",
100
+ version: Ask::MCP::VERSION
101
+ }
102
+ })
103
+
104
+ unless response.success?
105
+ raise ProtocolError, "Initialize failed: #{response.error[:message]}"
106
+ end
107
+
108
+ result = response.result
109
+ @server_info = result[:serverInfo] || {}
110
+ @capabilities = result[:capabilities] || {}
111
+
112
+ send_notification("notifications/initialized")
113
+ @initialized = true
114
+
115
+ @tools_cache = nil
116
+ @resources_cache = nil
117
+ @prompts_cache = nil
118
+ end
119
+
120
+ def send_request(method, params = {})
121
+ response = send_request_raw(method, params)
122
+ raise ProtocolError, "Request failed: #{response.error[:message]}" unless response.success?
123
+ response.result
124
+ end
125
+
126
+ def send_request_raw(method, params = {})
127
+ request = Native::Messages::Request.new(method:, params:, id: next_id)
128
+ wait_for_response(request)
129
+ end
130
+
131
+ def send_notification(method, params = {})
132
+ notification = Native::Messages::Notification.new(method:, params:)
133
+ @transport.send(notification)
134
+ end
135
+
136
+ def wait_for_response(request)
137
+ # Register the pending request BEFORE sending to avoid race
138
+ @pending_mutex.synchronize do
139
+ @pending_requests[request.id] = true
140
+ end
141
+
142
+ @transport.send(request)
143
+
144
+ timeout = @options[:timeout] || 60
145
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
146
+
147
+ @pending_mutex.synchronize do
148
+ loop do
149
+ val = @pending_requests[request.id]
150
+ if val.is_a?(Native::Messages::Response)
151
+ @pending_requests.delete(request.id)
152
+ return val
153
+ end
154
+
155
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ if remaining <= 0
157
+ @pending_requests.delete(request.id)
158
+ raise ConnectionError, "Request timed out after #{timeout}s"
159
+ end
160
+
161
+ @pending_condition.wait(@pending_mutex, remaining)
162
+ end
163
+ end
164
+ end
165
+
166
+ def handle_message(message)
167
+ case message
168
+ when Native::Messages::Response
169
+ handle_response(message)
170
+ when Native::Messages::Request
171
+ handle_request(message)
172
+ when Native::Messages::Notification
173
+ handle_notification(message)
174
+ when Exception
175
+ raise ConnectionError, "Transport error: #{message.message}"
176
+ end
177
+ end
178
+
179
+ def handle_response(response)
180
+ @pending_mutex.synchronize do
181
+ if @pending_requests.key?(response.id)
182
+ @pending_requests[response.id] = response
183
+ @pending_condition.broadcast
184
+ end
185
+ end
186
+ end
187
+
188
+ def handle_request(request)
189
+ response = Native::Messages::Response.new(
190
+ id: request.id,
191
+ error: {
192
+ code: Native::Messages::ErrorCodes::METHOD_NOT_FOUND,
193
+ message: "Method not implemented: #{request.method}"
194
+ }
195
+ )
196
+ @transport.send(response)
197
+ end
198
+
199
+ def handle_notification(notification)
200
+ case notification.method
201
+ when "notifications/tools/list_changed"
202
+ @tools_cache = nil
203
+ when "notifications/resources/list_changed"
204
+ @resources_cache = nil
205
+ when "notifications/prompts/list_changed"
206
+ @prompts_cache = nil
207
+ end
208
+ end
209
+
210
+ def index_by_name(objects)
211
+ objects.each_with_object({}) { |obj, hash| hash[obj.name] = obj }
212
+ end
213
+
214
+ def index_by_uri(objects)
215
+ objects.each_with_object({}) { |obj, hash| hash[obj.uri] = obj }
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ module Native
6
+ # JSON-RPC 2.0 message types for the Model Context Protocol.
7
+ module Messages
8
+ JSON_RPC_VERSION = "2.0"
9
+
10
+ class Request
11
+ attr_reader :id, :method, :params
12
+
13
+ def initialize(method:, params: nil, id: nil)
14
+ @id = id || SecureRandom.uuid
15
+ @method = method
16
+ @params = params
17
+ end
18
+
19
+ def to_h
20
+ h = {
21
+ jsonrpc: JSON_RPC_VERSION,
22
+ id: @id,
23
+ method: @method
24
+ }
25
+ h[:params] = @params if @params
26
+ h
27
+ end
28
+
29
+ def to_json(*args)
30
+ to_h.to_json(*args)
31
+ end
32
+ end
33
+
34
+ class Notification
35
+ attr_reader :method, :params
36
+
37
+ def initialize(method:, params: nil)
38
+ @method = method
39
+ @params = params
40
+ end
41
+
42
+ def to_h
43
+ h = {
44
+ jsonrpc: JSON_RPC_VERSION,
45
+ method: @method
46
+ }
47
+ h[:params] = @params if @params
48
+ h
49
+ end
50
+
51
+ def to_json(*args)
52
+ to_h.to_json(*args)
53
+ end
54
+ end
55
+
56
+ class Response
57
+ attr_reader :id, :result, :error
58
+
59
+ def initialize(id:, result: nil, error: nil)
60
+ @id = id
61
+ @result = result
62
+ @error = error
63
+ end
64
+
65
+ def success?
66
+ @error.nil?
67
+ end
68
+
69
+ def to_h
70
+ h = { jsonrpc: JSON_RPC_VERSION, id: @id }
71
+ if @error
72
+ h[:error] = {
73
+ code: @error[:code],
74
+ message: @error[:message]
75
+ }
76
+ h[:error][:data] = @error[:data] if @error[:data]
77
+ else
78
+ h[:result] = @result || {}
79
+ end
80
+ h
81
+ end
82
+
83
+ def to_json(*args)
84
+ to_h.to_json(*args)
85
+ end
86
+ end
87
+
88
+ module Parser
89
+ def self.parse(json_string)
90
+ data = JSON.parse(json_string, symbolize_names: true)
91
+ return data unless data.is_a?(Hash)
92
+
93
+ if data.key?(:id) && (data.key?(:result) || data.key?(:error))
94
+ Response.new(
95
+ id: data[:id],
96
+ result: data[:result],
97
+ error: data[:error]
98
+ )
99
+ elsif data.key?(:method) && data.key?(:id)
100
+ Request.new(
101
+ method: data[:method],
102
+ params: data[:params],
103
+ id: data[:id]
104
+ )
105
+ elsif data.key?(:method) && !data.key?(:id)
106
+ Notification.new(
107
+ method: data[:method],
108
+ params: data[:params]
109
+ )
110
+ else
111
+ data
112
+ end
113
+ end
114
+
115
+ def self.parse_response(json_string)
116
+ msg = parse(json_string)
117
+ raise ProtocolError, "Expected a Response, got #{msg.class}" unless msg.is_a?(Response)
118
+ msg
119
+ end
120
+
121
+ def self.parse_request(json_string)
122
+ msg = parse(json_string)
123
+ raise ProtocolError, "Expected a Request, got #{msg.class}" unless msg.is_a?(Request)
124
+ msg
125
+ end
126
+ end
127
+
128
+ # Standard JSON-RPC error codes
129
+ module ErrorCodes
130
+ PARSE_ERROR = -32700
131
+ INVALID_REQUEST = -32600
132
+ METHOD_NOT_FOUND = -32601
133
+ INVALID_PARAMS = -32602
134
+ INTERNAL_ERROR = -32603
135
+
136
+ # MCP-specific error codes
137
+ TOOL_NOT_FOUND = -32000
138
+ RESOURCE_NOT_FOUND = -32001
139
+ PROMPT_NOT_FOUND = -32002
140
+ AUTH_ERROR = -32003
141
+ CONNECTION_ERROR = -32004
142
+ TIMEOUT_ERROR = -32005
143
+
144
+ ERROR_MESSAGES = {
145
+ PARSE_ERROR => "Parse error",
146
+ INVALID_REQUEST => "Invalid request",
147
+ METHOD_NOT_FOUND => "Method not found",
148
+ INVALID_PARAMS => "Invalid params",
149
+ INTERNAL_ERROR => "Internal error",
150
+ TOOL_NOT_FOUND => "Tool not found",
151
+ RESOURCE_NOT_FOUND => "Resource not found",
152
+ PROMPT_NOT_FOUND => "Prompt not found",
153
+ AUTH_ERROR => "Authentication error",
154
+ CONNECTION_ERROR => "Connection error",
155
+ TIMEOUT_ERROR => "Timeout error"
156
+ }.freeze
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ class Prompt
6
+ attr_reader :name, :description, :arguments
7
+
8
+ def initialize(name:, description: nil, arguments: [])
9
+ @name = name
10
+ @description = description
11
+ @arguments = arguments
12
+ end
13
+
14
+ def to_h
15
+ h = { name: @name }
16
+ h[:description] = @description if @description
17
+ h[:arguments] = @arguments if @arguments.any?
18
+ h
19
+ end
20
+
21
+ def self.from_h(hash)
22
+ new(
23
+ name: hash[:name] || hash["name"],
24
+ description: hash[:description] || hash["description"],
25
+ arguments: hash[:arguments] || hash["arguments"] || []
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ class Resource
6
+ attr_reader :uri, :name, :description, :mime_type
7
+
8
+ def initialize(uri:, name:, description: nil, mime_type: nil)
9
+ @uri = uri
10
+ @name = name
11
+ @description = description
12
+ @mime_type = mime_type
13
+ end
14
+
15
+ def to_h
16
+ h = { uri: @uri, name: @name }
17
+ h[:description] = @description if @description
18
+ h[:mimeType] = @mime_type if @mime_type
19
+ h
20
+ end
21
+
22
+ def self.from_h(hash)
23
+ new(
24
+ uri: hash[:uri] || hash["uri"],
25
+ name: hash[:name] || hash["name"],
26
+ description: hash[:description] || hash["description"],
27
+ mime_type: hash[:mimeType] || hash["mime_type"] || hash[:mime_type]
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ class Server
6
+ attr_reader :name, :version, :capabilities, :tools, :resources, :prompts
7
+
8
+ def initialize(name:, version: "0.1.0", capabilities: {}, tools: {}, resources: {}, prompts: {})
9
+ @name = name
10
+ @version = version
11
+ @capabilities = capabilities
12
+ @tools = tools
13
+ @resources = resources
14
+ @prompts = prompts
15
+ end
16
+
17
+ def tool_names
18
+ @tools.keys
19
+ end
20
+
21
+ def resource_uris
22
+ @resources.keys
23
+ end
24
+
25
+ def prompt_names
26
+ @prompts.keys
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ name: @name,
32
+ version: @version,
33
+ capabilities: @capabilities,
34
+ tools: @tools.values.map(&:to_h),
35
+ resources: @resources.values.map(&:to_h),
36
+ prompts: @prompts.values.map(&:to_h)
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ class Tool
6
+ attr_reader :name, :description, :input_schema
7
+
8
+ def initialize(name:, description: "", input_schema: {})
9
+ @name = name
10
+ @description = description
11
+ @input_schema = input_schema
12
+ end
13
+
14
+ def to_ask_tool
15
+ require "ask/tools/tool"
16
+
17
+ Ask::Tools::Tool.new(
18
+ name: @name,
19
+ description: @description,
20
+ parameters: @input_schema
21
+ )
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ name: @name,
27
+ description: @description,
28
+ inputSchema: @input_schema
29
+ }
30
+ end
31
+
32
+ def self.from_h(hash)
33
+ new(
34
+ name: hash[:name] || hash["name"],
35
+ description: hash[:description] || hash["description"] || "",
36
+ input_schema: hash[:inputSchema] || hash["input_schema"] || hash[:input_schema] || hash["inputSchema"] || {}
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module MCP
5
+ module Transport
6
+ class SSE
7
+ attr_reader :url
8
+
9
+ def initialize(url, options = {})
10
+ @url = url
11
+ @options = options
12
+ @running = false
13
+ @buffer = +""
14
+ @message_handlers = []
15
+ @http = nil
16
+ @response = nil
17
+ @post_url = nil
18
+ @reconnect_delay = options[:reconnect_delay] || 1.0
19
+ @max_reconnect_delay = options[:max_reconnect_delay] || 30.0
20
+ @reconnect_jitter = options[:reconnect_jitter] || 0.1
21
+ @max_retries = options[:max_retries] || 5
22
+ @retries = 0
23
+ end
24
+
25
+ def on_message(&block)
26
+ @message_handlers << block
27
+ end
28
+
29
+ def start
30
+ require "httpx"
31
+
32
+ @running = true
33
+ @retries = 0
34
+ @post_url = nil
35
+ connect_stream
36
+ self
37
+ end
38
+
39
+ def stop
40
+ @running = false
41
+ @response&.close
42
+ @http&.close
43
+ end
44
+
45
+ def send(message)
46
+ require "httpx"
47
+
48
+ data = message.is_a?(String) ? message : message.to_json
49
+ target = @post_url || @url
50
+
51
+ headers = { "Content-Type" => "application/json" }
52
+ headers.merge!(@options[:headers]) if @options[:headers]
53
+
54
+ http = HTTPX.with(headers:)
55
+ response = http.post(target, body: data)
56
+
57
+ unless response.status == 200 || response.status == 202 || response.status == 204
58
+ raise ConnectionError, "Failed to send message: #{response.status} #{response.body.to_s}"
59
+ end
60
+
61
+ response
62
+ rescue HTTPX::Error => e
63
+ raise ConnectionError, "Failed to send message: #{e.message}"
64
+ end
65
+
66
+ def running?
67
+ @running
68
+ end
69
+
70
+ def shutdown
71
+ stop
72
+ end
73
+
74
+ private
75
+
76
+ def connect_stream
77
+ return unless @running
78
+
79
+ @http = HTTPX.with(timeout: { request_timeout: @options[:timeout] || 300 })
80
+ @http = @http.with(headers: @options[:headers]) if @options[:headers]
81
+
82
+ @response = @http.get(@url)
83
+
84
+ unless @response.status == 200
85
+ raise ConnectionError, "SSE connection failed: #{@response.status}"
86
+ end
87
+
88
+ @retries = 0
89
+ @current_event = nil
90
+ @buffer = +""
91
+
92
+ @response.body.each do |chunk|
93
+ process_chunk(chunk)
94
+ end
95
+ rescue HTTPX::Error, ConnectionError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
96
+ handle_disconnect(e)
97
+ end
98
+
99
+ def process_chunk(chunk)
100
+ @buffer << chunk
101
+ while (line = @buffer.slice!(/\A.*\n/))
102
+ line = line.strip
103
+ next if line.empty?
104
+
105
+ if line.start_with?("event: ")
106
+ @current_event = line[7..]
107
+ elsif line.start_with?("data: ")
108
+ data_line = line[6..]
109
+
110
+ case @current_event
111
+ when "endpoint"
112
+ @post_url = data_line.strip
113
+ @current_event = nil
114
+ when "message", nil
115
+ begin
116
+ message = Native::Messages::Parser.parse(data_line)
117
+ @message_handlers.each { |handler| handler.call(message) }
118
+ rescue JSON::ParserError
119
+ # Skip non-JSON data
120
+ end
121
+ @current_event = nil
122
+ else
123
+ @current_event = nil
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ def handle_disconnect(error)
130
+ return unless @running
131
+
132
+ @retries += 1
133
+
134
+ if @retries > @max_retries
135
+ @message_handlers.each { |h| h.call(error) }
136
+ return
137
+ end
138
+
139
+ delay = calculate_backoff
140
+ sleep(delay)
141
+ connect_stream
142
+ end
143
+
144
+ def calculate_backoff
145
+ delay = [@reconnect_delay * (2 ** (@retries - 1)), @max_reconnect_delay].min
146
+ jitter = delay * @reconnect_jitter * (rand - 0.5) * 2
147
+ delay + jitter
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end