model-context-protocol-rb 0.3.4 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +886 -196
  4. data/lib/model_context_protocol/server/cancellable.rb +54 -0
  5. data/lib/model_context_protocol/server/configuration.rb +80 -8
  6. data/lib/model_context_protocol/server/content.rb +321 -0
  7. data/lib/model_context_protocol/server/content_helpers.rb +84 -0
  8. data/lib/model_context_protocol/server/pagination.rb +71 -0
  9. data/lib/model_context_protocol/server/progressable.rb +72 -0
  10. data/lib/model_context_protocol/server/prompt.rb +108 -14
  11. data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
  12. data/lib/model_context_protocol/server/redis_config.rb +108 -0
  13. data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
  14. data/lib/model_context_protocol/server/registry.rb +94 -18
  15. data/lib/model_context_protocol/server/resource.rb +98 -25
  16. data/lib/model_context_protocol/server/resource_template.rb +26 -13
  17. data/lib/model_context_protocol/server/router.rb +36 -3
  18. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
  19. data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
  20. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
  21. data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
  22. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
  23. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
  24. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
  25. data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
  26. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
  27. data/lib/model_context_protocol/server/streamable_http_transport.rb +352 -112
  28. data/lib/model_context_protocol/server/tool.rb +79 -53
  29. data/lib/model_context_protocol/server.rb +124 -21
  30. data/lib/model_context_protocol/version.rb +1 -1
  31. data/tasks/mcp.rake +28 -2
  32. data/tasks/templates/dev-http.erb +288 -0
  33. data/tasks/templates/dev.erb +7 -1
  34. metadata +61 -3
@@ -0,0 +1,54 @@
1
+ require "concurrent-ruby"
2
+
3
+ module ModelContextProtocol
4
+ module Server::Cancellable
5
+ # Raised when a request has been cancelled by the client
6
+ class CancellationError < StandardError; end
7
+
8
+ # Execute a block with automatic cancellation support for blocking I/O operations.
9
+ # This method uses Concurrent::TimerTask to poll for cancellation every 100ms
10
+ # and can interrupt even blocking operations like HTTP requests or database queries.
11
+ #
12
+ # @param interval [Float] polling interval in seconds (default: 0.1)
13
+ # @yield block to execute with cancellation support
14
+ # @return [Object] the result of the block
15
+ # @raise [CancellationError] if the request is cancelled during execution
16
+ #
17
+ # @example
18
+ # cancellable do
19
+ # response = Net::HTTP.get(URI('https://slow-api.example.com'))
20
+ # process_response(response)
21
+ # end
22
+ def cancellable(interval: 0.1, &block)
23
+ context = Thread.current[:mcp_context]
24
+ executing_thread = Concurrent::AtomicReference.new(nil)
25
+
26
+ timer_task = Concurrent::TimerTask.new(execution_interval: interval) do
27
+ if context && context[:request_store] && context[:request_id]
28
+ if context[:request_store].cancelled?(context[:request_id])
29
+ thread = executing_thread.get
30
+ thread&.raise(CancellationError, "Request was cancelled") if thread&.alive?
31
+ end
32
+ end
33
+ end
34
+
35
+ begin
36
+ executing_thread.set(Thread.current)
37
+
38
+ if context && context[:request_store] && context[:request_id]
39
+ if context[:request_store].cancelled?(context[:request_id])
40
+ raise CancellationError, "Request #{context[:request_id]} was cancelled"
41
+ end
42
+ end
43
+
44
+ timer_task.execute
45
+
46
+ result = block.call
47
+ result
48
+ ensure
49
+ executing_thread.set(nil)
50
+ timer_task&.shutdown if timer_task&.running?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -8,6 +8,12 @@ module ModelContextProtocol
8
8
  # Raised when configured with invalid version.
9
9
  class InvalidServerVersionError < StandardError; end
10
10
 
11
+ # Raised when configured with invalid title.
12
+ class InvalidServerTitleError < StandardError; end
13
+
14
+ # Raised when configured with invalid instructions.
15
+ class InvalidServerInstructionsError < StandardError; end
16
+
11
17
  # Raised when configured with invalid registry.
12
18
  class InvalidRegistryError < StandardError; end
13
19
 
@@ -20,14 +26,16 @@ module ModelContextProtocol
20
26
  # Raised when an invalid log level is provided
21
27
  class InvalidLogLevelError < StandardError; end
22
28
 
29
+ # Raised when pagination configuration is invalid
30
+ class InvalidPaginationError < StandardError; end
31
+
23
32
  # Valid MCP log levels per the specification
24
33
  VALID_LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
25
34
 
26
- attr_accessor :name, :registry, :version, :transport
35
+ attr_accessor :name, :registry, :version, :transport, :pagination, :title, :instructions
27
36
  attr_reader :logger
28
37
 
29
38
  def initialize
30
- # Always create a logger - enabled by default
31
39
  @logging_enabled = true
32
40
  @default_log_level = "info"
33
41
  @logger = ModelContextProtocol::Server::MCPLogger.new(
@@ -77,11 +85,48 @@ module ModelContextProtocol
77
85
  end
78
86
  end
79
87
 
88
+ def pagination_enabled?
89
+ return true if pagination.nil?
90
+
91
+ case pagination
92
+ when Hash
93
+ pagination[:enabled] != false
94
+ when false
95
+ false
96
+ else
97
+ true
98
+ end
99
+ end
100
+
101
+ def pagination_options
102
+ case pagination
103
+ when Hash
104
+ {
105
+ enabled: pagination[:enabled] != false,
106
+ default_page_size: pagination[:default_page_size] || 100,
107
+ max_page_size: pagination[:max_page_size] || 1000,
108
+ cursor_ttl: pagination[:cursor_ttl] || 3600
109
+ }
110
+ when false
111
+ {enabled: false}
112
+ else
113
+ {
114
+ enabled: true,
115
+ default_page_size: 100,
116
+ max_page_size: 1000,
117
+ cursor_ttl: 3600
118
+ }
119
+ end
120
+ end
121
+
80
122
  def validate!
81
123
  raise InvalidServerNameError unless valid_name?
82
124
  raise InvalidRegistryError unless valid_registry?
83
125
  raise InvalidServerVersionError unless valid_version?
84
126
  validate_transport!
127
+ validate_pagination!
128
+ validate_title!
129
+ validate_instructions!
85
130
 
86
131
  validate_environment_variables!
87
132
  end
@@ -155,16 +200,43 @@ module ModelContextProtocol
155
200
  end
156
201
 
157
202
  def validate_streamable_http_transport!
158
- options = transport_options
203
+ unless ModelContextProtocol::Server::RedisConfig.configured?
204
+ raise InvalidTransportError,
205
+ "streamable_http transport requires Redis configuration. " \
206
+ "Call ModelContextProtocol::Server.configure_redis in an initializer."
207
+ end
208
+ end
209
+
210
+ def validate_pagination!
211
+ return unless pagination_enabled?
212
+
213
+ opts = pagination_options
214
+
215
+ if opts[:max_page_size] <= 0
216
+ raise InvalidPaginationError, "Invalid pagination max_page_size: must be positive"
217
+ end
159
218
 
160
- unless options[:redis_client]
161
- raise InvalidTransportError, "streamable_http transport requires redis_client option"
219
+ if opts[:default_page_size] <= 0 || opts[:default_page_size] > opts[:max_page_size]
220
+ raise InvalidPaginationError, "Invalid pagination default_page_size: must be between 1 and #{opts[:max_page_size]}"
162
221
  end
163
222
 
164
- redis_client = options[:redis_client]
165
- unless redis_client.respond_to?(:hset) && redis_client.respond_to?(:expire)
166
- raise InvalidTransportError, "redis_client must be a Redis-compatible client"
223
+ if opts[:cursor_ttl] && opts[:cursor_ttl] <= 0
224
+ raise InvalidPaginationError, "Invalid pagination cursor_ttl: must be positive or nil"
167
225
  end
168
226
  end
227
+
228
+ def validate_title!
229
+ return if title.nil?
230
+ return if title.is_a?(String)
231
+
232
+ raise InvalidServerTitleError, "Server title must be a string"
233
+ end
234
+
235
+ def validate_instructions!
236
+ return if instructions.nil?
237
+ return if instructions.is_a?(String)
238
+
239
+ raise InvalidServerInstructionsError, "Server instructions must be a string"
240
+ end
169
241
  end
170
242
  end
@@ -0,0 +1,321 @@
1
+ require "json-schema"
2
+
3
+ module ModelContextProtocol
4
+ module Server::Content
5
+ class ContentValidationError < StandardError; end
6
+
7
+ Text = Data.define(:meta, :annotations, :text) do
8
+ def serialized
9
+ serialized_data = {
10
+ _meta: meta,
11
+ annotations:,
12
+ text:,
13
+ type: "text"
14
+ }.compact
15
+
16
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
17
+ if validation_errors.empty?
18
+ serialized_data
19
+ else
20
+ raise ContentValidationError, validation_errors.join(", ")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def schema
27
+ {
28
+ type: "object",
29
+ required: ["type", "text"],
30
+ properties: {
31
+ _meta: {
32
+ type: "object",
33
+ description: "Contains metadata about the content block"
34
+ },
35
+ annotations: {
36
+ type: "object",
37
+ description: "Optional metadata about the purpose and use of the content block"
38
+ },
39
+ text: {
40
+ type: "string",
41
+ description: "The text content of the content block"
42
+ },
43
+ type: {
44
+ type: "string",
45
+ description: "The type of content block",
46
+ pattern: "^text$"
47
+ }
48
+ }
49
+ }
50
+ end
51
+ end
52
+
53
+ Image = Data.define(:meta, :annotations, :data, :mime_type) do
54
+ def serialized
55
+ serialized_data = {
56
+ _meta: meta,
57
+ annotations:,
58
+ data:,
59
+ mimeType: mime_type,
60
+ type: "image"
61
+ }.compact
62
+
63
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
64
+ if validation_errors.empty?
65
+ serialized_data
66
+ else
67
+ raise ContentValidationError, validation_errors.join(", ")
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def schema
74
+ {
75
+ type: "object",
76
+ required: ["data", "mimeType", "type"],
77
+ properties: {
78
+ _meta: {
79
+ type: "object",
80
+ description: "Contains metadata about the content block"
81
+ },
82
+ annotations: {
83
+ type: "object",
84
+ description: "Optional metadata about the purpose and use of the content block"
85
+ },
86
+ data: {
87
+ type: "string",
88
+ description: "The base64 encoded image data"
89
+ },
90
+ mimeType: {
91
+ type: "string",
92
+ description: "The mime type associated with the image data"
93
+ },
94
+ type: {
95
+ type: "string",
96
+ description: "The type of content block",
97
+ pattern: "^image$"
98
+ }
99
+ }
100
+ }
101
+ end
102
+ end
103
+
104
+ Audio = Data.define(:meta, :annotations, :data, :mime_type) do
105
+ def serialized
106
+ serialized_data = {
107
+ _meta: meta,
108
+ annotations:,
109
+ data:,
110
+ mimeType: mime_type,
111
+ type: "audio"
112
+ }.compact
113
+
114
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
115
+ if validation_errors.empty?
116
+ serialized_data
117
+ else
118
+ raise ContentValidationError, validation_errors.join(", ")
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def schema
125
+ {
126
+ type: "object",
127
+ required: ["data", "mimeType", "type"],
128
+ properties: {
129
+ _meta: {
130
+ type: "object",
131
+ description: "Contains metadata about the content block"
132
+ },
133
+ annotations: {
134
+ type: "object",
135
+ description: "Optional metadata about the purpose and use of the content block"
136
+ },
137
+ data: {
138
+ type: "string",
139
+ description: "The base64 encoded image data"
140
+ },
141
+ mimeType: {
142
+ type: "string",
143
+ description: "The mime type associated with the image data"
144
+ },
145
+ type: {
146
+ type: "string",
147
+ description: "The type of content block",
148
+ pattern: "^audio$"
149
+ }
150
+ }
151
+ }
152
+ end
153
+ end
154
+
155
+ EmbeddedResource = Data.define(:meta, :resource) do
156
+ def serialized
157
+ serialized_data = {
158
+ _meta: meta,
159
+ resource:,
160
+ type: "resource"
161
+ }.compact
162
+
163
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
164
+ if validation_errors.empty?
165
+ serialized_data
166
+ else
167
+ raise ContentValidationError, validation_errors.join(", ")
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def schema
174
+ {
175
+ type: "object",
176
+ required: ["type", "resource"],
177
+ properties: {
178
+ _meta: {
179
+ type: "object",
180
+ description: "Contains metadata about the content block"
181
+ },
182
+ resource: {
183
+ type: "object",
184
+ description: "The resource embedded in the content block"
185
+ },
186
+ type: {
187
+ type: "string",
188
+ description: "The type of content block",
189
+ pattern: "^resource$"
190
+ }
191
+ }
192
+ }
193
+ end
194
+ end
195
+
196
+ ResourceLink = Data.define(:meta, :annotations, :description, :mime_type, :name, :size, :title, :uri) do
197
+ def serialized
198
+ serialized_data = {
199
+ _meta: meta,
200
+ annotations:,
201
+ description:,
202
+ mimeType: mime_type,
203
+ name:,
204
+ size:,
205
+ title:,
206
+ type: "resource_link",
207
+ uri:
208
+ }.compact
209
+
210
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
211
+ if validation_errors.empty?
212
+ serialized_data
213
+ else
214
+ raise ContentValidationError, validation_errors.join(", ")
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ def schema
221
+ {
222
+ type: "object",
223
+ required: ["name", "type", "uri"],
224
+ properties: {
225
+ _meta: {
226
+ type: "object",
227
+ description: "Contains metadata about the content block"
228
+ },
229
+ annotations: {
230
+ type: "object",
231
+ description: "Optional metadata about the purpose and use of the content block"
232
+ },
233
+ description: {
234
+ type: "string",
235
+ description: "A description of what this resource represents"
236
+ },
237
+ mimeType: {
238
+ type: "string",
239
+ description: "The mime type of the resource in bytes (if known)"
240
+ },
241
+ name: {
242
+ type: "string",
243
+ description: "Name of the resource link, intended for programmatic use"
244
+ },
245
+ size: {
246
+ type: "number",
247
+ description: "The size of the raw resource content in bytes (if known)"
248
+ },
249
+ title: {
250
+ type: "string",
251
+ description: "Name of the resource link, intended for display purposes"
252
+ },
253
+ type: {
254
+ type: "string",
255
+ description: "The type of content block",
256
+ pattern: "^resource_link$"
257
+ },
258
+ uri: {
259
+ type: "string",
260
+ description: "The URI of this resource"
261
+ }
262
+ }
263
+ }
264
+ end
265
+ end
266
+
267
+ Annotations = Data.define(:audience, :last_modified, :priority) do
268
+ def serialized
269
+ serialized_data = {audience:, lastModified: last_modified, priority:}.compact
270
+
271
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_data)
272
+ unless validation_errors.empty?
273
+ raise ContentValidationError, validation_errors.join(", ")
274
+ end
275
+
276
+ serialized_data.empty? ? nil : serialized_data
277
+ end
278
+
279
+ private
280
+
281
+ def schema
282
+ {
283
+ type: "object",
284
+ description: "Optional metadata about the purpose and use of the content block",
285
+ properties: {
286
+ audience: {
287
+ oneOf: [
288
+ {
289
+ type: "string",
290
+ enum: ["user", "assistant"]
291
+ },
292
+ {
293
+ type: "array",
294
+ items: {
295
+ type: "string",
296
+ enum: ["user", "assistant"]
297
+ },
298
+ minItems: 1,
299
+ maxItems: 2,
300
+ uniqueItems: true
301
+ }
302
+ ],
303
+ description: "The intended audience for the content block"
304
+ },
305
+ lastModified: {
306
+ type: "string",
307
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{3})?Z$",
308
+ description: "The date the content was last modified (in ISO 8601 format)"
309
+ },
310
+ priority: {
311
+ type: "number",
312
+ description: "The weight of importance the audience should place on the contents of the content block",
313
+ minimum: 0.0,
314
+ maximum: 1.0
315
+ }
316
+ }
317
+ }
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,84 @@
1
+ module ModelContextProtocol
2
+ module Server::ContentHelpers
3
+ def text_content(text:, meta: nil, annotations: {})
4
+ serialized_annotations = ModelContextProtocol::Server::Content::Annotations[
5
+ audience: annotations[:audience],
6
+ last_modified: annotations[:last_modified],
7
+ priority: annotations[:priority]
8
+ ].serialized
9
+
10
+ ModelContextProtocol::Server::Content::Text[
11
+ meta:,
12
+ annotations: serialized_annotations,
13
+ text:
14
+ ]
15
+ end
16
+
17
+ def image_content(data:, mime_type:, meta: nil, annotations: {})
18
+ serialized_annotations = ModelContextProtocol::Server::Content::Annotations[
19
+ audience: annotations[:audience],
20
+ last_modified: annotations[:last_modified],
21
+ priority: annotations[:priority]
22
+ ].serialized
23
+
24
+ ModelContextProtocol::Server::Content::Image[
25
+ meta:,
26
+ annotations: serialized_annotations,
27
+ data:,
28
+ mime_type:
29
+ ]
30
+ end
31
+
32
+ def audio_content(data:, mime_type:, meta: nil, annotations: {})
33
+ serialized_annotations = ModelContextProtocol::Server::Content::Annotations[
34
+ audience: annotations[:audience],
35
+ last_modified: annotations[:last_modified],
36
+ priority: annotations[:priority]
37
+ ].serialized
38
+
39
+ ModelContextProtocol::Server::Content::Audio[
40
+ meta:,
41
+ annotations: serialized_annotations,
42
+ data:,
43
+ mime_type:
44
+ ]
45
+ end
46
+
47
+ def embedded_resource_content(resource:)
48
+ extracted_resource = resource.serialized[:contents].first
49
+ annotations = extracted_resource.key?(:annotations) ? extracted_resource.delete(:annotations) : {}
50
+
51
+ serialized_annotations = ModelContextProtocol::Server::Content::Annotations[
52
+ audience: annotations[:audience],
53
+ last_modified: annotations[:lastModified],
54
+ priority: annotations[:priority]
55
+ ].serialized
56
+
57
+ extracted_resource[:annotations] = serialized_annotations if serialized_annotations
58
+
59
+ ModelContextProtocol::Server::Content::EmbeddedResource[
60
+ meta: nil,
61
+ resource: extracted_resource
62
+ ]
63
+ end
64
+
65
+ def resource_link(name:, uri:, meta: nil, annotations: {}, description: nil, mime_type: nil, size: nil, title: nil)
66
+ serialized_annotations = ModelContextProtocol::Server::Content::Annotations[
67
+ audience: annotations[:audience],
68
+ last_modified: annotations[:last_modified],
69
+ priority: annotations[:priority]
70
+ ].serialized
71
+
72
+ ModelContextProtocol::Server::Content::ResourceLink[
73
+ meta:,
74
+ annotations: serialized_annotations,
75
+ description:,
76
+ mime_type:,
77
+ name:,
78
+ size:,
79
+ title:,
80
+ uri:
81
+ ].serialized
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,71 @@
1
+ require "json"
2
+ require "base64"
3
+
4
+ module ModelContextProtocol
5
+ class Server::Pagination
6
+ # Raised when an invalid cursor is provided
7
+ class InvalidCursorError < StandardError; end
8
+
9
+ DEFAULT_PAGE_SIZE = 100
10
+ MAX_PAGE_SIZE = 1000
11
+
12
+ PaginatedResponse = Data.define(:items, :next_cursor) do
13
+ def serialized(key)
14
+ result = {key => items}
15
+ result[:nextCursor] = next_cursor if next_cursor
16
+ result
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def paginate(items, cursor: nil, page_size: DEFAULT_PAGE_SIZE, cursor_ttl: nil)
22
+ page_size = [page_size, MAX_PAGE_SIZE].min
23
+ offset = cursor ? decode_cursor(cursor) : 0
24
+ page_items = items[offset, page_size] || []
25
+ next_offset = offset + page_items.length
26
+ next_cursor = if next_offset < items.length
27
+ encode_cursor(next_offset, items.length, ttl: cursor_ttl)
28
+ end
29
+
30
+ PaginatedResponse[items: page_items, next_cursor: next_cursor]
31
+ end
32
+
33
+ def encode_cursor(offset, total, ttl: nil)
34
+ data = {
35
+ offset: offset,
36
+ total: total,
37
+ timestamp: Time.now.to_i
38
+ }
39
+ data[:expires_at] = Time.now.to_i + ttl if ttl
40
+
41
+ Base64.urlsafe_encode64(JSON.generate(data), padding: false)
42
+ end
43
+
44
+ def decode_cursor(cursor, validate_ttl: true)
45
+ data = JSON.parse(Base64.urlsafe_decode64(cursor))
46
+
47
+ if validate_ttl && data["expires_at"] && Time.now.to_i > data["expires_at"]
48
+ raise InvalidCursorError, "Cursor has expired"
49
+ end
50
+
51
+ data["offset"]
52
+ rescue JSON::ParserError, ArgumentError => e
53
+ raise InvalidCursorError, "Invalid cursor format: #{e.message}"
54
+ end
55
+
56
+ def pagination_requested?(params)
57
+ params.key?("cursor") || params.key?("pageSize")
58
+ end
59
+
60
+ def extract_pagination_params(params, default_page_size: DEFAULT_PAGE_SIZE, max_page_size: MAX_PAGE_SIZE)
61
+ page_size = if params["pageSize"]
62
+ [params["pageSize"].to_i, max_page_size].min
63
+ else
64
+ default_page_size
65
+ end
66
+
67
+ {cursor: params["cursor"], page_size:}
68
+ end
69
+ end
70
+ end
71
+ end