mcp 0.1.0 → 0.3.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.
data/lib/mcp/methods.rb CHANGED
@@ -19,8 +19,20 @@ module MCP
19
19
  TOOLS_CALL = "tools/call"
20
20
  TOOLS_LIST = "tools/list"
21
21
 
22
+ ROOTS_LIST = "roots/list"
22
23
  SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
23
24
 
25
+ # Notification methods
26
+ NOTIFICATIONS_INITIALIZED = "notifications/initialized"
27
+ NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
28
+ NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
29
+ NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
30
+ NOTIFICATIONS_RESOURCES_UPDATED = "notifications/resources/updated"
31
+ NOTIFICATIONS_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"
32
+ NOTIFICATIONS_MESSAGE = "notifications/message"
33
+ NOTIFICATIONS_PROGRESS = "notifications/progress"
34
+ NOTIFICATIONS_CANCELLED = "notifications/cancelled"
35
+
24
36
  class MissingRequiredCapabilityError < StandardError
25
37
  attr_reader :method
26
38
  attr_reader :capability
@@ -32,41 +44,51 @@ module MCP
32
44
  end
33
45
  end
34
46
 
35
- extend self
36
-
37
- def ensure_capability!(method, capabilities)
38
- case method
39
- when PROMPTS_GET, PROMPTS_LIST
40
- unless capabilities[:prompts]
41
- raise MissingRequiredCapabilityError.new(method, :prompts)
42
- end
43
- when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ, RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE
44
- unless capabilities[:resources]
45
- raise MissingRequiredCapabilityError.new(method, :resources)
47
+ class << self
48
+ def ensure_capability!(method, capabilities)
49
+ case method
50
+ when PROMPTS_GET, PROMPTS_LIST
51
+ require_capability!(method, capabilities, :prompts)
52
+ when NOTIFICATIONS_PROMPTS_LIST_CHANGED
53
+ require_capability!(method, capabilities, :prompts)
54
+ require_capability!(method, capabilities, :prompts, :listChanged)
55
+ when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ
56
+ require_capability!(method, capabilities, :resources)
57
+ when NOTIFICATIONS_RESOURCES_LIST_CHANGED
58
+ require_capability!(method, capabilities, :resources)
59
+ require_capability!(method, capabilities, :resources, :listChanged)
60
+ when RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE, NOTIFICATIONS_RESOURCES_UPDATED
61
+ require_capability!(method, capabilities, :resources)
62
+ require_capability!(method, capabilities, :resources, :subscribe)
63
+ when TOOLS_CALL, TOOLS_LIST
64
+ require_capability!(method, capabilities, :tools)
65
+ when NOTIFICATIONS_TOOLS_LIST_CHANGED
66
+ require_capability!(method, capabilities, :tools)
67
+ require_capability!(method, capabilities, :tools, :listChanged)
68
+ when LOGGING_SET_LEVEL, NOTIFICATIONS_MESSAGE
69
+ require_capability!(method, capabilities, :logging)
70
+ when COMPLETION_COMPLETE
71
+ require_capability!(method, capabilities, :completions)
72
+ when ROOTS_LIST
73
+ require_capability!(method, capabilities, :roots)
74
+ when NOTIFICATIONS_ROOTS_LIST_CHANGED
75
+ require_capability!(method, capabilities, :roots)
76
+ require_capability!(method, capabilities, :roots, :listChanged)
77
+ when SAMPLING_CREATE_MESSAGE
78
+ require_capability!(method, capabilities, :sampling)
79
+ when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
80
+ # No specific capability required for initialize, ping, progress or cancelled
46
81
  end
82
+ end
47
83
 
48
- if method == RESOURCES_SUBSCRIBE && !capabilities[:resources][:subscribe]
49
- raise MissingRequiredCapabilityError.new(method, :resources_subscribe)
50
- end
51
- when TOOLS_CALL, TOOLS_LIST
52
- unless capabilities[:tools]
53
- raise MissingRequiredCapabilityError.new(method, :tools)
54
- end
55
- when SAMPLING_CREATE_MESSAGE
56
- unless capabilities[:sampling]
57
- raise MissingRequiredCapabilityError.new(method, :sampling)
58
- end
59
- when COMPLETION_COMPLETE
60
- unless capabilities[:completions]
61
- raise MissingRequiredCapabilityError.new(method, :completions)
62
- end
63
- when LOGGING_SET_LEVEL
64
- # Logging is unsupported by the Server
65
- unless capabilities[:logging]
66
- raise MissingRequiredCapabilityError.new(method, :logging)
67
- end
68
- when INITIALIZE, PING
69
- # No specific capability required for initialize or ping
84
+ private
85
+
86
+ def require_capability!(method, capabilities, *keys)
87
+ name = keys.join(".") # :resources, :subscribe -> "resources.subscribe"
88
+ has_capability = capabilities.dig(*keys)
89
+ return if has_capability
90
+
91
+ raise MissingRequiredCapabilityError.new(method, name)
70
92
  end
71
93
  end
72
94
  end
data/lib/mcp/prompt.rb CHANGED
@@ -6,20 +6,22 @@ module MCP
6
6
  class << self
7
7
  NOT_SET = Object.new
8
8
 
9
+ attr_reader :title_value
9
10
  attr_reader :description_value
10
11
  attr_reader :arguments_value
11
12
 
12
- def template(args, server_context:)
13
+ def template(args, server_context: nil)
13
14
  raise NotImplementedError, "Subclasses must implement template"
14
15
  end
15
16
 
16
17
  def to_h
17
- { name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
18
+ { name: name_value, title: title_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
18
19
  end
19
20
 
20
21
  def inherited(subclass)
21
22
  super
22
23
  subclass.instance_variable_set(:@name_value, nil)
24
+ subclass.instance_variable_set(:@title_value, nil)
23
25
  subclass.instance_variable_set(:@description_value, nil)
24
26
  subclass.instance_variable_set(:@arguments_value, nil)
25
27
  end
@@ -36,6 +38,14 @@ module MCP
36
38
  @name_value || StringUtils.handle_from_class_name(name)
37
39
  end
38
40
 
41
+ def title(value = NOT_SET)
42
+ if value == NOT_SET
43
+ @title_value
44
+ else
45
+ @title_value = value
46
+ end
47
+ end
48
+
39
49
  def description(value = NOT_SET)
40
50
  if value == NOT_SET
41
51
  @description_value
@@ -52,12 +62,13 @@ module MCP
52
62
  end
53
63
  end
54
64
 
55
- def define(name: nil, description: nil, arguments: [], &block)
65
+ def define(name: nil, title: nil, description: nil, arguments: [], &block)
56
66
  Class.new(self) do
57
67
  prompt_name name
68
+ title title
58
69
  description description
59
70
  arguments arguments
60
- define_singleton_method(:template) do |args, server_context:|
71
+ define_singleton_method(:template) do |args, server_context: nil|
61
72
  instance_exec(args, server_context:, &block)
62
73
  end
63
74
  end
data/lib/mcp/resource.rb CHANGED
@@ -3,21 +3,23 @@
3
3
 
4
4
  module MCP
5
5
  class Resource
6
- attr_reader :uri, :name, :description, :mime_type
6
+ attr_reader :uri, :name, :title, :description, :mime_type
7
7
 
8
- def initialize(uri:, name:, description:, mime_type:)
8
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil)
9
9
  @uri = uri
10
10
  @name = name
11
+ @title = title
11
12
  @description = description
12
13
  @mime_type = mime_type
13
14
  end
14
15
 
15
16
  def to_h
16
17
  {
17
- uri: @uri,
18
- name: @name,
19
- description: @description,
20
- mimeType: @mime_type,
18
+ uri: uri,
19
+ name: name,
20
+ title: title,
21
+ description: description,
22
+ mimeType: mime_type,
21
23
  }.compact
22
24
  end
23
25
  end
@@ -3,21 +3,23 @@
3
3
 
4
4
  module MCP
5
5
  class ResourceTemplate
6
- attr_reader :uri_template, :name, :description, :mime_type
6
+ attr_reader :uri_template, :name, :title, :description, :mime_type
7
7
 
8
- def initialize(uri_template:, name:, description: nil, mime_type: nil)
8
+ def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil)
9
9
  @uri_template = uri_template
10
10
  @name = name
11
+ @title = title
11
12
  @description = description
12
13
  @mime_type = mime_type
13
14
  end
14
15
 
15
16
  def to_h
16
17
  {
17
- uriTemplate: @uri_template,
18
- name: @name,
19
- description: @description,
20
- mimeType: @mime_type,
18
+ uriTemplate: uri_template,
19
+ name: name,
20
+ title: title,
21
+ description: description,
22
+ mimeType: mime_type,
21
23
  }.compact
22
24
  end
23
25
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ class Capabilities
6
+ def initialize(capabilities_hash = nil)
7
+ @completions = nil
8
+ @experimental = nil
9
+ @logging = nil
10
+ @prompts = nil
11
+ @resources = nil
12
+ @tools = nil
13
+
14
+ if capabilities_hash
15
+ support_completions if capabilities_hash.key?(:completions)
16
+ support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
17
+ support_logging if capabilities_hash.key?(:logging)
18
+
19
+ if capabilities_hash.key?(:prompts)
20
+ support_prompts
21
+ prompts_config = capabilities_hash[:prompts] || {}
22
+ support_prompts_list_changed if prompts_config[:listChanged]
23
+ end
24
+
25
+ if capabilities_hash.key?(:resources)
26
+ support_resources
27
+ resources_config = capabilities_hash[:resources] || {}
28
+ support_resources_list_changed if resources_config[:listChanged]
29
+ support_resources_subscribe if resources_config[:subscribe]
30
+ end
31
+
32
+ if capabilities_hash.key?(:tools)
33
+ support_tools
34
+ tools_config = capabilities_hash[:tools] || {}
35
+ support_tools_list_changed if tools_config[:listChanged]
36
+ end
37
+ end
38
+ end
39
+
40
+ def support_completions
41
+ @completions ||= {}
42
+ end
43
+
44
+ def support_experimental(config = {})
45
+ @experimental = config || {}
46
+ end
47
+
48
+ def support_logging
49
+ @logging ||= {}
50
+ end
51
+
52
+ def support_prompts
53
+ @prompts ||= {}
54
+ end
55
+
56
+ def support_prompts_list_changed
57
+ support_prompts
58
+ @prompts[:listChanged] = true
59
+ end
60
+
61
+ def support_resources
62
+ @resources ||= {}
63
+ end
64
+
65
+ def support_resources_list_changed
66
+ support_resources
67
+ @resources[:listChanged] = true
68
+ end
69
+
70
+ def support_resources_subscribe
71
+ support_resources
72
+ @resources[:subscribe] = true
73
+ end
74
+
75
+ def support_tools
76
+ @tools ||= {}
77
+ end
78
+
79
+ def support_tools_list_changed
80
+ support_tools
81
+ @tools[:listChanged] = true
82
+ end
83
+
84
+ def to_h
85
+ {
86
+ completions: @completions,
87
+ experimental: @experimental,
88
+ logging: @logging,
89
+ prompts: @prompts,
90
+ resources: @resources,
91
+ tools: @tools,
92
+ }.compact
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../transport"
4
+ require "json"
5
+
6
+ module MCP
7
+ class Server
8
+ module Transports
9
+ class StdioTransport < Transport
10
+ STATUS_INTERRUPTED = Signal.list["INT"] + 128
11
+
12
+ def initialize(server)
13
+ @server = server
14
+ @open = false
15
+ $stdin.set_encoding("UTF-8")
16
+ $stdout.set_encoding("UTF-8")
17
+ super
18
+ end
19
+
20
+ def open
21
+ @open = true
22
+ while @open && (line = $stdin.gets)
23
+ handle_json_request(line.strip)
24
+ end
25
+ rescue Interrupt
26
+ warn("\nExiting...")
27
+
28
+ exit(STATUS_INTERRUPTED)
29
+ end
30
+
31
+ def close
32
+ @open = false
33
+ end
34
+
35
+ def send_response(message)
36
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
37
+ $stdout.puts(json_message)
38
+ $stdout.flush
39
+ end
40
+
41
+ def send_notification(method, params = nil)
42
+ notification = {
43
+ jsonrpc: "2.0",
44
+ method: method,
45
+ }
46
+ notification[:params] = params if params
47
+
48
+ send_response(notification)
49
+ true
50
+ rescue => e
51
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
52
+ false
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,301 @@
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(method, params = nil, session_id: nil)
38
+ notification = {
39
+ jsonrpc: "2.0",
40
+ method:,
41
+ }
42
+ notification[:params] = params if params
43
+
44
+ @mutex.synchronize do
45
+ if session_id
46
+ # Send to specific session
47
+ session = @sessions[session_id]
48
+ return false unless session && session[:stream]
49
+
50
+ begin
51
+ send_to_stream(session[:stream], notification)
52
+ true
53
+ rescue IOError, Errno::EPIPE => e
54
+ MCP.configuration.exception_reporter.call(
55
+ e,
56
+ { session_id: session_id, error: "Failed to send notification" },
57
+ )
58
+ cleanup_session_unsafe(session_id)
59
+ false
60
+ end
61
+ else
62
+ # Broadcast to all connected SSE sessions
63
+ sent_count = 0
64
+ failed_sessions = []
65
+
66
+ @sessions.each do |sid, session|
67
+ next unless session[:stream]
68
+
69
+ begin
70
+ send_to_stream(session[:stream], notification)
71
+ sent_count += 1
72
+ rescue IOError, Errno::EPIPE => e
73
+ MCP.configuration.exception_reporter.call(
74
+ e,
75
+ { session_id: sid, error: "Failed to send notification" },
76
+ )
77
+ failed_sessions << sid
78
+ end
79
+ end
80
+
81
+ # Clean up failed sessions
82
+ failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
83
+
84
+ sent_count
85
+ end
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def send_to_stream(stream, data)
92
+ message = data.is_a?(String) ? data : data.to_json
93
+ stream.write("data: #{message}\n\n")
94
+ stream.flush if stream.respond_to?(:flush)
95
+ end
96
+
97
+ def send_ping_to_stream(stream)
98
+ stream.write(": ping #{Time.now.iso8601}\n\n")
99
+ stream.flush if stream.respond_to?(:flush)
100
+ end
101
+
102
+ def handle_post(request)
103
+ body_string = request.body.read
104
+ session_id = extract_session_id(request)
105
+
106
+ body = parse_request_body(body_string)
107
+ return body unless body.is_a?(Hash) # Error response
108
+
109
+ if body["method"] == "initialize"
110
+ handle_initialization(body_string, body)
111
+ elsif body["method"] == MCP::Methods::NOTIFICATIONS_INITIALIZED
112
+ handle_notification_initialized
113
+ else
114
+ handle_regular_request(body_string, session_id)
115
+ end
116
+ rescue StandardError => e
117
+ MCP.configuration.exception_reporter.call(e, { request: body_string })
118
+ [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
119
+ end
120
+
121
+ def handle_get(request)
122
+ session_id = extract_session_id(request)
123
+
124
+ return missing_session_id_response unless session_id
125
+ return session_not_found_response unless session_exists?(session_id)
126
+
127
+ setup_sse_stream(session_id)
128
+ end
129
+
130
+ def handle_delete(request)
131
+ session_id = request.env["HTTP_MCP_SESSION_ID"]
132
+
133
+ return [
134
+ 400,
135
+ { "Content-Type" => "application/json" },
136
+ [{ error: "Missing session ID" }.to_json],
137
+ ] unless session_id
138
+
139
+ cleanup_session(session_id)
140
+ [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
141
+ end
142
+
143
+ def cleanup_session(session_id)
144
+ @mutex.synchronize do
145
+ cleanup_session_unsafe(session_id)
146
+ end
147
+ end
148
+
149
+ def cleanup_session_unsafe(session_id)
150
+ session = @sessions[session_id]
151
+ return unless session
152
+
153
+ begin
154
+ session[:stream]&.close
155
+ rescue
156
+ nil
157
+ end
158
+ @sessions.delete(session_id)
159
+ end
160
+
161
+ def extract_session_id(request)
162
+ request.env["HTTP_MCP_SESSION_ID"]
163
+ end
164
+
165
+ def parse_request_body(body_string)
166
+ JSON.parse(body_string)
167
+ rescue JSON::ParserError, TypeError
168
+ [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
169
+ end
170
+
171
+ def handle_initialization(body_string, body)
172
+ session_id = SecureRandom.uuid
173
+
174
+ @mutex.synchronize do
175
+ @sessions[session_id] = {
176
+ stream: nil,
177
+ }
178
+ end
179
+
180
+ response = @server.handle_json(body_string)
181
+
182
+ headers = {
183
+ "Content-Type" => "application/json",
184
+ "Mcp-Session-Id" => session_id,
185
+ }
186
+
187
+ [200, headers, [response]]
188
+ end
189
+
190
+ def handle_notification_initialized
191
+ [202, {}, []]
192
+ end
193
+
194
+ def handle_regular_request(body_string, session_id)
195
+ # If session ID is provided, but not in the sessions hash, return an error
196
+ if session_id && !@sessions.key?(session_id)
197
+ return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
198
+ end
199
+
200
+ response = @server.handle_json(body_string)
201
+ stream = get_session_stream(session_id) if session_id
202
+
203
+ if stream
204
+ send_response_to_stream(stream, response, session_id)
205
+ else
206
+ [200, { "Content-Type" => "application/json" }, [response]]
207
+ end
208
+ end
209
+
210
+ def get_session_stream(session_id)
211
+ @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
212
+ end
213
+
214
+ def send_response_to_stream(stream, response, session_id)
215
+ message = JSON.parse(response)
216
+ send_to_stream(stream, message)
217
+ [200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
218
+ rescue IOError, Errno::EPIPE => e
219
+ MCP.configuration.exception_reporter.call(
220
+ e,
221
+ { session_id: session_id, error: "Stream closed during response" },
222
+ )
223
+ cleanup_session(session_id)
224
+ [200, { "Content-Type" => "application/json" }, [response]]
225
+ end
226
+
227
+ def session_exists?(session_id)
228
+ @mutex.synchronize { @sessions.key?(session_id) }
229
+ end
230
+
231
+ def missing_session_id_response
232
+ [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
233
+ end
234
+
235
+ def session_not_found_response
236
+ [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
237
+ end
238
+
239
+ def setup_sse_stream(session_id)
240
+ body = create_sse_body(session_id)
241
+
242
+ headers = {
243
+ "Content-Type" => "text/event-stream",
244
+ "Cache-Control" => "no-cache",
245
+ "Connection" => "keep-alive",
246
+ }
247
+
248
+ [200, headers, body]
249
+ end
250
+
251
+ def create_sse_body(session_id)
252
+ proc do |stream|
253
+ store_stream_for_session(session_id, stream)
254
+ start_keepalive_thread(session_id)
255
+ end
256
+ end
257
+
258
+ def store_stream_for_session(session_id, stream)
259
+ @mutex.synchronize do
260
+ if @sessions[session_id]
261
+ @sessions[session_id][:stream] = stream
262
+ else
263
+ stream.close
264
+ end
265
+ end
266
+ end
267
+
268
+ def start_keepalive_thread(session_id)
269
+ Thread.new do
270
+ while session_active_with_stream?(session_id)
271
+ sleep(30)
272
+ send_keepalive_ping(session_id)
273
+ end
274
+ rescue StandardError => e
275
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id })
276
+ ensure
277
+ cleanup_session(session_id)
278
+ end
279
+ end
280
+
281
+ def session_active_with_stream?(session_id)
282
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
283
+ end
284
+
285
+ def send_keepalive_ping(session_id)
286
+ @mutex.synchronize do
287
+ if @sessions[session_id] && @sessions[session_id][:stream]
288
+ send_ping_to_stream(@sessions[session_id][:stream])
289
+ end
290
+ end
291
+ rescue IOError, Errno::EPIPE => e
292
+ MCP.configuration.exception_reporter.call(
293
+ e,
294
+ { session_id: session_id, error: "Stream closed" },
295
+ )
296
+ raise # Re-raise to exit the keepalive loop
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end