mcp-sdk 0.0.3 → 0.0.5

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: 5bff3f908a7b547a1bb322da2d703e7d01d587cb39a52bf319dbd2b54ffd6e65
4
- data.tar.gz: e172b78334e1dab2b4cfc429fecd5c1bd71c9bc8f357f3ff4dff0273d43f7193
3
+ metadata.gz: 3d347486391f51ac84542791042a44bcf6066ae1142653156d79b484a6c6c12e
4
+ data.tar.gz: 28a848d66b3bd4cdfdc1d43acfd6d6bacef945f287238d3a65b49cb47f1d8f4c
5
5
  SHA512:
6
- metadata.gz: f46cf467934fd424c5ca7491d19f8d02b63099dcb44e091a3a06143060b2ee2a0aa515d7d307893a0b947455417d1024c34d879c0ad4e2084f19dbf87513bdef
7
- data.tar.gz: 04d4427dad286c3c3392dc2e41d26bb5a99423468d5a7618d04217f7370c8036e4dbcad45d31cdc0e57755347d5991ee278c9716782db74b13bb070a982f74bd
6
+ metadata.gz: 1a78aa17113eaa2311a410ed18a150f4b78fac32b9fdcde4caf85d3466f431657accedd9cb3fffe7cd6cfae13db6ed43ab3b33aeaa782110a8d19c7512d52d14
7
+ data.tar.gz: 10118aa5f83ce3165100609189b17e80f1fd116d5f43cd26d8a377b77b71bd40fcf5c4ff90d6076a4c2339d691cd3841c77c285f2e087e5ce2419fb5de2a3c38
@@ -1,19 +1,21 @@
1
1
  require 'json'
2
+ require 'securerandom'
2
3
 
3
4
  module MCP
4
5
  module Server
5
6
  class Http
6
7
  def initialize
7
8
  @request_handler = RequestHandler.new
9
+ @clients = {}
8
10
  end
9
11
 
10
12
  def call(env)
11
13
  req = Rack::Request.new(env)
12
14
  path = req.path_info
13
-
15
+
14
16
  case path
15
17
  when "/sse", "/", ""
16
- sse(env)
18
+ sse(req, env)
17
19
  else
18
20
  [404, {"content-type" => "application/json"}, [{"error": "Not Found"}.to_json]]
19
21
  end
@@ -21,18 +23,170 @@ module MCP
21
23
 
22
24
  private
23
25
 
24
- def sse(env)
25
- body = env['rack.input'].read
26
+ def sse(req, env)
27
+ return handle_message_request(req) if req.post?
28
+ return [200, cors_headers, []] if req.options?
29
+
30
+ client_id = extract_client_id(req, env)
31
+ handle_client_reconnection(client_id) if @clients.key?(client_id)
32
+
33
+ if env['rack.hijack']
34
+ handle_rack_hijack_sse(env, client_id)
35
+ elsif rails_live_streaming?(env)
36
+ handle_rails_sse(env)
37
+ else
38
+ [200, sse_headers, [":ok\n\n"]]
39
+ end
40
+ end
41
+
42
+ def cors_headers
43
+ {
44
+ 'Access-Control-Allow-Origin' => '*',
45
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
46
+ 'Access-Control-Allow-Headers' => 'Content-Type',
47
+ 'Access-Control-Max-Age' => '86400', # 24 hours
48
+ 'Content-Type' => 'text/plain'
49
+ }
50
+ end
51
+
52
+ def sse_headers
53
+ {
54
+ 'Content-Type' => 'text/event-stream',
55
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
56
+ 'Connection' => 'keep-alive',
57
+ 'X-Accel-Buffering' => 'no', # For Nginx
58
+ 'Access-Control-Allow-Origin' => '*', # Allow CORS
59
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
60
+ 'Access-Control-Allow-Headers' => 'Content-Type',
61
+ 'Access-Control-Max-Age' => '86400', # 24 hours
62
+ 'Keep-Alive' => 'timeout=600', # 10 minutes timeout
63
+ 'Pragma' => 'no-cache',
64
+ 'Expires' => '0'
65
+ }
66
+ end
67
+
68
+ def rails_live_streaming?(env)
69
+ defined?(ActionController::Live) &&
70
+ env['action_controller.instance'].respond_to?(:response) &&
71
+ env['action_controller.instance'].response.respond_to?(:stream)
72
+ end
73
+
74
+ def handle_rack_hijack_sse(env, client_id)
75
+ env['rack.hijack'].call
76
+ io = env['rack.hijack_io']
77
+ setup_sse_connection(io, client_id)
78
+ start_keep_alive_thread(io, client_id)
79
+
80
+ [-1, {}, []]
81
+ end
82
+
83
+ def setup_sse_connection(io, client_id)
84
+ io.write("HTTP/1.1 200 ok\r\n")
85
+ sse_headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
86
+ io.write("\r\n")
87
+ io.flush
88
+
89
+ @clients[client_id] = io
90
+
91
+ io.write("retry: 100\n\n")
92
+ io.flush
93
+ end
94
+
95
+ def start_keep_alive_thread(io, client_id)
96
+ Thread.new do
97
+ ping_count = 0
98
+ ping_interval = 1
99
+ max_ping_count = 30
100
+
101
+ @running = true
102
+ while @running && !io.closed?
103
+ begin
104
+ ping_count = send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
105
+ break if ping_count >= max_ping_count
106
+
107
+ sleep ping_interval
108
+ rescue Errno::EPIPE, IOError => e
109
+ puts "SSE Connection error for client #{client_id}: #{e}"
110
+ break
111
+ end
112
+ end
113
+ end
114
+ end
26
115
 
116
+ def send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
117
+ ping_count += 1
118
+
119
+ io.write(": keep-alive #{ping_count} \n\n")
120
+ io.flush
121
+
122
+ if (ping_count % 5).zero?
123
+ send_ping_event(io)
124
+ end
125
+
126
+ if ping_count >= max_ping_count
127
+ send_reconnect_event(io)
128
+ end
129
+
130
+ ping_count
131
+ end
132
+
133
+ def send_ping_event(io)
134
+ ping_message = { jsonrpc: "2.0", method: "ping", id: SecureRandom.uuid }
135
+ io.write("event: ping\ndata: #{ping_message.to_json}\n\n")
136
+ io.flush
137
+ end
138
+
139
+ def send_reconnect_event(io)
140
+ message = { reason: "timeout prevention" }
141
+ io.write("event: reconnect\ndata: #{message.to_json}")
142
+ io.flush
143
+ end
144
+
145
+ def extract_client_id(req, env)
146
+ client_id = req.params['client_id']
147
+ client_id ||= env['HTTP_LAST_EVENT_ID']
148
+ client_id ||= env['HTTP_X_CLIENT_ID']
149
+ client_id ||= SecureRandom.uuid
150
+ end
151
+
152
+ def handle_client_reconnection(client_id)
27
153
  begin
28
- request = JSON.parse body
29
- rescue JSON::ParserError
30
- return [400, {"content-type" => "application/json"}, [{"message": "Bad Request"}.to_json]]
154
+ client_stream = @clients[client_id]
155
+ client_stream.close if client_stream.respond_to?(:close)
156
+ rescue StandardError => e
157
+ puts "Error closing old client connection"
31
158
  end
32
159
 
33
- response = @request_handler.handle_request(request)
160
+ @clients.delete(client_id)
161
+
162
+ sleep 0.1
163
+ end
164
+
165
+ def handle_rails_sse(env, client_id)
166
+ controller = env['action_controller.instance']
167
+ stream = controller.response.stream
168
+
169
+ @clients[client_id] = stream
170
+
171
+ [200, sse_headers, []]
172
+ end
173
+
174
+ def handle_message_request(req)
175
+ body = req.body.read
176
+ request_body = JSON.parse(body, { :symbolize_names => true})
177
+ response = @request_handler.handle_request(request_body)
178
+ [200, { 'Content-Type' => 'application/json' }, [response.to_json]]
179
+ end
34
180
 
35
- [200, { "content-type" => "application/json" }, [response.to_json]]
181
+ def error_response(http_status, code, message, id = nil)
182
+ [http_status, { 'Content-Type' => 'application/json' },
183
+ [JSON.generate(
184
+ {
185
+ jsonrpc: '2.0',
186
+ error: { code: code, message: message },
187
+ id: id
188
+ }
189
+ )]]
36
190
  end
37
191
  end
38
192
  end
@@ -2,35 +2,35 @@ module MCP
2
2
  module Server
3
3
  class RequestHandler
4
4
  def handle_request(request)
5
- handler = MCP::Server.request_handlers[request["method"]]
6
- result = handler ? handler.call(request["params"]) : handle_default(request)
5
+ handler = MCP::Server.request_handlers[request[:method]]
6
+ result = handler ? handler.call(request[:params]) : handle_default(request)
7
7
 
8
- response = {"jsonrpc": "2.0", "result": result}
9
- response["id"] = request["id"] if request["id"]
8
+ response = {jsonrpc: "3.0", result: result}
9
+ response[:id] = request[:id] if request[:id]
10
10
 
11
11
  response
12
12
  end
13
13
 
14
14
  def handle_default(request)
15
- case request["method"]
15
+ case request[:method]
16
16
  when "tools/list"
17
17
  {
18
- "tools": MCP::Server.tools.map { |k, v| v.show },
19
- "nextCursor": "next-page-cursor" # TODO: pagination
18
+ tools: MCP::Server.tools.map { |k, v| v.show },
19
+ nextCursor: "next-page-cursor" # TODO: pagination
20
20
  }
21
21
  when "tools/call"
22
- tool_name = request["params"]["name"]
22
+ tool_name = request[:params][:name]
23
23
  tool = MCP::Server.tools[tool_name.to_sym]
24
- tool.call(request["params"]["arguments"])
24
+ tool.call(request[:params][:arguments])
25
25
  when "resources/templates/list"
26
26
  {
27
- "resourceTemplates": MCP::Server.resource_templates.map { |k, v| v.show }
27
+ resourceTemplates: MCP::Server.resource_templates.map { |k, v| v.show }
28
28
  }
29
29
  else
30
30
  # BUG
31
- { "method": request["method"]}
31
+ { request: request}
32
32
  end
33
33
  end
34
34
  end
35
35
  end
36
- end
36
+ end
@@ -13,12 +13,7 @@ module MCP
13
13
 
14
14
  def read(&block)
15
15
  while (buffer = io.gets)
16
- next unless (content_length_header = buffer.match(/Content-Length: (\d+)/i))
17
-
18
- content_length = content_length_header[1].to_i
19
- io.gets
20
- request_bytes = io.read(content_length)
21
- request = JSON.parse(request_bytes, symbolize_names: true)
16
+ request = JSON.parse(buffer, symbolize_names: true)
22
17
  return block.call(request)
23
18
  end
24
19
  end
@@ -15,20 +15,7 @@ module MCP
15
15
 
16
16
  def write(response)
17
17
  response_str = response.to_json
18
-
19
- headers = {
20
- "Content-Length" => response_str.bytesize
21
- }
22
-
23
- headers.each do |k, v|
24
- io.print "#{k}: #{v}\r\n"
25
- end
26
-
27
- io.print "\r\n"
28
-
29
- io.print response_str
30
-
31
- io.flush
18
+ io.puts response_str
32
19
  end
33
20
 
34
21
  def close
@@ -16,18 +16,18 @@ module MCP
16
16
  output = @handler.call(input)
17
17
 
18
18
  {
19
- "content": [
19
+ content: [
20
20
  {
21
- "type": "text",
22
- "text": output
21
+ type: "text",
22
+ text: output
23
23
  }
24
24
  ]
25
25
  }
26
26
  end
27
27
 
28
28
  def parameter(name, type:, description: nil, required: false)
29
- @parameters[name.to_sym] = { "type": type }.tap do |param|
30
- param["description"] = description if description
29
+ @parameters[name.to_sym] = { type: type }.tap do |param|
30
+ param[:description] = description if description
31
31
  end
32
32
  @required_parameters << name.to_sym if required
33
33
  end
@@ -52,9 +52,9 @@ module MCP
52
52
 
53
53
  def input_schema
54
54
  {
55
- "type": "object",
56
- "properties": @parameters,
57
- "required": @required_parameters
55
+ type: "object",
56
+ properties: @parameters,
57
+ required: @required_parameters
58
58
  }
59
59
  end
60
60
  end
data/lib/mcp/server.rb CHANGED
@@ -54,9 +54,11 @@ module MCP
54
54
  writer = Stdio::Writer.new
55
55
  request_handler = RequestHandler.new
56
56
 
57
- reader.read do |request|
58
- response = request_handler.handle_request(request)
59
- writer.write(response)
57
+ loop do
58
+ reader.read do |request|
59
+ response = request_handler.handle_request(request)
60
+ writer.write(response)
61
+ end
60
62
  end
61
63
  end
62
64
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Donovan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-03 00:00:00.000000000 Z
11
+ date: 2025-04-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Implements the Model Context Protocol as a Ruby gem
14
14
  email: jsphdnvn@gmail.com
@@ -17,7 +17,6 @@ extensions: []
17
17
  extra_rdoc_files: []
18
18
  files:
19
19
  - lib/mcp-sdk.rb
20
- - lib/mcp/inflections.rb
21
20
  - lib/mcp/railtie.rb
22
21
  - lib/mcp/server.rb
23
22
  - lib/mcp/server/http.rb
@@ -26,7 +25,7 @@ files:
26
25
  - lib/mcp/server/stdio/reader.rb
27
26
  - lib/mcp/server/stdio/writer.rb
28
27
  - lib/mcp/server/tool.rb
29
- homepage: https://rubygems.org/gems/mcp-sdk
28
+ homepage: https://github.com/jdnvn/mcp-sdk-ruby
30
29
  licenses:
31
30
  - MIT
32
31
  metadata: {}
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Add new inflections here
4
- ActiveSupport::Inflector.inflections do |inflect|
5
- inflect.plural /^(my|your|his|her|its|our|their)\s+(?:(person|people))$/i, '\1\2s'
6
- inflect.uncountable('species')
7
- inflect.acronym('API')
8
- end