model-context-protocol-rb 0.3.3 → 0.4.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.
@@ -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
@@ -166,5 +211,37 @@ module ModelContextProtocol
166
211
  raise InvalidTransportError, "redis_client must be a Redis-compatible client"
167
212
  end
168
213
  end
214
+
215
+ def validate_pagination!
216
+ return unless pagination_enabled?
217
+
218
+ opts = pagination_options
219
+
220
+ if opts[:max_page_size] <= 0
221
+ raise InvalidPaginationError, "Invalid pagination max_page_size: must be positive"
222
+ end
223
+
224
+ if opts[:default_page_size] <= 0 || opts[:default_page_size] > opts[:max_page_size]
225
+ raise InvalidPaginationError, "Invalid pagination default_page_size: must be between 1 and #{opts[:max_page_size]}"
226
+ end
227
+
228
+ if opts[:cursor_ttl] && opts[:cursor_ttl] <= 0
229
+ raise InvalidPaginationError, "Invalid pagination cursor_ttl: must be positive or nil"
230
+ end
231
+ end
232
+
233
+ def validate_title!
234
+ return if title.nil?
235
+ return if title.is_a?(String)
236
+
237
+ raise InvalidServerTitleError, "Server title must be a string"
238
+ end
239
+
240
+ def validate_instructions!
241
+ return if instructions.nil?
242
+ return if instructions.is_a?(String)
243
+
244
+ raise InvalidServerInstructionsError, "Server instructions must be a string"
245
+ end
169
246
  end
170
247
  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