model-context-protocol-rb 0.3.1 → 0.3.3

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.
@@ -12,16 +12,19 @@ module ModelContextProtocol
12
12
  end
13
13
  end
14
14
 
15
- attr_reader :logger, :router
15
+ attr_reader :router, :configuration
16
16
 
17
- def initialize(logger:, router:)
18
- @logger = logger
17
+ def initialize(router:, configuration:)
19
18
  @router = router
19
+ @configuration = configuration
20
20
  end
21
21
 
22
- def begin
22
+ def handle
23
+ # Connect logger to transport
24
+ @configuration.logger.connect_transport(self)
25
+
23
26
  loop do
24
- line = $stdin.gets
27
+ line = receive_message
25
28
  break unless line
26
29
 
27
30
  begin
@@ -31,18 +34,17 @@ module ModelContextProtocol
31
34
  result = router.route(message)
32
35
  send_message(Response[id: message["id"], result: result.serialized])
33
36
  rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
34
- log("Validation error: #{validation_error.message}")
37
+ @configuration.logger.error("Validation error", error: validation_error.message)
35
38
  send_message(
36
39
  ErrorResponse[id: message["id"], error: {code: -32602, message: validation_error.message}]
37
40
  )
38
41
  rescue JSON::ParserError => parser_error
39
- log("Parser error: #{parser_error.message}")
42
+ @configuration.logger.error("Parser error", error: parser_error.message)
40
43
  send_message(
41
44
  ErrorResponse[id: "", error: {code: -32700, message: parser_error.message}]
42
45
  )
43
46
  rescue => error
44
- log("Internal error: #{error.message}")
45
- log(error.backtrace)
47
+ @configuration.logger.error("Internal error", error: error.message, backtrace: error.backtrace.first(5))
46
48
  send_message(
47
49
  ErrorResponse[id: message["id"], error: {code: -32603, message: error.message}]
48
50
  )
@@ -50,10 +52,23 @@ module ModelContextProtocol
50
52
  end
51
53
  end
52
54
 
55
+ def send_notification(method, params)
56
+ notification = {
57
+ jsonrpc: "2.0",
58
+ method: method,
59
+ params: params
60
+ }
61
+ $stdout.puts(JSON.generate(notification))
62
+ $stdout.flush
63
+ rescue IOError => e
64
+ # Handle broken pipe gracefully
65
+ @configuration.logger.debug("Failed to send notification", error: e.message) if @configuration.logging_enabled?
66
+ end
67
+
53
68
  private
54
69
 
55
- def log(output, level = :error)
56
- logger.send(level.to_sym, output)
70
+ def receive_message
71
+ $stdin.gets
57
72
  end
58
73
 
59
74
  def send_message(message)
@@ -0,0 +1,291 @@
1
+ require "json"
2
+ require "securerandom"
3
+
4
+ module ModelContextProtocol
5
+ class Server::StreamableHttpTransport
6
+ Response = Data.define(:id, :result) do
7
+ def serialized
8
+ {jsonrpc: "2.0", id:, result:}
9
+ end
10
+ end
11
+
12
+ ErrorResponse = Data.define(:id, :error) do
13
+ def serialized
14
+ {jsonrpc: "2.0", id:, error:}
15
+ end
16
+ end
17
+ def initialize(router:, configuration:)
18
+ @router = router
19
+ @configuration = configuration
20
+
21
+ transport_options = @configuration.transport_options
22
+ @redis = transport_options[:redis_client]
23
+
24
+ @session_store = ModelContextProtocol::Server::SessionStore.new(
25
+ @redis,
26
+ ttl: transport_options[:session_ttl] || 3600
27
+ )
28
+
29
+ @server_instance = "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(4)}"
30
+ @local_streams = {}
31
+ @notification_queue = []
32
+
33
+ setup_redis_subscriber
34
+ end
35
+
36
+ def handle
37
+ @configuration.logger.connect_transport(self)
38
+
39
+ request = @configuration.transport_options[:request]
40
+ response = @configuration.transport_options[:response]
41
+
42
+ unless request && response
43
+ raise ArgumentError, "StreamableHTTP transport requires request and response objects in transport_options"
44
+ end
45
+
46
+ case request.method
47
+ when "POST"
48
+ handle_post_request(request)
49
+ when "GET"
50
+ handle_sse_request(request, response)
51
+ when "DELETE"
52
+ handle_delete_request(request)
53
+ else
54
+ error_response = ErrorResponse[id: nil, error: {code: -32601, message: "Method not allowed"}]
55
+ {json: error_response.serialized, status: 405}
56
+ end
57
+ end
58
+
59
+ def send_notification(method, params)
60
+ notification = {
61
+ jsonrpc: "2.0",
62
+ method: method,
63
+ params: params
64
+ }
65
+
66
+ if has_active_streams?
67
+ deliver_to_active_streams(notification)
68
+ else
69
+ @notification_queue << notification
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def handle_post_request(request)
76
+ body_string = request.body.read
77
+ body = JSON.parse(body_string)
78
+ session_id = request.headers["Mcp-Session-Id"]
79
+
80
+ case body["method"]
81
+ when "initialize"
82
+ handle_initialization(body)
83
+ else
84
+ handle_regular_request(body, session_id)
85
+ end
86
+ rescue JSON::ParserError
87
+ error_response = ErrorResponse[id: "", error: {code: -32700, message: "Parse error"}]
88
+ {json: error_response.serialized, status: 400}
89
+ rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
90
+ @configuration.logger.error("Validation error", error: validation_error.message)
91
+ error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32602, message: validation_error.message}]
92
+ {json: error_response.serialized, status: 400}
93
+ rescue => e
94
+ @configuration.logger.error("Error handling POST request", error: e.message, backtrace: e.backtrace.first(5))
95
+ error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32603, message: "Internal error"}]
96
+ {json: error_response.serialized, status: 500}
97
+ end
98
+
99
+ def handle_initialization(body)
100
+ session_id = SecureRandom.uuid
101
+
102
+ @session_store.create_session(session_id, {
103
+ server_instance: @server_instance,
104
+ context: @configuration.context || {},
105
+ created_at: Time.now.to_f
106
+ })
107
+
108
+ result = @router.route(body)
109
+ response = Response[id: body["id"], result: result.serialized]
110
+
111
+ {
112
+ json: response.serialized,
113
+ status: 200,
114
+ headers: {"Mcp-Session-Id" => session_id}
115
+ }
116
+ end
117
+
118
+ def handle_regular_request(body, session_id)
119
+ unless session_id && @session_store.session_exists?(session_id)
120
+ error_response = ErrorResponse[id: body["id"], error: {code: -32600, message: "Invalid or missing session ID"}]
121
+ return {json: error_response.serialized, status: 400}
122
+ end
123
+
124
+ result = @router.route(body)
125
+ response = Response[id: body["id"], result: result.serialized]
126
+
127
+ if @session_store.session_has_active_stream?(session_id)
128
+ deliver_to_session_stream(session_id, response.serialized)
129
+ {json: {accepted: true}, status: 200}
130
+ else
131
+ {json: response.serialized, status: 200}
132
+ end
133
+ end
134
+
135
+ def handle_sse_request(request, response)
136
+ session_id = request.headers["Mcp-Session-Id"]
137
+
138
+ unless session_id && @session_store.session_exists?(session_id)
139
+ error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid or missing session ID"}]
140
+ return {json: error_response.serialized, status: 400}
141
+ end
142
+
143
+ @session_store.mark_stream_active(session_id, @server_instance)
144
+
145
+ {
146
+ stream: true,
147
+ headers: {
148
+ "Content-Type" => "text/event-stream",
149
+ "Cache-Control" => "no-cache",
150
+ "Connection" => "keep-alive"
151
+ },
152
+ stream_proc: create_sse_stream_proc(session_id)
153
+ }
154
+ end
155
+
156
+ def handle_delete_request(request)
157
+ session_id = request.headers["Mcp-Session-Id"]
158
+
159
+ if session_id
160
+ cleanup_session(session_id)
161
+ end
162
+
163
+ {json: {success: true}, status: 200}
164
+ end
165
+
166
+ def create_sse_stream_proc(session_id)
167
+ proc do |stream|
168
+ register_local_stream(session_id, stream)
169
+
170
+ flush_notifications_to_stream(stream)
171
+
172
+ start_keepalive_thread(session_id, stream)
173
+
174
+ loop do
175
+ break unless stream_connected?(stream)
176
+ sleep 0.1
177
+ end
178
+ ensure
179
+ cleanup_local_stream(session_id)
180
+ end
181
+ end
182
+
183
+ def register_local_stream(session_id, stream)
184
+ @local_streams[session_id] = stream
185
+ end
186
+
187
+ def cleanup_local_stream(session_id)
188
+ @local_streams.delete(session_id)
189
+ @session_store.mark_stream_inactive(session_id)
190
+ end
191
+
192
+ def stream_connected?(stream)
193
+ return false unless stream
194
+
195
+ begin
196
+ stream.write(": ping\n\n")
197
+ stream.flush if stream.respond_to?(:flush)
198
+ true
199
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
200
+ false
201
+ end
202
+ end
203
+
204
+ def start_keepalive_thread(session_id, stream)
205
+ Thread.new do
206
+ loop do
207
+ sleep 30
208
+ break unless stream_connected?(stream)
209
+
210
+ begin
211
+ send_ping_to_stream(stream)
212
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
213
+ break
214
+ end
215
+ end
216
+ rescue => e
217
+ @configuration.logger.error("Keepalive thread error", error: e.message)
218
+ ensure
219
+ cleanup_local_stream(session_id)
220
+ end
221
+ end
222
+
223
+ def send_ping_to_stream(stream)
224
+ stream.write(": ping #{Time.now.iso8601}\n\n")
225
+ stream.flush if stream.respond_to?(:flush)
226
+ end
227
+
228
+ def send_to_stream(stream, data)
229
+ message = data.is_a?(String) ? data : data.to_json
230
+ stream.write("data: #{message}\n\n")
231
+ stream.flush if stream.respond_to?(:flush)
232
+ end
233
+
234
+ def deliver_to_session_stream(session_id, data)
235
+ if @local_streams[session_id]
236
+ begin
237
+ send_to_stream(@local_streams[session_id], data)
238
+ return true
239
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
240
+ cleanup_local_stream(session_id)
241
+ end
242
+ end
243
+
244
+ @session_store.route_message_to_session(session_id, data)
245
+ end
246
+
247
+ def cleanup_session(session_id)
248
+ cleanup_local_stream(session_id)
249
+ @session_store.cleanup_session(session_id)
250
+ end
251
+
252
+ def setup_redis_subscriber
253
+ Thread.new do
254
+ @session_store.subscribe_to_server(@server_instance) do |data|
255
+ session_id = data["session_id"]
256
+ message = data["message"]
257
+
258
+ if @local_streams[session_id]
259
+ begin
260
+ send_to_stream(@local_streams[session_id], message)
261
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
262
+ cleanup_local_stream(session_id)
263
+ end
264
+ end
265
+ end
266
+ rescue => e
267
+ @configuration.logger.error("Redis subscriber error", error: e.message, backtrace: e.backtrace.first(5))
268
+ sleep 5
269
+ retry
270
+ end
271
+ end
272
+
273
+ def has_active_streams?
274
+ @local_streams.any?
275
+ end
276
+
277
+ def deliver_to_active_streams(notification)
278
+ @local_streams.each do |session_id, stream|
279
+ send_to_stream(stream, notification)
280
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
281
+ cleanup_local_stream(session_id)
282
+ end
283
+ end
284
+
285
+ def flush_notifications_to_stream(stream)
286
+ while (notification = @notification_queue.shift)
287
+ send_to_stream(stream, notification)
288
+ end
289
+ end
290
+ end
291
+ end
@@ -2,11 +2,13 @@ require "json-schema"
2
2
 
3
3
  module ModelContextProtocol
4
4
  class Server::Tool
5
- attr_reader :params
5
+ attr_reader :params, :context, :logger
6
6
 
7
- def initialize(params)
7
+ def initialize(params, logger, context = {})
8
8
  validate!(params)
9
9
  @params = params
10
+ @context = context
11
+ @logger = logger
10
12
  end
11
13
 
12
14
  def call
@@ -76,11 +78,12 @@ module ModelContextProtocol
76
78
  attr_reader :name, :description, :input_schema
77
79
 
78
80
  def with_metadata(&block)
79
- metadata = instance_eval(&block)
81
+ metadata_dsl = MetadataDSL.new
82
+ metadata_dsl.instance_eval(&block)
80
83
 
81
- @name = metadata[:name]
82
- @description = metadata[:description]
83
- @input_schema = metadata[:inputSchema]
84
+ @name = metadata_dsl.name
85
+ @description = metadata_dsl.description
86
+ @input_schema = metadata_dsl.input_schema
84
87
  end
85
88
 
86
89
  def inherited(subclass)
@@ -89,8 +92,8 @@ module ModelContextProtocol
89
92
  subclass.instance_variable_set(:@input_schema, @input_schema)
90
93
  end
91
94
 
92
- def call(params)
93
- new(params).call
95
+ def call(params, logger, context = {})
96
+ new(params, logger, context).call
94
97
  rescue JSON::Schema::ValidationError => validation_error
95
98
  raise ModelContextProtocol::Server::ParameterValidationError, validation_error.message
96
99
  rescue ModelContextProtocol::Server::ResponseArgumentsError => response_arguments_error
@@ -103,5 +106,22 @@ module ModelContextProtocol
103
106
  {name: @name, description: @description, inputSchema: @input_schema}
104
107
  end
105
108
  end
109
+
110
+ class MetadataDSL
111
+ def name(value = nil)
112
+ @name = value if value
113
+ @name
114
+ end
115
+
116
+ def description(value = nil)
117
+ @description = value if value
118
+ @description
119
+ end
120
+
121
+ def input_schema(&block)
122
+ @input_schema = instance_eval(&block) if block_given?
123
+ @input_schema
124
+ end
125
+ end
106
126
  end
107
127
  end
@@ -19,13 +19,25 @@ module ModelContextProtocol
19
19
 
20
20
  def start
21
21
  configuration.validate!
22
- logdev = configuration.logging_enabled? ? $stderr : File::NULL
23
- StdioTransport.new(logger: Logger.new(logdev), router:).begin
22
+
23
+ transport = case configuration.transport_type
24
+ when :stdio, nil
25
+ StdioTransport.new(router: @router, configuration: @configuration)
26
+ when :streamable_http
27
+ StreamableHttpTransport.new(
28
+ router: @router,
29
+ configuration: @configuration
30
+ )
31
+ else
32
+ raise ArgumentError, "Unknown transport: #{configuration.transport_type}"
33
+ end
34
+
35
+ transport.handle
24
36
  end
25
37
 
26
38
  private
27
39
 
28
- PROTOCOL_VERSION = "2024-11-05".freeze
40
+ PROTOCOL_VERSION = "2025-06-18".freeze
29
41
  private_constant :PROTOCOL_VERSION
30
42
 
31
43
  InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info) do
@@ -44,6 +56,12 @@ module ModelContextProtocol
44
56
  end
45
57
  end
46
58
 
59
+ LoggingSetLevelResponse = Data.define do
60
+ def serialized
61
+ {}
62
+ end
63
+ end
64
+
47
65
  def map_handlers
48
66
  router.map("initialize") do |_message|
49
67
  InitializeResponse[
@@ -60,12 +78,56 @@ module ModelContextProtocol
60
78
  PingResponse[]
61
79
  end
62
80
 
81
+ router.map("logging/setLevel") do |message|
82
+ level = message["params"]["level"]
83
+
84
+ unless Configuration::VALID_LOG_LEVELS.include?(level)
85
+ raise ParameterValidationError, "Invalid log level: #{level}. Valid levels are: #{Configuration::VALID_LOG_LEVELS.join(", ")}"
86
+ end
87
+
88
+ configuration.logger.set_mcp_level(level)
89
+ LoggingSetLevelResponse[]
90
+ end
91
+
92
+ router.map("completion/complete") do |message|
93
+ type = message["params"]["ref"]["type"]
94
+
95
+ completion_source = case type
96
+ when "ref/prompt"
97
+ name = message["params"]["ref"]["name"]
98
+ configuration.registry.find_prompt(name)
99
+ when "ref/resource"
100
+ uri = message["params"]["ref"]["uri"]
101
+ configuration.registry.find_resource_template(uri)
102
+ else
103
+ raise ModelContextProtocol::Server::ParameterValidationError, "ref/type invalid"
104
+ end
105
+
106
+ arg_name, arg_value = message["params"]["argument"].values_at("name", "value")
107
+
108
+ if completion_source
109
+ completion_source.complete_for(arg_name, arg_value)
110
+ else
111
+ ModelContextProtocol::Server::NullCompletion.call(arg_name, arg_value)
112
+ end
113
+ end
114
+
63
115
  router.map("resources/list") do
64
116
  configuration.registry.resources_data
65
117
  end
66
118
 
67
119
  router.map("resources/read") do |message|
68
- configuration.registry.find_resource(message["params"]["uri"]).call
120
+ uri = message["params"]["uri"]
121
+ resource = configuration.registry.find_resource(uri)
122
+ unless resource
123
+ raise ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{uri}"
124
+ end
125
+
126
+ resource.call(configuration.logger, configuration.context)
127
+ end
128
+
129
+ router.map("resources/templates/list") do |message|
130
+ configuration.registry.resource_templates_data
69
131
  end
70
132
 
71
133
  router.map("prompts/list") do
@@ -73,7 +135,12 @@ module ModelContextProtocol
73
135
  end
74
136
 
75
137
  router.map("prompts/get") do |message|
76
- configuration.registry.find_prompt(message["params"]["name"]).call(message["params"]["arguments"])
138
+ arguments = message["params"]["arguments"]
139
+ symbolized_arguments = arguments.transform_keys(&:to_sym)
140
+ configuration
141
+ .registry
142
+ .find_prompt(message["params"]["name"])
143
+ .call(symbolized_arguments, configuration.logger, configuration.context)
77
144
  end
78
145
 
79
146
  router.map("tools/list") do
@@ -81,12 +148,18 @@ module ModelContextProtocol
81
148
  end
82
149
 
83
150
  router.map("tools/call") do |message|
84
- configuration.registry.find_tool(message["params"]["name"]).call(message["params"]["arguments"])
151
+ arguments = message["params"]["arguments"]
152
+ symbolized_arguments = arguments.transform_keys(&:to_sym)
153
+ configuration
154
+ .registry
155
+ .find_tool(message["params"]["name"])
156
+ .call(symbolized_arguments, configuration.logger, configuration.context)
85
157
  end
86
158
  end
87
159
 
88
160
  def build_capabilities
89
161
  {}.tap do |capabilities|
162
+ capabilities[:completions] = {}
90
163
  capabilities[:logging] = {} if configuration.logging_enabled?
91
164
 
92
165
  registry = configuration.registry
@@ -94,7 +167,7 @@ module ModelContextProtocol
94
167
  if registry.prompts_options.any? && !registry.instance_variable_get(:@prompts).empty?
95
168
  capabilities[:prompts] = {
96
169
  listChanged: registry.prompts_options[:list_changed]
97
- }.compact
170
+ }.except(:completions).compact
98
171
  end
99
172
 
100
173
  if registry.resources_options.any? && !registry.instance_variable_get(:@resources).empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModelContextProtocol
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ require "addressable/template"
2
2
 
3
3
  Dir[File.join(__dir__, "model_context_protocol/", "**", "*.rb")].sort.each { |file| require_relative file }
4
4
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env <%= @ruby_path %>
2
2
 
3
3
  require "bundler/setup"
4
+ require "securerandom"
4
5
  require_relative "../lib/model_context_protocol"
5
6
 
6
7
  Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
@@ -8,7 +9,15 @@ Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
8
9
  server = ModelContextProtocol::Server.new do |config|
9
10
  config.name = "MCP Development Server"
10
11
  config.version = "1.0.0"
11
- config.enable_log = true
12
+ config.logging_enabled = true
13
+
14
+ config.set_environment_variable("MCP_ENV", "development")
15
+
16
+ config.context = {
17
+ user_id: "123456",
18
+ request_id: SecureRandom.uuid
19
+ }
20
+
12
21
  config.registry = ModelContextProtocol::Server::Registry.new do
13
22
  prompts list_changed: true do
14
23
  register TestPrompt
@@ -19,6 +28,10 @@ server = ModelContextProtocol::Server.new do |config|
19
28
  register TestBinaryResource
20
29
  end
21
30
 
31
+ resource_templates do
32
+ register TestResourceTemplate
33
+ end
34
+
22
35
  tools list_changed: true do
23
36
  register TestToolWithTextResponse
24
37
  register TestToolWithImageResponse
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model-context-protocol-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dick Davis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-05 00:00:00.000000000 Z
11
+ date: 2025-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json-schema
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '5.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: addressable
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.8'
27
41
  description:
28
42
  email:
29
43
  - dick@hey.com
@@ -42,12 +56,17 @@ files:
42
56
  - Rakefile
43
57
  - lib/model_context_protocol.rb
44
58
  - lib/model_context_protocol/server.rb
59
+ - lib/model_context_protocol/server/completion.rb
45
60
  - lib/model_context_protocol/server/configuration.rb
61
+ - lib/model_context_protocol/server/mcp_logger.rb
46
62
  - lib/model_context_protocol/server/prompt.rb
47
63
  - lib/model_context_protocol/server/registry.rb
48
64
  - lib/model_context_protocol/server/resource.rb
65
+ - lib/model_context_protocol/server/resource_template.rb
49
66
  - lib/model_context_protocol/server/router.rb
67
+ - lib/model_context_protocol/server/session_store.rb
50
68
  - lib/model_context_protocol/server/stdio_transport.rb
69
+ - lib/model_context_protocol/server/streamable_http_transport.rb
51
70
  - lib/model_context_protocol/server/tool.rb
52
71
  - lib/model_context_protocol/version.rb
53
72
  - tasks/mcp.rake