mcp 0.22.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 +74 -4
- data/lib/mcp/client/stdio.rb +44 -10
- 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 +14 -0
- data/lib/mcp/server_context.rb +10 -0
- data/lib/mcp/server_session.rb +10 -0
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3d294b5e8b52b58ba3d2a2752db94f3a0d836ed6003d10ba0220d9d31b2d267
|
|
4
|
+
data.tar.gz: 1f0e92d3a77fc36166a1f68c3443aa9d5174ee139491b784dfa3cdcb92caa8e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 975ebdaeab3e8dd8219c81c812fcbde232047a9baabf6924bea6fba7f4eae05b6eab96aa02efd601e83e443fc0ad62107f9379a1ab07924d1fcccedae48c6232
|
|
7
|
+
data.tar.gz: 624de6afb8859bce866dabef20c6357913ff8adf2e479f84d2472049f7f7f2853dc65f27ed1925f1c3f76eae880a3886806136dea6d94459988d24e6f973d304
|
data/README.md
CHANGED
|
@@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
|
102
102
|
transport.open
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB).
|
|
106
|
+
|
|
105
107
|
You can run this script and then type in requests to the server at the command line.
|
|
106
108
|
|
|
107
109
|
```console
|
|
@@ -128,6 +130,27 @@ The following examples show two common integration styles in Rails.
|
|
|
128
130
|
>
|
|
129
131
|
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
|
|
130
132
|
|
|
133
|
+
> [!IMPORTANT]
|
|
134
|
+
> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
|
|
135
|
+
> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
|
|
136
|
+
> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
|
|
137
|
+
> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
|
|
138
|
+
> header are unaffected.
|
|
139
|
+
>
|
|
140
|
+
> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
|
|
141
|
+
>
|
|
142
|
+
> ```ruby
|
|
143
|
+
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
144
|
+
> server,
|
|
145
|
+
> allowed_hosts: ["mcp.example.com"],
|
|
146
|
+
> allowed_origins: ["https://app.example.com"],
|
|
147
|
+
> )
|
|
148
|
+
> ```
|
|
149
|
+
>
|
|
150
|
+
> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
|
|
151
|
+
> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
|
|
152
|
+
> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
|
|
153
|
+
|
|
131
154
|
##### Rails (mount)
|
|
132
155
|
|
|
133
156
|
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
|
|
@@ -1758,12 +1781,58 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl
|
|
|
1758
1781
|
broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
|
|
1759
1782
|
This mode is suitable for simple tool servers that do not need server-initiated requests.
|
|
1760
1783
|
|
|
1761
|
-
By default, sessions
|
|
1762
|
-
|
|
1784
|
+
By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted:
|
|
1785
|
+
they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
|
|
1786
|
+
session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it
|
|
1787
|
+
(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
|
|
1788
|
+
so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
|
|
1789
|
+
and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
|
|
1790
|
+
|
|
1791
|
+
```ruby
|
|
1792
|
+
# Tune the limits
|
|
1793
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
|
|
1794
|
+
|
|
1795
|
+
# Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
|
|
1796
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)
|
|
1797
|
+
```
|
|
1798
|
+
|
|
1799
|
+
Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it.
|
|
1800
|
+
|
|
1801
|
+
#### Session Ownership
|
|
1802
|
+
|
|
1803
|
+
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session
|
|
1804
|
+
existence and idle timeout only. It does not bind a session to a user, because the transport never receives
|
|
1805
|
+
an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
|
|
1806
|
+
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
|
|
1807
|
+
|
|
1808
|
+
The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }`
|
|
1809
|
+
on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs,
|
|
1810
|
+
so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return
|
|
1811
|
+
rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded
|
|
1812
|
+
when the session was created:
|
|
1813
|
+
|
|
1814
|
+
```ruby
|
|
1815
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
1816
|
+
server,
|
|
1817
|
+
session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
|
|
1818
|
+
)
|
|
1819
|
+
```
|
|
1820
|
+
|
|
1821
|
+
Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
|
|
1822
|
+
it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only
|
|
1823
|
+
when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
|
|
1824
|
+
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
|
|
1825
|
+
|
|
1826
|
+
#### Request Size Limits
|
|
1827
|
+
|
|
1828
|
+
`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory
|
|
1829
|
+
with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413,
|
|
1830
|
+
and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON
|
|
1831
|
+
string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only
|
|
1832
|
+
if you exchange unusually large payloads:
|
|
1763
1833
|
|
|
1764
1834
|
```ruby
|
|
1765
|
-
|
|
1766
|
-
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
|
|
1835
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024)
|
|
1767
1836
|
```
|
|
1768
1837
|
|
|
1769
1838
|
### Pagination
|
|
@@ -1958,6 +2027,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s
|
|
|
1958
2027
|
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
|
1959
2028
|
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
|
1960
2029
|
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
|
|
2030
|
+
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
|
|
1961
2031
|
|
|
1962
2032
|
Example usage:
|
|
1963
2033
|
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -19,13 +19,28 @@ module MCP
|
|
|
19
19
|
CLOSE_TIMEOUT = 2
|
|
20
20
|
STDERR_READ_SIZE = 4096
|
|
21
21
|
|
|
22
|
+
# Default upper bound on a single newline-delimited frame read from the
|
|
23
|
+
# server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a
|
|
24
|
+
# newline arrives, so a spawned server that never emits one can grow a single
|
|
25
|
+
# String until the host process is OOM-killed. 4 MiB is large enough for any
|
|
26
|
+
# realistic JSON-RPC frame, including base64-embedded images.
|
|
27
|
+
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
28
|
+
|
|
22
29
|
attr_reader :command, :args, :env, :server_info
|
|
23
30
|
|
|
24
|
-
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
|
31
|
+
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
|
|
32
|
+
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
|
|
33
|
+
# limit read without an upper bound, which would silently disable the
|
|
34
|
+
# protection this option exists to provide.
|
|
35
|
+
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
|
|
36
|
+
raise ArgumentError, "max_line_bytes must be a positive Integer"
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
@command = command
|
|
26
40
|
@args = args
|
|
27
41
|
@env = env
|
|
28
42
|
@read_timeout = read_timeout
|
|
43
|
+
@max_line_bytes = max_line_bytes
|
|
29
44
|
@stdin = nil
|
|
30
45
|
@stdout = nil
|
|
31
46
|
@stderr = nil
|
|
@@ -128,9 +143,7 @@ module MCP
|
|
|
128
143
|
@server_info
|
|
129
144
|
end
|
|
130
145
|
|
|
131
|
-
# Returns true once `connect`
|
|
132
|
-
# `send_request`) has completed. Returns false before the handshake
|
|
133
|
-
# and after `close`.
|
|
146
|
+
# Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
|
|
134
147
|
def connected?
|
|
135
148
|
@initialized
|
|
136
149
|
end
|
|
@@ -140,11 +153,7 @@ module MCP
|
|
|
140
153
|
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
|
141
154
|
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
|
142
155
|
def send_request(request:)
|
|
143
|
-
|
|
144
|
-
unless @initialized
|
|
145
|
-
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
|
|
146
|
-
connect
|
|
147
|
-
end
|
|
156
|
+
raise "MCP::Client#connect must be called before sending requests." unless @initialized
|
|
148
157
|
|
|
149
158
|
@write_mutex.synchronize do
|
|
150
159
|
write_message(request)
|
|
@@ -245,7 +254,7 @@ module MCP
|
|
|
245
254
|
loop do
|
|
246
255
|
ensure_running!
|
|
247
256
|
wait_for_readable!(method, params) if @read_timeout
|
|
248
|
-
line =
|
|
257
|
+
line = read_line(method, params)
|
|
249
258
|
raise_connection_error!(method, params) if line.nil?
|
|
250
259
|
|
|
251
260
|
parsed = JSON.parse(line.strip)
|
|
@@ -284,6 +293,31 @@ module MCP
|
|
|
284
293
|
)
|
|
285
294
|
end
|
|
286
295
|
|
|
296
|
+
# Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
|
|
297
|
+
# Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
|
|
298
|
+
# is reached before a newline arrives, which signals a server streaming an unbounded frame.
|
|
299
|
+
# A short final frame without a trailing newline (EOF) is still returned, since its length
|
|
300
|
+
# stays under the limit.
|
|
301
|
+
def read_line(method, params)
|
|
302
|
+
line = @stdout.gets("\n", @max_line_bytes)
|
|
303
|
+
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
|
|
304
|
+
|
|
305
|
+
# The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
|
|
306
|
+
# cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
|
|
307
|
+
# of parsing a truncated frame.
|
|
308
|
+
begin
|
|
309
|
+
close
|
|
310
|
+
rescue StandardError
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
raise RequestHandlerError.new(
|
|
315
|
+
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
|
|
316
|
+
{ method: method, params: params },
|
|
317
|
+
error_type: :internal_error,
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
287
321
|
def raise_connection_error!(method, params)
|
|
288
322
|
raise RequestHandlerError.new(
|
|
289
323
|
"Server process closed stdout unexpectedly",
|
data/lib/mcp/methods.rb
CHANGED
|
@@ -9,10 +9,25 @@ module MCP
|
|
|
9
9
|
class StdioTransport < Transport
|
|
10
10
|
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
|
|
13
|
+
# without a limit accumulates bytes until a newline arrives, so a peer that
|
|
14
|
+
# never emits one can grow a single String until the process is OOM-killed.
|
|
15
|
+
# 4 MiB is large enough for any realistic JSON-RPC frame, including
|
|
16
|
+
# base64-embedded images.
|
|
17
|
+
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
def initialize(server, max_line_bytes: MAX_LINE_BYTES)
|
|
13
20
|
super(server)
|
|
14
21
|
@open = false
|
|
15
22
|
@session = nil
|
|
23
|
+
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
|
|
24
|
+
# limit read without an upper bound, which would silently disable the
|
|
25
|
+
# protection this option exists to provide.
|
|
26
|
+
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
|
|
27
|
+
raise ArgumentError, "max_line_bytes must be a positive Integer"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@max_line_bytes = max_line_bytes
|
|
16
31
|
$stdin.set_encoding("UTF-8")
|
|
17
32
|
$stdout.set_encoding("UTF-8")
|
|
18
33
|
end
|
|
@@ -20,7 +35,20 @@ module MCP
|
|
|
20
35
|
def open
|
|
21
36
|
@open = true
|
|
22
37
|
@session = ServerSession.new(server: @server, transport: self)
|
|
23
|
-
while @open
|
|
38
|
+
while @open
|
|
39
|
+
begin
|
|
40
|
+
line = read_line($stdin)
|
|
41
|
+
rescue RequestHandlerError => e
|
|
42
|
+
# Stop accumulating and end the connection gracefully rather than
|
|
43
|
+
# letting an unbounded read exhaust memory or escape as an uncaught
|
|
44
|
+
# backtrace. Scoped to the read so genuine request errors raised while
|
|
45
|
+
# handling a frame are not swallowed here.
|
|
46
|
+
@open = false
|
|
47
|
+
MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
break if line.nil?
|
|
51
|
+
|
|
24
52
|
response = @session.handle_json(line.strip)
|
|
25
53
|
send_response(response) if response
|
|
26
54
|
end
|
|
@@ -73,7 +101,7 @@ module MCP
|
|
|
73
101
|
raise
|
|
74
102
|
end
|
|
75
103
|
|
|
76
|
-
while @open && (line = $stdin
|
|
104
|
+
while @open && (line = read_line($stdin))
|
|
77
105
|
begin
|
|
78
106
|
parsed = JSON.parse(line.strip, symbolize_names: true)
|
|
79
107
|
rescue JSON::ParserError => e
|
|
@@ -95,6 +123,26 @@ module MCP
|
|
|
95
123
|
|
|
96
124
|
raise "Transport closed while waiting for response to #{method} request."
|
|
97
125
|
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
|
|
130
|
+
# the line (including its trailing newline) or `nil` at EOF. Raises when the
|
|
131
|
+
# limit is reached before a newline arrives, which signals a peer streaming
|
|
132
|
+
# an unbounded frame. A short final frame without a trailing newline (EOF) is
|
|
133
|
+
# still returned, since its length stays under the limit.
|
|
134
|
+
def read_line(io)
|
|
135
|
+
line = io.gets("\n", @max_line_bytes)
|
|
136
|
+
if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
|
|
137
|
+
raise RequestHandlerError.new(
|
|
138
|
+
"stdio frame exceeds #{@max_line_bytes} bytes without a newline",
|
|
139
|
+
nil,
|
|
140
|
+
error_type: :internal_error,
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
line
|
|
145
|
+
end
|
|
98
146
|
end
|
|
99
147
|
end
|
|
100
148
|
end
|
|
@@ -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.
|
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)
|
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)
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -90,7 +90,7 @@ licenses:
|
|
|
90
90
|
- Apache-2.0
|
|
91
91
|
metadata:
|
|
92
92
|
allowed_push_host: https://rubygems.org
|
|
93
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
93
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.23.0
|
|
94
94
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
95
95
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
96
96
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|