mcp 0.8.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 +176 -5
- data/lib/mcp/client/stdio.rb +222 -0
- data/lib/mcp/client.rb +21 -3
- data/lib/mcp/progress.rb +22 -0
- data/lib/mcp/prompt.rb +4 -0
- 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 +140 -31
- data/lib/mcp/server/transports.rb +10 -0
- data/lib/mcp/server.rb +71 -39
- data/lib/mcp/server_context.rb +44 -0
- data/lib/mcp/server_session.rb +79 -0
- 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 -36
- data/.gitattributes +0 -4
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/ci.yml +0 -54
- data/.github/workflows/conformance.yml +0 -29
- data/.github/workflows/release.yml +0 -57
- data/.gitignore +0 -11
- data/.rubocop.yml +0 -15
- data/AGENTS.md +0 -107
- data/CHANGELOG.md +0 -168
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -29
- data/RELEASE.md +0 -12
- data/Rakefile +0 -56
- data/SECURITY.md +0 -21
- data/bin/console +0 -15
- data/bin/generate-gh-pages.sh +0 -119
- data/bin/rake +0 -31
- data/bin/setup +0 -8
- data/conformance/README.md +0 -103
- data/conformance/expected_failures.yml +0 -9
- data/conformance/runner.rb +0 -101
- data/conformance/server.rb +0 -547
- 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,25 +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
|
|
22
33
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
34
|
+
SESSION_REAP_INTERVAL = 60
|
|
23
35
|
|
|
24
36
|
def handle_request(request)
|
|
25
37
|
case request.env["REQUEST_METHOD"]
|
|
@@ -35,6 +47,9 @@ module MCP
|
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
def close
|
|
50
|
+
@reaper_thread&.kill
|
|
51
|
+
@reaper_thread = nil
|
|
52
|
+
|
|
38
53
|
@mutex.synchronize do
|
|
39
54
|
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
|
40
55
|
end
|
|
@@ -56,6 +71,11 @@ module MCP
|
|
|
56
71
|
session = @sessions[session_id]
|
|
57
72
|
return false unless session && session[:stream]
|
|
58
73
|
|
|
74
|
+
if session_expired?(session)
|
|
75
|
+
cleanup_session_unsafe(session_id)
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
59
79
|
begin
|
|
60
80
|
send_to_stream(session[:stream], notification)
|
|
61
81
|
true
|
|
@@ -75,6 +95,11 @@ module MCP
|
|
|
75
95
|
@sessions.each do |sid, session|
|
|
76
96
|
next unless session[:stream]
|
|
77
97
|
|
|
98
|
+
if session_expired?(session)
|
|
99
|
+
failed_sessions << sid
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
78
103
|
begin
|
|
79
104
|
send_to_stream(session[:stream], notification)
|
|
80
105
|
sent_count += 1
|
|
@@ -97,6 +122,39 @@ module MCP
|
|
|
97
122
|
|
|
98
123
|
private
|
|
99
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
|
+
|
|
100
158
|
def send_to_stream(stream, data)
|
|
101
159
|
message = data.is_a?(String) ? data : data.to_json
|
|
102
160
|
stream.write("data: #{message}\n\n")
|
|
@@ -120,10 +178,14 @@ module MCP
|
|
|
120
178
|
|
|
121
179
|
if body["method"] == "initialize"
|
|
122
180
|
handle_initialization(body_string, body)
|
|
123
|
-
elsif notification?(body) || response?(body)
|
|
124
|
-
handle_accepted
|
|
125
181
|
else
|
|
126
|
-
|
|
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
|
|
127
189
|
end
|
|
128
190
|
rescue StandardError => e
|
|
129
191
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -141,7 +203,10 @@ module MCP
|
|
|
141
203
|
session_id = extract_session_id(request)
|
|
142
204
|
|
|
143
205
|
return missing_session_id_response unless session_id
|
|
144
|
-
|
|
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)
|
|
145
210
|
|
|
146
211
|
setup_sse_stream(session_id)
|
|
147
212
|
end
|
|
@@ -154,15 +219,11 @@ module MCP
|
|
|
154
219
|
return success_response
|
|
155
220
|
end
|
|
156
221
|
|
|
157
|
-
session_id = request.env["HTTP_MCP_SESSION_ID"]
|
|
158
|
-
|
|
159
|
-
return [
|
|
160
|
-
400,
|
|
161
|
-
{ "Content-Type" => "application/json" },
|
|
162
|
-
[{ error: "Missing session ID" }.to_json],
|
|
163
|
-
] 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)
|
|
164
224
|
|
|
165
225
|
cleanup_session(session_id)
|
|
226
|
+
|
|
166
227
|
success_response
|
|
167
228
|
end
|
|
168
229
|
|
|
@@ -193,6 +254,8 @@ module MCP
|
|
|
193
254
|
return not_acceptable_response(required_types) unless accept_header
|
|
194
255
|
|
|
195
256
|
accepted_types = parse_accept_header(accept_header)
|
|
257
|
+
return if accepted_types.include?("*/*")
|
|
258
|
+
|
|
196
259
|
missing_types = required_types - accepted_types
|
|
197
260
|
return not_acceptable_response(required_types) unless missing_types.empty?
|
|
198
261
|
|
|
@@ -229,18 +292,26 @@ module MCP
|
|
|
229
292
|
|
|
230
293
|
def handle_initialization(body_string, body)
|
|
231
294
|
session_id = nil
|
|
295
|
+
server_session = nil
|
|
232
296
|
|
|
233
297
|
unless @stateless
|
|
234
298
|
session_id = SecureRandom.uuid
|
|
299
|
+
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
235
300
|
|
|
236
301
|
@mutex.synchronize do
|
|
237
302
|
@sessions[session_id] = {
|
|
238
303
|
stream: nil,
|
|
304
|
+
server_session: server_session,
|
|
305
|
+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
239
306
|
}
|
|
240
307
|
end
|
|
241
308
|
end
|
|
242
309
|
|
|
243
|
-
response =
|
|
310
|
+
response = if server_session
|
|
311
|
+
server_session.handle_json(body_string)
|
|
312
|
+
else
|
|
313
|
+
@server.handle_json(body_string)
|
|
314
|
+
end
|
|
244
315
|
|
|
245
316
|
headers = {
|
|
246
317
|
"Content-Type" => "application/json",
|
|
@@ -256,30 +327,49 @@ module MCP
|
|
|
256
327
|
end
|
|
257
328
|
|
|
258
329
|
def handle_regular_request(body_string, session_id)
|
|
330
|
+
server_session = nil
|
|
331
|
+
stream = nil
|
|
332
|
+
|
|
259
333
|
unless @stateless
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
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
|
|
263
343
|
end
|
|
264
344
|
end
|
|
265
345
|
|
|
266
|
-
response =
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
346
|
+
response = if server_session
|
|
347
|
+
server_session.handle_json(body_string)
|
|
348
|
+
else
|
|
349
|
+
@server.handle_json(body_string)
|
|
350
|
+
end
|
|
270
351
|
|
|
271
352
|
if stream
|
|
272
353
|
send_response_to_stream(stream, response, session_id)
|
|
273
|
-
elsif response.nil? && notification_request?(body_string)
|
|
274
|
-
[202, { "Content-Type" => "application/json" }, [response]]
|
|
275
354
|
else
|
|
276
355
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
277
356
|
end
|
|
278
357
|
end
|
|
279
358
|
|
|
280
|
-
def
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
283
373
|
end
|
|
284
374
|
|
|
285
375
|
def get_session_stream(session_id)
|
|
@@ -315,6 +405,14 @@ module MCP
|
|
|
315
405
|
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
|
316
406
|
end
|
|
317
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
|
+
|
|
318
416
|
def setup_sse_stream(session_id)
|
|
319
417
|
body = create_sse_body(session_id)
|
|
320
418
|
|
|
@@ -329,17 +427,22 @@ module MCP
|
|
|
329
427
|
|
|
330
428
|
def create_sse_body(session_id)
|
|
331
429
|
proc do |stream|
|
|
332
|
-
store_stream_for_session(session_id, stream)
|
|
333
|
-
start_keepalive_thread(session_id)
|
|
430
|
+
stored = store_stream_for_session(session_id, stream)
|
|
431
|
+
start_keepalive_thread(session_id) if stored
|
|
334
432
|
end
|
|
335
433
|
end
|
|
336
434
|
|
|
337
435
|
def store_stream_for_session(session_id, stream)
|
|
338
436
|
@mutex.synchronize do
|
|
339
|
-
|
|
340
|
-
|
|
437
|
+
session = @sessions[session_id]
|
|
438
|
+
if session && !session[:stream]
|
|
439
|
+
session[:stream] = stream
|
|
341
440
|
else
|
|
441
|
+
# Either session was removed, or another request already established a stream.
|
|
342
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
|
|
343
446
|
end
|
|
344
447
|
end
|
|
345
448
|
end
|
|
@@ -374,6 +477,12 @@ module MCP
|
|
|
374
477
|
)
|
|
375
478
|
raise # Re-raise to exit the keepalive loop
|
|
376
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
|
|
377
486
|
end
|
|
378
487
|
end
|
|
379
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,6 +99,7 @@ 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
|
|
@@ -107,15 +111,29 @@ module MCP
|
|
|
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!
|
|
@@ -278,11 +278,12 @@ module MCP
|
|
|
278
278
|
end
|
|
279
279
|
end
|
|
280
280
|
|
|
281
|
-
def handle_request(request, method)
|
|
281
|
+
def handle_request(request, method, session: nil)
|
|
282
282
|
handler = @handlers[method]
|
|
283
283
|
unless handler
|
|
284
284
|
instrument_call("unsupported_method") do
|
|
285
|
-
|
|
285
|
+
client = session&.client || @client
|
|
286
|
+
add_instrumentation_data(client: client) if client
|
|
286
287
|
end
|
|
287
288
|
return
|
|
288
289
|
end
|
|
@@ -292,6 +293,8 @@ module MCP
|
|
|
292
293
|
->(params) {
|
|
293
294
|
instrument_call(method) do
|
|
294
295
|
result = case method
|
|
296
|
+
when Methods::INITIALIZE
|
|
297
|
+
init(params, session: session)
|
|
295
298
|
when Methods::TOOLS_LIST
|
|
296
299
|
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
|
|
297
300
|
when Methods::PROMPTS_LIST
|
|
@@ -302,10 +305,15 @@ module MCP
|
|
|
302
305
|
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
|
|
303
306
|
when Methods::RESOURCES_TEMPLATES_LIST
|
|
304
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)
|
|
305
312
|
else
|
|
306
313
|
@handlers[method].call(params)
|
|
307
314
|
end
|
|
308
|
-
|
|
315
|
+
client = session&.client || @client
|
|
316
|
+
add_instrumentation_data(client: client) if client
|
|
309
317
|
|
|
310
318
|
result
|
|
311
319
|
rescue => e
|
|
@@ -341,8 +349,14 @@ module MCP
|
|
|
341
349
|
}.compact
|
|
342
350
|
end
|
|
343
351
|
|
|
344
|
-
def init(params)
|
|
345
|
-
|
|
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
|
|
346
360
|
|
|
347
361
|
protocol_version = params[:protocolVersion] if params
|
|
348
362
|
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
@@ -370,7 +384,7 @@ module MCP
|
|
|
370
384
|
}.compact
|
|
371
385
|
end
|
|
372
386
|
|
|
373
|
-
def configure_logging_level(request)
|
|
387
|
+
def configure_logging_level(request, session: nil)
|
|
374
388
|
if capabilities[:logging].nil?
|
|
375
389
|
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
|
|
376
390
|
end
|
|
@@ -380,6 +394,7 @@ module MCP
|
|
|
380
394
|
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
|
|
381
395
|
end
|
|
382
396
|
|
|
397
|
+
session&.configure_logging(logging_message_notification)
|
|
383
398
|
@logging_message_notification = logging_message_notification
|
|
384
399
|
|
|
385
400
|
{}
|
|
@@ -389,7 +404,7 @@ module MCP
|
|
|
389
404
|
@tools.values.map(&:to_h)
|
|
390
405
|
end
|
|
391
406
|
|
|
392
|
-
def call_tool(request)
|
|
407
|
+
def call_tool(request, session: nil)
|
|
393
408
|
tool_name = request[:name]
|
|
394
409
|
|
|
395
410
|
tool = tools[tool_name]
|
|
@@ -419,7 +434,9 @@ module MCP
|
|
|
419
434
|
end
|
|
420
435
|
end
|
|
421
436
|
|
|
422
|
-
|
|
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)
|
|
423
440
|
rescue RequestHandlerError
|
|
424
441
|
raise
|
|
425
442
|
rescue => e
|
|
@@ -445,7 +462,7 @@ module MCP
|
|
|
445
462
|
prompt_args = request[:arguments]
|
|
446
463
|
prompt.validate_arguments!(prompt_args)
|
|
447
464
|
|
|
448
|
-
call_prompt_template_with_args(prompt, prompt_args)
|
|
465
|
+
call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
|
|
449
466
|
end
|
|
450
467
|
|
|
451
468
|
def list_resources(request)
|
|
@@ -488,22 +505,37 @@ module MCP
|
|
|
488
505
|
parameters.any? { |type, name| type == :keyrest || name == :server_context }
|
|
489
506
|
end
|
|
490
507
|
|
|
491
|
-
def call_tool_with_args(tool, arguments)
|
|
508
|
+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
|
|
492
509
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
493
510
|
|
|
494
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)
|
|
495
514
|
tool.call(**args, server_context: server_context).to_h
|
|
496
515
|
else
|
|
497
516
|
tool.call(**args).to_h
|
|
498
517
|
end
|
|
499
518
|
end
|
|
500
519
|
|
|
501
|
-
def call_prompt_template_with_args(prompt, args)
|
|
520
|
+
def call_prompt_template_with_args(prompt, args, server_context)
|
|
502
521
|
if accepts_server_context?(prompt.method(:template))
|
|
503
522
|
prompt.template(args, server_context: server_context).to_h
|
|
504
523
|
else
|
|
505
524
|
prompt.template(args).to_h
|
|
506
525
|
end
|
|
507
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
|
|
508
540
|
end
|
|
509
541
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class ServerContext
|
|
5
|
+
def initialize(context, progress:, notification_target:)
|
|
6
|
+
@context = context
|
|
7
|
+
@progress = progress
|
|
8
|
+
@notification_target = notification_target
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Reports progress for the current tool operation.
|
|
12
|
+
# The notification is automatically scoped to the originating session.
|
|
13
|
+
#
|
|
14
|
+
# @param progress [Numeric] Current progress value.
|
|
15
|
+
# @param total [Numeric, nil] Total expected value.
|
|
16
|
+
# @param message [String, nil] Human-readable status message.
|
|
17
|
+
def report_progress(progress, total: nil, message: nil)
|
|
18
|
+
@progress.report(progress, total: total, message: message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sends a log message notification scoped to the originating session.
|
|
22
|
+
#
|
|
23
|
+
# @param data [Object] The log data to send.
|
|
24
|
+
# @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
|
|
25
|
+
# @param logger [String, nil] Logger name.
|
|
26
|
+
def notify_log_message(data:, level:, logger: nil)
|
|
27
|
+
return unless @notification_target
|
|
28
|
+
|
|
29
|
+
@notification_target.notify_log_message(data: data, level: level, logger: logger)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def method_missing(name, ...)
|
|
33
|
+
if @context.respond_to?(name)
|
|
34
|
+
@context.public_send(name, ...)
|
|
35
|
+
else
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def respond_to_missing?(name, include_private = false)
|
|
41
|
+
@context.respond_to?(name) || super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "methods"
|
|
4
|
+
|
|
5
|
+
module MCP
|
|
6
|
+
# Holds per-connection state for a single client session.
|
|
7
|
+
# Created by the transport layer; delegates request handling to the shared `Server`.
|
|
8
|
+
class ServerSession
|
|
9
|
+
attr_reader :session_id, :client, :logging_message_notification
|
|
10
|
+
|
|
11
|
+
def initialize(server:, transport:, session_id: nil)
|
|
12
|
+
@server = server
|
|
13
|
+
@transport = transport
|
|
14
|
+
@session_id = session_id
|
|
15
|
+
@client = nil
|
|
16
|
+
@client_capabilities = nil # TODO: Use for per-session capability validation.
|
|
17
|
+
@logging_message_notification = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle(request)
|
|
21
|
+
@server.handle(request, session: self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle_json(request_json)
|
|
25
|
+
@server.handle_json(request_json, session: self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Called by `Server#init` during the initialization handshake.
|
|
29
|
+
def store_client_info(client:, capabilities: nil)
|
|
30
|
+
@client = client
|
|
31
|
+
@client_capabilities = capabilities
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Called by `Server#configure_logging_level`.
|
|
35
|
+
def configure_logging(logging_message_notification)
|
|
36
|
+
@logging_message_notification = logging_message_notification
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sends a progress notification to this session only.
|
|
40
|
+
def notify_progress(progress_token:, progress:, total: nil, message: nil)
|
|
41
|
+
params = {
|
|
42
|
+
"progressToken" => progress_token,
|
|
43
|
+
"progress" => progress,
|
|
44
|
+
"total" => total,
|
|
45
|
+
"message" => message,
|
|
46
|
+
}.compact
|
|
47
|
+
|
|
48
|
+
send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params)
|
|
49
|
+
rescue => e
|
|
50
|
+
@server.report_exception(e, notification: "progress")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sends a log message notification to this session only.
|
|
54
|
+
def notify_log_message(data:, level:, logger: nil)
|
|
55
|
+
effective_logging = @logging_message_notification || @server.logging_message_notification
|
|
56
|
+
return unless effective_logging&.should_notify?(level)
|
|
57
|
+
|
|
58
|
+
params = { "data" => data, "level" => level }
|
|
59
|
+
params["logger"] = logger if logger
|
|
60
|
+
|
|
61
|
+
send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params)
|
|
62
|
+
rescue => e
|
|
63
|
+
@server.report_exception(e, { notification: "log_message" })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# TODO: When Ruby 2.7 support is dropped, replace with a direct call:
|
|
69
|
+
# `@transport.send_notification(method, params, session_id: @session_id)` and
|
|
70
|
+
# add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
|
|
71
|
+
def send_to_transport(method, params)
|
|
72
|
+
if @session_id
|
|
73
|
+
@transport.send_notification(method, params, session_id: @session_id)
|
|
74
|
+
else
|
|
75
|
+
@transport.send_notification(method, params)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|