mcp 0.14.0 → 0.16.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 +149 -1
- data/lib/json_rpc_handler.rb +6 -0
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +99 -2
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +41 -0
- data/lib/mcp/configuration.rb +22 -1
- data/lib/mcp/server/transports/stdio_transport.rb +7 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +63 -1
- data/lib/mcp/server.rb +160 -19
- data/lib/mcp/server_context.rb +12 -1
- data/lib/mcp/server_session.rb +105 -20
- data/lib/mcp/tool/schema.rb +22 -4
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +5 -4
- data/lib/mcp/transports/stdio.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b7c0c225e89be4cfc9c73a0707b8a6a61d0d76a8a21340b20b3dd276f36eec6
|
|
4
|
+
data.tar.gz: fdd1b11cbe6ac733820bc604f72c95c6f0aa67162776c8778de7adb04aa7ca39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6dce7fe76f5f3996c9f5caffe884237147c74840d4d68dce5ec6ec24be49e65e0eea39306c2c52353c5014c8247295491b3337e166cec662eca19742565d741
|
|
7
|
+
data.tar.gz: b944ae1938952c5e4fd20fb4b5d1ade071cf5ffa193b6c0cd56ba25e79aa583e2de38804045ce0d038eb2e38b5ea5bed4fd6b6073a16fea8bdba02f7e1e8b148
|
data/README.md
CHANGED
|
@@ -41,6 +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 server-side cancellation of in-flight requests (notifications/cancelled)
|
|
44
45
|
|
|
45
46
|
### Supported Methods
|
|
46
47
|
|
|
@@ -644,6 +645,19 @@ MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/l
|
|
|
644
645
|
|
|
645
646
|
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
|
|
646
647
|
|
|
648
|
+
By default, server-side validation of tool results against `output_schema` is disabled for backwards compatibility. To validate successful tool responses, enable `validate_tool_call_results`:
|
|
649
|
+
|
|
650
|
+
```ruby
|
|
651
|
+
configuration = MCP::Configuration.new(validate_tool_call_results: true)
|
|
652
|
+
server = MCP::Server.new(
|
|
653
|
+
name: "example_server",
|
|
654
|
+
tools: [WeatherTool],
|
|
655
|
+
configuration: configuration
|
|
656
|
+
)
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
When enabled, successful tool responses for tools with an `output_schema` must include `structured_content` that conforms to the schema. Error responses are not validated against the output schema.
|
|
660
|
+
|
|
647
661
|
### Tool Responses with Structured Content
|
|
648
662
|
|
|
649
663
|
Tools can return structured data alongside text content using the `structured_content` parameter.
|
|
@@ -1096,9 +1110,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
|
|
|
1096
1110
|
- `notifications/tools/list_changed`
|
|
1097
1111
|
- `notifications/prompts/list_changed`
|
|
1098
1112
|
- `notifications/resources/list_changed`
|
|
1113
|
+
- `notifications/cancelled`
|
|
1099
1114
|
- `notifications/progress`
|
|
1100
1115
|
- `notifications/message`
|
|
1101
1116
|
|
|
1117
|
+
### Cancellation
|
|
1118
|
+
|
|
1119
|
+
The MCP Ruby SDK supports server-side handling of the
|
|
1120
|
+
[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
|
|
1121
|
+
When a client sends `notifications/cancelled` for an in-flight request, the server stops
|
|
1122
|
+
processing cooperatively and suppresses the JSON-RPC response for that request.
|
|
1123
|
+
|
|
1124
|
+
Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
|
|
1125
|
+
a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools
|
|
1126
|
+
poll it to exit early. When a tool returns after cancellation has been observed,
|
|
1127
|
+
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
|
|
1128
|
+
is never cancellable per the spec.
|
|
1129
|
+
|
|
1130
|
+
> [!NOTE]
|
|
1131
|
+
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
|
|
1132
|
+
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
|
|
1133
|
+
> from the client side can be done by constructing the notification payload and writing it
|
|
1134
|
+
> directly through the transport, but the calling thread does not yet unwind automatically.
|
|
1135
|
+
> This is tracked as a follow-up.
|
|
1136
|
+
|
|
1137
|
+
#### Server-Side: Handlers that Check for Cancellation
|
|
1138
|
+
|
|
1139
|
+
Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
|
|
1140
|
+
`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
|
|
1141
|
+
`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
|
|
1142
|
+
an `MCP::ServerContext` wired to the in-flight request's cancellation token.
|
|
1143
|
+
Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise
|
|
1144
|
+
`MCP::CancelledError` at a safe point:
|
|
1145
|
+
|
|
1146
|
+
```ruby
|
|
1147
|
+
class LongRunningTool < MCP::Tool
|
|
1148
|
+
description "A tool that supports cancellation"
|
|
1149
|
+
input_schema(properties: { count: { type: "integer" } }, required: ["count"])
|
|
1150
|
+
|
|
1151
|
+
def self.call(count:, server_context:)
|
|
1152
|
+
count.times do |i|
|
|
1153
|
+
# Exit early if the client has sent `notifications/cancelled`.
|
|
1154
|
+
break if server_context.cancelled?
|
|
1155
|
+
|
|
1156
|
+
do_work(i)
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
|
|
1160
|
+
end
|
|
1161
|
+
end
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
Alternatively, raise at the next safe point with `raise_if_cancelled!`:
|
|
1165
|
+
|
|
1166
|
+
```ruby
|
|
1167
|
+
def self.call(count:, server_context:)
|
|
1168
|
+
count.times do |i|
|
|
1169
|
+
server_context.raise_if_cancelled!
|
|
1170
|
+
|
|
1171
|
+
do_work(i)
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
|
|
1175
|
+
end
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
When a handler observes cancellation (either by returning early with `cancelled?` or
|
|
1179
|
+
by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and
|
|
1180
|
+
no JSON-RPC result is sent to the client.
|
|
1181
|
+
|
|
1182
|
+
The same pattern works for other handler types:
|
|
1183
|
+
|
|
1184
|
+
```ruby
|
|
1185
|
+
# resources/read
|
|
1186
|
+
server.resources_read_handler do |params, server_context:|
|
|
1187
|
+
server_context.raise_if_cancelled!
|
|
1188
|
+
# read the resource
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# completion/complete
|
|
1192
|
+
server.completion_handler do |params, server_context:|
|
|
1193
|
+
server_context.raise_if_cancelled!
|
|
1194
|
+
# compute completions
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# custom method
|
|
1198
|
+
server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
|
|
1199
|
+
server_context.raise_if_cancelled!
|
|
1200
|
+
# do work
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# prompts (via Prompt subclass)
|
|
1204
|
+
class SlowPrompt < MCP::Prompt
|
|
1205
|
+
prompt_name "slow_prompt"
|
|
1206
|
+
|
|
1207
|
+
def self.template(args, server_context:)
|
|
1208
|
+
server_context.raise_if_cancelled!
|
|
1209
|
+
MCP::Prompt::Result.new(messages: [])
|
|
1210
|
+
end
|
|
1211
|
+
end
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
Handlers that do not declare a `server_context:` keyword continue to work unchanged -
|
|
1215
|
+
the opt-in detection only wraps the context when the block signature asks for it.
|
|
1216
|
+
|
|
1217
|
+
#### Nested Server-to-Client Requests Are Cancelled Automatically
|
|
1218
|
+
|
|
1219
|
+
When a tool handler is waiting on a nested server-to-client request
|
|
1220
|
+
(`server_context.create_sampling_message`, `create_form_elicitation`, or
|
|
1221
|
+
`create_url_elicitation`), cancelling the parent tool call automatically raises
|
|
1222
|
+
`MCP::CancelledError` from the nested call, so the tool does not need to wrap it
|
|
1223
|
+
in its own `cancelled?` checks:
|
|
1224
|
+
|
|
1225
|
+
```ruby
|
|
1226
|
+
def self.call(server_context:)
|
|
1227
|
+
result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
|
|
1228
|
+
# If the parent tools/call is cancelled while waiting above, MCP::CancelledError
|
|
1229
|
+
# is raised here and the tool can let it propagate or clean up as needed.
|
|
1230
|
+
MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
|
|
1231
|
+
rescue MCP::CancelledError
|
|
1232
|
+
# Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
|
|
1233
|
+
# will still suppress the JSON-RPC response per the MCP spec.
|
|
1234
|
+
raise
|
|
1235
|
+
end
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
|
1239
|
+
`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested
|
|
1240
|
+
`server_context.create_sampling_message` inside a tool runs to completion even if
|
|
1241
|
+
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
|
1242
|
+
via `server_context.cancelled?` between nested calls.
|
|
1243
|
+
|
|
1102
1244
|
### Ping
|
|
1103
1245
|
|
|
1104
1246
|
The MCP Ruby SDK supports the
|
|
@@ -1597,7 +1739,7 @@ This class supports:
|
|
|
1597
1739
|
- Tool invocation via the `tools/call` method (`MCP::Client#call_tools`)
|
|
1598
1740
|
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
|
1599
1741
|
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
|
|
1600
|
-
- Resource reading via the `resources/read` method (`MCP::Client#
|
|
1742
|
+
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
|
|
1601
1743
|
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
|
|
1602
1744
|
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
|
|
1603
1745
|
- Completion requests via the `completion/complete` method (`MCP::Client#complete`)
|
|
@@ -1653,6 +1795,9 @@ stdio_transport = MCP::Client::Stdio.new(
|
|
|
1653
1795
|
)
|
|
1654
1796
|
client = MCP::Client.new(transport: stdio_transport)
|
|
1655
1797
|
|
|
1798
|
+
# Perform the MCP initialization handshake before sending any requests.
|
|
1799
|
+
client.connect
|
|
1800
|
+
|
|
1656
1801
|
# List available tools.
|
|
1657
1802
|
tools = client.tools
|
|
1658
1803
|
tools.each do |tool|
|
|
@@ -1693,6 +1838,9 @@ Example usage:
|
|
|
1693
1838
|
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
|
1694
1839
|
client = MCP::Client.new(transport: http_transport)
|
|
1695
1840
|
|
|
1841
|
+
# Perform the MCP initialization handshake before sending any requests.
|
|
1842
|
+
client.connect
|
|
1843
|
+
|
|
1696
1844
|
# List available tools
|
|
1697
1845
|
tools = client.tools
|
|
1698
1846
|
tools.each do |tool|
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -18,6 +18,11 @@ module JsonRpcHandler
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
|
|
20
20
|
|
|
21
|
+
# Sentinel return value from a handler. When a handler returns this,
|
|
22
|
+
# `process_request` emits no JSON-RPC response for the request,
|
|
23
|
+
# matching the notification-style semantics (id is ignored).
|
|
24
|
+
NO_RESPONSE = Object.new.freeze
|
|
25
|
+
|
|
21
26
|
extend self
|
|
22
27
|
|
|
23
28
|
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
@@ -103,6 +108,7 @@ module JsonRpcHandler
|
|
|
103
108
|
end
|
|
104
109
|
|
|
105
110
|
result = method.call(params)
|
|
111
|
+
return if result.equal?(NO_RESPONSE)
|
|
106
112
|
|
|
107
113
|
success_response(id: id, result: result)
|
|
108
114
|
rescue MCP::Server::RequestHandlerError => e
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cancelled_error"
|
|
4
|
+
|
|
5
|
+
module MCP
|
|
6
|
+
class Cancellation
|
|
7
|
+
attr_reader :reason, :request_id
|
|
8
|
+
|
|
9
|
+
def initialize(request_id: nil)
|
|
10
|
+
@request_id = request_id
|
|
11
|
+
@reason = nil
|
|
12
|
+
@cancelled = false
|
|
13
|
+
@callbacks = []
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancelled?
|
|
18
|
+
@mutex.synchronize { @cancelled }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cancel(reason: nil)
|
|
22
|
+
callbacks = @mutex.synchronize do
|
|
23
|
+
return false if @cancelled
|
|
24
|
+
|
|
25
|
+
@cancelled = true
|
|
26
|
+
@reason = reason
|
|
27
|
+
@callbacks.tap { @callbacks = [] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
callbacks.each do |callback|
|
|
31
|
+
callback.call(reason)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Registers a callback invoked synchronously on the first `cancel` call.
|
|
40
|
+
# If already cancelled, fires immediately.
|
|
41
|
+
#
|
|
42
|
+
# Returns the block itself as a handle that can be passed to `off_cancel`
|
|
43
|
+
# to deregister it (e.g. when a nested request completes normally and the
|
|
44
|
+
# hook should not fire on a later parent cancellation).
|
|
45
|
+
def on_cancel(&block)
|
|
46
|
+
fire_now = false
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if @cancelled
|
|
49
|
+
fire_now = true
|
|
50
|
+
else
|
|
51
|
+
@callbacks << block
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
block.call(@reason) if fire_now
|
|
56
|
+
block
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Removes a previously-registered `on_cancel` callback. Returns `true`
|
|
60
|
+
# if the callback was still pending (i.e. had not yet fired), `false`
|
|
61
|
+
# otherwise. Safe to call with `nil`.
|
|
62
|
+
def off_cancel(handle)
|
|
63
|
+
return false unless handle
|
|
64
|
+
|
|
65
|
+
@mutex.synchronize { !@callbacks.delete(handle).nil? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def raise_if_cancelled!
|
|
69
|
+
raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class CancelledError < StandardError
|
|
5
|
+
attr_reader :request_id, :reason
|
|
6
|
+
|
|
7
|
+
def initialize(message = "Request was cancelled", request_id: nil, reason: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@request_id = request_id
|
|
10
|
+
@reason = reason
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../../json_rpc_handler"
|
|
5
|
+
require_relative "../configuration"
|
|
3
6
|
require_relative "../methods"
|
|
7
|
+
require_relative "../version"
|
|
4
8
|
|
|
5
9
|
module MCP
|
|
6
10
|
class Client
|
|
@@ -13,7 +17,7 @@ module MCP
|
|
|
13
17
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
14
18
|
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
|
15
19
|
|
|
16
|
-
attr_reader :url, :session_id, :protocol_version
|
|
20
|
+
attr_reader :url, :session_id, :protocol_version, :server_info
|
|
17
21
|
|
|
18
22
|
def initialize(url:, headers: {}, &block)
|
|
19
23
|
@url = url
|
|
@@ -21,6 +25,94 @@ module MCP
|
|
|
21
25
|
@faraday_customizer = block
|
|
22
26
|
@session_id = nil
|
|
23
27
|
@protocol_version = nil
|
|
28
|
+
@server_info = nil
|
|
29
|
+
@connected = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
33
|
+
# followed by the required `notifications/initialized` notification. The
|
|
34
|
+
# server's `InitializeResult` (protocol version, capabilities, server
|
|
35
|
+
# info, instructions) is cached on the transport and returned.
|
|
36
|
+
#
|
|
37
|
+
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
38
|
+
# contacting the server. After `close`, state is cleared and `connect`
|
|
39
|
+
# will handshake again.
|
|
40
|
+
#
|
|
41
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
42
|
+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
43
|
+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
|
44
|
+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
|
45
|
+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
46
|
+
# @return [Hash] The server's `InitializeResult`.
|
|
47
|
+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
|
48
|
+
# or a malformed result.
|
|
49
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
50
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
51
|
+
return @server_info if connected?
|
|
52
|
+
|
|
53
|
+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
54
|
+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
55
|
+
|
|
56
|
+
response = send_request(request: {
|
|
57
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
58
|
+
id: SecureRandom.uuid,
|
|
59
|
+
method: MCP::Methods::INITIALIZE,
|
|
60
|
+
params: {
|
|
61
|
+
protocolVersion: protocol_version,
|
|
62
|
+
capabilities: capabilities,
|
|
63
|
+
clientInfo: client_info,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if response.is_a?(Hash) && response.key?("error")
|
|
68
|
+
clear_session
|
|
69
|
+
error = response["error"]
|
|
70
|
+
raise RequestHandlerError.new(
|
|
71
|
+
"Server initialization failed: #{error["message"]}",
|
|
72
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
73
|
+
error_type: :internal_error,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
|
78
|
+
clear_session
|
|
79
|
+
raise RequestHandlerError.new(
|
|
80
|
+
"Server initialization failed: missing result in response",
|
|
81
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
82
|
+
error_type: :internal_error,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@server_info = response["result"]
|
|
87
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
88
|
+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
89
|
+
clear_session
|
|
90
|
+
raise RequestHandlerError.new(
|
|
91
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
92
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
93
|
+
error_type: :internal_error,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
send_request(request: {
|
|
99
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
100
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
101
|
+
})
|
|
102
|
+
rescue StandardError
|
|
103
|
+
clear_session
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@connected = true
|
|
108
|
+
@server_info
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns true once `connect` has completed the full handshake
|
|
112
|
+
# (`initialize` response received and `notifications/initialized` sent).
|
|
113
|
+
# Returns false before the first handshake and after `close`.
|
|
114
|
+
def connected?
|
|
115
|
+
@connected
|
|
24
116
|
end
|
|
25
117
|
|
|
26
118
|
# Sends a JSON-RPC request and returns the parsed response body.
|
|
@@ -105,7 +197,10 @@ module MCP
|
|
|
105
197
|
# session state is cleared either way.
|
|
106
198
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
|
107
199
|
def close
|
|
108
|
-
|
|
200
|
+
unless @session_id
|
|
201
|
+
clear_session
|
|
202
|
+
return
|
|
203
|
+
end
|
|
109
204
|
|
|
110
205
|
begin
|
|
111
206
|
client.delete("", nil, session_headers)
|
|
@@ -159,6 +254,8 @@ module MCP
|
|
|
159
254
|
def clear_session
|
|
160
255
|
@session_id = nil
|
|
161
256
|
@protocol_version = nil
|
|
257
|
+
@server_info = nil
|
|
258
|
+
@connected = false
|
|
162
259
|
end
|
|
163
260
|
|
|
164
261
|
def require_faraday!
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -19,7 +19,7 @@ module MCP
|
|
|
19
19
|
CLOSE_TIMEOUT = 2
|
|
20
20
|
STDERR_READ_SIZE = 4096
|
|
21
21
|
|
|
22
|
-
attr_reader :command, :args, :env
|
|
22
|
+
attr_reader :command, :args, :env, :server_info
|
|
23
23
|
|
|
24
24
|
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
|
25
25
|
@command = command
|
|
@@ -33,11 +33,108 @@ module MCP
|
|
|
33
33
|
@stderr_thread = nil
|
|
34
34
|
@started = false
|
|
35
35
|
@initialized = false
|
|
36
|
+
@server_info = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
40
|
+
# followed by the required `notifications/initialized` notification. The
|
|
41
|
+
# server's `InitializeResult` (protocol version, capabilities, server
|
|
42
|
+
# info, instructions) is cached on the transport and returned.
|
|
43
|
+
#
|
|
44
|
+
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
45
|
+
# contacting the server. After `close`, state is cleared and `connect`
|
|
46
|
+
# will handshake again. Spawns the subprocess via `start` if it has not
|
|
47
|
+
# been started yet.
|
|
48
|
+
#
|
|
49
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
50
|
+
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
51
|
+
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
|
52
|
+
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
|
53
|
+
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
54
|
+
# @return [Hash] The server's `InitializeResult`.
|
|
55
|
+
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
|
56
|
+
# a malformed result, or an unsupported protocol version.
|
|
57
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
58
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
59
|
+
return @server_info if @initialized
|
|
60
|
+
|
|
61
|
+
start unless @started
|
|
62
|
+
|
|
63
|
+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
64
|
+
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
65
|
+
|
|
66
|
+
init_request = {
|
|
67
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
68
|
+
id: SecureRandom.uuid,
|
|
69
|
+
method: MCP::Methods::INITIALIZE,
|
|
70
|
+
params: {
|
|
71
|
+
protocolVersion: protocol_version,
|
|
72
|
+
capabilities: capabilities,
|
|
73
|
+
clientInfo: client_info,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
write_message(init_request)
|
|
78
|
+
response = read_response(init_request)
|
|
79
|
+
|
|
80
|
+
if response.key?("error")
|
|
81
|
+
error = response["error"]
|
|
82
|
+
raise RequestHandlerError.new(
|
|
83
|
+
"Server initialization failed: #{error["message"]}",
|
|
84
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
85
|
+
error_type: :internal_error,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless response["result"].is_a?(Hash)
|
|
90
|
+
raise RequestHandlerError.new(
|
|
91
|
+
"Server initialization failed: missing result in response",
|
|
92
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
93
|
+
error_type: :internal_error,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@server_info = response["result"]
|
|
98
|
+
|
|
99
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
100
|
+
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
101
|
+
# Per spec, if the client does not support the server's returned protocol version,
|
|
102
|
+
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
|
|
103
|
+
# raising so a retry starts without a stale `server_info`.
|
|
104
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
105
|
+
@server_info = nil
|
|
106
|
+
raise RequestHandlerError.new(
|
|
107
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
108
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
109
|
+
error_type: :internal_error,
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
notification = {
|
|
115
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
116
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
117
|
+
}
|
|
118
|
+
write_message(notification)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
@server_info = nil
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@initialized = true
|
|
125
|
+
@server_info
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns true once `connect` (or the implicit handshake on the first
|
|
129
|
+
# `send_request`) has completed. Returns false before the handshake
|
|
130
|
+
# and after `close`.
|
|
131
|
+
def connected?
|
|
132
|
+
@initialized
|
|
36
133
|
end
|
|
37
134
|
|
|
38
135
|
def send_request(request:)
|
|
39
136
|
start unless @started
|
|
40
|
-
|
|
137
|
+
connect unless @initialized
|
|
41
138
|
|
|
42
139
|
write_message(request)
|
|
43
140
|
read_response(request)
|
|
@@ -98,57 +195,11 @@ module MCP
|
|
|
98
195
|
@stderr_thread.join(CLOSE_TIMEOUT)
|
|
99
196
|
@started = false
|
|
100
197
|
@initialized = false
|
|
198
|
+
@server_info = nil
|
|
101
199
|
end
|
|
102
200
|
|
|
103
201
|
private
|
|
104
202
|
|
|
105
|
-
# The client MUST send a protocol version it supports. This SHOULD be the latest version.
|
|
106
|
-
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
107
|
-
#
|
|
108
|
-
# Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
|
|
109
|
-
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
|
|
110
|
-
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
|
|
111
|
-
def initialize_session
|
|
112
|
-
init_request = {
|
|
113
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
114
|
-
id: SecureRandom.uuid,
|
|
115
|
-
method: MCP::Methods::INITIALIZE,
|
|
116
|
-
params: {
|
|
117
|
-
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
|
118
|
-
capabilities: {},
|
|
119
|
-
clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
|
|
120
|
-
},
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
write_message(init_request)
|
|
124
|
-
response = read_response(init_request)
|
|
125
|
-
|
|
126
|
-
if response.key?("error")
|
|
127
|
-
error = response["error"]
|
|
128
|
-
raise RequestHandlerError.new(
|
|
129
|
-
"Server initialization failed: #{error["message"]}",
|
|
130
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
131
|
-
error_type: :internal_error,
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
unless response.key?("result")
|
|
136
|
-
raise RequestHandlerError.new(
|
|
137
|
-
"Server initialization failed: missing result in response",
|
|
138
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
139
|
-
error_type: :internal_error,
|
|
140
|
-
)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
notification = {
|
|
144
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
145
|
-
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
146
|
-
}
|
|
147
|
-
write_message(notification)
|
|
148
|
-
|
|
149
|
-
@initialized = true
|
|
150
|
-
end
|
|
151
|
-
|
|
152
203
|
def write_message(message)
|
|
153
204
|
ensure_running!
|
|
154
205
|
json = JSON.generate(message)
|
data/lib/mcp/client.rb
CHANGED
|
@@ -59,6 +59,46 @@ module MCP
|
|
|
59
59
|
# So keeping it public
|
|
60
60
|
attr_reader :transport
|
|
61
61
|
|
|
62
|
+
# The server's `InitializeResult` (protocol version, capabilities, server info,
|
|
63
|
+
# instructions), as reported by the transport after a successful `connect`.
|
|
64
|
+
# Returns `nil` before `connect`, after `close`, or when the transport does
|
|
65
|
+
# not expose a cached handshake result.
|
|
66
|
+
def server_info
|
|
67
|
+
transport.server_info if transport.respond_to?(:server_info)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Performs the MCP `initialize` handshake by delegating to the transport
|
|
71
|
+
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
|
|
72
|
+
# `InitializeResult`.
|
|
73
|
+
#
|
|
74
|
+
# When the transport does not respond to `:connect`, this is a no-op and
|
|
75
|
+
# returns `nil`.
|
|
76
|
+
#
|
|
77
|
+
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
78
|
+
# @param protocol_version [String, nil] Protocol version to offer.
|
|
79
|
+
# @param capabilities [Hash] Capabilities advertised by the client.
|
|
80
|
+
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
|
81
|
+
# does not expose an explicit handshake.
|
|
82
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
83
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
84
|
+
return unless transport.respond_to?(:connect)
|
|
85
|
+
|
|
86
|
+
transport.connect(
|
|
87
|
+
client_info: client_info,
|
|
88
|
+
protocol_version: protocol_version,
|
|
89
|
+
capabilities: capabilities,
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns true once `connect` has completed the handshake on the underlying
|
|
94
|
+
# transport. Transports that do not expose connection state are assumed
|
|
95
|
+
# connected and return `true`.
|
|
96
|
+
def connected?
|
|
97
|
+
return transport.connected? if transport.respond_to?(:connected?)
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
62
102
|
# Returns a single page of tools from the server.
|
|
63
103
|
#
|
|
64
104
|
# @param cursor [String, nil] Cursor from a previous page response.
|
|
@@ -83,6 +123,7 @@ module MCP
|
|
|
83
123
|
name: tool["name"],
|
|
84
124
|
description: tool["description"],
|
|
85
125
|
input_schema: tool["inputSchema"],
|
|
126
|
+
output_schema: tool["outputSchema"],
|
|
86
127
|
)
|
|
87
128
|
end
|
|
88
129
|
|