mcp 0.18.0 → 0.21.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 +131 -3
- data/lib/json_rpc_handler.rb +7 -2
- data/lib/mcp/annotations.rb +2 -0
- data/lib/mcp/client/http.rb +68 -7
- data/lib/mcp/client/oauth/client_credentials_provider.rb +89 -0
- data/lib/mcp/client/oauth/discovery.rb +86 -1
- data/lib/mcp/client/oauth/flow.rb +241 -25
- data/lib/mcp/client/oauth/provider.rb +45 -27
- data/lib/mcp/client/oauth/storage_backed_provider.rb +43 -0
- data/lib/mcp/client/oauth.rb +3 -1
- data/lib/mcp/client.rb +74 -80
- data/lib/mcp/configuration.rb +1 -0
- data/lib/mcp/resource.rb +6 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +78 -50
- data/lib/mcp/server.rb +38 -2
- data/lib/mcp/tool/input_schema.rb +2 -2
- data/lib/mcp/tool/schema.rb +38 -19
- data/lib/mcp/trace_context.rb +23 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -5
data/lib/mcp/client.rb
CHANGED
|
@@ -77,7 +77,9 @@ module MCP
|
|
|
77
77
|
#
|
|
78
78
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
79
79
|
# @param protocol_version [String, nil] Protocol version to offer.
|
|
80
|
-
# @param capabilities [Hash] Capabilities advertised by the client.
|
|
80
|
+
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
|
81
|
+
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
|
82
|
+
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
|
81
83
|
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
|
82
84
|
# does not expose an explicit handshake.
|
|
83
85
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
@@ -103,6 +105,8 @@ module MCP
|
|
|
103
105
|
# Returns a single page of tools from the server.
|
|
104
106
|
#
|
|
105
107
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
108
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
109
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
106
110
|
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
|
|
107
111
|
# and `next_cursor` (String or nil).
|
|
108
112
|
#
|
|
@@ -114,9 +118,9 @@ module MCP
|
|
|
114
118
|
# cursor = page.next_cursor
|
|
115
119
|
# break unless cursor
|
|
116
120
|
# end
|
|
117
|
-
def list_tools(cursor: nil)
|
|
121
|
+
def list_tools(cursor: nil, meta: nil)
|
|
118
122
|
params = cursor ? { cursor: cursor } : nil
|
|
119
|
-
response = request(method: "tools/list", params: params)
|
|
123
|
+
response = request(method: "tools/list", params: params, meta: meta)
|
|
120
124
|
result = response["result"] || {}
|
|
121
125
|
|
|
122
126
|
tools = (result["tools"] || []).map do |tool|
|
|
@@ -146,31 +150,19 @@ module MCP
|
|
|
146
150
|
# end
|
|
147
151
|
def tools
|
|
148
152
|
# TODO: consider renaming to `list_all_tools`.
|
|
149
|
-
|
|
150
|
-
seen = Set.new
|
|
151
|
-
cursor = nil
|
|
152
|
-
|
|
153
|
-
loop do
|
|
154
|
-
page = list_tools(cursor: cursor)
|
|
155
|
-
all_tools.concat(page.tools)
|
|
156
|
-
next_cursor = page.next_cursor
|
|
157
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
158
|
-
|
|
159
|
-
seen << next_cursor
|
|
160
|
-
cursor = next_cursor
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
all_tools
|
|
153
|
+
fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
|
|
164
154
|
end
|
|
165
155
|
|
|
166
156
|
# Returns a single page of resources from the server.
|
|
167
157
|
#
|
|
168
158
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
159
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
160
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
169
161
|
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
|
|
170
162
|
# and `next_cursor` (String or nil).
|
|
171
|
-
def list_resources(cursor: nil)
|
|
163
|
+
def list_resources(cursor: nil, meta: nil)
|
|
172
164
|
params = cursor ? { cursor: cursor } : nil
|
|
173
|
-
response = request(method: "resources/list", params: params)
|
|
165
|
+
response = request(method: "resources/list", params: params, meta: meta)
|
|
174
166
|
result = response["result"] || {}
|
|
175
167
|
|
|
176
168
|
ListResourcesResult.new(
|
|
@@ -189,31 +181,19 @@ module MCP
|
|
|
189
181
|
# @return [Array<Hash>] An array of available resources.
|
|
190
182
|
def resources
|
|
191
183
|
# TODO: consider renaming to `list_all_resources`.
|
|
192
|
-
|
|
193
|
-
seen = Set.new
|
|
194
|
-
cursor = nil
|
|
195
|
-
|
|
196
|
-
loop do
|
|
197
|
-
page = list_resources(cursor: cursor)
|
|
198
|
-
all_resources.concat(page.resources)
|
|
199
|
-
next_cursor = page.next_cursor
|
|
200
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
201
|
-
|
|
202
|
-
seen << next_cursor
|
|
203
|
-
cursor = next_cursor
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
all_resources
|
|
184
|
+
fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
|
|
207
185
|
end
|
|
208
186
|
|
|
209
187
|
# Returns a single page of resource templates from the server.
|
|
210
188
|
#
|
|
211
189
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
190
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
191
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
212
192
|
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
|
|
213
193
|
# (Array<Hash>) and `next_cursor` (String or nil).
|
|
214
|
-
def list_resource_templates(cursor: nil)
|
|
194
|
+
def list_resource_templates(cursor: nil, meta: nil)
|
|
215
195
|
params = cursor ? { cursor: cursor } : nil
|
|
216
|
-
response = request(method: "resources/templates/list", params: params)
|
|
196
|
+
response = request(method: "resources/templates/list", params: params, meta: meta)
|
|
217
197
|
result = response["result"] || {}
|
|
218
198
|
|
|
219
199
|
ListResourceTemplatesResult.new(
|
|
@@ -232,31 +212,19 @@ module MCP
|
|
|
232
212
|
# @return [Array<Hash>] An array of available resource templates.
|
|
233
213
|
def resource_templates
|
|
234
214
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
235
|
-
|
|
236
|
-
seen = Set.new
|
|
237
|
-
cursor = nil
|
|
238
|
-
|
|
239
|
-
loop do
|
|
240
|
-
page = list_resource_templates(cursor: cursor)
|
|
241
|
-
all_templates.concat(page.resource_templates)
|
|
242
|
-
next_cursor = page.next_cursor
|
|
243
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
244
|
-
|
|
245
|
-
seen << next_cursor
|
|
246
|
-
cursor = next_cursor
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
all_templates
|
|
215
|
+
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
|
|
250
216
|
end
|
|
251
217
|
|
|
252
218
|
# Returns a single page of prompts from the server.
|
|
253
219
|
#
|
|
254
220
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
221
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
222
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
255
223
|
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
|
|
256
224
|
# and `next_cursor` (String or nil).
|
|
257
|
-
def list_prompts(cursor: nil)
|
|
225
|
+
def list_prompts(cursor: nil, meta: nil)
|
|
258
226
|
params = cursor ? { cursor: cursor } : nil
|
|
259
|
-
response = request(method: "prompts/list", params: params)
|
|
227
|
+
response = request(method: "prompts/list", params: params, meta: meta)
|
|
260
228
|
result = response["result"] || {}
|
|
261
229
|
|
|
262
230
|
ListPromptsResult.new(
|
|
@@ -275,21 +243,7 @@ module MCP
|
|
|
275
243
|
# @return [Array<Hash>] An array of available prompts.
|
|
276
244
|
def prompts
|
|
277
245
|
# TODO: consider renaming to `list_all_prompts`.
|
|
278
|
-
|
|
279
|
-
seen = Set.new
|
|
280
|
-
cursor = nil
|
|
281
|
-
|
|
282
|
-
loop do
|
|
283
|
-
page = list_prompts(cursor: cursor)
|
|
284
|
-
all_prompts.concat(page.prompts)
|
|
285
|
-
next_cursor = page.next_cursor
|
|
286
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
287
|
-
|
|
288
|
-
seen << next_cursor
|
|
289
|
-
cursor = next_cursor
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
all_prompts
|
|
246
|
+
fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
|
|
293
247
|
end
|
|
294
248
|
|
|
295
249
|
# Calls a tool via the transport layer and returns the full response from the server.
|
|
@@ -298,6 +252,10 @@ module MCP
|
|
|
298
252
|
# @param tool [MCP::Client::Tool] The tool to be called.
|
|
299
253
|
# @param arguments [Object, nil] The arguments to pass to the tool.
|
|
300
254
|
# @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution.
|
|
255
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
256
|
+
# e.g. the W3C Trace Context keys reserved by SEP-414
|
|
257
|
+
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
|
|
258
|
+
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
|
|
301
259
|
# @return [Hash] The full JSON-RPC response from the transport.
|
|
302
260
|
#
|
|
303
261
|
# @example Call by name
|
|
@@ -312,14 +270,17 @@ module MCP
|
|
|
312
270
|
# @note
|
|
313
271
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
314
272
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
315
|
-
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil)
|
|
273
|
+
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
|
|
316
274
|
tool_name = name || tool&.name
|
|
317
275
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
318
276
|
|
|
319
277
|
params = { name: tool_name, arguments: arguments }
|
|
278
|
+
meta_entries = meta ? meta.dup : {}
|
|
320
279
|
if progress_token
|
|
321
|
-
|
|
280
|
+
meta_entries.delete("progressToken")
|
|
281
|
+
meta_entries[:progressToken] = progress_token
|
|
322
282
|
end
|
|
283
|
+
params[:_meta] = meta_entries unless meta_entries.empty?
|
|
323
284
|
|
|
324
285
|
request(method: "tools/call", params: params)
|
|
325
286
|
end
|
|
@@ -327,9 +288,11 @@ module MCP
|
|
|
327
288
|
# Reads a resource from the server by URI and returns the contents.
|
|
328
289
|
#
|
|
329
290
|
# @param uri [String] The URI of the resource to read.
|
|
291
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
292
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
330
293
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
331
|
-
def read_resource(uri:)
|
|
332
|
-
response = request(method: "resources/read", params: { uri: uri })
|
|
294
|
+
def read_resource(uri:, meta: nil)
|
|
295
|
+
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
|
|
333
296
|
|
|
334
297
|
response.dig("result", "contents") || []
|
|
335
298
|
end
|
|
@@ -337,9 +300,11 @@ module MCP
|
|
|
337
300
|
# Gets a prompt from the server by name and returns its details.
|
|
338
301
|
#
|
|
339
302
|
# @param name [String] The name of the prompt to get.
|
|
303
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
304
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
340
305
|
# @return [Hash] A hash containing the prompt details.
|
|
341
|
-
def get_prompt(name:)
|
|
342
|
-
response = request(method: "prompts/get", params: { name: name })
|
|
306
|
+
def get_prompt(name:, meta: nil)
|
|
307
|
+
response = request(method: "prompts/get", params: { name: name }, meta: meta)
|
|
343
308
|
|
|
344
309
|
response.fetch("result", {})
|
|
345
310
|
end
|
|
@@ -350,12 +315,14 @@ module MCP
|
|
|
350
315
|
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
|
|
351
316
|
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
|
|
352
317
|
# @param context [Hash, nil] Optional context with previously resolved arguments.
|
|
318
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
|
|
319
|
+
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
353
320
|
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
|
|
354
|
-
def complete(ref:, argument:, context: nil)
|
|
321
|
+
def complete(ref:, argument:, context: nil, meta: nil)
|
|
355
322
|
params = { ref: ref, argument: argument }
|
|
356
323
|
params[:context] = context if context
|
|
357
324
|
|
|
358
|
-
response = request(method: "completion/complete", params: params)
|
|
325
|
+
response = request(method: "completion/complete", params: params, meta: meta)
|
|
359
326
|
|
|
360
327
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
361
328
|
end
|
|
@@ -371,8 +338,8 @@ module MCP
|
|
|
371
338
|
# client.ping # => {}
|
|
372
339
|
#
|
|
373
340
|
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
|
|
374
|
-
def ping
|
|
375
|
-
result = request(method: Methods::PING)["result"]
|
|
341
|
+
def ping(meta: nil)
|
|
342
|
+
result = request(method: Methods::PING, meta: meta)["result"]
|
|
376
343
|
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
377
344
|
|
|
378
345
|
result
|
|
@@ -380,7 +347,34 @@ module MCP
|
|
|
380
347
|
|
|
381
348
|
private
|
|
382
349
|
|
|
383
|
-
|
|
350
|
+
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
|
351
|
+
# the page results. The `seen` set guards against a server that repeats or
|
|
352
|
+
# cycles cursors, so the loop always terminates.
|
|
353
|
+
def fetch_all_pages
|
|
354
|
+
pages = []
|
|
355
|
+
seen = Set.new
|
|
356
|
+
cursor = nil
|
|
357
|
+
|
|
358
|
+
loop do
|
|
359
|
+
page = yield(cursor)
|
|
360
|
+
pages << page
|
|
361
|
+
next_cursor = page.next_cursor
|
|
362
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
363
|
+
|
|
364
|
+
seen << next_cursor
|
|
365
|
+
cursor = next_cursor
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
pages
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Merges caller-supplied `meta` entries into the request params as `_meta`,
|
|
372
|
+
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
373
|
+
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
374
|
+
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
375
|
+
def request(method:, params: nil, meta: nil)
|
|
376
|
+
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
377
|
+
|
|
384
378
|
request_body = {
|
|
385
379
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
386
380
|
id: request_id,
|
data/lib/mcp/configuration.rb
CHANGED
data/lib/mcp/resource.rb
CHANGED
|
@@ -5,15 +5,17 @@ require_relative "resource/embedded"
|
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Resource
|
|
8
|
-
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta
|
|
8
|
+
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
|
|
9
9
|
|
|
10
|
-
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
|
|
10
|
+
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
|
|
11
11
|
@uri = uri
|
|
12
12
|
@name = name
|
|
13
13
|
@title = title
|
|
14
14
|
@description = description
|
|
15
15
|
@icons = icons
|
|
16
16
|
@mime_type = mime_type
|
|
17
|
+
@annotations = annotations
|
|
18
|
+
@size = size
|
|
17
19
|
@meta = meta
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -25,6 +27,8 @@ module MCP
|
|
|
25
27
|
description: description,
|
|
26
28
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
27
29
|
mimeType: mime_type,
|
|
30
|
+
annotations: annotations&.to_h,
|
|
31
|
+
size: size,
|
|
28
32
|
_meta: meta,
|
|
29
33
|
}.compact
|
|
30
34
|
end
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class ResourceTemplate
|
|
5
|
-
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta
|
|
5
|
+
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
|
|
6
6
|
|
|
7
|
-
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
|
|
7
|
+
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
|
|
8
8
|
@uri_template = uri_template
|
|
9
9
|
@name = name
|
|
10
10
|
@title = title
|
|
11
11
|
@description = description
|
|
12
12
|
@icons = icons
|
|
13
13
|
@mime_type = mime_type
|
|
14
|
+
@annotations = annotations
|
|
14
15
|
@meta = meta
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ module MCP
|
|
|
22
23
|
description: description,
|
|
23
24
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
24
25
|
mimeType: mime_type,
|
|
26
|
+
annotations: annotations&.to_h,
|
|
25
27
|
_meta: meta,
|
|
26
28
|
}.compact
|
|
27
29
|
end
|
|
@@ -6,6 +6,7 @@ module MCP
|
|
|
6
6
|
def initialize(capabilities_hash = nil)
|
|
7
7
|
@completions = nil
|
|
8
8
|
@experimental = nil
|
|
9
|
+
@extensions = nil
|
|
9
10
|
@logging = nil
|
|
10
11
|
@prompts = nil
|
|
11
12
|
@resources = nil
|
|
@@ -14,6 +15,7 @@ module MCP
|
|
|
14
15
|
if capabilities_hash
|
|
15
16
|
support_completions if capabilities_hash.key?(:completions)
|
|
16
17
|
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
|
18
|
+
support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions)
|
|
17
19
|
support_logging if capabilities_hash.key?(:logging)
|
|
18
20
|
|
|
19
21
|
if capabilities_hash.key?(:prompts)
|
|
@@ -45,6 +47,17 @@ module MCP
|
|
|
45
47
|
@experimental = config || {}
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Declares support for capability extensions per SEP-2133. Keys are
|
|
51
|
+
# extension identifiers using the reverse-DNS prefix convention
|
|
52
|
+
# (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
|
53
|
+
# values are arbitrary extension-defined configuration objects,
|
|
54
|
+
# with an empty hash meaning "supported with no settings".
|
|
55
|
+
# Repeated calls merge, so several extensions can be declared independently.
|
|
56
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
|
|
57
|
+
def support_extensions(extensions = {})
|
|
58
|
+
@extensions = (@extensions || {}).merge(extensions || {})
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def support_logging
|
|
49
62
|
@logging ||= {}
|
|
50
63
|
end
|
|
@@ -85,6 +98,7 @@ module MCP
|
|
|
85
98
|
{
|
|
86
99
|
completions: @completions,
|
|
87
100
|
experimental: @experimental,
|
|
101
|
+
extensions: @extensions,
|
|
88
102
|
logging: @logging,
|
|
89
103
|
prompts: @prompts,
|
|
90
104
|
resources: @resources,
|
|
@@ -85,8 +85,10 @@ module MCP
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
88
|
-
# Stateless mode
|
|
89
|
-
|
|
88
|
+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
|
89
|
+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
|
90
|
+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
|
91
|
+
return false if @stateless
|
|
90
92
|
|
|
91
93
|
notification = {
|
|
92
94
|
jsonrpc: "2.0",
|
|
@@ -389,7 +391,11 @@ module MCP
|
|
|
389
391
|
end
|
|
390
392
|
rescue StandardError => e
|
|
391
393
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
392
|
-
|
|
394
|
+
json_rpc_error_response(
|
|
395
|
+
status: 500,
|
|
396
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
397
|
+
message: "Internal server error",
|
|
398
|
+
)
|
|
393
399
|
end
|
|
394
400
|
|
|
395
401
|
def handle_get(request)
|
|
@@ -513,19 +519,19 @@ module MCP
|
|
|
513
519
|
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
514
520
|
return if media_type == "application/json"
|
|
515
521
|
|
|
516
|
-
|
|
517
|
-
415,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
522
|
+
json_rpc_error_response(
|
|
523
|
+
status: 415,
|
|
524
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
525
|
+
message: "Unsupported Media Type: Content-Type must be application/json",
|
|
526
|
+
)
|
|
521
527
|
end
|
|
522
528
|
|
|
523
529
|
def not_acceptable_response(required_types)
|
|
524
|
-
|
|
525
|
-
406,
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
530
|
+
json_rpc_error_response(
|
|
531
|
+
status: 406,
|
|
532
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
533
|
+
message: "Not Acceptable: Accept header must include #{required_types.join(" and ")}",
|
|
534
|
+
)
|
|
529
535
|
end
|
|
530
536
|
|
|
531
537
|
def parse_request_body(body_string)
|
|
@@ -535,7 +541,11 @@ module MCP
|
|
|
535
541
|
end
|
|
536
542
|
|
|
537
543
|
def invalid_json_response
|
|
538
|
-
|
|
544
|
+
json_rpc_error_response(
|
|
545
|
+
status: 400,
|
|
546
|
+
code: JsonRpcHandler::ErrorCode::PARSE_ERROR,
|
|
547
|
+
message: "Parse error: Invalid JSON",
|
|
548
|
+
)
|
|
539
549
|
end
|
|
540
550
|
|
|
541
551
|
def initialize_request?(body)
|
|
@@ -543,20 +553,20 @@ module MCP
|
|
|
543
553
|
end
|
|
544
554
|
|
|
545
555
|
def validate_protocol_version_header(request)
|
|
546
|
-
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
547
|
-
return if header_value.nil?
|
|
556
|
+
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
548
557
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
549
558
|
|
|
550
559
|
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
+
json_rpc_error_response(
|
|
561
|
+
status: 400,
|
|
562
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
563
|
+
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
|
564
|
+
)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def json_rpc_error_response(status:, code:, message:)
|
|
568
|
+
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
|
|
569
|
+
[status, { "Content-Type" => "application/json" }, [body.to_json]]
|
|
560
570
|
end
|
|
561
571
|
|
|
562
572
|
def notification?(body)
|
|
@@ -567,7 +577,9 @@ module MCP
|
|
|
567
577
|
# `notifications/initialized`) through the server so it can update session state.
|
|
568
578
|
def dispatch_notification(body_string, session_id)
|
|
569
579
|
server_session = nil
|
|
570
|
-
if
|
|
580
|
+
if @stateless
|
|
581
|
+
server_session = ephemeral_session
|
|
582
|
+
elsif session_id
|
|
571
583
|
@mutex.synchronize do
|
|
572
584
|
session = @sessions[session_id]
|
|
573
585
|
server_session = session[:server_session] if session
|
|
@@ -603,9 +615,10 @@ module MCP
|
|
|
603
615
|
|
|
604
616
|
def handle_initialization(body_string, body)
|
|
605
617
|
session_id = nil
|
|
606
|
-
server_session = nil
|
|
607
618
|
|
|
608
|
-
|
|
619
|
+
if @stateless
|
|
620
|
+
server_session = ephemeral_session
|
|
621
|
+
else
|
|
609
622
|
session_id = SecureRandom.uuid
|
|
610
623
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
611
624
|
|
|
@@ -618,17 +631,13 @@ module MCP
|
|
|
618
631
|
end
|
|
619
632
|
end
|
|
620
633
|
|
|
621
|
-
response =
|
|
622
|
-
server_session.handle_json(body_string)
|
|
623
|
-
else
|
|
624
|
-
@server.handle_json(body_string)
|
|
625
|
-
end
|
|
634
|
+
response = server_session.handle_json(body_string)
|
|
626
635
|
|
|
627
636
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
628
637
|
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
629
638
|
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
630
639
|
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
|
631
|
-
if
|
|
640
|
+
if session_id && !server_session.initialized?
|
|
632
641
|
cleanup_session(session_id)
|
|
633
642
|
session_id = nil
|
|
634
643
|
end
|
|
@@ -649,15 +658,15 @@ module MCP
|
|
|
649
658
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
650
659
|
server_session = nil
|
|
651
660
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
661
|
+
if @stateless
|
|
662
|
+
server_session = ephemeral_session
|
|
663
|
+
elsif session_id
|
|
664
|
+
error_response = validate_and_touch_session(session_id)
|
|
665
|
+
return error_response if error_response
|
|
656
666
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
end
|
|
667
|
+
@mutex.synchronize do
|
|
668
|
+
session = @sessions[session_id]
|
|
669
|
+
server_session = session[:server_session] if session
|
|
661
670
|
end
|
|
662
671
|
end
|
|
663
672
|
|
|
@@ -767,6 +776,13 @@ module MCP
|
|
|
767
776
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
768
777
|
end
|
|
769
778
|
|
|
779
|
+
# Each stateless POST is self-contained (SEP-2567): handlers run against an ephemeral per-request `ServerSession`
|
|
780
|
+
# so client info, logging level, and initialized state never leak onto the shared `Server` instance or across concurrent requests.
|
|
781
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
|
|
782
|
+
def ephemeral_session
|
|
783
|
+
ServerSession.new(server: @server, transport: self, session_id: nil)
|
|
784
|
+
end
|
|
785
|
+
|
|
770
786
|
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
|
771
787
|
# are evicted as a side effect so a live request never observes a zombie session that
|
|
772
788
|
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
|
@@ -793,15 +809,27 @@ module MCP
|
|
|
793
809
|
end
|
|
794
810
|
|
|
795
811
|
def method_not_allowed_response
|
|
796
|
-
|
|
812
|
+
json_rpc_error_response(
|
|
813
|
+
status: 405,
|
|
814
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
815
|
+
message: "Method not allowed",
|
|
816
|
+
)
|
|
797
817
|
end
|
|
798
818
|
|
|
799
819
|
def missing_session_id_response
|
|
800
|
-
|
|
820
|
+
json_rpc_error_response(
|
|
821
|
+
status: 400,
|
|
822
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
823
|
+
message: "Missing session ID",
|
|
824
|
+
)
|
|
801
825
|
end
|
|
802
826
|
|
|
803
827
|
def session_not_found_response
|
|
804
|
-
|
|
828
|
+
json_rpc_error_response(
|
|
829
|
+
status: 404,
|
|
830
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
831
|
+
message: "Session not found",
|
|
832
|
+
)
|
|
805
833
|
end
|
|
806
834
|
|
|
807
835
|
def already_initialized_response(request_id)
|
|
@@ -821,11 +849,11 @@ module MCP
|
|
|
821
849
|
end
|
|
822
850
|
|
|
823
851
|
def session_already_connected_response
|
|
824
|
-
|
|
825
|
-
409,
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
852
|
+
json_rpc_error_response(
|
|
853
|
+
status: 409,
|
|
854
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
855
|
+
message: "Conflict: Only one SSE stream is allowed per session",
|
|
856
|
+
)
|
|
829
857
|
end
|
|
830
858
|
|
|
831
859
|
def setup_sse_stream(session_id)
|