mcp 0.3.0 → 0.5.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +6 -0
  3. data/.github/workflows/ci.yml +22 -7
  4. data/.github/workflows/release.yml +34 -2
  5. data/.rubocop.yml +3 -0
  6. data/AGENTS.md +107 -0
  7. data/CHANGELOG.md +58 -0
  8. data/Gemfile +6 -4
  9. data/README.md +135 -39
  10. data/RELEASE.md +12 -0
  11. data/bin/generate-gh-pages.sh +119 -0
  12. data/dev.yml +1 -2
  13. data/docs/_config.yml +6 -0
  14. data/docs/index.md +7 -0
  15. data/docs/latest/index.html +19 -0
  16. data/examples/http_server.rb +0 -2
  17. data/examples/stdio_server.rb +0 -1
  18. data/examples/streamable_http_server.rb +0 -2
  19. data/lib/json_rpc_handler.rb +151 -0
  20. data/lib/mcp/client/http.rb +23 -7
  21. data/lib/mcp/client.rb +62 -5
  22. data/lib/mcp/configuration.rb +38 -14
  23. data/lib/mcp/content.rb +2 -3
  24. data/lib/mcp/icon.rb +22 -0
  25. data/lib/mcp/instrumentation.rb +1 -1
  26. data/lib/mcp/methods.rb +3 -0
  27. data/lib/mcp/prompt/argument.rb +9 -5
  28. data/lib/mcp/prompt/message.rb +1 -2
  29. data/lib/mcp/prompt/result.rb +1 -2
  30. data/lib/mcp/prompt.rb +32 -4
  31. data/lib/mcp/resource/contents.rb +1 -2
  32. data/lib/mcp/resource/embedded.rb +1 -2
  33. data/lib/mcp/resource.rb +4 -3
  34. data/lib/mcp/resource_template.rb +4 -3
  35. data/lib/mcp/server/transports/streamable_http_transport.rb +96 -18
  36. data/lib/mcp/server.rb +92 -26
  37. data/lib/mcp/string_utils.rb +3 -4
  38. data/lib/mcp/tool/annotations.rb +1 -1
  39. data/lib/mcp/tool/input_schema.rb +6 -52
  40. data/lib/mcp/tool/output_schema.rb +3 -51
  41. data/lib/mcp/tool/response.rb +5 -4
  42. data/lib/mcp/tool/schema.rb +65 -0
  43. data/lib/mcp/tool.rb +47 -8
  44. data/lib/mcp/version.rb +1 -1
  45. data/lib/mcp.rb +2 -0
  46. data/mcp.gemspec +5 -2
  47. metadata +16 -18
  48. data/.cursor/rules/release-changelogs.mdc +0 -17
data/lib/mcp/methods.rb CHANGED
@@ -21,6 +21,7 @@ module MCP
21
21
 
22
22
  ROOTS_LIST = "roots/list"
23
23
  SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
24
+ ELICITATION_CREATE = "elicitation/create"
24
25
 
25
26
  # Notification methods
26
27
  NOTIFICATIONS_INITIALIZED = "notifications/initialized"
@@ -76,6 +77,8 @@ module MCP
76
77
  require_capability!(method, capabilities, :roots, :listChanged)
77
78
  when SAMPLING_CREATE_MESSAGE
78
79
  require_capability!(method, capabilities, :sampling)
80
+ when ELICITATION_CREATE
81
+ require_capability!(method, capabilities, :elicitation)
79
82
  when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
80
83
  # No specific capability required for initialize, ping, progress or cancelled
81
84
  end
@@ -1,20 +1,24 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
5
4
  class Prompt
6
5
  class Argument
7
- attr_reader :name, :description, :required, :arguments
6
+ attr_reader :name, :title, :description, :required
8
7
 
9
- def initialize(name:, description: nil, required: false)
8
+ def initialize(name:, title: nil, description: nil, required: false)
10
9
  @name = name
10
+ @title = title
11
11
  @description = description
12
12
  @required = required
13
- @arguments = arguments
14
13
  end
15
14
 
16
15
  def to_h
17
- { name:, description:, required: }.compact
16
+ {
17
+ name: name,
18
+ title: title,
19
+ description: description,
20
+ required: required,
21
+ }.compact
18
22
  end
19
23
  end
20
24
  end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -12,7 +11,7 @@ module MCP
12
11
  end
13
12
 
14
13
  def to_h
15
- { role:, content: content.to_h }.compact
14
+ { role: role, content: content.to_h }.compact
16
15
  end
17
16
  end
18
17
  end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -12,7 +11,7 @@ module MCP
12
11
  end
13
12
 
14
13
  def to_h
15
- { description:, messages: messages.map(&:to_h) }.compact
14
+ { description: description, messages: messages.map(&:to_h) }.compact
16
15
  end
17
16
  end
18
17
  end
data/lib/mcp/prompt.rb CHANGED
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -8,14 +7,23 @@ module MCP
8
7
 
9
8
  attr_reader :title_value
10
9
  attr_reader :description_value
10
+ attr_reader :icons_value
11
11
  attr_reader :arguments_value
12
+ attr_reader :meta_value
12
13
 
13
14
  def template(args, server_context: nil)
14
15
  raise NotImplementedError, "Subclasses must implement template"
15
16
  end
16
17
 
17
18
  def to_h
18
- { name: name_value, title: title_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
19
+ {
20
+ name: name_value,
21
+ title: title_value,
22
+ description: description_value,
23
+ icons: icons&.map(&:to_h),
24
+ arguments: arguments_value&.map(&:to_h),
25
+ _meta: meta_value,
26
+ }.compact
19
27
  end
20
28
 
21
29
  def inherited(subclass)
@@ -23,7 +31,9 @@ module MCP
23
31
  subclass.instance_variable_set(:@name_value, nil)
24
32
  subclass.instance_variable_set(:@title_value, nil)
25
33
  subclass.instance_variable_set(:@description_value, nil)
34
+ subclass.instance_variable_set(:@icons_value, nil)
26
35
  subclass.instance_variable_set(:@arguments_value, nil)
36
+ subclass.instance_variable_set(:@meta_value, nil)
27
37
  end
28
38
 
29
39
  def prompt_name(value = NOT_SET)
@@ -54,6 +64,14 @@ module MCP
54
64
  end
55
65
  end
56
66
 
67
+ def icons(value = NOT_SET)
68
+ if value == NOT_SET
69
+ @icons_value
70
+ else
71
+ @icons_value = value
72
+ end
73
+ end
74
+
57
75
  def arguments(value = NOT_SET)
58
76
  if value == NOT_SET
59
77
  @arguments_value
@@ -62,15 +80,25 @@ module MCP
62
80
  end
63
81
  end
64
82
 
65
- def define(name: nil, title: nil, description: nil, arguments: [], &block)
83
+ def meta(value = NOT_SET)
84
+ if value == NOT_SET
85
+ @meta_value
86
+ else
87
+ @meta_value = value
88
+ end
89
+ end
90
+
91
+ def define(name: nil, title: nil, description: nil, icons: [], arguments: [], meta: nil, &block)
66
92
  Class.new(self) do
67
93
  prompt_name name
68
94
  title title
69
95
  description description
96
+ icons icons
70
97
  arguments arguments
71
98
  define_singleton_method(:template) do |args, server_context: nil|
72
- instance_exec(args, server_context:, &block)
99
+ instance_exec(args, server_context: server_context, &block)
73
100
  end
101
+ meta meta
74
102
  end
75
103
  end
76
104
 
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -12,7 +11,7 @@ module MCP
12
11
  end
13
12
 
14
13
  def to_h
15
- { uri:, mime_type: }.compact
14
+ { uri: uri, mime_type: mime_type }.compact
16
15
  end
17
16
  end
18
17
 
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -11,7 +10,7 @@ module MCP
11
10
  end
12
11
 
13
12
  def to_h
14
- { resource: resource.to_h, annotations: }.compact
13
+ { resource: resource.to_h, annotations: annotations }.compact
15
14
  end
16
15
  end
17
16
  end
data/lib/mcp/resource.rb CHANGED
@@ -1,15 +1,15 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
5
4
  class Resource
6
- attr_reader :uri, :name, :title, :description, :mime_type
5
+ attr_reader :uri, :name, :title, :description, :icons, :mime_type
7
6
 
8
- def initialize(uri:, name:, title: nil, description: nil, mime_type: nil)
7
+ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil)
9
8
  @uri = uri
10
9
  @name = name
11
10
  @title = title
12
11
  @description = description
12
+ @icons = icons
13
13
  @mime_type = mime_type
14
14
  end
15
15
 
@@ -19,6 +19,7 @@ module MCP
19
19
  name: name,
20
20
  title: title,
21
21
  description: description,
22
+ icons: icons.map(&:to_h),
22
23
  mimeType: mime_type,
23
24
  }.compact
24
25
  end
@@ -1,15 +1,15 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
5
4
  class ResourceTemplate
6
- attr_reader :uri_template, :name, :title, :description, :mime_type
5
+ attr_reader :uri_template, :name, :title, :description, :icons, :mime_type
7
6
 
8
- def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil)
7
+ def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil)
9
8
  @uri_template = uri_template
10
9
  @name = name
11
10
  @title = title
12
11
  @description = description
12
+ @icons = icons
13
13
  @mime_type = mime_type
14
14
  end
15
15
 
@@ -19,6 +19,7 @@ module MCP
19
19
  name: name,
20
20
  title: title,
21
21
  description: description,
22
+ icons: icons.map(&:to_h),
22
23
  mimeType: mime_type,
23
24
  }.compact
24
25
  end
@@ -8,13 +8,18 @@ module MCP
8
8
  class Server
9
9
  module Transports
10
10
  class StreamableHTTPTransport < Transport
11
- def initialize(server)
12
- super
11
+ def initialize(server, stateless: false)
12
+ super(server)
13
13
  # { session_id => { stream: stream_object }
14
14
  @sessions = {}
15
15
  @mutex = Mutex.new
16
+
17
+ @stateless = stateless
16
18
  end
17
19
 
20
+ REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
21
+ REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
22
+
18
23
  def handle_request(request)
19
24
  case request.env["REQUEST_METHOD"]
20
25
  when "POST"
@@ -24,7 +29,7 @@ module MCP
24
29
  when "DELETE"
25
30
  handle_delete(request)
26
31
  else
27
- [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
32
+ method_not_allowed_response
28
33
  end
29
34
  end
30
35
 
@@ -35,9 +40,12 @@ module MCP
35
40
  end
36
41
 
37
42
  def send_notification(method, params = nil, session_id: nil)
43
+ # Stateless mode doesn't support notifications
44
+ raise "Stateless mode does not support notifications" if @stateless
45
+
38
46
  notification = {
39
47
  jsonrpc: "2.0",
40
- method:,
48
+ method: method,
41
49
  }
42
50
  notification[:params] = params if params
43
51
 
@@ -100,6 +108,9 @@ module MCP
100
108
  end
101
109
 
102
110
  def handle_post(request)
111
+ accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
112
+ return accept_error if accept_error
113
+
103
114
  body_string = request.body.read
104
115
  session_id = extract_session_id(request)
105
116
 
@@ -108,8 +119,8 @@ module MCP
108
119
 
109
120
  if body["method"] == "initialize"
110
121
  handle_initialization(body_string, body)
111
- elsif body["method"] == MCP::Methods::NOTIFICATIONS_INITIALIZED
112
- handle_notification_initialized
122
+ elsif notification?(body) || response?(body)
123
+ handle_accepted
113
124
  else
114
125
  handle_regular_request(body_string, session_id)
115
126
  end
@@ -119,6 +130,13 @@ module MCP
119
130
  end
120
131
 
121
132
  def handle_get(request)
133
+ if @stateless
134
+ return method_not_allowed_response
135
+ end
136
+
137
+ accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
138
+ return accept_error if accept_error
139
+
122
140
  session_id = extract_session_id(request)
123
141
 
124
142
  return missing_session_id_response unless session_id
@@ -128,6 +146,13 @@ module MCP
128
146
  end
129
147
 
130
148
  def handle_delete(request)
149
+ success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
150
+
151
+ if @stateless
152
+ # Stateless mode doesn't support sessions, so we can just return a success response
153
+ return success_response
154
+ end
155
+
131
156
  session_id = request.env["HTTP_MCP_SESSION_ID"]
132
157
 
133
158
  return [
@@ -137,7 +162,7 @@ module MCP
137
162
  ] unless session_id
138
163
 
139
164
  cleanup_session(session_id)
140
- [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
165
+ success_response
141
166
  end
142
167
 
143
168
  def cleanup_session(session_id)
@@ -162,51 +187,100 @@ module MCP
162
187
  request.env["HTTP_MCP_SESSION_ID"]
163
188
  end
164
189
 
190
+ def validate_accept_header(request, required_types)
191
+ accept_header = request.env["HTTP_ACCEPT"]
192
+ return not_acceptable_response(required_types) unless accept_header
193
+
194
+ accepted_types = parse_accept_header(accept_header)
195
+ missing_types = required_types - accepted_types
196
+ return not_acceptable_response(required_types) unless missing_types.empty?
197
+
198
+ nil
199
+ end
200
+
201
+ def parse_accept_header(header)
202
+ header.split(",").map do |part|
203
+ part.split(";").first.strip
204
+ end
205
+ end
206
+
207
+ def not_acceptable_response(required_types)
208
+ [
209
+ 406,
210
+ { "Content-Type" => "application/json" },
211
+ [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
212
+ ]
213
+ end
214
+
165
215
  def parse_request_body(body_string)
166
216
  JSON.parse(body_string)
167
217
  rescue JSON::ParserError, TypeError
168
218
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
169
219
  end
170
220
 
221
+ def notification?(body)
222
+ !body["id"] && !!body["method"]
223
+ end
224
+
225
+ def response?(body)
226
+ !!body["id"] && !body["method"]
227
+ end
228
+
171
229
  def handle_initialization(body_string, body)
172
- session_id = SecureRandom.uuid
230
+ session_id = nil
173
231
 
174
- @mutex.synchronize do
175
- @sessions[session_id] = {
176
- stream: nil,
177
- }
232
+ unless @stateless
233
+ session_id = SecureRandom.uuid
234
+
235
+ @mutex.synchronize do
236
+ @sessions[session_id] = {
237
+ stream: nil,
238
+ }
239
+ end
178
240
  end
179
241
 
180
242
  response = @server.handle_json(body_string)
181
243
 
182
244
  headers = {
183
245
  "Content-Type" => "application/json",
184
- "Mcp-Session-Id" => session_id,
185
246
  }
186
247
 
248
+ headers["Mcp-Session-Id"] = session_id if session_id
249
+
187
250
  [200, headers, [response]]
188
251
  end
189
252
 
190
- def handle_notification_initialized
253
+ def handle_accepted
191
254
  [202, {}, []]
192
255
  end
193
256
 
194
257
  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]]
258
+ unless @stateless
259
+ # If session ID is provided, but not in the sessions hash, return an error
260
+ if session_id && !@sessions.key?(session_id)
261
+ return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
262
+ end
198
263
  end
199
264
 
200
- response = @server.handle_json(body_string)
265
+ response = @server.handle_json(body_string) || ""
266
+
267
+ # Stream can be nil since stateless mode doesn't retain streams
201
268
  stream = get_session_stream(session_id) if session_id
202
269
 
203
270
  if stream
204
271
  send_response_to_stream(stream, response, session_id)
272
+ elsif response.nil? && notification_request?(body_string)
273
+ [202, { "Content-Type" => "application/json" }, [response]]
205
274
  else
206
275
  [200, { "Content-Type" => "application/json" }, [response]]
207
276
  end
208
277
  end
209
278
 
279
+ def notification_request?(body_string)
280
+ body = parse_request_body(body_string)
281
+ body.is_a?(Hash) && body["method"].start_with?("notifications/")
282
+ end
283
+
210
284
  def get_session_stream(session_id)
211
285
  @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
212
286
  end
@@ -228,6 +302,10 @@ module MCP
228
302
  @mutex.synchronize { @sessions.key?(session_id) }
229
303
  end
230
304
 
305
+ def method_not_allowed_response
306
+ [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
307
+ end
308
+
231
309
  def missing_session_id_response
232
310
  [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
233
311
  end