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.
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
- all_tools = []
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
- all_resources = []
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
- all_templates = []
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
- all_prompts = []
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
- params[:_meta] = { progressToken: progress_token }
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
- def request(method:, params: nil)
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,
@@ -6,6 +6,7 @@ module MCP
6
6
  SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
7
7
  LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
8
  ]
9
+ DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
9
10
 
10
11
  attr_writer :exception_reporter, :around_request
11
12
 
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 doesn't support notifications
89
- raise "Stateless mode does not support notifications" if @stateless
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
- [500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
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
- { "Content-Type" => "application/json" },
519
- [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
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
- { "Content-Type" => "application/json" },
527
- [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
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
- [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
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
- body = {
552
- jsonrpc: "2.0",
553
- id: nil,
554
- error: {
555
- code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
556
- message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
557
- },
558
- }
559
- [400, { "Content-Type" => "application/json" }, [body.to_json]]
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 session_id && !@stateless
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
- unless @stateless
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 = if server_session
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 server_session && !server_session.initialized?
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
- unless @stateless
653
- if session_id
654
- error_response = validate_and_touch_session(session_id)
655
- return error_response if error_response
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
- @mutex.synchronize do
658
- session = @sessions[session_id]
659
- server_session = session[:server_session] if session
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
- [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
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
- [400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
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
- [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
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
- { "Content-Type" => "application/json" },
827
- [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
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)