mcp 0.21.0 → 0.23.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 +156 -13
- data/lib/mcp/annotations.rb +5 -0
- data/lib/mcp/client/http.rb +66 -4
- data/lib/mcp/client/stdio.rb +65 -11
- data/lib/mcp/client.rb +194 -30
- data/lib/mcp/methods.rb +4 -0
- data/lib/mcp/server/transports/stdio_transport.rb +51 -3
- data/lib/mcp/server/transports/streamable_http_transport.rb +272 -16
- data/lib/mcp/server.rb +27 -1
- data/lib/mcp/server_context.rb +17 -2
- data/lib/mcp/server_session.rb +10 -0
- data/lib/mcp/tool/output_schema.rb +17 -0
- data/lib/mcp/tool/schema.rb +66 -8
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
|
@@ -24,17 +24,97 @@ module MCP
|
|
|
24
24
|
"Connection" => "keep-alive",
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
|
|
28
|
+
# so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted.
|
|
29
|
+
# These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that
|
|
30
|
+
# hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport
|
|
31
|
+
# rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts
|
|
32
|
+
# the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on
|
|
33
|
+
# an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to
|
|
34
|
+
# opt out of expiry and `max_sessions: nil` to opt out of the cap.
|
|
35
|
+
DEFAULT_SESSION_IDLE_TIMEOUT = 1800
|
|
36
|
+
DEFAULT_MAX_SESSIONS = 10_000
|
|
37
|
+
|
|
38
|
+
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
|
39
|
+
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
|
40
|
+
private_constant :UNSET_IDLE_TIMEOUT
|
|
41
|
+
|
|
42
|
+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
|
43
|
+
# body into memory and parses it, so without a cap a single unauthenticated POST
|
|
44
|
+
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
|
|
45
|
+
# fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
|
|
46
|
+
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
|
|
47
|
+
# TypeScript SDK's 4 MB default.
|
|
48
|
+
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
49
|
+
|
|
50
|
+
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
|
|
51
|
+
# the stack or amplify parse cost (complements the byte cap).
|
|
52
|
+
MAX_JSON_NESTING = 64
|
|
53
|
+
|
|
54
|
+
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
|
|
55
|
+
#
|
|
56
|
+
# @param server [MCP::Server] the server whose requests this transport dispatches.
|
|
57
|
+
# @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
|
|
58
|
+
# @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
|
|
59
|
+
# object instead of an SSE stream.
|
|
60
|
+
# @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults
|
|
61
|
+
# to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables
|
|
62
|
+
# expiry. Not supported in stateless mode.
|
|
63
|
+
# @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new
|
|
64
|
+
# `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap.
|
|
65
|
+
# @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
|
|
66
|
+
# same-origin requests, for DNS rebinding protection.
|
|
67
|
+
# @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
|
|
68
|
+
# defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
|
|
69
|
+
# or a full `host:port`.
|
|
70
|
+
# @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
|
|
71
|
+
# `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
|
|
72
|
+
# validates them.
|
|
73
|
+
# @param session_request_validator [#call, nil] An optional
|
|
74
|
+
# `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE
|
|
75
|
+
# against an existing session (regular requests, notifications, and client responses alike).
|
|
76
|
+
# Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid`
|
|
77
|
+
# session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is
|
|
78
|
+
# the deploying application's responsibility (the transport never receives the authenticated identity
|
|
79
|
+
# on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
|
|
80
|
+
# ownership is not enforced.
|
|
81
|
+
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
|
|
82
|
+
# requests are rejected with HTTP 413. Defaults to 4 MiB.
|
|
83
|
+
def initialize(
|
|
84
|
+
server,
|
|
85
|
+
stateless: false,
|
|
86
|
+
enable_json_response: false,
|
|
87
|
+
session_idle_timeout: UNSET_IDLE_TIMEOUT,
|
|
88
|
+
max_sessions: DEFAULT_MAX_SESSIONS,
|
|
89
|
+
allowed_origins: nil,
|
|
90
|
+
allowed_hosts: nil,
|
|
91
|
+
dns_rebinding_protection: true,
|
|
92
|
+
session_request_validator: nil,
|
|
93
|
+
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
|
|
94
|
+
)
|
|
28
95
|
super(server)
|
|
29
|
-
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
96
|
+
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
|
|
30
97
|
@sessions = {}
|
|
31
98
|
@mutex = Mutex.new
|
|
32
99
|
|
|
33
100
|
@stateless = stateless
|
|
34
101
|
@enable_json_response = enable_json_response
|
|
35
|
-
@
|
|
102
|
+
@session_request_validator = session_request_validator
|
|
103
|
+
@dns_rebinding_protection = dns_rebinding_protection
|
|
104
|
+
|
|
105
|
+
# Host names are case-insensitive, so the allow lists are compared down-cased.
|
|
106
|
+
@allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
|
|
107
|
+
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
36
108
|
@pending_responses = {}
|
|
37
109
|
|
|
110
|
+
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
|
|
111
|
+
# which does not apply to stateless mode since it retains no sessions.
|
|
112
|
+
@session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
|
|
113
|
+
stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT
|
|
114
|
+
else
|
|
115
|
+
session_idle_timeout
|
|
116
|
+
end
|
|
117
|
+
|
|
38
118
|
if @session_idle_timeout
|
|
39
119
|
if @stateless
|
|
40
120
|
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
|
|
@@ -43,6 +123,19 @@ module MCP
|
|
|
43
123
|
end
|
|
44
124
|
end
|
|
45
125
|
|
|
126
|
+
unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0)
|
|
127
|
+
raise ArgumentError, "max_sessions must be a positive Integer or nil"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The cap guards the stateful session store; stateless mode keeps none.
|
|
131
|
+
@max_sessions = stateless ? nil : max_sessions
|
|
132
|
+
|
|
133
|
+
unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0
|
|
134
|
+
raise ArgumentError, "max_request_bytes must be a positive Integer"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@max_request_bytes = max_request_bytes
|
|
138
|
+
|
|
46
139
|
start_reaper_thread if @session_idle_timeout
|
|
47
140
|
end
|
|
48
141
|
|
|
@@ -52,12 +145,19 @@ module MCP
|
|
|
52
145
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
53
146
|
SESSION_REAP_INTERVAL = 60
|
|
54
147
|
|
|
148
|
+
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
|
|
149
|
+
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
|
|
150
|
+
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
|
|
151
|
+
|
|
55
152
|
# Rack app interface. This transport can be mounted as a Rack app.
|
|
56
153
|
def call(env)
|
|
57
154
|
handle_request(Rack::Request.new(env))
|
|
58
155
|
end
|
|
59
156
|
|
|
60
157
|
def handle_request(request)
|
|
158
|
+
rebinding_error = validate_dns_rebinding(request)
|
|
159
|
+
return rebinding_error if rebinding_error
|
|
160
|
+
|
|
61
161
|
case request.env["REQUEST_METHOD"]
|
|
62
162
|
when "POST"
|
|
63
163
|
handle_post(request)
|
|
@@ -340,7 +440,9 @@ module MCP
|
|
|
340
440
|
content_type_error = validate_content_type(request)
|
|
341
441
|
return content_type_error if content_type_error
|
|
342
442
|
|
|
343
|
-
body_string = request
|
|
443
|
+
body_string = read_bounded_body(request)
|
|
444
|
+
return payload_too_large_response if body_string.nil?
|
|
445
|
+
|
|
344
446
|
session_id = extract_session_id(request)
|
|
345
447
|
|
|
346
448
|
begin
|
|
@@ -378,16 +480,25 @@ module MCP
|
|
|
378
480
|
return session_not_found_response
|
|
379
481
|
end
|
|
380
482
|
|
|
381
|
-
handle_initialization(body_string, body)
|
|
382
|
-
elsif notification?(body)
|
|
383
|
-
dispatch_notification(body_string, session_id)
|
|
384
|
-
handle_accepted
|
|
385
|
-
elsif response?(body)
|
|
386
|
-
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
387
|
-
|
|
388
|
-
handle_response(body, session_id: session_id)
|
|
483
|
+
handle_initialization(request, body_string, body)
|
|
389
484
|
else
|
|
390
|
-
|
|
485
|
+
# Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
|
|
486
|
+
# and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
|
|
487
|
+
# otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
|
|
488
|
+
if !@stateless && session_id && !validate_session_request(request, session_id)
|
|
489
|
+
return forbidden_response
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
if notification?(body)
|
|
493
|
+
dispatch_notification(body_string, session_id)
|
|
494
|
+
handle_accepted
|
|
495
|
+
elsif response?(body)
|
|
496
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
497
|
+
|
|
498
|
+
handle_response(body, session_id: session_id)
|
|
499
|
+
else
|
|
500
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
501
|
+
end
|
|
391
502
|
end
|
|
392
503
|
rescue StandardError => e
|
|
393
504
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -412,6 +523,7 @@ module MCP
|
|
|
412
523
|
|
|
413
524
|
error_response = validate_and_touch_session(session_id)
|
|
414
525
|
return error_response if error_response
|
|
526
|
+
return forbidden_response unless validate_session_request(request, session_id)
|
|
415
527
|
|
|
416
528
|
protocol_version_error = validate_protocol_version_header(request)
|
|
417
529
|
return protocol_version_error if protocol_version_error
|
|
@@ -434,6 +546,7 @@ module MCP
|
|
|
434
546
|
|
|
435
547
|
return missing_session_id_response unless (session_id = extract_session_id(request))
|
|
436
548
|
return session_not_found_response unless session_exists?(session_id)
|
|
549
|
+
return forbidden_response unless validate_session_request(request, session_id)
|
|
437
550
|
|
|
438
551
|
protocol_version_error = validate_protocol_version_header(request)
|
|
439
552
|
return protocol_version_error if protocol_version_error
|
|
@@ -495,6 +608,27 @@ module MCP
|
|
|
495
608
|
request.env["HTTP_MCP_SESSION_ID"]
|
|
496
609
|
end
|
|
497
610
|
|
|
611
|
+
# Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
|
|
612
|
+
# The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
|
|
613
|
+
# Two layers, both returning `false` to trigger a 403:
|
|
614
|
+
#
|
|
615
|
+
# - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
|
|
616
|
+
# at `initialize` and this request carries a different one, reject. Both must be present to compare,
|
|
617
|
+
# so non-browser clients that send no `Origin` are unaffected.
|
|
618
|
+
# - The application-supplied `session_request_validator`, which can enforce true ownership when it has
|
|
619
|
+
# an authenticated principal.
|
|
620
|
+
def validate_session_request(request, session_id)
|
|
621
|
+
session = @mutex.synchronize { @sessions[session_id] }
|
|
622
|
+
return true unless session
|
|
623
|
+
|
|
624
|
+
session_origin = session[:origin]
|
|
625
|
+
request_origin = request.env["HTTP_ORIGIN"]
|
|
626
|
+
return false if session_origin && request_origin && session_origin != request_origin
|
|
627
|
+
return @session_request_validator.call(request, session_id) if @session_request_validator
|
|
628
|
+
|
|
629
|
+
true
|
|
630
|
+
end
|
|
631
|
+
|
|
498
632
|
def validate_accept_header(request, required_types)
|
|
499
633
|
accept_header = request.env["HTTP_ACCEPT"]
|
|
500
634
|
return not_acceptable_response(required_types) unless accept_header
|
|
@@ -534,8 +668,34 @@ module MCP
|
|
|
534
668
|
)
|
|
535
669
|
end
|
|
536
670
|
|
|
671
|
+
# Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
|
|
672
|
+
# memory. A declared `Content-Length` over the cap is rejected
|
|
673
|
+
# without reading; the actual read is also bounded to one byte past the cap, so
|
|
674
|
+
# a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
|
|
675
|
+
# Returns `nil` when the body exceeds the cap.
|
|
676
|
+
def read_bounded_body(request)
|
|
677
|
+
content_length = request.content_length
|
|
678
|
+
return if content_length && content_length.to_i > @max_request_bytes
|
|
679
|
+
|
|
680
|
+
body = request.body.read(@max_request_bytes + 1)
|
|
681
|
+
return "" if body.nil?
|
|
682
|
+
return if body.bytesize > @max_request_bytes
|
|
683
|
+
|
|
684
|
+
body
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def payload_too_large_response
|
|
688
|
+
json_rpc_error_response(
|
|
689
|
+
status: 413,
|
|
690
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
691
|
+
message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
|
|
692
|
+
)
|
|
693
|
+
end
|
|
694
|
+
|
|
537
695
|
def parse_request_body(body_string)
|
|
538
|
-
|
|
696
|
+
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
697
|
+
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
|
698
|
+
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
|
|
539
699
|
rescue JSON::ParserError, TypeError
|
|
540
700
|
raise InvalidJsonError
|
|
541
701
|
end
|
|
@@ -613,7 +773,7 @@ module MCP
|
|
|
613
773
|
handle_accepted
|
|
614
774
|
end
|
|
615
775
|
|
|
616
|
-
def handle_initialization(body_string, body)
|
|
776
|
+
def handle_initialization(request, body_string, body)
|
|
617
777
|
session_id = nil
|
|
618
778
|
|
|
619
779
|
if @stateless
|
|
@@ -622,13 +782,33 @@ module MCP
|
|
|
622
782
|
session_id = SecureRandom.uuid
|
|
623
783
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
624
784
|
|
|
625
|
-
|
|
785
|
+
# Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
|
|
786
|
+
# The check and insert share the mutex so concurrent initializes cannot race past the limit.
|
|
787
|
+
reclaimed = []
|
|
788
|
+
inserted = @mutex.synchronize do
|
|
789
|
+
# When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
|
|
790
|
+
# so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
|
|
791
|
+
if @max_sessions && @sessions.size >= @max_sessions
|
|
792
|
+
@sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
|
|
793
|
+
cleanup_and_collect_stream(id, reclaimed)
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
next false if @max_sessions && @sessions.size >= @max_sessions
|
|
798
|
+
|
|
626
799
|
@sessions[session_id] = {
|
|
627
800
|
get_sse_stream: nil,
|
|
628
801
|
server_session: server_session,
|
|
629
802
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
803
|
+
# Captured for the built-in Origin-consistency defense in `validate_session_request`.
|
|
804
|
+
# Not authentication.
|
|
805
|
+
origin: request.env["HTTP_ORIGIN"],
|
|
630
806
|
}
|
|
807
|
+
true
|
|
631
808
|
end
|
|
809
|
+
|
|
810
|
+
reclaimed.each { |stream| close_stream_safely(stream) }
|
|
811
|
+
return too_many_sessions_response unless inserted
|
|
632
812
|
end
|
|
633
813
|
|
|
634
814
|
response = server_session.handle_json(body_string)
|
|
@@ -655,6 +835,14 @@ module MCP
|
|
|
655
835
|
[202, {}, []]
|
|
656
836
|
end
|
|
657
837
|
|
|
838
|
+
def too_many_sessions_response
|
|
839
|
+
json_rpc_error_response(
|
|
840
|
+
status: 503,
|
|
841
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
842
|
+
message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
|
|
843
|
+
)
|
|
844
|
+
end
|
|
845
|
+
|
|
658
846
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
659
847
|
server_session = nil
|
|
660
848
|
|
|
@@ -808,6 +996,74 @@ module MCP
|
|
|
808
996
|
active
|
|
809
997
|
end
|
|
810
998
|
|
|
999
|
+
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
|
|
1000
|
+
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
|
|
1001
|
+
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
|
|
1002
|
+
# performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
|
|
1003
|
+
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
|
|
1004
|
+
def validate_dns_rebinding(request)
|
|
1005
|
+
return unless @dns_rebinding_protection
|
|
1006
|
+
|
|
1007
|
+
validate_host(request) || validate_origin(request)
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
|
|
1011
|
+
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
|
|
1012
|
+
def validate_host(request)
|
|
1013
|
+
host = request.env["HTTP_HOST"]
|
|
1014
|
+
return if host.nil?
|
|
1015
|
+
|
|
1016
|
+
# An `allowed_hosts:` entry matches either the bare host name (any port)
|
|
1017
|
+
# or the full `host:port` value, so both `"app.example.com"` and
|
|
1018
|
+
# `"app.example.com:8443"` can be configured.
|
|
1019
|
+
normalized = host.downcase
|
|
1020
|
+
return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
|
|
1021
|
+
|
|
1022
|
+
forbidden_response("Forbidden: Invalid Host header")
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
|
|
1026
|
+
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
|
|
1027
|
+
def validate_origin(request)
|
|
1028
|
+
origin = request.env["HTTP_ORIGIN"]
|
|
1029
|
+
return if origin.nil?
|
|
1030
|
+
return if same_origin?(origin, request)
|
|
1031
|
+
return if @allowed_origins.include?(origin.downcase)
|
|
1032
|
+
|
|
1033
|
+
forbidden_response("Forbidden: Invalid Origin header")
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
|
|
1037
|
+
# (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
|
|
1038
|
+
def request_hostname(host)
|
|
1039
|
+
return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
|
|
1040
|
+
|
|
1041
|
+
host.split(":").first
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# Compares the `Origin` authority (host:port) against the request's own `Host`.
|
|
1045
|
+
# Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
|
|
1046
|
+
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
|
|
1047
|
+
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
|
|
1048
|
+
def same_origin?(origin, request)
|
|
1049
|
+
host = request.env["HTTP_HOST"]
|
|
1050
|
+
return false if host.nil?
|
|
1051
|
+
|
|
1052
|
+
normalized = origin.downcase
|
|
1053
|
+
default_port = normalized.start_with?("https://") ? ":443" : ":80"
|
|
1054
|
+
authority = normalized.sub(%r{\Ahttps?://}, "")
|
|
1055
|
+
|
|
1056
|
+
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def forbidden_response(message = "Forbidden: session request validation failed")
|
|
1060
|
+
json_rpc_error_response(
|
|
1061
|
+
status: 403,
|
|
1062
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1063
|
+
message: message,
|
|
1064
|
+
)
|
|
1065
|
+
end
|
|
1066
|
+
|
|
811
1067
|
def method_not_allowed_response
|
|
812
1068
|
json_rpc_error_response(
|
|
813
1069
|
status: 405,
|
data/lib/mcp/server.rb
CHANGED
|
@@ -256,6 +256,9 @@ module MCP
|
|
|
256
256
|
report_exception(e, { notification: "resources_list_changed" })
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
|
|
260
|
+
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
261
|
+
# Use stderr or OpenTelemetry instead.
|
|
259
262
|
def notify_log_message(data:, level:, logger: nil)
|
|
260
263
|
return unless @transport
|
|
261
264
|
return unless logging_message_notification&.should_notify?(level)
|
|
@@ -272,6 +275,10 @@ module MCP
|
|
|
272
275
|
# Called when a client notifies the server that its filesystem roots have changed.
|
|
273
276
|
#
|
|
274
277
|
# @yield [params] The notification params (typically `nil`).
|
|
278
|
+
# @deprecated MCP Roots (`roots/list` and
|
|
279
|
+
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
|
280
|
+
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
|
281
|
+
# server configuration, or environment variables instead.
|
|
275
282
|
def roots_list_changed_handler(&block)
|
|
276
283
|
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
|
277
284
|
end
|
|
@@ -423,6 +430,13 @@ module MCP
|
|
|
423
430
|
end
|
|
424
431
|
|
|
425
432
|
def handle_request(request, method, session: nil, related_request_id: nil)
|
|
433
|
+
# A well-formed notification carries no JSON-RPC id and receives no response.
|
|
434
|
+
# If a client erroneously sends a notification-only method with an id, the message
|
|
435
|
+
# is framed as a request; since notification methods have no request handler,
|
|
436
|
+
# returning `nil` here makes `JsonRpcHandler` report "Method not found", matching
|
|
437
|
+
# the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
|
|
438
|
+
return if Methods.notification?(method) && !related_request_id.nil?
|
|
439
|
+
|
|
426
440
|
# `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
|
|
427
441
|
# and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
|
|
428
442
|
# and rescue blocks below.
|
|
@@ -636,7 +650,7 @@ module MCP
|
|
|
636
650
|
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
|
|
637
651
|
)
|
|
638
652
|
validate_tool_call_result!(tool, result)
|
|
639
|
-
result
|
|
653
|
+
serialize_structured_content_fallback(result)
|
|
640
654
|
rescue RequestHandlerError, CancelledError
|
|
641
655
|
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
642
656
|
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
@@ -801,6 +815,18 @@ module MCP
|
|
|
801
815
|
tool.output_schema.validate_result(result[:structuredContent])
|
|
802
816
|
end
|
|
803
817
|
|
|
818
|
+
# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
|
|
819
|
+
# Clients on older protocol versions may only read `content`,
|
|
820
|
+
# so when a tool returns non-object structured content without providing
|
|
821
|
+
# any content blocks, mirror the value into `content` as serialized JSON text.
|
|
822
|
+
def serialize_structured_content_fallback(result)
|
|
823
|
+
structured = result[:structuredContent]
|
|
824
|
+
return result if structured.nil? || structured.is_a?(Hash)
|
|
825
|
+
return result unless result[:content].nil? || result[:content].empty?
|
|
826
|
+
|
|
827
|
+
result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
|
|
828
|
+
end
|
|
829
|
+
|
|
804
830
|
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
805
831
|
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
806
832
|
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
data/lib/mcp/server_context.rb
CHANGED
|
@@ -35,6 +35,9 @@ module MCP
|
|
|
35
35
|
# @param data [Object] The log data to send.
|
|
36
36
|
# @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
|
|
37
37
|
# @param logger [String, nil] Logger name.
|
|
38
|
+
# @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
|
|
39
|
+
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
40
|
+
# Use stderr or OpenTelemetry instead.
|
|
38
41
|
def notify_log_message(data:, level:, logger: nil)
|
|
39
42
|
return unless @notification_target
|
|
40
43
|
|
|
@@ -51,6 +54,10 @@ module MCP
|
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
# Delegates to the session so the request is scoped to the originating client.
|
|
57
|
+
# @deprecated MCP Roots (`roots/list` and
|
|
58
|
+
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
|
59
|
+
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
|
60
|
+
# server configuration, or environment variables instead.
|
|
54
61
|
def list_roots
|
|
55
62
|
if @notification_target.respond_to?(:list_roots)
|
|
56
63
|
@notification_target.list_roots(related_request_id: @related_request_id)
|
|
@@ -84,6 +91,9 @@ module MCP
|
|
|
84
91
|
# Delegates to the session so the request is scoped to the originating client.
|
|
85
92
|
# Falls back to `@context` (via `method_missing`) when `@notification_target`
|
|
86
93
|
# does not support sampling.
|
|
94
|
+
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
|
|
95
|
+
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
|
96
|
+
# APIs instead.
|
|
87
97
|
def create_sampling_message(**kwargs)
|
|
88
98
|
if @notification_target.respond_to?(:create_sampling_message)
|
|
89
99
|
@notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
|
|
@@ -130,9 +140,14 @@ module MCP
|
|
|
130
140
|
end
|
|
131
141
|
end
|
|
132
142
|
|
|
133
|
-
|
|
143
|
+
# Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax.
|
|
144
|
+
# The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8,
|
|
145
|
+
# so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it
|
|
146
|
+
# raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps
|
|
147
|
+
# this method loadable on Ruby 2.7.0.
|
|
148
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
134
149
|
if @context.respond_to?(name)
|
|
135
|
-
@context.public_send(name,
|
|
150
|
+
@context.public_send(name, *args, **kwargs, &block)
|
|
136
151
|
else
|
|
137
152
|
super
|
|
138
153
|
end
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -98,6 +98,10 @@ module MCP
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
# Sends a `roots/list` request scoped to this session.
|
|
101
|
+
# @deprecated MCP Roots (`roots/list` and
|
|
102
|
+
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
|
103
|
+
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
|
104
|
+
# server configuration, or environment variables instead.
|
|
101
105
|
def list_roots(related_request_id: nil)
|
|
102
106
|
unless client_capabilities&.dig(:roots)
|
|
103
107
|
raise "Client does not support roots."
|
|
@@ -115,6 +119,9 @@ module MCP
|
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
# Sends a `sampling/createMessage` request scoped to this session.
|
|
122
|
+
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
|
|
123
|
+
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
|
124
|
+
# APIs instead.
|
|
118
125
|
def create_sampling_message(related_request_id: nil, **kwargs)
|
|
119
126
|
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
|
120
127
|
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
|
|
@@ -188,6 +195,9 @@ module MCP
|
|
|
188
195
|
end
|
|
189
196
|
|
|
190
197
|
# Sends a log message notification to this session only.
|
|
198
|
+
# @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
|
|
199
|
+
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
200
|
+
# Use stderr or OpenTelemetry instead.
|
|
191
201
|
def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
|
|
192
202
|
effective_logging = @logging_message_notification || @server.logging_message_notification
|
|
193
203
|
return unless effective_logging&.should_notify?(level)
|
|
@@ -7,12 +7,29 @@ module MCP
|
|
|
7
7
|
class OutputSchema < Schema
|
|
8
8
|
class ValidationError < StandardError; end
|
|
9
9
|
|
|
10
|
+
# Root-level keywords whose presence means the user already chose a root schema shape,
|
|
11
|
+
# so no `type: "object"` default should be merged in.
|
|
12
|
+
ROOT_SCHEMA_KEYWORDS = [:type, :"$ref", :oneOf, :anyOf, :allOf, :not, :if, :const, :enum].freeze
|
|
13
|
+
|
|
10
14
|
def validate_result(result)
|
|
11
15
|
errors = fully_validate(result)
|
|
12
16
|
if errors.any?
|
|
13
17
|
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
|
14
18
|
end
|
|
15
19
|
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Per SEP-2106, an output schema may be ANY valid JSON Schema 2020-12 document: object, array, primitive,
|
|
24
|
+
# or a root-level composition.
|
|
25
|
+
# Default the root to an object only when no root schema keyword is present, which preserves the wire output
|
|
26
|
+
# of the common `properties`-only shape while leaving e.g. `{ type: "array" }` or `{ oneOf: [...] }` untouched
|
|
27
|
+
# (the old unconditional default merged `type: "object"` into root combinators, producing a wrong schema).
|
|
28
|
+
def apply_default_root_type!
|
|
29
|
+
return if ROOT_SCHEMA_KEYWORDS.any? { |keyword| @schema.key?(keyword) }
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
16
33
|
end
|
|
17
34
|
end
|
|
18
35
|
end
|