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,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
|
data/lib/ask/mcp/tool.rb
ADDED
|
@@ -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
|