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
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
|
@@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
|
|
|
41
41
|
- Supports roots (server-to-client filesystem boundary queries)
|
|
42
42
|
- Supports sampling (server-to-client LLM completion requests)
|
|
43
43
|
- Supports cursor-based pagination for list operations
|
|
44
|
-
- Supports
|
|
44
|
+
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
|
|
45
45
|
|
|
46
46
|
### Supported Methods
|
|
47
47
|
|
|
@@ -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:
|
|
@@ -698,8 +721,28 @@ class WeatherTool < MCP::Tool
|
|
|
698
721
|
end
|
|
699
722
|
```
|
|
700
723
|
|
|
701
|
-
Please note: in this case, you must provide `type: "array"`. The default type
|
|
702
|
-
|
|
724
|
+
Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`,
|
|
725
|
+
applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`).
|
|
726
|
+
|
|
727
|
+
Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
|
|
728
|
+
(`{ type: "string" }`) or a root-level composition:
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
class FlexibleTool < MCP::Tool
|
|
732
|
+
output_schema(
|
|
733
|
+
oneOf: [
|
|
734
|
+
{ type: "string" },
|
|
735
|
+
{ type: "array", items: { type: "number" } }
|
|
736
|
+
]
|
|
737
|
+
)
|
|
738
|
+
end
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it
|
|
742
|
+
(`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to
|
|
743
|
+
all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are
|
|
744
|
+
capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects;
|
|
745
|
+
violations raise `ArgumentError` at construction time.
|
|
703
746
|
|
|
704
747
|
MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:
|
|
705
748
|
|
|
@@ -729,6 +772,10 @@ Tools can return structured data alongside text content using the `structured_co
|
|
|
729
772
|
|
|
730
773
|
The structured content will be included in the JSON-RPC response as the `structuredContent` field.
|
|
731
774
|
|
|
775
|
+
Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
|
|
776
|
+
without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients
|
|
777
|
+
that only read `content` still receive the data.
|
|
778
|
+
|
|
732
779
|
```ruby
|
|
733
780
|
class WeatherTool < MCP::Tool
|
|
734
781
|
description "Get current weather and return structured data"
|
|
@@ -1205,12 +1252,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
|
|
|
1205
1252
|
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
|
|
1206
1253
|
is never cancellable per the spec.
|
|
1207
1254
|
|
|
1208
|
-
|
|
1209
|
-
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
|
|
1210
|
-
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
|
|
1211
|
-
> from the client side can be done by constructing the notification payload and writing it
|
|
1212
|
-
> directly through the transport, but the calling thread does not yet unwind automatically.
|
|
1213
|
-
> This is tracked as a follow-up.
|
|
1255
|
+
Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
|
|
1214
1256
|
|
|
1215
1257
|
#### Server-Side: Handlers that Check for Cancellation
|
|
1216
1258
|
|
|
@@ -1319,6 +1361,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
|
|
1319
1361
|
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
|
1320
1362
|
via `server_context.cancelled?` between nested calls.
|
|
1321
1363
|
|
|
1364
|
+
#### Client-Side: Cancelling an In-Flight Request
|
|
1365
|
+
|
|
1366
|
+
`MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass
|
|
1367
|
+
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
|
1368
|
+
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
|
1369
|
+
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
|
1370
|
+
|
|
1371
|
+
```ruby
|
|
1372
|
+
client = MCP::Client.new(transport: transport)
|
|
1373
|
+
cancellation = MCP::Cancellation.new
|
|
1374
|
+
|
|
1375
|
+
Thread.new do
|
|
1376
|
+
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
|
1377
|
+
rescue MCP::CancelledError
|
|
1378
|
+
# cleanup
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
# Later, from another thread:
|
|
1382
|
+
cancellation.cancel(reason: "user pressed cancel")
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
|
1386
|
+
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
|
|
1387
|
+
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
|
1388
|
+
|
|
1389
|
+
> [!NOTE]
|
|
1390
|
+
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
|
1391
|
+
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
|
1392
|
+
> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
|
1393
|
+
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
|
1394
|
+
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
|
1395
|
+
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
|
1396
|
+
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
|
1397
|
+
|
|
1398
|
+
##### Wire-order guarantees
|
|
1399
|
+
|
|
1400
|
+
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
|
1401
|
+
so the server is guaranteed to read the request line before the cancel line.
|
|
1402
|
+
|
|
1403
|
+
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
|
1404
|
+
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
|
1405
|
+
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
|
1406
|
+
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
|
|
1407
|
+
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
|
1408
|
+
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
|
1409
|
+
|
|
1410
|
+
##### Custom transports
|
|
1411
|
+
|
|
1412
|
+
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
|
1413
|
+
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
|
1414
|
+
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
|
1415
|
+
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
|
1416
|
+
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
|
1417
|
+
|
|
1322
1418
|
### Ping
|
|
1323
1419
|
|
|
1324
1420
|
The MCP Ruby SDK supports the
|
|
@@ -1685,12 +1781,58 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl
|
|
|
1685
1781
|
broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
|
|
1686
1782
|
This mode is suitable for simple tool servers that do not need server-initiated requests.
|
|
1687
1783
|
|
|
1688
|
-
By default, sessions
|
|
1689
|
-
|
|
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:
|
|
1690
1833
|
|
|
1691
1834
|
```ruby
|
|
1692
|
-
|
|
1693
|
-
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)
|
|
1694
1836
|
```
|
|
1695
1837
|
|
|
1696
1838
|
### Pagination
|
|
@@ -1885,6 +2027,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s
|
|
|
1885
2027
|
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
|
1886
2028
|
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
|
1887
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). |
|
|
1888
2031
|
|
|
1889
2032
|
Example usage:
|
|
1890
2033
|
|
data/lib/mcp/annotations.rb
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class Annotations
|
|
5
|
+
SUPPORTED_AUDIENCES = ["user", "assistant"].freeze
|
|
6
|
+
|
|
5
7
|
attr_reader :audience, :priority, :last_modified
|
|
6
8
|
|
|
7
9
|
def initialize(audience: nil, priority: nil, last_modified: nil)
|
|
10
|
+
if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) })
|
|
11
|
+
raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".'
|
|
12
|
+
end
|
|
8
13
|
raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
|
|
9
14
|
|
|
10
15
|
@audience = audience
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -16,6 +16,8 @@ module MCP
|
|
|
16
16
|
ACCEPT_HEADER = "application/json, text/event-stream"
|
|
17
17
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
18
18
|
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
19
|
+
METHOD_HEADER = "Mcp-Method"
|
|
20
|
+
NAME_HEADER = "Mcp-Name"
|
|
19
21
|
|
|
20
22
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
21
23
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
@@ -178,9 +180,18 @@ module MCP
|
|
|
178
180
|
end
|
|
179
181
|
|
|
180
182
|
# Sends a JSON-RPC request and returns the parsed response body.
|
|
181
|
-
# After a successful `initialize` handshake, the session ID and protocol
|
|
182
|
-
#
|
|
183
|
-
#
|
|
183
|
+
# After a successful `initialize` handshake, the session ID and protocol version returned by
|
|
184
|
+
# the server are captured and automatically included on subsequent requests.
|
|
185
|
+
#
|
|
186
|
+
# If a block is given, it is invoked just before Faraday's `post` is called.
|
|
187
|
+
# Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
|
188
|
+
# so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
|
|
189
|
+
# inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release
|
|
190
|
+
# the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST
|
|
191
|
+
# that may overlap with the original request on the network. The spec covers this:
|
|
192
|
+
# the sender has issued the request and still believes it in-progress, and receivers MAY ignore
|
|
193
|
+
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
|
|
194
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
|
|
184
195
|
def send_request(request:)
|
|
185
196
|
method = request[:method] || request["method"]
|
|
186
197
|
params = request[:params] || request["params"]
|
|
@@ -188,7 +199,8 @@ module MCP
|
|
|
188
199
|
step_up_retried = false
|
|
189
200
|
|
|
190
201
|
begin
|
|
191
|
-
|
|
202
|
+
yield if block_given?
|
|
203
|
+
response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
|
|
192
204
|
body = parse_response_body(response, method, params)
|
|
193
205
|
|
|
194
206
|
capture_session_info(method, response, body)
|
|
@@ -274,6 +286,23 @@ module MCP
|
|
|
274
286
|
end
|
|
275
287
|
end
|
|
276
288
|
|
|
289
|
+
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
|
290
|
+
# `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted
|
|
291
|
+
# per the Streamable HTTP spec.
|
|
292
|
+
def send_notification(notification:)
|
|
293
|
+
method = notification[:method] || notification["method"]
|
|
294
|
+
|
|
295
|
+
client.post("", notification, session_headers)
|
|
296
|
+
nil
|
|
297
|
+
rescue Faraday::Error => e
|
|
298
|
+
raise RequestHandlerError.new(
|
|
299
|
+
"Failed to send #{method} notification",
|
|
300
|
+
{ method: method },
|
|
301
|
+
error_type: :internal_error,
|
|
302
|
+
original_error: e,
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
277
306
|
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
|
278
307
|
# with the current `Mcp-Session-Id` header, and clears locally tracked
|
|
279
308
|
# session state afterward. No-op when no session has been established.
|
|
@@ -341,6 +370,39 @@ module MCP
|
|
|
341
370
|
request_headers
|
|
342
371
|
end
|
|
343
372
|
|
|
373
|
+
# Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
|
|
374
|
+
# can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
|
|
375
|
+
# (tools, prompts) or, when absent, `params.uri` (resources).
|
|
376
|
+
#
|
|
377
|
+
# https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
|
|
378
|
+
def request_metadata_headers(method, params)
|
|
379
|
+
return {} unless method
|
|
380
|
+
|
|
381
|
+
metadata_headers = { METHOD_HEADER => method }
|
|
382
|
+
if params.is_a?(Hash)
|
|
383
|
+
name = params[:name] || params["name"]
|
|
384
|
+
name = params[:uri] || params["uri"] unless name.is_a?(String)
|
|
385
|
+
metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
metadata_headers
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
|
|
392
|
+
# which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
|
|
393
|
+
# `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
|
|
394
|
+
def encode_header_value(value)
|
|
395
|
+
return value if safe_header_value?(value)
|
|
396
|
+
|
|
397
|
+
"=?base64?#{[value].pack("m0")}?="
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def safe_header_value?(value)
|
|
401
|
+
value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
|
|
402
|
+
(value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
|
|
403
|
+
!(value.start_with?("=?base64?") && value.end_with?("?="))
|
|
404
|
+
end
|
|
405
|
+
|
|
344
406
|
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
|
345
407
|
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
|
346
408
|
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
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
|
|
@@ -34,6 +49,9 @@ module MCP
|
|
|
34
49
|
@started = false
|
|
35
50
|
@initialized = false
|
|
36
51
|
@server_info = nil
|
|
52
|
+
# Serializes writes to `@stdin` so a request line and a notification line emitted from
|
|
53
|
+
# different threads (e.g. cancellation) cannot interleave on the wire.
|
|
54
|
+
@write_mutex = Mutex.new
|
|
37
55
|
end
|
|
38
56
|
|
|
39
57
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
@@ -125,24 +143,35 @@ module MCP
|
|
|
125
143
|
@server_info
|
|
126
144
|
end
|
|
127
145
|
|
|
128
|
-
# Returns true once `connect`
|
|
129
|
-
# `send_request`) has completed. Returns false before the handshake
|
|
130
|
-
# and after `close`.
|
|
146
|
+
# Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
|
|
131
147
|
def connected?
|
|
132
148
|
@initialized
|
|
133
149
|
end
|
|
134
150
|
|
|
151
|
+
# Transports may yield once the request line has been written to `@stdin`.
|
|
152
|
+
# `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
|
|
153
|
+
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
|
154
|
+
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
|
135
155
|
def send_request(request:)
|
|
136
|
-
|
|
137
|
-
unless @initialized
|
|
138
|
-
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
|
|
139
|
-
connect
|
|
140
|
-
end
|
|
156
|
+
raise "MCP::Client#connect must be called before sending requests." unless @initialized
|
|
141
157
|
|
|
142
|
-
|
|
158
|
+
@write_mutex.synchronize do
|
|
159
|
+
write_message(request)
|
|
160
|
+
yield if block_given?
|
|
161
|
+
end
|
|
143
162
|
read_response(request)
|
|
144
163
|
end
|
|
145
164
|
|
|
165
|
+
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
|
166
|
+
# `notifications/cancelled` for an in-flight request.
|
|
167
|
+
def send_notification(notification:)
|
|
168
|
+
start unless @started
|
|
169
|
+
connect unless @initialized
|
|
170
|
+
|
|
171
|
+
@write_mutex.synchronize { write_message(notification) }
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
146
175
|
def start
|
|
147
176
|
raise "MCP::Client::Stdio already started" if @started
|
|
148
177
|
|
|
@@ -225,7 +254,7 @@ module MCP
|
|
|
225
254
|
loop do
|
|
226
255
|
ensure_running!
|
|
227
256
|
wait_for_readable!(method, params) if @read_timeout
|
|
228
|
-
line =
|
|
257
|
+
line = read_line(method, params)
|
|
229
258
|
raise_connection_error!(method, params) if line.nil?
|
|
230
259
|
|
|
231
260
|
parsed = JSON.parse(line.strip)
|
|
@@ -264,6 +293,31 @@ module MCP
|
|
|
264
293
|
)
|
|
265
294
|
end
|
|
266
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
|
+
|
|
267
321
|
def raise_connection_error!(method, params)
|
|
268
322
|
raise RequestHandlerError.new(
|
|
269
323
|
"Server process closed stdout unexpectedly",
|