mcp 0.11.0 → 0.13.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.
@@ -117,20 +117,27 @@ module JsonRpcHandler
117
117
  end
118
118
 
119
119
  def handle_request_error(error, id, id_validation_pattern)
120
- error_type = error.respond_to?(:error_type) ? error.error_type : nil
121
-
122
- code, message = case error_type
123
- when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
124
- when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
125
- when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
126
- when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
127
- else [ErrorCode::INTERNAL_ERROR, "Internal error"]
120
+ if error.respond_to?(:error_code) && error.error_code
121
+ code = error.error_code
122
+ message = error.message
123
+ else
124
+ error_type = error.respond_to?(:error_type) ? error.error_type : nil
125
+
126
+ code, message = case error_type
127
+ when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
128
+ when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
129
+ when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
130
+ when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
131
+ else [ErrorCode::INTERNAL_ERROR, "Internal error"]
132
+ end
128
133
  end
129
134
 
135
+ data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message
136
+
130
137
  error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
131
138
  code: code,
132
139
  message: message,
133
- data: error.message,
140
+ data: data,
134
141
  })
135
142
  end
136
143
 
@@ -7,9 +7,10 @@ module MCP
7
7
 
8
8
  attr_reader :url
9
9
 
10
- def initialize(url:, headers: {})
10
+ def initialize(url:, headers: {}, &block)
11
11
  @url = url
12
12
  @headers = headers
13
+ @faraday_customizer = block
13
14
  end
14
15
 
15
16
  def send_request(request:)
@@ -78,6 +79,8 @@ module MCP
78
79
  headers.each do |key, value|
79
80
  faraday.headers[key] = value
80
81
  end
82
+
83
+ @faraday_customizer&.call(faraday)
81
84
  end
82
85
  end
83
86
 
@@ -7,11 +7,18 @@ module MCP
7
7
  LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
8
  ]
9
9
 
10
- attr_writer :exception_reporter, :instrumentation_callback
10
+ attr_writer :exception_reporter, :around_request
11
11
 
12
- def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil,
12
+ # @deprecated Use {#around_request=} instead. `instrumentation_callback`
13
+ # fires only after a request completes and cannot wrap execution in a
14
+ # surrounding block (e.g. for Application Performance Monitoring (APM) spans).
15
+ # @see #around_request=
16
+ attr_writer :instrumentation_callback
17
+
18
+ def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
13
19
  validate_tool_call_arguments: true)
14
20
  @exception_reporter = exception_reporter
21
+ @around_request = around_request
15
22
  @instrumentation_callback = instrumentation_callback
16
23
  @protocol_version = protocol_version
17
24
  if protocol_version
@@ -50,10 +57,24 @@ module MCP
50
57
  !@exception_reporter.nil?
51
58
  end
52
59
 
60
+ def around_request
61
+ @around_request || default_around_request
62
+ end
63
+
64
+ def around_request?
65
+ !@around_request.nil?
66
+ end
67
+
68
+ # @deprecated Use {#around_request} instead. `instrumentation_callback`
69
+ # fires only after a request completes and cannot wrap execution in a
70
+ # surrounding block (e.g. for Application Performance Monitoring (APM) spans).
71
+ # @see #around_request
53
72
  def instrumentation_callback
54
73
  @instrumentation_callback || default_instrumentation_callback
55
74
  end
56
75
 
76
+ # @deprecated Use {#around_request?} instead.
77
+ # @see #around_request?
57
78
  def instrumentation_callback?
58
79
  !@instrumentation_callback.nil?
59
80
  end
@@ -72,20 +93,30 @@ module MCP
72
93
  else
73
94
  @exception_reporter
74
95
  end
96
+
97
+ around_request = if other.around_request?
98
+ other.around_request
99
+ else
100
+ @around_request
101
+ end
102
+
75
103
  instrumentation_callback = if other.instrumentation_callback?
76
104
  other.instrumentation_callback
77
105
  else
78
106
  @instrumentation_callback
79
107
  end
108
+
80
109
  protocol_version = if other.protocol_version?
81
110
  other.protocol_version
82
111
  else
83
112
  @protocol_version
84
113
  end
114
+
85
115
  validate_tool_call_arguments = other.validate_tool_call_arguments
86
116
 
87
117
  Configuration.new(
88
118
  exception_reporter: exception_reporter,
119
+ around_request: around_request,
89
120
  instrumentation_callback: instrumentation_callback,
90
121
  protocol_version: protocol_version,
91
122
  validate_tool_call_arguments: validate_tool_call_arguments,
@@ -111,6 +142,11 @@ module MCP
111
142
  @default_exception_reporter ||= ->(exception, server_context) {}
112
143
  end
113
144
 
145
+ def default_around_request
146
+ @default_around_request ||= ->(_data, &request_handler) { request_handler.call }
147
+ end
148
+
149
+ # @deprecated Use {#default_around_request} instead.
114
150
  def default_instrumentation_callback
115
151
  @default_instrumentation_callback ||= ->(data) {}
116
152
  end
data/lib/mcp/content.rb CHANGED
@@ -3,56 +3,60 @@
3
3
  module MCP
4
4
  module Content
5
5
  class Text
6
- attr_reader :text, :annotations
6
+ attr_reader :text, :annotations, :meta
7
7
 
8
- def initialize(text, annotations: nil)
8
+ def initialize(text, annotations: nil, meta: nil)
9
9
  @text = text
10
10
  @annotations = annotations
11
+ @meta = meta
11
12
  end
12
13
 
13
14
  def to_h
14
- { text: text, annotations: annotations, type: "text" }.compact
15
+ { text: text, annotations: annotations, _meta: meta, type: "text" }.compact
15
16
  end
16
17
  end
17
18
 
18
19
  class Image
19
- attr_reader :data, :mime_type, :annotations
20
+ attr_reader :data, :mime_type, :annotations, :meta
20
21
 
21
- def initialize(data, mime_type, annotations: nil)
22
+ def initialize(data, mime_type, annotations: nil, meta: nil)
22
23
  @data = data
23
24
  @mime_type = mime_type
24
25
  @annotations = annotations
26
+ @meta = meta
25
27
  end
26
28
 
27
29
  def to_h
28
- { data: data, mimeType: mime_type, annotations: annotations, type: "image" }.compact
30
+ { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "image" }.compact
29
31
  end
30
32
  end
31
33
 
32
34
  class Audio
33
- attr_reader :data, :mime_type, :annotations
35
+ attr_reader :data, :mime_type, :annotations, :meta
34
36
 
35
- def initialize(data, mime_type, annotations: nil)
37
+ def initialize(data, mime_type, annotations: nil, meta: nil)
36
38
  @data = data
37
39
  @mime_type = mime_type
38
40
  @annotations = annotations
41
+ @meta = meta
39
42
  end
40
43
 
41
44
  def to_h
42
- { data: data, mimeType: mime_type, annotations: annotations, type: "audio" }.compact
45
+ { data: data, mimeType: mime_type, annotations: annotations, _meta: meta, type: "audio" }.compact
43
46
  end
44
47
  end
45
48
 
46
49
  class EmbeddedResource
47
- attr_reader :resource, :annotations
50
+ attr_reader :resource, :annotations, :meta
48
51
 
49
- def initialize(resource, annotations: nil)
52
+ def initialize(resource, annotations: nil, meta: nil)
50
53
  @resource = resource
51
54
  @annotations = annotations
55
+ @meta = meta
52
56
  end
53
57
 
54
58
  def to_h
55
- { resource: resource.to_h, annotations: annotations, type: "resource" }.compact
59
+ { resource: resource.to_h, annotations: annotations, _meta: meta, type: "resource" }.compact
56
60
  end
57
61
  end
58
62
  end
@@ -2,19 +2,40 @@
2
2
 
3
3
  module MCP
4
4
  module Instrumentation
5
- def instrument_call(method, &block)
5
+ def instrument_call(method, server_context: {}, exception_already_reported: nil, &block)
6
6
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
7
  begin
8
8
  @instrumentation_data = {}
9
9
  add_instrumentation_data(method: method)
10
10
 
11
- result = yield block
11
+ result = configuration.around_request.call(@instrumentation_data, &block)
12
12
 
13
13
  result
14
+ rescue => e
15
+ already_reported = begin
16
+ !!exception_already_reported&.call(e)
17
+ # rubocop:disable Lint/RescueException
18
+ rescue Exception
19
+ # rubocop:enable Lint/RescueException
20
+ # The predicate is expected to be side-effect-free and return a boolean.
21
+ # Any exception raised from it (including non-StandardError such as SystemExit)
22
+ # must not shadow the original exception.
23
+ false
24
+ end
25
+
26
+ unless already_reported
27
+ add_instrumentation_data(error: :internal_error) unless @instrumentation_data.key?(:error)
28
+ configuration.exception_reporter.call(e, server_context)
29
+ end
30
+
31
+ raise
14
32
  ensure
15
33
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
34
  add_instrumentation_data(duration: end_time - start_time)
17
35
 
36
+ # Backward compatibility: `instrumentation_callback` is soft-deprecated
37
+ # in favor of `around_request`, but existing callers still expect it
38
+ # to fire after every request until it is removed in a future version.
18
39
  configuration.instrumentation_callback.call(@instrumentation_data)
19
40
  end
20
41
  end
data/lib/mcp/methods.rb CHANGED
@@ -33,6 +33,7 @@ module MCP
33
33
  NOTIFICATIONS_MESSAGE = "notifications/message"
34
34
  NOTIFICATIONS_PROGRESS = "notifications/progress"
35
35
  NOTIFICATIONS_CANCELLED = "notifications/cancelled"
36
+ NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"
36
37
 
37
38
  class MissingRequiredCapabilityError < StandardError
38
39
  attr_reader :method
@@ -79,8 +80,8 @@ module MCP
79
80
  require_capability!(method, capabilities, :sampling)
80
81
  when ELICITATION_CREATE
81
82
  require_capability!(method, capabilities, :elicitation)
82
- when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
83
- # No specific capability required for initialize, ping, progress or cancelled
83
+ when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
84
+ # No specific capability required.
84
85
  end
85
86
  end
86
87
 
@@ -3,15 +3,16 @@
3
3
  module MCP
4
4
  class Prompt
5
5
  class Result
6
- attr_reader :description, :messages
6
+ attr_reader :description, :messages, :meta
7
7
 
8
- def initialize(description: nil, messages: [])
8
+ def initialize(description: nil, messages: [], meta: nil)
9
9
  @description = description
10
10
  @messages = messages
11
+ @meta = meta
11
12
  end
12
13
 
13
14
  def to_h
14
- { description: description, messages: messages.map(&:to_h) }.compact
15
+ { description: description, messages: messages.map(&:to_h), _meta: meta }.compact
15
16
  end
16
17
  end
17
18
  end
@@ -3,23 +3,24 @@
3
3
  module MCP
4
4
  class Resource
5
5
  class Contents
6
- attr_reader :uri, :mime_type
6
+ attr_reader :uri, :mime_type, :meta
7
7
 
8
- def initialize(uri:, mime_type: nil)
8
+ def initialize(uri:, mime_type: nil, meta: nil)
9
9
  @uri = uri
10
10
  @mime_type = mime_type
11
+ @meta = meta
11
12
  end
12
13
 
13
14
  def to_h
14
- { uri: uri, mimeType: mime_type }.compact
15
+ { uri: uri, mimeType: mime_type, _meta: meta }.compact
15
16
  end
16
17
  end
17
18
 
18
19
  class TextContents < Contents
19
20
  attr_reader :text
20
21
 
21
- def initialize(text:, uri:, mime_type:)
22
- super(uri: uri, mime_type: mime_type)
22
+ def initialize(text:, uri:, mime_type:, meta: nil)
23
+ super(uri: uri, mime_type: mime_type, meta: meta)
23
24
  @text = text
24
25
  end
25
26
 
@@ -31,8 +32,8 @@ module MCP
31
32
  class BlobContents < Contents
32
33
  attr_reader :data
33
34
 
34
- def initialize(data:, uri:, mime_type:)
35
- super(uri: uri, mime_type: mime_type)
35
+ def initialize(data:, uri:, mime_type:, meta: nil)
36
+ super(uri: uri, mime_type: mime_type, meta: meta)
36
37
  @data = data
37
38
  end
38
39
 
data/lib/mcp/resource.rb CHANGED
@@ -5,15 +5,16 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
- attr_reader :uri, :name, :title, :description, :icons, :mime_type
8
+ attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta
9
9
 
10
- def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil)
10
+ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: 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
+ @meta = meta
17
18
  end
18
19
 
19
20
  def to_h
@@ -24,6 +25,7 @@ module MCP
24
25
  description: description,
25
26
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
26
27
  mimeType: mime_type,
28
+ _meta: meta,
27
29
  }.compact
28
30
  end
29
31
  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
5
+ attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :meta
6
6
 
7
- def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil)
7
+ def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: 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
+ @meta = meta
14
15
  end
15
16
 
16
17
  def to_h
@@ -21,6 +22,7 @@ module MCP
21
22
  description: description,
22
23
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
23
24
  mimeType: mime_type,
25
+ _meta: meta,
24
26
  }.compact
25
27
  end
26
28
  end
@@ -3,6 +3,15 @@
3
3
  require "json"
4
4
  require_relative "../../transport"
5
5
 
6
+ # This file is autoloaded only when `StreamableHTTPTransport` is referenced,
7
+ # so the `rack` dependency does not affect `StdioTransport` users.
8
+ begin
9
+ require "rack"
10
+ rescue LoadError
11
+ raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
12
+ "Add it to your Gemfile: gem 'rack'"
13
+ end
14
+
6
15
  module MCP
7
16
  class Server
8
17
  module Transports
@@ -15,7 +24,7 @@ module MCP
15
24
 
16
25
  def initialize(server, stateless: false, session_idle_timeout: nil)
17
26
  super(server)
18
- # Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
27
+ # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
19
28
  @sessions = {}
20
29
  @mutex = Mutex.new
21
30
 
@@ -39,6 +48,11 @@ module MCP
39
48
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
40
49
  SESSION_REAP_INTERVAL = 60
41
50
 
51
+ # Rack app interface. This transport can be mounted as a Rack app.
52
+ def call(env)
53
+ handle_request(Rack::Request.new(env))
54
+ end
55
+
42
56
  def handle_request(request)
43
57
  case request.env["REQUEST_METHOD"]
44
58
  when "POST"
@@ -61,7 +75,7 @@ module MCP
61
75
  end
62
76
 
63
77
  removed_sessions.each do |session|
64
- close_stream_safely(session[:stream])
78
+ close_stream_safely(session[:get_sse_stream])
65
79
  close_post_request_streams(session)
66
80
  end
67
81
  end
@@ -113,7 +127,7 @@ module MCP
113
127
  failed_sessions = []
114
128
 
115
129
  @sessions.each do |sid, session|
116
- next unless (stream = session[:stream])
130
+ next unless (stream = session[:get_sse_stream])
117
131
 
118
132
  if session_expired?(session)
119
133
  failed_sessions << sid
@@ -247,7 +261,7 @@ module MCP
247
261
  end
248
262
 
249
263
  removed_sessions.each do |session|
250
- close_stream_safely(session[:stream])
264
+ close_stream_safely(session[:get_sse_stream])
251
265
  close_post_request_streams(session)
252
266
  end
253
267
  end
@@ -267,6 +281,9 @@ module MCP
267
281
  accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
268
282
  return accept_error if accept_error
269
283
 
284
+ content_type_error = validate_content_type(request)
285
+ return content_type_error if content_type_error
286
+
270
287
  body_string = request.body.read
271
288
  session_id = extract_session_id(request)
272
289
 
@@ -334,7 +351,7 @@ module MCP
334
351
  end
335
352
 
336
353
  if session
337
- close_stream_safely(session[:stream])
354
+ close_stream_safely(session[:get_sse_stream])
338
355
  close_post_request_streams(session)
339
356
  end
340
357
  end
@@ -358,7 +375,7 @@ module MCP
358
375
  def cleanup_and_collect_stream(session_id, streams_to_close)
359
376
  return unless (removed = cleanup_session_unsafe(session_id))
360
377
 
361
- streams_to_close << removed[:stream]
378
+ streams_to_close << removed[:get_sse_stream]
362
379
  removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
363
380
  end
364
381
 
@@ -399,6 +416,18 @@ module MCP
399
416
  end
400
417
  end
401
418
 
419
+ def validate_content_type(request)
420
+ content_type = request.env["CONTENT_TYPE"]
421
+ media_type = content_type&.split(";")&.first&.strip&.downcase
422
+ return if media_type == "application/json"
423
+
424
+ [
425
+ 415,
426
+ { "Content-Type" => "application/json" },
427
+ [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
428
+ ]
429
+ end
430
+
402
431
  def not_acceptable_response(required_types)
403
432
  [
404
433
  406,
@@ -449,7 +478,7 @@ module MCP
449
478
 
450
479
  @mutex.synchronize do
451
480
  @sessions[session_id] = {
452
- stream: nil,
481
+ get_sse_stream: nil,
453
482
  server_session: server_session,
454
483
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
455
484
  }
@@ -531,7 +560,7 @@ module MCP
531
560
  end
532
561
  end
533
562
 
534
- [200, SSE_HEADERS, body]
563
+ [200, SSE_HEADERS.dup, body]
535
564
  end
536
565
 
537
566
  # Returns the SSE stream available for server-to-client messages.
@@ -543,7 +572,7 @@ module MCP
543
572
  if related_request_id
544
573
  session.dig(:post_request_streams, related_request_id)
545
574
  else
546
- session[:stream]
575
+ session[:get_sse_stream]
547
576
  end
548
577
  end
549
578
 
@@ -572,7 +601,7 @@ module MCP
572
601
  end
573
602
 
574
603
  if removed
575
- close_stream_safely(removed[:stream])
604
+ close_stream_safely(removed[:get_sse_stream])
576
605
 
577
606
  removed[:post_request_streams]&.each_value do |stream|
578
607
  close_stream_safely(stream)
@@ -583,7 +612,7 @@ module MCP
583
612
  end
584
613
 
585
614
  def get_session_stream(session_id)
586
- @mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
615
+ @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
587
616
  end
588
617
 
589
618
  def session_exists?(session_id)
@@ -613,7 +642,7 @@ module MCP
613
642
  def setup_sse_stream(session_id)
614
643
  body = create_sse_body(session_id)
615
644
 
616
- [200, SSE_HEADERS, body]
645
+ [200, SSE_HEADERS.dup, body]
617
646
  end
618
647
 
619
648
  def create_sse_body(session_id)
@@ -626,8 +655,8 @@ module MCP
626
655
  def store_stream_for_session(session_id, stream)
627
656
  @mutex.synchronize do
628
657
  session = @sessions[session_id]
629
- if session && !session[:stream]
630
- session[:stream] = stream
658
+ if session && !session[:get_sse_stream]
659
+ session[:get_sse_stream] = stream
631
660
  else
632
661
  # Either session was removed, or another request already established a stream.
633
662
  stream.close
@@ -652,13 +681,13 @@ module MCP
652
681
  end
653
682
 
654
683
  def session_active_with_stream?(session_id)
655
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
684
+ @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
656
685
  end
657
686
 
658
687
  def send_keepalive_ping(session_id)
659
688
  @mutex.synchronize do
660
- if @sessions[session_id] && @sessions[session_id][:stream]
661
- send_ping_to_stream(@sessions[session_id][:stream])
689
+ if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
690
+ send_ping_to_stream(@sessions[session_id][:get_sse_stream])
662
691
  end
663
692
  end
664
693
  rescue *STREAM_WRITE_ERRORS => e