mcp 0.7.0 → 0.10.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/README.md +182 -5
- data/lib/mcp/client/stdio.rb +222 -0
- data/lib/mcp/client.rb +21 -3
- data/lib/mcp/content.rb +28 -1
- data/lib/mcp/progress.rb +22 -0
- data/lib/mcp/prompt.rb +8 -3
- data/lib/mcp/resource/contents.rb +2 -2
- data/lib/mcp/resource.rb +3 -0
- data/lib/mcp/server/transports/stdio_transport.rb +6 -4
- data/lib/mcp/server/transports/streamable_http_transport.rb +146 -36
- data/lib/mcp/server/transports.rb +10 -0
- data/lib/mcp/server.rb +98 -43
- data/lib/mcp/server_context.rb +44 -0
- data/lib/mcp/server_session.rb +79 -0
- data/lib/mcp/tool/schema.rb +0 -4
- data/lib/mcp/tool.rb +5 -0
- data/lib/mcp/transport.rb +2 -2
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +11 -24
- metadata +8 -30
- data/.gitattributes +0 -4
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/ci.yml +0 -54
- data/.github/workflows/release.yml +0 -57
- data/.gitignore +0 -10
- data/.rubocop.yml +0 -15
- data/AGENTS.md +0 -107
- data/CHANGELOG.md +0 -143
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -29
- data/RELEASE.md +0 -12
- data/Rakefile +0 -17
- data/bin/console +0 -15
- data/bin/generate-gh-pages.sh +0 -119
- data/bin/rake +0 -31
- data/bin/setup +0 -8
- data/dev.yml +0 -30
- data/docs/_config.yml +0 -6
- data/docs/index.md +0 -7
- data/docs/latest/index.html +0 -19
- data/examples/README.md +0 -197
- data/examples/http_client.rb +0 -184
- data/examples/http_server.rb +0 -169
- data/examples/stdio_server.rb +0 -94
- data/examples/streamable_http_client.rb +0 -207
- data/examples/streamable_http_server.rb +0 -172
- data/mcp.gemspec +0 -35
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../../transport"
|
|
4
3
|
require "json"
|
|
4
|
+
require_relative "../../transport"
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Server
|
|
@@ -10,17 +10,19 @@ module MCP
|
|
|
10
10
|
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
|
11
11
|
|
|
12
12
|
def initialize(server)
|
|
13
|
-
|
|
13
|
+
super(server)
|
|
14
14
|
@open = false
|
|
15
|
+
@session = nil
|
|
15
16
|
$stdin.set_encoding("UTF-8")
|
|
16
17
|
$stdout.set_encoding("UTF-8")
|
|
17
|
-
super
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def open
|
|
21
21
|
@open = true
|
|
22
|
+
@session = ServerSession.new(server: @server, transport: self)
|
|
22
23
|
while @open && (line = $stdin.gets)
|
|
23
|
-
|
|
24
|
+
response = @session.handle_json(line.strip)
|
|
25
|
+
send_response(response) if response
|
|
24
26
|
end
|
|
25
27
|
rescue Interrupt
|
|
26
28
|
warn("\nExiting...")
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../../transport"
|
|
4
3
|
require "json"
|
|
5
4
|
require "securerandom"
|
|
5
|
+
require_relative "../../transport"
|
|
6
6
|
|
|
7
7
|
module MCP
|
|
8
8
|
class Server
|
|
9
9
|
module Transports
|
|
10
10
|
class StreamableHTTPTransport < Transport
|
|
11
|
-
def initialize(server, stateless: false)
|
|
11
|
+
def initialize(server, stateless: false, session_idle_timeout: nil)
|
|
12
12
|
super(server)
|
|
13
|
-
#
|
|
13
|
+
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
14
14
|
@sessions = {}
|
|
15
15
|
@mutex = Mutex.new
|
|
16
16
|
|
|
17
17
|
@stateless = stateless
|
|
18
|
+
@session_idle_timeout = session_idle_timeout
|
|
19
|
+
|
|
20
|
+
if @session_idle_timeout
|
|
21
|
+
if @stateless
|
|
22
|
+
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
|
|
23
|
+
elsif @session_idle_timeout <= 0
|
|
24
|
+
raise ArgumentError, "session_idle_timeout must be a positive number."
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
start_reaper_thread if @session_idle_timeout
|
|
18
29
|
end
|
|
19
30
|
|
|
20
31
|
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
|
|
21
32
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
33
|
+
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
34
|
+
SESSION_REAP_INTERVAL = 60
|
|
22
35
|
|
|
23
36
|
def handle_request(request)
|
|
24
37
|
case request.env["REQUEST_METHOD"]
|
|
@@ -34,6 +47,9 @@ module MCP
|
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
def close
|
|
50
|
+
@reaper_thread&.kill
|
|
51
|
+
@reaper_thread = nil
|
|
52
|
+
|
|
37
53
|
@mutex.synchronize do
|
|
38
54
|
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
|
39
55
|
end
|
|
@@ -55,10 +71,15 @@ module MCP
|
|
|
55
71
|
session = @sessions[session_id]
|
|
56
72
|
return false unless session && session[:stream]
|
|
57
73
|
|
|
74
|
+
if session_expired?(session)
|
|
75
|
+
cleanup_session_unsafe(session_id)
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
58
79
|
begin
|
|
59
80
|
send_to_stream(session[:stream], notification)
|
|
60
81
|
true
|
|
61
|
-
rescue
|
|
82
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
62
83
|
MCP.configuration.exception_reporter.call(
|
|
63
84
|
e,
|
|
64
85
|
{ session_id: session_id, error: "Failed to send notification" },
|
|
@@ -74,10 +95,15 @@ module MCP
|
|
|
74
95
|
@sessions.each do |sid, session|
|
|
75
96
|
next unless session[:stream]
|
|
76
97
|
|
|
98
|
+
if session_expired?(session)
|
|
99
|
+
failed_sessions << sid
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
77
103
|
begin
|
|
78
104
|
send_to_stream(session[:stream], notification)
|
|
79
105
|
sent_count += 1
|
|
80
|
-
rescue
|
|
106
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
81
107
|
MCP.configuration.exception_reporter.call(
|
|
82
108
|
e,
|
|
83
109
|
{ session_id: sid, error: "Failed to send notification" },
|
|
@@ -96,6 +122,39 @@ module MCP
|
|
|
96
122
|
|
|
97
123
|
private
|
|
98
124
|
|
|
125
|
+
def start_reaper_thread
|
|
126
|
+
@reaper_thread = Thread.new do
|
|
127
|
+
loop do
|
|
128
|
+
sleep(SESSION_REAP_INTERVAL)
|
|
129
|
+
reap_expired_sessions
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def reap_expired_sessions
|
|
137
|
+
return unless @session_idle_timeout
|
|
138
|
+
|
|
139
|
+
expired_streams = @mutex.synchronize do
|
|
140
|
+
@sessions.each_with_object([]) do |(session_id, session), streams|
|
|
141
|
+
next unless session_expired?(session)
|
|
142
|
+
|
|
143
|
+
streams << session[:stream] if session[:stream]
|
|
144
|
+
@sessions.delete(session_id)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
expired_streams.each do |stream|
|
|
149
|
+
# Closing outside the mutex is safe because expired sessions are already
|
|
150
|
+
# removed from `@sessions` above, so other threads will not find them
|
|
151
|
+
# and will not attempt to close the same stream.
|
|
152
|
+
stream.close
|
|
153
|
+
rescue
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
99
158
|
def send_to_stream(stream, data)
|
|
100
159
|
message = data.is_a?(String) ? data : data.to_json
|
|
101
160
|
stream.write("data: #{message}\n\n")
|
|
@@ -119,10 +178,14 @@ module MCP
|
|
|
119
178
|
|
|
120
179
|
if body["method"] == "initialize"
|
|
121
180
|
handle_initialization(body_string, body)
|
|
122
|
-
elsif notification?(body) || response?(body)
|
|
123
|
-
handle_accepted
|
|
124
181
|
else
|
|
125
|
-
|
|
182
|
+
return missing_session_id_response if !@stateless && !session_id
|
|
183
|
+
|
|
184
|
+
if notification?(body) || response?(body)
|
|
185
|
+
handle_accepted
|
|
186
|
+
else
|
|
187
|
+
handle_regular_request(body_string, session_id)
|
|
188
|
+
end
|
|
126
189
|
end
|
|
127
190
|
rescue StandardError => e
|
|
128
191
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -140,7 +203,10 @@ module MCP
|
|
|
140
203
|
session_id = extract_session_id(request)
|
|
141
204
|
|
|
142
205
|
return missing_session_id_response unless session_id
|
|
143
|
-
|
|
206
|
+
|
|
207
|
+
error_response = validate_and_touch_session(session_id)
|
|
208
|
+
return error_response if error_response
|
|
209
|
+
return session_already_connected_response if get_session_stream(session_id)
|
|
144
210
|
|
|
145
211
|
setup_sse_stream(session_id)
|
|
146
212
|
end
|
|
@@ -153,15 +219,11 @@ module MCP
|
|
|
153
219
|
return success_response
|
|
154
220
|
end
|
|
155
221
|
|
|
156
|
-
session_id = request.env["HTTP_MCP_SESSION_ID"]
|
|
157
|
-
|
|
158
|
-
return [
|
|
159
|
-
400,
|
|
160
|
-
{ "Content-Type" => "application/json" },
|
|
161
|
-
[{ error: "Missing session ID" }.to_json],
|
|
162
|
-
] unless session_id
|
|
222
|
+
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
|
|
223
|
+
return session_not_found_response unless session_exists?(session_id)
|
|
163
224
|
|
|
164
225
|
cleanup_session(session_id)
|
|
226
|
+
|
|
165
227
|
success_response
|
|
166
228
|
end
|
|
167
229
|
|
|
@@ -192,6 +254,8 @@ module MCP
|
|
|
192
254
|
return not_acceptable_response(required_types) unless accept_header
|
|
193
255
|
|
|
194
256
|
accepted_types = parse_accept_header(accept_header)
|
|
257
|
+
return if accepted_types.include?("*/*")
|
|
258
|
+
|
|
195
259
|
missing_types = required_types - accepted_types
|
|
196
260
|
return not_acceptable_response(required_types) unless missing_types.empty?
|
|
197
261
|
|
|
@@ -228,18 +292,26 @@ module MCP
|
|
|
228
292
|
|
|
229
293
|
def handle_initialization(body_string, body)
|
|
230
294
|
session_id = nil
|
|
295
|
+
server_session = nil
|
|
231
296
|
|
|
232
297
|
unless @stateless
|
|
233
298
|
session_id = SecureRandom.uuid
|
|
299
|
+
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
234
300
|
|
|
235
301
|
@mutex.synchronize do
|
|
236
302
|
@sessions[session_id] = {
|
|
237
303
|
stream: nil,
|
|
304
|
+
server_session: server_session,
|
|
305
|
+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
238
306
|
}
|
|
239
307
|
end
|
|
240
308
|
end
|
|
241
309
|
|
|
242
|
-
response =
|
|
310
|
+
response = if server_session
|
|
311
|
+
server_session.handle_json(body_string)
|
|
312
|
+
else
|
|
313
|
+
@server.handle_json(body_string)
|
|
314
|
+
end
|
|
243
315
|
|
|
244
316
|
headers = {
|
|
245
317
|
"Content-Type" => "application/json",
|
|
@@ -255,30 +327,49 @@ module MCP
|
|
|
255
327
|
end
|
|
256
328
|
|
|
257
329
|
def handle_regular_request(body_string, session_id)
|
|
330
|
+
server_session = nil
|
|
331
|
+
stream = nil
|
|
332
|
+
|
|
258
333
|
unless @stateless
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return
|
|
334
|
+
if session_id
|
|
335
|
+
error_response = validate_and_touch_session(session_id)
|
|
336
|
+
return error_response if error_response
|
|
337
|
+
|
|
338
|
+
@mutex.synchronize do
|
|
339
|
+
session = @sessions[session_id]
|
|
340
|
+
server_session = session[:server_session] if session
|
|
341
|
+
stream = session[:stream] if session
|
|
342
|
+
end
|
|
262
343
|
end
|
|
263
344
|
end
|
|
264
345
|
|
|
265
|
-
response =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
346
|
+
response = if server_session
|
|
347
|
+
server_session.handle_json(body_string)
|
|
348
|
+
else
|
|
349
|
+
@server.handle_json(body_string)
|
|
350
|
+
end
|
|
269
351
|
|
|
270
352
|
if stream
|
|
271
353
|
send_response_to_stream(stream, response, session_id)
|
|
272
|
-
elsif response.nil? && notification_request?(body_string)
|
|
273
|
-
[202, { "Content-Type" => "application/json" }, [response]]
|
|
274
354
|
else
|
|
275
355
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
276
356
|
end
|
|
277
357
|
end
|
|
278
358
|
|
|
279
|
-
def
|
|
280
|
-
|
|
281
|
-
|
|
359
|
+
def validate_and_touch_session(session_id)
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
return session_not_found_response unless (session = @sessions[session_id])
|
|
362
|
+
return unless @session_idle_timeout
|
|
363
|
+
|
|
364
|
+
if session_expired?(session)
|
|
365
|
+
cleanup_session_unsafe(session_id)
|
|
366
|
+
return session_not_found_response
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
nil
|
|
282
373
|
end
|
|
283
374
|
|
|
284
375
|
def get_session_stream(session_id)
|
|
@@ -288,8 +379,8 @@ module MCP
|
|
|
288
379
|
def send_response_to_stream(stream, response, session_id)
|
|
289
380
|
message = JSON.parse(response)
|
|
290
381
|
send_to_stream(stream, message)
|
|
291
|
-
|
|
292
|
-
rescue
|
|
382
|
+
handle_accepted
|
|
383
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
293
384
|
MCP.configuration.exception_reporter.call(
|
|
294
385
|
e,
|
|
295
386
|
{ session_id: session_id, error: "Stream closed during response" },
|
|
@@ -314,6 +405,14 @@ module MCP
|
|
|
314
405
|
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
|
315
406
|
end
|
|
316
407
|
|
|
408
|
+
def session_already_connected_response
|
|
409
|
+
[
|
|
410
|
+
409,
|
|
411
|
+
{ "Content-Type" => "application/json" },
|
|
412
|
+
[{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
|
|
413
|
+
]
|
|
414
|
+
end
|
|
415
|
+
|
|
317
416
|
def setup_sse_stream(session_id)
|
|
318
417
|
body = create_sse_body(session_id)
|
|
319
418
|
|
|
@@ -328,17 +427,22 @@ module MCP
|
|
|
328
427
|
|
|
329
428
|
def create_sse_body(session_id)
|
|
330
429
|
proc do |stream|
|
|
331
|
-
store_stream_for_session(session_id, stream)
|
|
332
|
-
start_keepalive_thread(session_id)
|
|
430
|
+
stored = store_stream_for_session(session_id, stream)
|
|
431
|
+
start_keepalive_thread(session_id) if stored
|
|
333
432
|
end
|
|
334
433
|
end
|
|
335
434
|
|
|
336
435
|
def store_stream_for_session(session_id, stream)
|
|
337
436
|
@mutex.synchronize do
|
|
338
|
-
|
|
339
|
-
|
|
437
|
+
session = @sessions[session_id]
|
|
438
|
+
if session && !session[:stream]
|
|
439
|
+
session[:stream] = stream
|
|
340
440
|
else
|
|
441
|
+
# Either session was removed, or another request already established a stream.
|
|
341
442
|
stream.close
|
|
443
|
+
# `stream.close` may return a truthy value depending on the stream class.
|
|
444
|
+
# Explicitly return nil to guarantee a falsy return for callers.
|
|
445
|
+
nil
|
|
342
446
|
end
|
|
343
447
|
end
|
|
344
448
|
end
|
|
@@ -366,13 +470,19 @@ module MCP
|
|
|
366
470
|
send_ping_to_stream(@sessions[session_id][:stream])
|
|
367
471
|
end
|
|
368
472
|
end
|
|
369
|
-
rescue
|
|
473
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
370
474
|
MCP.configuration.exception_reporter.call(
|
|
371
475
|
e,
|
|
372
476
|
{ session_id: session_id, error: "Stream closed" },
|
|
373
477
|
)
|
|
374
478
|
raise # Re-raise to exit the keepalive loop
|
|
375
479
|
end
|
|
480
|
+
|
|
481
|
+
def session_expired?(session)
|
|
482
|
+
return false unless @session_idle_timeout
|
|
483
|
+
|
|
484
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
|
|
485
|
+
end
|
|
376
486
|
end
|
|
377
487
|
end
|
|
378
488
|
end
|
data/lib/mcp/server.rb
CHANGED
|
@@ -4,6 +4,9 @@ require_relative "../json_rpc_handler"
|
|
|
4
4
|
require_relative "instrumentation"
|
|
5
5
|
require_relative "methods"
|
|
6
6
|
require_relative "logging_message_notification"
|
|
7
|
+
require_relative "progress"
|
|
8
|
+
require_relative "server_context"
|
|
9
|
+
require_relative "server/transports"
|
|
7
10
|
|
|
8
11
|
module MCP
|
|
9
12
|
class ToolNotUnique < StandardError
|
|
@@ -96,26 +99,41 @@ module MCP
|
|
|
96
99
|
Methods::INITIALIZE => method(:init),
|
|
97
100
|
Methods::PING => ->(_) { {} },
|
|
98
101
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
|
102
|
+
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
|
|
99
103
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
|
100
104
|
|
|
101
105
|
# No op handlers for currently unsupported methods
|
|
102
|
-
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
|
|
103
|
-
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
|
|
104
|
-
Methods::COMPLETION_COMPLETE => ->(_) {},
|
|
106
|
+
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
107
|
+
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
108
|
+
Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
|
|
105
109
|
Methods::ELICITATION_CREATE => ->(_) {},
|
|
106
110
|
}
|
|
107
111
|
@transport = transport
|
|
108
112
|
end
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
# Processes a parsed JSON-RPC request and returns the response as a Hash.
|
|
115
|
+
#
|
|
116
|
+
# @param request [Hash] A parsed JSON-RPC request.
|
|
117
|
+
# @param session [ServerSession, nil] Per-connection session. Passed by
|
|
118
|
+
# `ServerSession#handle` for session-scoped notification delivery.
|
|
119
|
+
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
120
|
+
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
|
|
121
|
+
def handle(request, session: nil)
|
|
111
122
|
JsonRpcHandler.handle(request) do |method|
|
|
112
|
-
handle_request(request, method)
|
|
123
|
+
handle_request(request, method, session: session)
|
|
113
124
|
end
|
|
114
125
|
end
|
|
115
126
|
|
|
116
|
-
|
|
127
|
+
# Processes a JSON-RPC request string and returns the response as a JSON string.
|
|
128
|
+
#
|
|
129
|
+
# @param request [String] A JSON-RPC request as a JSON string.
|
|
130
|
+
# @param session [ServerSession, nil] Per-connection session. Passed by
|
|
131
|
+
# `ServerSession#handle_json` for session-scoped notification delivery.
|
|
132
|
+
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
133
|
+
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
|
|
134
|
+
def handle_json(request, session: nil)
|
|
117
135
|
JsonRpcHandler.handle_json(request) do |method|
|
|
118
|
-
handle_request(request, method)
|
|
136
|
+
handle_request(request, method, session: session)
|
|
119
137
|
end
|
|
120
138
|
end
|
|
121
139
|
|
|
@@ -180,34 +198,16 @@ module MCP
|
|
|
180
198
|
report_exception(e, { notification: "log_message" })
|
|
181
199
|
end
|
|
182
200
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
# Sets a custom handler for `resources/read` requests.
|
|
202
|
+
# The block receives the parsed request params and should return resource
|
|
203
|
+
# contents. The return value is set as the `contents` field of the response.
|
|
204
|
+
#
|
|
205
|
+
# @yield [params] The request params containing `:uri`.
|
|
206
|
+
# @yieldreturn [Array<Hash>, Hash] Resource contents.
|
|
187
207
|
def resources_read_handler(&block)
|
|
188
208
|
@handlers[Methods::RESOURCES_READ] = block
|
|
189
209
|
end
|
|
190
210
|
|
|
191
|
-
def resources_templates_list_handler(&block)
|
|
192
|
-
@handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def tools_list_handler(&block)
|
|
196
|
-
@handlers[Methods::TOOLS_LIST] = block
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def tools_call_handler(&block)
|
|
200
|
-
@handlers[Methods::TOOLS_CALL] = block
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def prompts_list_handler(&block)
|
|
204
|
-
@handlers[Methods::PROMPTS_LIST] = block
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def prompts_get_handler(&block)
|
|
208
|
-
@handlers[Methods::PROMPTS_GET] = block
|
|
209
|
-
end
|
|
210
|
-
|
|
211
211
|
private
|
|
212
212
|
|
|
213
213
|
def validate!
|
|
@@ -219,6 +219,14 @@ module MCP
|
|
|
219
219
|
message = "Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier"
|
|
220
220
|
raise ArgumentError, message
|
|
221
221
|
end
|
|
222
|
+
|
|
223
|
+
tools_with_ref = @tools.each_with_object([]) do |(tool_name, tool), names|
|
|
224
|
+
names << tool_name if schema_contains_ref?(tool.input_schema_value.to_h)
|
|
225
|
+
end
|
|
226
|
+
unless tools_with_ref.empty?
|
|
227
|
+
message = "Error occurred in #{tools_with_ref.join(", ")}. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher"
|
|
228
|
+
raise ArgumentError, message
|
|
229
|
+
end
|
|
222
230
|
end
|
|
223
231
|
|
|
224
232
|
if @configuration.protocol_version <= "2025-03-26"
|
|
@@ -259,11 +267,23 @@ module MCP
|
|
|
259
267
|
raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty?
|
|
260
268
|
end
|
|
261
269
|
|
|
262
|
-
def
|
|
270
|
+
def schema_contains_ref?(schema)
|
|
271
|
+
case schema
|
|
272
|
+
when Hash
|
|
273
|
+
schema.any? { |key, value| key.to_s == "$ref" || schema_contains_ref?(value) }
|
|
274
|
+
when Array
|
|
275
|
+
schema.any? { |element| schema_contains_ref?(element) }
|
|
276
|
+
else
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def handle_request(request, method, session: nil)
|
|
263
282
|
handler = @handlers[method]
|
|
264
283
|
unless handler
|
|
265
284
|
instrument_call("unsupported_method") do
|
|
266
|
-
|
|
285
|
+
client = session&.client || @client
|
|
286
|
+
add_instrumentation_data(client: client) if client
|
|
267
287
|
end
|
|
268
288
|
return
|
|
269
289
|
end
|
|
@@ -273,6 +293,8 @@ module MCP
|
|
|
273
293
|
->(params) {
|
|
274
294
|
instrument_call(method) do
|
|
275
295
|
result = case method
|
|
296
|
+
when Methods::INITIALIZE
|
|
297
|
+
init(params, session: session)
|
|
276
298
|
when Methods::TOOLS_LIST
|
|
277
299
|
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
|
|
278
300
|
when Methods::PROMPTS_LIST
|
|
@@ -283,10 +305,15 @@ module MCP
|
|
|
283
305
|
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
|
|
284
306
|
when Methods::RESOURCES_TEMPLATES_LIST
|
|
285
307
|
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
|
|
308
|
+
when Methods::TOOLS_CALL
|
|
309
|
+
call_tool(params, session: session)
|
|
310
|
+
when Methods::LOGGING_SET_LEVEL
|
|
311
|
+
configure_logging_level(params, session: session)
|
|
286
312
|
else
|
|
287
313
|
@handlers[method].call(params)
|
|
288
314
|
end
|
|
289
|
-
|
|
315
|
+
client = session&.client || @client
|
|
316
|
+
add_instrumentation_data(client: client) if client
|
|
290
317
|
|
|
291
318
|
result
|
|
292
319
|
rescue => e
|
|
@@ -322,8 +349,14 @@ module MCP
|
|
|
322
349
|
}.compact
|
|
323
350
|
end
|
|
324
351
|
|
|
325
|
-
def init(params)
|
|
326
|
-
|
|
352
|
+
def init(params, session: nil)
|
|
353
|
+
if params
|
|
354
|
+
if session
|
|
355
|
+
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
|
356
|
+
else
|
|
357
|
+
@client = params[:clientInfo]
|
|
358
|
+
end
|
|
359
|
+
end
|
|
327
360
|
|
|
328
361
|
protocol_version = params[:protocolVersion] if params
|
|
329
362
|
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
@@ -351,7 +384,7 @@ module MCP
|
|
|
351
384
|
}.compact
|
|
352
385
|
end
|
|
353
386
|
|
|
354
|
-
def configure_logging_level(request)
|
|
387
|
+
def configure_logging_level(request, session: nil)
|
|
355
388
|
if capabilities[:logging].nil?
|
|
356
389
|
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
|
|
357
390
|
end
|
|
@@ -361,21 +394,24 @@ module MCP
|
|
|
361
394
|
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
|
|
362
395
|
end
|
|
363
396
|
|
|
397
|
+
session&.configure_logging(logging_message_notification)
|
|
364
398
|
@logging_message_notification = logging_message_notification
|
|
399
|
+
|
|
400
|
+
{}
|
|
365
401
|
end
|
|
366
402
|
|
|
367
403
|
def list_tools(request)
|
|
368
404
|
@tools.values.map(&:to_h)
|
|
369
405
|
end
|
|
370
406
|
|
|
371
|
-
def call_tool(request)
|
|
407
|
+
def call_tool(request, session: nil)
|
|
372
408
|
tool_name = request[:name]
|
|
373
409
|
|
|
374
410
|
tool = tools[tool_name]
|
|
375
411
|
unless tool
|
|
376
412
|
add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
|
|
377
413
|
|
|
378
|
-
|
|
414
|
+
raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
|
|
379
415
|
end
|
|
380
416
|
|
|
381
417
|
arguments = request[:arguments] || {}
|
|
@@ -398,7 +434,11 @@ module MCP
|
|
|
398
434
|
end
|
|
399
435
|
end
|
|
400
436
|
|
|
401
|
-
|
|
437
|
+
progress_token = request.dig(:_meta, :progressToken)
|
|
438
|
+
|
|
439
|
+
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
|
|
440
|
+
rescue RequestHandlerError
|
|
441
|
+
raise
|
|
402
442
|
rescue => e
|
|
403
443
|
report_exception(e, request: request)
|
|
404
444
|
|
|
@@ -422,7 +462,7 @@ module MCP
|
|
|
422
462
|
prompt_args = request[:arguments]
|
|
423
463
|
prompt.validate_arguments!(prompt_args)
|
|
424
464
|
|
|
425
|
-
call_prompt_template_with_args(prompt, prompt_args)
|
|
465
|
+
call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
|
|
426
466
|
end
|
|
427
467
|
|
|
428
468
|
def list_resources(request)
|
|
@@ -465,22 +505,37 @@ module MCP
|
|
|
465
505
|
parameters.any? { |type, name| type == :keyrest || name == :server_context }
|
|
466
506
|
end
|
|
467
507
|
|
|
468
|
-
def call_tool_with_args(tool, arguments)
|
|
508
|
+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
|
|
469
509
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
470
510
|
|
|
471
511
|
if accepts_server_context?(tool.method(:call))
|
|
512
|
+
progress = Progress.new(notification_target: session, progress_token: progress_token)
|
|
513
|
+
server_context = ServerContext.new(context, progress: progress, notification_target: session)
|
|
472
514
|
tool.call(**args, server_context: server_context).to_h
|
|
473
515
|
else
|
|
474
516
|
tool.call(**args).to_h
|
|
475
517
|
end
|
|
476
518
|
end
|
|
477
519
|
|
|
478
|
-
def call_prompt_template_with_args(prompt, args)
|
|
520
|
+
def call_prompt_template_with_args(prompt, args, server_context)
|
|
479
521
|
if accepts_server_context?(prompt.method(:template))
|
|
480
522
|
prompt.template(args, server_context: server_context).to_h
|
|
481
523
|
else
|
|
482
524
|
prompt.template(args).to_h
|
|
483
525
|
end
|
|
484
526
|
end
|
|
527
|
+
|
|
528
|
+
def server_context_with_meta(request)
|
|
529
|
+
meta = request[:_meta]
|
|
530
|
+
if meta && server_context.is_a?(Hash)
|
|
531
|
+
context = server_context.dup
|
|
532
|
+
context[:_meta] = meta
|
|
533
|
+
context
|
|
534
|
+
elsif meta && server_context.nil?
|
|
535
|
+
{ _meta: meta }
|
|
536
|
+
else
|
|
537
|
+
server_context
|
|
538
|
+
end
|
|
539
|
+
end
|
|
485
540
|
end
|
|
486
541
|
end
|