mcp 0.13.0 → 0.15.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 +373 -6
- data/lib/json_rpc_handler.rb +6 -0
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +230 -14
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +235 -22
- data/lib/mcp/methods.rb +2 -4
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +7 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +82 -7
- data/lib/mcp/server.rb +204 -33
- data/lib/mcp/server_context.rb +30 -1
- data/lib/mcp/server_session.rb +121 -20
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +8 -5
- data/lib/mcp/transports/stdio.rb +0 -15
data/lib/mcp/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client/stdio"
|
|
4
4
|
require_relative "client/http"
|
|
5
|
+
require_relative "client/paginated_result"
|
|
5
6
|
require_relative "client/tool"
|
|
6
7
|
|
|
7
8
|
module MCP
|
|
@@ -27,6 +28,21 @@ module MCP
|
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
|
|
31
|
+
# Raised when a server response fails client-side validation, e.g., a success response
|
|
32
|
+
# whose `result` field is missing or has the wrong type. This is distinct from a
|
|
33
|
+
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
|
34
|
+
class ValidationError < StandardError; end
|
|
35
|
+
|
|
36
|
+
# Raised when the server responds 404 to a request containing a session ID,
|
|
37
|
+
# indicating the session has expired. Inherits from `RequestHandlerError` for
|
|
38
|
+
# backward compatibility with callers that rescue the generic error. Per spec,
|
|
39
|
+
# clients MUST start a new session with a fresh `initialize` request in response.
|
|
40
|
+
class SessionExpiredError < RequestHandlerError
|
|
41
|
+
def initialize(message, request, original_error: nil)
|
|
42
|
+
super(message, request, error_type: :not_found, original_error: original_error)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
30
46
|
# Initializes a new MCP::Client instance.
|
|
31
47
|
#
|
|
32
48
|
# @param transport [Object] The transport object to use for communication with the server.
|
|
@@ -43,8 +59,81 @@ module MCP
|
|
|
43
59
|
# So keeping it public
|
|
44
60
|
attr_reader :transport
|
|
45
61
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
62
|
+
# The server's `InitializeResult` (protocol version, capabilities, server info,
|
|
63
|
+
# instructions), as reported by the transport after a successful `connect`.
|
|
64
|
+
# Returns `nil` before `connect`, after `close`, or when the transport does
|
|
65
|
+
# not expose a cached handshake result.
|
|
66
|
+
def server_info
|
|
67
|
+
transport.server_info if transport.respond_to?(:server_info)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Performs the MCP `initialize` handshake by delegating to the transport
|
|
71
|
+
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
|
|
72
|
+
# `InitializeResult`.
|
|
73
|
+
#
|
|
74
|
+
# When the transport does not respond to `:connect`, this is a no-op and
|
|
75
|
+
# returns `nil`.
|
|
76
|
+
#
|
|
77
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
78
|
+
# @param protocol_version [String, nil] Protocol version to offer.
|
|
79
|
+
# @param capabilities [Hash] Capabilities advertised by the client.
|
|
80
|
+
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
|
81
|
+
# does not expose an explicit handshake.
|
|
82
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
83
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
84
|
+
return unless transport.respond_to?(:connect)
|
|
85
|
+
|
|
86
|
+
transport.connect(
|
|
87
|
+
client_info: client_info,
|
|
88
|
+
protocol_version: protocol_version,
|
|
89
|
+
capabilities: capabilities,
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns true once `connect` has completed the handshake on the underlying
|
|
94
|
+
# transport. Transports that do not expose connection state are assumed
|
|
95
|
+
# connected and return `true`.
|
|
96
|
+
def connected?
|
|
97
|
+
return transport.connected? if transport.respond_to?(:connected?)
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns a single page of tools from the server.
|
|
103
|
+
#
|
|
104
|
+
# @param cursor [String, nil] Cursor from a previous page response.
|
|
105
|
+
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
|
106
|
+
# and `next_cursor` (String or nil).
|
|
107
|
+
#
|
|
108
|
+
# @example Iterate all pages
|
|
109
|
+
# cursor = nil
|
|
110
|
+
# loop do
|
|
111
|
+
# page = client.list_tools(cursor: cursor)
|
|
112
|
+
# page.tools.each { |tool| puts tool.name }
|
|
113
|
+
# cursor = page.next_cursor
|
|
114
|
+
# break unless cursor
|
|
115
|
+
# end
|
|
116
|
+
def list_tools(cursor: nil)
|
|
117
|
+
params = cursor ? { cursor: cursor } : nil
|
|
118
|
+
response = request(method: "tools/list", params: params)
|
|
119
|
+
result = response["result"] || {}
|
|
120
|
+
|
|
121
|
+
tools = (result["tools"] || []).map do |tool|
|
|
122
|
+
Tool.new(
|
|
123
|
+
name: tool["name"],
|
|
124
|
+
description: tool["description"],
|
|
125
|
+
input_schema: tool["inputSchema"],
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Returns every tool available on the server. Iterates through all pages automatically
|
|
133
|
+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
|
|
134
|
+
# Use {#list_tools} when you need fine-grained cursor control.
|
|
135
|
+
#
|
|
136
|
+
# Each call will make a new request - the result is not cached.
|
|
48
137
|
#
|
|
49
138
|
# @return [Array<MCP::Client::Tool>] An array of available tools.
|
|
50
139
|
#
|
|
@@ -54,45 +143,151 @@ module MCP
|
|
|
54
143
|
# puts tool.name
|
|
55
144
|
# end
|
|
56
145
|
def tools
|
|
57
|
-
|
|
146
|
+
# TODO: consider renaming to `list_all_tools`.
|
|
147
|
+
all_tools = []
|
|
148
|
+
seen = Set.new
|
|
149
|
+
cursor = nil
|
|
58
150
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
151
|
+
loop do
|
|
152
|
+
page = list_tools(cursor: cursor)
|
|
153
|
+
all_tools.concat(page.tools)
|
|
154
|
+
next_cursor = page.next_cursor
|
|
155
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
156
|
+
|
|
157
|
+
seen << next_cursor
|
|
158
|
+
cursor = next_cursor
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
all_tools
|
|
66
162
|
end
|
|
67
163
|
|
|
68
|
-
# Returns
|
|
69
|
-
#
|
|
164
|
+
# Returns a single page of resources from the server.
|
|
165
|
+
#
|
|
166
|
+
# @param cursor [String, nil] Cursor from a previous page response.
|
|
167
|
+
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
|
168
|
+
# and `next_cursor` (String or nil).
|
|
169
|
+
def list_resources(cursor: nil)
|
|
170
|
+
params = cursor ? { cursor: cursor } : nil
|
|
171
|
+
response = request(method: "resources/list", params: params)
|
|
172
|
+
result = response["result"] || {}
|
|
173
|
+
|
|
174
|
+
ListResourcesResult.new(
|
|
175
|
+
resources: result["resources"] || [],
|
|
176
|
+
next_cursor: result["nextCursor"],
|
|
177
|
+
meta: result["_meta"],
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Returns every resource available on the server. Iterates through all pages automatically
|
|
182
|
+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
|
|
183
|
+
# Use {#list_resources} when you need fine-grained cursor control.
|
|
184
|
+
#
|
|
185
|
+
# Each call will make a new request - the result is not cached.
|
|
70
186
|
#
|
|
71
187
|
# @return [Array<Hash>] An array of available resources.
|
|
72
188
|
def resources
|
|
73
|
-
|
|
189
|
+
# TODO: consider renaming to `list_all_resources`.
|
|
190
|
+
all_resources = []
|
|
191
|
+
seen = Set.new
|
|
192
|
+
cursor = nil
|
|
74
193
|
|
|
75
|
-
|
|
194
|
+
loop do
|
|
195
|
+
page = list_resources(cursor: cursor)
|
|
196
|
+
all_resources.concat(page.resources)
|
|
197
|
+
next_cursor = page.next_cursor
|
|
198
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
199
|
+
|
|
200
|
+
seen << next_cursor
|
|
201
|
+
cursor = next_cursor
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
all_resources
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Returns a single page of resource templates from the server.
|
|
208
|
+
#
|
|
209
|
+
# @param cursor [String, nil] Cursor from a previous page response.
|
|
210
|
+
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
|
211
|
+
# (Array<Hash>) and `next_cursor` (String or nil).
|
|
212
|
+
def list_resource_templates(cursor: nil)
|
|
213
|
+
params = cursor ? { cursor: cursor } : nil
|
|
214
|
+
response = request(method: "resources/templates/list", params: params)
|
|
215
|
+
result = response["result"] || {}
|
|
216
|
+
|
|
217
|
+
ListResourceTemplatesResult.new(
|
|
218
|
+
resource_templates: result["resourceTemplates"] || [],
|
|
219
|
+
next_cursor: result["nextCursor"],
|
|
220
|
+
meta: result["_meta"],
|
|
221
|
+
)
|
|
76
222
|
end
|
|
77
223
|
|
|
78
|
-
# Returns
|
|
79
|
-
#
|
|
224
|
+
# Returns every resource template available on the server. Iterates through all pages automatically
|
|
225
|
+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
|
|
226
|
+
# Use {#list_resource_templates} when you need fine-grained cursor control.
|
|
227
|
+
#
|
|
228
|
+
# Each call will make a new request - the result is not cached.
|
|
80
229
|
#
|
|
81
230
|
# @return [Array<Hash>] An array of available resource templates.
|
|
82
231
|
def resource_templates
|
|
83
|
-
|
|
232
|
+
# TODO: consider renaming to `list_all_resource_templates`.
|
|
233
|
+
all_templates = []
|
|
234
|
+
seen = Set.new
|
|
235
|
+
cursor = nil
|
|
84
236
|
|
|
85
|
-
|
|
237
|
+
loop do
|
|
238
|
+
page = list_resource_templates(cursor: cursor)
|
|
239
|
+
all_templates.concat(page.resource_templates)
|
|
240
|
+
next_cursor = page.next_cursor
|
|
241
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
242
|
+
|
|
243
|
+
seen << next_cursor
|
|
244
|
+
cursor = next_cursor
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
all_templates
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Returns a single page of prompts from the server.
|
|
251
|
+
#
|
|
252
|
+
# @param cursor [String, nil] Cursor from a previous page response.
|
|
253
|
+
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
|
254
|
+
# and `next_cursor` (String or nil).
|
|
255
|
+
def list_prompts(cursor: nil)
|
|
256
|
+
params = cursor ? { cursor: cursor } : nil
|
|
257
|
+
response = request(method: "prompts/list", params: params)
|
|
258
|
+
result = response["result"] || {}
|
|
259
|
+
|
|
260
|
+
ListPromptsResult.new(
|
|
261
|
+
prompts: result["prompts"] || [],
|
|
262
|
+
next_cursor: result["nextCursor"],
|
|
263
|
+
meta: result["_meta"],
|
|
264
|
+
)
|
|
86
265
|
end
|
|
87
266
|
|
|
88
|
-
# Returns
|
|
89
|
-
#
|
|
267
|
+
# Returns every prompt available on the server. Iterates through all pages automatically
|
|
268
|
+
# when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
|
|
269
|
+
# Use {#list_prompts} when you need fine-grained cursor control.
|
|
270
|
+
#
|
|
271
|
+
# Each call will make a new request - the result is not cached.
|
|
90
272
|
#
|
|
91
273
|
# @return [Array<Hash>] An array of available prompts.
|
|
92
274
|
def prompts
|
|
93
|
-
|
|
275
|
+
# TODO: consider renaming to `list_all_prompts`.
|
|
276
|
+
all_prompts = []
|
|
277
|
+
seen = Set.new
|
|
278
|
+
cursor = nil
|
|
279
|
+
|
|
280
|
+
loop do
|
|
281
|
+
page = list_prompts(cursor: cursor)
|
|
282
|
+
all_prompts.concat(page.prompts)
|
|
283
|
+
next_cursor = page.next_cursor
|
|
284
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
285
|
+
|
|
286
|
+
seen << next_cursor
|
|
287
|
+
cursor = next_cursor
|
|
288
|
+
end
|
|
94
289
|
|
|
95
|
-
|
|
290
|
+
all_prompts
|
|
96
291
|
end
|
|
97
292
|
|
|
98
293
|
# Calls a tool via the transport layer and returns the full response from the server.
|
|
@@ -163,6 +358,24 @@ module MCP
|
|
|
163
358
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
164
359
|
end
|
|
165
360
|
|
|
361
|
+
# Sends a `ping` request to the server to verify the connection is alive.
|
|
362
|
+
# Per the MCP spec, the server responds with an empty result.
|
|
363
|
+
#
|
|
364
|
+
# @return [Hash] An empty hash on success.
|
|
365
|
+
# @raise [ServerError] If the server returns a JSON-RPC error.
|
|
366
|
+
# @raise [ValidationError] If the response `result` is missing or not a Hash.
|
|
367
|
+
#
|
|
368
|
+
# @example
|
|
369
|
+
# client.ping # => {}
|
|
370
|
+
#
|
|
371
|
+
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
|
372
|
+
def ping
|
|
373
|
+
result = request(method: Methods::PING)["result"]
|
|
374
|
+
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
375
|
+
|
|
376
|
+
result
|
|
377
|
+
end
|
|
378
|
+
|
|
166
379
|
private
|
|
167
380
|
|
|
168
381
|
def request(method:, params: nil)
|
data/lib/mcp/methods.rb
CHANGED
|
@@ -73,14 +73,12 @@ module MCP
|
|
|
73
73
|
require_capability!(method, capabilities, :completions)
|
|
74
74
|
when ROOTS_LIST
|
|
75
75
|
require_capability!(method, capabilities, :roots)
|
|
76
|
-
when NOTIFICATIONS_ROOTS_LIST_CHANGED
|
|
77
|
-
require_capability!(method, capabilities, :roots)
|
|
78
|
-
require_capability!(method, capabilities, :roots, :listChanged)
|
|
79
76
|
when SAMPLING_CREATE_MESSAGE
|
|
80
77
|
require_capability!(method, capabilities, :sampling)
|
|
81
78
|
when ELICITATION_CREATE
|
|
82
79
|
require_capability!(method, capabilities, :elicitation)
|
|
83
|
-
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED,
|
|
80
|
+
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
81
|
+
NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
|
|
84
82
|
# No specific capability required.
|
|
85
83
|
end
|
|
86
84
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Server
|
|
5
|
+
module Pagination
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def cursor_from(request)
|
|
9
|
+
return if request.nil?
|
|
10
|
+
|
|
11
|
+
unless request.is_a?(Hash)
|
|
12
|
+
raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
request[:cursor]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def paginate(items, cursor:, page_size:, request:, &block)
|
|
19
|
+
start_index = 0
|
|
20
|
+
|
|
21
|
+
if cursor
|
|
22
|
+
unless cursor.is_a?(String)
|
|
23
|
+
raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
start_index = Integer(cursor, exception: false)
|
|
27
|
+
if start_index.nil? || start_index < 0 || start_index >= items.size
|
|
28
|
+
raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end_index = page_size ? start_index + page_size : items.size
|
|
33
|
+
page = items[start_index...end_index]
|
|
34
|
+
page = page.map(&block) if block
|
|
35
|
+
|
|
36
|
+
result = { items: page }
|
|
37
|
+
result[:next_cursor] = end_index.to_s if end_index < items.size
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -54,6 +54,13 @@ module MCP
|
|
|
54
54
|
false
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# NOTE: This signature deliberately matches the abstract `Transport#send_request` contract
|
|
58
|
+
# (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts.
|
|
59
|
+
# On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with
|
|
60
|
+
# explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be
|
|
61
|
+
# auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request
|
|
62
|
+
# cancellation has very limited value here regardless; servers that need cancellation propagation for nested
|
|
63
|
+
# server-to-client requests should use `StreamableHTTPTransport`.
|
|
57
64
|
def send_request(method, params = nil)
|
|
58
65
|
request_id = generate_request_id
|
|
59
66
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
@@ -22,13 +22,14 @@ module MCP
|
|
|
22
22
|
"Connection" => "keep-alive",
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
|
-
def initialize(server, stateless: false, session_idle_timeout: nil)
|
|
25
|
+
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
|
|
26
26
|
super(server)
|
|
27
27
|
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
28
28
|
@sessions = {}
|
|
29
29
|
@mutex = Mutex.new
|
|
30
30
|
|
|
31
31
|
@stateless = stateless
|
|
32
|
+
@enable_json_response = enable_json_response
|
|
32
33
|
@session_idle_timeout = session_idle_timeout
|
|
33
34
|
@pending_responses = {}
|
|
34
35
|
|
|
@@ -43,7 +44,8 @@ module MCP
|
|
|
43
44
|
start_reaper_thread if @session_idle_timeout
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
|
|
48
|
+
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
|
|
47
49
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
48
50
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
49
51
|
SESSION_REAP_INTERVAL = 60
|
|
@@ -94,6 +96,12 @@ module MCP
|
|
|
94
96
|
|
|
95
97
|
result = @mutex.synchronize do
|
|
96
98
|
if session_id
|
|
99
|
+
# JSON response mode returns a single JSON object as the POST response,
|
|
100
|
+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
|
|
101
|
+
# alongside it. Session-scoped standalone notifications
|
|
102
|
+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
|
|
103
|
+
next false if @enable_json_response && related_request_id
|
|
104
|
+
|
|
97
105
|
# Send to specific session
|
|
98
106
|
if (session = @sessions[session_id])
|
|
99
107
|
stream = active_stream(session, related_request_id: related_request_id)
|
|
@@ -167,17 +175,22 @@ module MCP
|
|
|
167
175
|
# sends the request via SSE stream, then blocks on `queue.pop`.
|
|
168
176
|
# When the client POSTs a response, `handle_response` matches it by `request_id`
|
|
169
177
|
# and pushes the result onto the queue, unblocking this thread.
|
|
170
|
-
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
|
|
178
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
|
|
171
179
|
if @stateless
|
|
172
180
|
raise "Stateless mode does not support server-to-client requests."
|
|
173
181
|
end
|
|
174
182
|
|
|
183
|
+
if @enable_json_response
|
|
184
|
+
raise "JSON response mode does not support server-to-client requests."
|
|
185
|
+
end
|
|
186
|
+
|
|
175
187
|
unless session_id
|
|
176
188
|
raise "session_id is required for server-to-client requests."
|
|
177
189
|
end
|
|
178
190
|
|
|
179
191
|
request_id = generate_request_id
|
|
180
192
|
queue = Queue.new
|
|
193
|
+
cancel_hook = nil
|
|
181
194
|
|
|
182
195
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
183
196
|
request[:params] = params if params
|
|
@@ -217,6 +230,16 @@ module MCP
|
|
|
217
230
|
raise "No active stream for #{method} request."
|
|
218
231
|
end
|
|
219
232
|
|
|
233
|
+
if parent_cancellation && server_session
|
|
234
|
+
cancel_hook = parent_cancellation.on_cancel do |reason|
|
|
235
|
+
server_session.send_peer_cancellation(
|
|
236
|
+
nested_request_id: request_id,
|
|
237
|
+
related_request_id: related_request_id,
|
|
238
|
+
reason: reason,
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
220
243
|
response = queue.pop
|
|
221
244
|
|
|
222
245
|
if response.is_a?(Hash) && response.key?(:error)
|
|
@@ -227,8 +250,18 @@ module MCP
|
|
|
227
250
|
raise "SSE session closed while waiting for #{method} response."
|
|
228
251
|
end
|
|
229
252
|
|
|
253
|
+
if response == :cancelled
|
|
254
|
+
reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
|
|
255
|
+
raise MCP::CancelledError.new(
|
|
256
|
+
"#{method} request was cancelled",
|
|
257
|
+
request_id: request_id,
|
|
258
|
+
reason: reason,
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
230
262
|
response
|
|
231
263
|
ensure
|
|
264
|
+
parent_cancellation.off_cancel(cancel_hook) if cancel_hook
|
|
232
265
|
if request_id
|
|
233
266
|
@mutex.synchronize do
|
|
234
267
|
@pending_responses.delete(request_id)
|
|
@@ -236,6 +269,24 @@ module MCP
|
|
|
236
269
|
end
|
|
237
270
|
end
|
|
238
271
|
|
|
272
|
+
# Unblocks a `send_request` awaiting a response when the peer is being cancelled.
|
|
273
|
+
# The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
|
|
274
|
+
#
|
|
275
|
+
# Race note: this is first-writer-wins on the pending-response queue. If a real response
|
|
276
|
+
# has already been pushed (client responded before the cancel hook fired), that response
|
|
277
|
+
# wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
|
|
278
|
+
# the real response and deletes the pending entry in its `ensure` block. Conversely,
|
|
279
|
+
# if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
|
|
280
|
+
# because the pending entry has been removed.
|
|
281
|
+
def cancel_pending_request(request_id, reason: nil)
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
if (pending = @pending_responses[request_id])
|
|
284
|
+
pending[:cancel_reason] = reason
|
|
285
|
+
pending[:queue].push(:cancelled)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
239
290
|
private
|
|
240
291
|
|
|
241
292
|
def start_reaper_thread
|
|
@@ -269,16 +320,17 @@ module MCP
|
|
|
269
320
|
def send_to_stream(stream, data)
|
|
270
321
|
message = data.is_a?(String) ? data : data.to_json
|
|
271
322
|
stream.write("data: #{message}\n\n")
|
|
272
|
-
stream.flush
|
|
323
|
+
stream.flush
|
|
273
324
|
end
|
|
274
325
|
|
|
275
326
|
def send_ping_to_stream(stream)
|
|
276
327
|
stream.write(": ping #{Time.now.iso8601}\n\n")
|
|
277
|
-
stream.flush
|
|
328
|
+
stream.flush
|
|
278
329
|
end
|
|
279
330
|
|
|
280
331
|
def handle_post(request)
|
|
281
|
-
|
|
332
|
+
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
|
|
333
|
+
accept_error = validate_accept_header(request, required_types)
|
|
282
334
|
return accept_error if accept_error
|
|
283
335
|
|
|
284
336
|
content_type_error = validate_content_type(request)
|
|
@@ -296,6 +348,7 @@ module MCP
|
|
|
296
348
|
return missing_session_id_response if !@stateless && !session_id
|
|
297
349
|
|
|
298
350
|
if notification?(body)
|
|
351
|
+
dispatch_notification(body_string, session_id)
|
|
299
352
|
handle_accepted
|
|
300
353
|
elsif response?(body)
|
|
301
354
|
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
@@ -446,6 +499,22 @@ module MCP
|
|
|
446
499
|
!body[:id] && !!body[:method]
|
|
447
500
|
end
|
|
448
501
|
|
|
502
|
+
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
503
|
+
# `notifications/initialized`) through the server so it can update session state.
|
|
504
|
+
def dispatch_notification(body_string, session_id)
|
|
505
|
+
server_session = nil
|
|
506
|
+
if session_id && !@stateless
|
|
507
|
+
@mutex.synchronize do
|
|
508
|
+
session = @sessions[session_id]
|
|
509
|
+
server_session = session[:server_session] if session
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
dispatch_handle_json(body_string, server_session)
|
|
514
|
+
rescue => e
|
|
515
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
|
|
516
|
+
end
|
|
517
|
+
|
|
449
518
|
def response?(body)
|
|
450
519
|
!!body[:id] && !body[:method]
|
|
451
520
|
end
|
|
@@ -519,10 +588,16 @@ module MCP
|
|
|
519
588
|
end
|
|
520
589
|
end
|
|
521
590
|
|
|
522
|
-
if session_id && !@stateless
|
|
591
|
+
if session_id && !@stateless && !@enable_json_response
|
|
523
592
|
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
524
593
|
else
|
|
525
594
|
response = dispatch_handle_json(body_string, server_session)
|
|
595
|
+
|
|
596
|
+
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
|
|
597
|
+
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
|
|
598
|
+
# which would produce an empty body the client cannot parse as JSON.
|
|
599
|
+
return handle_accepted if response.nil?
|
|
600
|
+
|
|
526
601
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
527
602
|
end
|
|
528
603
|
end
|