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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -1
- data/README.md +886 -196
- data/lib/model_context_protocol/server/cancellable.rb +54 -0
- data/lib/model_context_protocol/server/configuration.rb +80 -8
- data/lib/model_context_protocol/server/content.rb +321 -0
- data/lib/model_context_protocol/server/content_helpers.rb +84 -0
- data/lib/model_context_protocol/server/pagination.rb +71 -0
- data/lib/model_context_protocol/server/progressable.rb +72 -0
- data/lib/model_context_protocol/server/prompt.rb +108 -14
- data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
- data/lib/model_context_protocol/server/redis_config.rb +108 -0
- data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
- data/lib/model_context_protocol/server/registry.rb +94 -18
- data/lib/model_context_protocol/server/resource.rb +98 -25
- data/lib/model_context_protocol/server/resource_template.rb +26 -13
- data/lib/model_context_protocol/server/router.rb +36 -3
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
- data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
- data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
- data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
- data/lib/model_context_protocol/server/streamable_http_transport.rb +352 -112
- data/lib/model_context_protocol/server/tool.rb +79 -53
- data/lib/model_context_protocol/server.rb +124 -21
- data/lib/model_context_protocol/version.rb +1 -1
- data/tasks/mcp.rake +28 -2
- data/tasks/templates/dev-http.erb +288 -0
- data/tasks/templates/dev.erb +7 -1
- 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
|
-
|
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
|
-
|
161
|
-
raise
|
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
|
-
|
165
|
-
|
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
|