mcp 0.1.0 → 0.2.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,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../transport"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module MCP
8
+ class Server
9
+ module Transports
10
+ class StreamableHTTPTransport < Transport
11
+ def initialize(server)
12
+ super
13
+ # { session_id => { stream: stream_object }
14
+ @sessions = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def handle_request(request)
19
+ case request.env["REQUEST_METHOD"]
20
+ when "POST"
21
+ handle_post(request)
22
+ when "GET"
23
+ handle_get(request)
24
+ when "DELETE"
25
+ handle_delete(request)
26
+ else
27
+ [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
28
+ end
29
+ end
30
+
31
+ def close
32
+ @mutex.synchronize do
33
+ @sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
34
+ end
35
+ end
36
+
37
+ def send_notification(notification, session_id: nil)
38
+ @mutex.synchronize do
39
+ if session_id
40
+ # Send to specific session
41
+ session = @sessions[session_id]
42
+ return false unless session && session[:stream]
43
+
44
+ begin
45
+ send_to_stream(session[:stream], notification)
46
+ true
47
+ rescue IOError, Errno::EPIPE => e
48
+ MCP.configuration.exception_reporter.call(
49
+ e,
50
+ { session_id: session_id, error: "Failed to send notification" },
51
+ )
52
+ cleanup_session_unsafe(session_id)
53
+ false
54
+ end
55
+ else
56
+ # Broadcast to all connected SSE sessions
57
+ sent_count = 0
58
+ failed_sessions = []
59
+
60
+ @sessions.each do |sid, session|
61
+ next unless session[:stream]
62
+
63
+ begin
64
+ send_to_stream(session[:stream], notification)
65
+ sent_count += 1
66
+ rescue IOError, Errno::EPIPE => e
67
+ MCP.configuration.exception_reporter.call(
68
+ e,
69
+ { session_id: sid, error: "Failed to send notification" },
70
+ )
71
+ failed_sessions << sid
72
+ end
73
+ end
74
+
75
+ # Clean up failed sessions
76
+ failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
77
+
78
+ sent_count
79
+ end
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def send_to_stream(stream, data)
86
+ message = data.is_a?(String) ? data : data.to_json
87
+ stream.write("data: #{message}\n\n")
88
+ stream.flush if stream.respond_to?(:flush)
89
+ end
90
+
91
+ def send_ping_to_stream(stream)
92
+ stream.write(": ping #{Time.now.iso8601}\n\n")
93
+ stream.flush if stream.respond_to?(:flush)
94
+ end
95
+
96
+ def handle_post(request)
97
+ body_string = request.body.read
98
+ session_id = extract_session_id(request)
99
+
100
+ body = parse_request_body(body_string)
101
+ return body unless body.is_a?(Hash) # Error response
102
+
103
+ if body["method"] == "initialize"
104
+ handle_initialization(body_string, body)
105
+ else
106
+ handle_regular_request(body_string, session_id)
107
+ end
108
+ rescue StandardError => e
109
+ MCP.configuration.exception_reporter.call(e, { request: body_string })
110
+ [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
111
+ end
112
+
113
+ def handle_get(request)
114
+ session_id = extract_session_id(request)
115
+
116
+ return missing_session_id_response unless session_id
117
+ return session_not_found_response unless session_exists?(session_id)
118
+
119
+ setup_sse_stream(session_id)
120
+ end
121
+
122
+ def handle_delete(request)
123
+ session_id = request.env["HTTP_MCP_SESSION_ID"]
124
+
125
+ return [
126
+ 400,
127
+ { "Content-Type" => "application/json" },
128
+ [{ error: "Missing session ID" }.to_json],
129
+ ] unless session_id
130
+
131
+ cleanup_session(session_id)
132
+ [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
133
+ end
134
+
135
+ def cleanup_session(session_id)
136
+ @mutex.synchronize do
137
+ cleanup_session_unsafe(session_id)
138
+ end
139
+ end
140
+
141
+ def cleanup_session_unsafe(session_id)
142
+ session = @sessions[session_id]
143
+ return unless session
144
+
145
+ begin
146
+ session[:stream]&.close
147
+ rescue
148
+ nil
149
+ end
150
+ @sessions.delete(session_id)
151
+ end
152
+
153
+ def extract_session_id(request)
154
+ request.env["HTTP_MCP_SESSION_ID"]
155
+ end
156
+
157
+ def parse_request_body(body_string)
158
+ JSON.parse(body_string)
159
+ rescue JSON::ParserError, TypeError
160
+ [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
161
+ end
162
+
163
+ def handle_initialization(body_string, body)
164
+ session_id = SecureRandom.uuid
165
+
166
+ @mutex.synchronize do
167
+ @sessions[session_id] = {
168
+ stream: nil,
169
+ }
170
+ end
171
+
172
+ response = @server.handle_json(body_string)
173
+
174
+ headers = {
175
+ "Content-Type" => "application/json",
176
+ "Mcp-Session-Id" => session_id,
177
+ }
178
+
179
+ [200, headers, [response]]
180
+ end
181
+
182
+ def handle_regular_request(body_string, session_id)
183
+ # If session ID is provided, but not in the sessions hash, return an error
184
+ if session_id && !@sessions.key?(session_id)
185
+ return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
186
+ end
187
+
188
+ response = @server.handle_json(body_string)
189
+ stream = get_session_stream(session_id) if session_id
190
+
191
+ if stream
192
+ send_response_to_stream(stream, response, session_id)
193
+ else
194
+ [200, { "Content-Type" => "application/json" }, [response]]
195
+ end
196
+ end
197
+
198
+ def get_session_stream(session_id)
199
+ @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
200
+ end
201
+
202
+ def send_response_to_stream(stream, response, session_id)
203
+ message = JSON.parse(response)
204
+ send_to_stream(stream, message)
205
+ [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
206
+ rescue IOError, Errno::EPIPE => e
207
+ MCP.configuration.exception_reporter.call(
208
+ e,
209
+ { session_id: session_id, error: "Stream closed during response" },
210
+ )
211
+ cleanup_session(session_id)
212
+ [200, { "Content-Type" => "application/json" }, [response]]
213
+ end
214
+
215
+ def session_exists?(session_id)
216
+ @mutex.synchronize { @sessions.key?(session_id) }
217
+ end
218
+
219
+ def missing_session_id_response
220
+ [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
221
+ end
222
+
223
+ def session_not_found_response
224
+ [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
225
+ end
226
+
227
+ def setup_sse_stream(session_id)
228
+ body = create_sse_body(session_id)
229
+
230
+ headers = {
231
+ "Content-Type" => "text/event-stream",
232
+ "Cache-Control" => "no-cache",
233
+ "Connection" => "keep-alive",
234
+ }
235
+
236
+ [200, headers, body]
237
+ end
238
+
239
+ def create_sse_body(session_id)
240
+ proc do |stream|
241
+ store_stream_for_session(session_id, stream)
242
+ start_keepalive_thread(session_id)
243
+ end
244
+ end
245
+
246
+ def store_stream_for_session(session_id, stream)
247
+ @mutex.synchronize do
248
+ if @sessions[session_id]
249
+ @sessions[session_id][:stream] = stream
250
+ else
251
+ stream.close
252
+ end
253
+ end
254
+ end
255
+
256
+ def start_keepalive_thread(session_id)
257
+ Thread.new do
258
+ while session_active_with_stream?(session_id)
259
+ sleep(30)
260
+ send_keepalive_ping(session_id)
261
+ end
262
+ rescue StandardError => e
263
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id })
264
+ ensure
265
+ cleanup_session(session_id)
266
+ end
267
+ end
268
+
269
+ def session_active_with_stream?(session_id)
270
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
271
+ end
272
+
273
+ def send_keepalive_ping(session_id)
274
+ @mutex.synchronize do
275
+ if @sessions[session_id] && @sessions[session_id][:stream]
276
+ send_ping_to_stream(@sessions[session_id][:stream])
277
+ end
278
+ end
279
+ rescue IOError, Errno::EPIPE => e
280
+ MCP.configuration.exception_reporter.call(
281
+ e,
282
+ { session_id: session_id, error: "Stream closed" },
283
+ )
284
+ raise # Re-raise to exit the keepalive loop
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
data/lib/mcp/server.rb CHANGED
@@ -20,10 +20,18 @@ module MCP
20
20
  end
21
21
  end
22
22
 
23
+ class MethodAlreadyDefinedError < StandardError
24
+ attr_reader :method_name
25
+
26
+ def initialize(method_name)
27
+ super("Method #{method_name} already defined")
28
+ @method_name = method_name
29
+ end
30
+ end
31
+
23
32
  include Instrumentation
24
33
 
25
- attr_writer :capabilities
26
- attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration
34
+ attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
27
35
 
28
36
  def initialize(
29
37
  name: "model_context_protocol",
@@ -34,7 +42,8 @@ module MCP
34
42
  resource_templates: [],
35
43
  server_context: nil,
36
44
  configuration: nil,
37
- capabilities: nil
45
+ capabilities: nil,
46
+ transport: nil
38
47
  )
39
48
  @name = name
40
49
  @version = version
@@ -45,6 +54,7 @@ module MCP
45
54
  @resource_index = index_resources_by_uri(resources)
46
55
  @server_context = server_context
47
56
  @configuration = MCP.configuration.merge(configuration)
57
+ @capabilities = capabilities || default_capabilities
48
58
 
49
59
  @handlers = {
50
60
  Methods::RESOURCES_LIST => method(:list_resources),
@@ -63,10 +73,7 @@ module MCP
63
73
  Methods::COMPLETION_COMPLETE => ->(_) {},
64
74
  Methods::LOGGING_SET_LEVEL => ->(_) {},
65
75
  }
66
- end
67
-
68
- def capabilities
69
- @capabilities ||= determine_capabilities
76
+ @transport = transport
70
77
  end
71
78
 
72
79
  def handle(request)
@@ -91,6 +98,38 @@ module MCP
91
98
  @prompts[prompt.name_value] = prompt
92
99
  end
93
100
 
101
+ def define_custom_method(method_name:, &block)
102
+ if @handlers.key?(method_name)
103
+ raise MethodAlreadyDefinedError, method_name
104
+ end
105
+
106
+ @handlers[method_name] = block
107
+ end
108
+
109
+ def notify_tools_list_changed
110
+ return unless @transport
111
+
112
+ @transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
113
+ rescue => e
114
+ report_exception(e, { notification: "tools_list_changed" })
115
+ end
116
+
117
+ def notify_prompts_list_changed
118
+ return unless @transport
119
+
120
+ @transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED)
121
+ rescue => e
122
+ report_exception(e, { notification: "prompts_list_changed" })
123
+ end
124
+
125
+ def notify_resources_list_changed
126
+ return unless @transport
127
+
128
+ @transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED)
129
+ rescue => e
130
+ report_exception(e, { notification: "resources_list_changed" })
131
+ end
132
+
94
133
  def resources_list_handler(&block)
95
134
  @handlers[Methods::RESOURCES_LIST] = block
96
135
  end
@@ -159,16 +198,12 @@ module MCP
159
198
  }
160
199
  end
161
200
 
162
- def determine_capabilities
163
- defines_prompts = @prompts.any? || @handlers[Methods::PROMPTS_LIST] != method(:list_prompts)
164
- defines_tools = @tools.any? || @handlers[Methods::TOOLS_LIST] != method(:list_tools)
165
- defines_resources = @resources.any? || @handlers[Methods::RESOURCES_LIST] != method(:list_resources)
166
- defines_resource_templates = @resource_templates.any? || @handlers[Methods::RESOURCES_TEMPLATES_LIST] != method(:list_resource_templates)
201
+ def default_capabilities
167
202
  {
168
- prompts: defines_prompts ? {} : nil,
169
- resources: defines_resources || defines_resource_templates ? {} : nil,
170
- tools: defines_tools ? {} : nil,
171
- }.compact
203
+ tools: { listChanged: true },
204
+ prompts: { listChanged: true },
205
+ resources: { listChanged: true },
206
+ }
172
207
  end
173
208
 
174
209
  def server_info
@@ -179,7 +214,6 @@ module MCP
179
214
  end
180
215
 
181
216
  def init(request)
182
- add_instrumentation_data(method: Methods::INITIALIZE)
183
217
  {
184
218
  protocolVersion: configuration.protocol_version,
185
219
  capabilities: capabilities,
@@ -188,12 +222,10 @@ module MCP
188
222
  end
189
223
 
190
224
  def list_tools(request)
191
- add_instrumentation_data(method: Methods::TOOLS_LIST)
192
225
  @tools.map { |_, tool| tool.to_h }
193
226
  end
194
227
 
195
228
  def call_tool(request)
196
- add_instrumentation_data(method: Methods::TOOLS_CALL)
197
229
  tool_name = request[:name]
198
230
  tool = tools[tool_name]
199
231
  unless tool
@@ -213,26 +245,27 @@ module MCP
213
245
  )
214
246
  end
215
247
 
216
- begin
217
- call_params = tool_call_parameters(tool)
218
-
219
- if call_params.include?(:server_context)
220
- tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h
221
- else
222
- tool.call(**arguments.transform_keys(&:to_sym)).to_h
248
+ if configuration.validate_tool_call_arguments && tool.input_schema
249
+ begin
250
+ tool.input_schema.validate_arguments(arguments)
251
+ rescue Tool::InputSchema::ValidationError => e
252
+ add_instrumentation_data(error: :invalid_schema)
253
+ raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
223
254
  end
255
+ end
256
+
257
+ begin
258
+ call_tool_with_args(tool, arguments)
224
259
  rescue => e
225
260
  raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
226
261
  end
227
262
  end
228
263
 
229
264
  def list_prompts(request)
230
- add_instrumentation_data(method: Methods::PROMPTS_LIST)
231
265
  @prompts.map { |_, prompt| prompt.to_h }
232
266
  end
233
267
 
234
268
  def get_prompt(request)
235
- add_instrumentation_data(method: Methods::PROMPTS_GET)
236
269
  prompt_name = request[:name]
237
270
  prompt = @prompts[prompt_name]
238
271
  unless prompt
@@ -245,25 +278,20 @@ module MCP
245
278
  prompt_args = request[:arguments]
246
279
  prompt.validate_arguments!(prompt_args)
247
280
 
248
- prompt.template(prompt_args, server_context:).to_h
281
+ call_prompt_template_with_args(prompt, prompt_args)
249
282
  end
250
283
 
251
284
  def list_resources(request)
252
- add_instrumentation_data(method: Methods::RESOURCES_LIST)
253
-
254
285
  @resources.map(&:to_h)
255
286
  end
256
287
 
257
288
  # Server implementation should set `resources_read_handler` to override no-op default
258
289
  def read_resource_no_content(request)
259
- add_instrumentation_data(method: Methods::RESOURCES_READ)
260
290
  add_instrumentation_data(resource_uri: request[:uri])
261
291
  []
262
292
  end
263
293
 
264
294
  def list_resource_templates(request)
265
- add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
266
-
267
295
  @resource_templates.map(&:to_h)
268
296
  end
269
297
 
@@ -277,22 +305,29 @@ module MCP
277
305
  end
278
306
  end
279
307
 
280
- def tool_call_parameters(tool)
281
- method_def = tool_call_method_def(tool)
282
- method_def.parameters.flatten
308
+ def accepts_server_context?(method_object)
309
+ parameters = method_object.parameters
310
+ accepts_server_context = parameters.any? { |_type, name| name == :server_context }
311
+ has_kwargs = parameters.any? { |type, _| type == :keyrest }
312
+
313
+ accepts_server_context || has_kwargs
283
314
  end
284
315
 
285
- def tool_call_method_def(tool)
286
- method = tool.method(:call)
316
+ def call_tool_with_args(tool, arguments)
317
+ args = arguments.transform_keys(&:to_sym)
287
318
 
288
- if defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method)
289
- sorbet_typed_method_definition = T::Utils.signature_for_method(method)&.method
319
+ if accepts_server_context?(tool.method(:call))
320
+ tool.call(**args, server_context: server_context).to_h
321
+ else
322
+ tool.call(**args).to_h
323
+ end
324
+ end
290
325
 
291
- # Return the Sorbet typed method definition if it exists, otherwise fallback to original method
292
- # definition if Sorbet is defined but not used by this tool.
293
- sorbet_typed_method_definition || method
326
+ def call_prompt_template_with_args(prompt, args)
327
+ if accepts_server_context?(prompt.method(:template))
328
+ prompt.template(args, server_context: server_context).to_h
294
329
  else
295
- method
330
+ prompt.template(args).to_h
296
331
  end
297
332
  end
298
333
  end
@@ -1,17 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json-schema"
4
+
3
5
  module MCP
4
6
  class Tool
5
7
  class InputSchema
8
+ class ValidationError < StandardError; end
9
+
6
10
  attr_reader :properties, :required
7
11
 
8
12
  def initialize(properties: {}, required: [])
9
13
  @properties = properties
10
14
  @required = required.map(&:to_sym)
15
+ validate_schema!
11
16
  end
12
17
 
13
18
  def to_h
14
- { type: "object", properties:, required: }
19
+ { type: "object", properties: }.tap do |hsh|
20
+ hsh[:required] = required if required.any?
21
+ end
15
22
  end
16
23
 
17
24
  def missing_required_arguments?(arguments)
@@ -21,6 +28,42 @@ module MCP
21
28
  def missing_required_arguments(arguments)
22
29
  (required - arguments.keys.map(&:to_sym))
23
30
  end
31
+
32
+ def validate_arguments(arguments)
33
+ errors = JSON::Validator.fully_validate(to_h, arguments)
34
+ if errors.any?
35
+ raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def validate_schema!
42
+ check_for_refs!
43
+ schema = to_h
44
+ schema_reader = JSON::Schema::Reader.new(
45
+ accept_uri: false,
46
+ accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
47
+ )
48
+ metaschema = JSON::Validator.validator_for_name("draft4").metaschema
49
+ errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
50
+ if errors.any?
51
+ raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
52
+ end
53
+ end
54
+
55
+ def check_for_refs!(obj = properties)
56
+ case obj
57
+ when Hash
58
+ if obj.key?("$ref") || obj.key?(:$ref)
59
+ raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
60
+ end
61
+
62
+ obj.each_value { |value| check_for_refs!(value) }
63
+ when Array
64
+ obj.each { |item| check_for_refs!(item) }
65
+ end
66
+ end
24
67
  end
25
68
  end
26
69
  end
data/lib/mcp/tool.rb CHANGED
@@ -9,7 +9,7 @@ module MCP
9
9
  attr_reader :input_schema_value
10
10
  attr_reader :annotations_value
11
11
 
12
- def call(*args, server_context:)
12
+ def call(*args, server_context: nil)
13
13
  raise NotImplementedError, "Subclasses must implement call"
14
14
  end
15
15
 
data/lib/mcp/transport.rb CHANGED
@@ -2,32 +2,44 @@
2
2
 
3
3
  module MCP
4
4
  class Transport
5
+ # Initialize the transport with the server instance
5
6
  def initialize(server)
6
7
  @server = server
7
8
  end
8
9
 
10
+ # Send a response to the client
9
11
  def send_response(response)
10
12
  raise NotImplementedError, "Subclasses must implement send_response"
11
13
  end
12
14
 
15
+ # Open the transport connection
13
16
  def open
14
17
  raise NotImplementedError, "Subclasses must implement open"
15
18
  end
16
19
 
20
+ # Close the transport connection
17
21
  def close
18
22
  raise NotImplementedError, "Subclasses must implement close"
19
23
  end
20
24
 
21
- private
25
+ # Handle a JSON request
26
+ # Returns a response that should be sent back to the client
27
+ def handle_json_request(request)
28
+ response = @server.handle_json(request)
29
+ send_response(response) if response
30
+ end
22
31
 
32
+ # Handle an incoming request
33
+ # Returns a response that should be sent back to the client
23
34
  def handle_request(request)
24
35
  response = @server.handle(request)
25
36
  send_response(response) if response
26
37
  end
27
38
 
28
- def handle_json_request(request)
29
- response = @server.handle_json(request)
30
- send_response(response) if response
39
+ # Send a notification to the client
40
+ # Returns true if the notification was sent successfully
41
+ def send_notification(method, params = nil)
42
+ raise NotImplementedError, "Subclasses must implement send_notification"
31
43
  end
32
44
  end
33
45
  end
@@ -1,35 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../transport"
4
- require "json"
3
+ require_relative "../server/transports/stdio_transport"
4
+
5
+ warn <<~MESSAGE, uplevel: 3
6
+ Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`.
7
+ Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`.
8
+ This API is deprecated and will be removed in a future release.
9
+ MESSAGE
5
10
 
6
11
  module MCP
7
12
  module Transports
8
- class StdioTransport < Transport
9
- def initialize(server)
10
- @server = server
11
- @open = false
12
- $stdin.set_encoding("UTF-8")
13
- $stdout.set_encoding("UTF-8")
14
- super
15
- end
16
-
17
- def open
18
- @open = true
19
- while @open && (line = $stdin.gets)
20
- handle_json_request(line.strip)
21
- end
22
- end
23
-
24
- def close
25
- @open = false
26
- end
27
-
28
- def send_response(message)
29
- json_message = message.is_a?(String) ? message : JSON.generate(message)
30
- $stdout.puts(json_message)
31
- $stdout.flush
32
- end
33
- end
13
+ StdioTransport = Server::Transports::StdioTransport
34
14
  end
35
15
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end