mcp 0.22.0 → 0.24.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 +324 -10
- data/lib/json_rpc_handler.rb +17 -0
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +326 -31
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/flow.rb +148 -5
- data/lib/mcp/client/oauth/in_memory_storage.rb +3 -1
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +128 -0
- data/lib/mcp/client/oauth/provider.rb +11 -3
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +47 -11
- data/lib/mcp/client.rb +89 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/methods.rb +7 -1
- data/lib/mcp/resource.rb +135 -0
- data/lib/mcp/resource_template.rb +149 -0
- data/lib/mcp/result_type.rb +18 -0
- data/lib/mcp/server/transports/stdio_transport.rb +51 -3
- data/lib/mcp/server/transports/streamable_http_transport.rb +385 -94
- data/lib/mcp/server.rb +205 -20
- data/lib/mcp/server_context.rb +22 -0
- data/lib/mcp/server_session.rb +50 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +7 -2
data/lib/mcp/client.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "client/elicitation"
|
|
3
4
|
require_relative "client/oauth"
|
|
4
5
|
require_relative "client/stdio"
|
|
5
6
|
require_relative "client/http"
|
|
6
7
|
require_relative "client/paginated_result"
|
|
7
8
|
require_relative "client/tool"
|
|
9
|
+
require_relative "result_type"
|
|
8
10
|
|
|
9
11
|
module MCP
|
|
10
12
|
class Client
|
|
@@ -29,11 +31,44 @@ module MCP
|
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`)
|
|
35
|
+
# to answer the request with a specific JSON-RPC error code instead of the default internal error.
|
|
36
|
+
# Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example,
|
|
37
|
+
# the sampling spec answers a rejected request with code `-1`.
|
|
38
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
|
|
39
|
+
class ServerRequestError < StandardError
|
|
40
|
+
attr_reader :code
|
|
41
|
+
|
|
42
|
+
def initialize(message, code:)
|
|
43
|
+
super(message)
|
|
44
|
+
@code = code
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
32
48
|
# Raised when a server response fails client-side validation, e.g., a success response
|
|
33
49
|
# whose `result` field is missing or has the wrong type. This is distinct from a
|
|
34
50
|
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
|
35
51
|
class ValidationError < StandardError; end
|
|
36
52
|
|
|
53
|
+
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
|
|
54
|
+
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
|
|
55
|
+
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
|
56
|
+
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
|
|
57
|
+
# the original request with `inputResponses` plus the echoed opaque `requestState`.
|
|
58
|
+
# This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
|
|
59
|
+
# and respond manually.
|
|
60
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
61
|
+
class InputRequiredError < StandardError
|
|
62
|
+
attr_reader :input_requests, :request_state, :result
|
|
63
|
+
|
|
64
|
+
def initialize(message, input_requests:, request_state: nil, result: nil)
|
|
65
|
+
super(message)
|
|
66
|
+
@input_requests = input_requests
|
|
67
|
+
@request_state = request_state
|
|
68
|
+
@result = result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
37
72
|
# Raised when the server responds 404 to a request containing a session ID,
|
|
38
73
|
# indicating the session has expired. Inherits from `RequestHandlerError` for
|
|
39
74
|
# backward compatibility with callers that rescue the generic error. Per spec,
|
|
@@ -134,7 +169,13 @@ module MCP
|
|
|
134
169
|
)
|
|
135
170
|
end
|
|
136
171
|
|
|
137
|
-
ListToolsResult.new(
|
|
172
|
+
ListToolsResult.new(
|
|
173
|
+
tools: tools,
|
|
174
|
+
next_cursor: result["nextCursor"],
|
|
175
|
+
meta: result["_meta"],
|
|
176
|
+
ttl_ms: result["ttlMs"],
|
|
177
|
+
cache_scope: result["cacheScope"],
|
|
178
|
+
)
|
|
138
179
|
end
|
|
139
180
|
|
|
140
181
|
# Returns every tool available on the server. Iterates through all pages automatically
|
|
@@ -175,6 +216,8 @@ module MCP
|
|
|
175
216
|
resources: result["resources"] || [],
|
|
176
217
|
next_cursor: result["nextCursor"],
|
|
177
218
|
meta: result["_meta"],
|
|
219
|
+
ttl_ms: result["ttlMs"],
|
|
220
|
+
cache_scope: result["cacheScope"],
|
|
178
221
|
)
|
|
179
222
|
end
|
|
180
223
|
|
|
@@ -208,6 +251,8 @@ module MCP
|
|
|
208
251
|
resource_templates: result["resourceTemplates"] || [],
|
|
209
252
|
next_cursor: result["nextCursor"],
|
|
210
253
|
meta: result["_meta"],
|
|
254
|
+
ttl_ms: result["ttlMs"],
|
|
255
|
+
cache_scope: result["cacheScope"],
|
|
211
256
|
)
|
|
212
257
|
end
|
|
213
258
|
|
|
@@ -241,6 +286,8 @@ module MCP
|
|
|
241
286
|
prompts: result["prompts"] || [],
|
|
242
287
|
next_cursor: result["nextCursor"],
|
|
243
288
|
meta: result["_meta"],
|
|
289
|
+
ttl_ms: result["ttlMs"],
|
|
290
|
+
cache_scope: result["cacheScope"],
|
|
244
291
|
)
|
|
245
292
|
end
|
|
246
293
|
|
|
@@ -352,6 +399,30 @@ module MCP
|
|
|
352
399
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
353
400
|
end
|
|
354
401
|
|
|
402
|
+
# Registers a handler for `elicitation/create` requests the server sends while one of
|
|
403
|
+
# this client's requests is in flight. The handler receives the request `params`
|
|
404
|
+
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
|
405
|
+
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
|
406
|
+
#
|
|
407
|
+
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
|
|
408
|
+
# pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
|
|
409
|
+
#
|
|
410
|
+
# @example Accept with schema defaults applied (SEP-1034)
|
|
411
|
+
# client.on_elicitation do |params|
|
|
412
|
+
# {
|
|
413
|
+
# action: "accept",
|
|
414
|
+
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
|
|
415
|
+
# }
|
|
416
|
+
# end
|
|
417
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
418
|
+
def on_elicitation(&handler)
|
|
419
|
+
unless transport.respond_to?(:on_server_request)
|
|
420
|
+
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
424
|
+
end
|
|
425
|
+
|
|
355
426
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
356
427
|
# Per the MCP spec, the server responds with an empty result.
|
|
357
428
|
#
|
|
@@ -422,9 +493,26 @@ module MCP
|
|
|
422
493
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
|
423
494
|
end
|
|
424
495
|
|
|
496
|
+
raise_on_input_required(response)
|
|
497
|
+
|
|
425
498
|
response
|
|
426
499
|
end
|
|
427
500
|
|
|
501
|
+
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
|
502
|
+
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
|
503
|
+
def raise_on_input_required(response)
|
|
504
|
+
result = response.is_a?(Hash) ? response["result"] : nil
|
|
505
|
+
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
506
|
+
|
|
507
|
+
raise InputRequiredError.new(
|
|
508
|
+
"Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
|
|
509
|
+
"Inspect `input_requests` to respond manually.",
|
|
510
|
+
input_requests: result["inputRequests"] || {},
|
|
511
|
+
request_state: result["requestState"],
|
|
512
|
+
result: result,
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
428
516
|
# Generates a fresh JSON-RPC request id for an outgoing request.
|
|
429
517
|
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
|
|
430
518
|
# an `MCP::Cancellation` token instead.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
# MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`.
|
|
5
|
+
#
|
|
6
|
+
# Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
|
|
7
|
+
# `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not
|
|
8
|
+
# support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY`
|
|
9
|
+
# rejects a request that requires a client capability the request did not declare
|
|
10
|
+
# (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise
|
|
11
|
+
# these codes itself yet.
|
|
12
|
+
#
|
|
13
|
+
# The values come from the spec's MCP-specific error code block, which is allocated sequentially from
|
|
14
|
+
# `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here.
|
|
15
|
+
#
|
|
16
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
17
|
+
module ErrorCodes
|
|
18
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
|
|
19
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/mcp/methods.rb
CHANGED
|
@@ -5,6 +5,8 @@ module MCP
|
|
|
5
5
|
INITIALIZE = "initialize"
|
|
6
6
|
PING = "ping"
|
|
7
7
|
LOGGING_SET_LEVEL = "logging/setLevel"
|
|
8
|
+
# Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
|
|
9
|
+
SERVER_DISCOVER = "server/discover"
|
|
8
10
|
|
|
9
11
|
PROMPTS_GET = "prompts/get"
|
|
10
12
|
PROMPTS_LIST = "prompts/list"
|
|
@@ -47,6 +49,10 @@ module MCP
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
class << self
|
|
52
|
+
def notification?(method)
|
|
53
|
+
method.is_a?(String) && method.start_with?("notifications/")
|
|
54
|
+
end
|
|
55
|
+
|
|
50
56
|
def ensure_capability!(method, capabilities)
|
|
51
57
|
case method
|
|
52
58
|
when PROMPTS_GET, PROMPTS_LIST
|
|
@@ -77,7 +83,7 @@ module MCP
|
|
|
77
83
|
require_capability!(method, capabilities, :sampling)
|
|
78
84
|
when ELICITATION_CREATE
|
|
79
85
|
require_capability!(method, capabilities, :elicitation)
|
|
80
|
-
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
86
|
+
when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
81
87
|
NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
|
|
82
88
|
# No specific capability required.
|
|
83
89
|
end
|
data/lib/mcp/resource.rb
CHANGED
|
@@ -5,6 +5,141 @@ require_relative "resource/embedded"
|
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Resource
|
|
8
|
+
class << self
|
|
9
|
+
NOT_SET = Object.new
|
|
10
|
+
|
|
11
|
+
attr_reader :uri_value
|
|
12
|
+
attr_reader :title_value
|
|
13
|
+
attr_reader :description_value
|
|
14
|
+
attr_reader :icons_value
|
|
15
|
+
attr_reader :mime_type_value
|
|
16
|
+
attr_reader :annotations_value
|
|
17
|
+
attr_reader :size_value
|
|
18
|
+
attr_reader :meta_value
|
|
19
|
+
|
|
20
|
+
def contents(server_context: nil)
|
|
21
|
+
raise NotImplementedError, "Subclasses must implement contents"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
uri: uri_value,
|
|
27
|
+
name: name_value,
|
|
28
|
+
title: title_value,
|
|
29
|
+
description: description_value,
|
|
30
|
+
icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
31
|
+
mimeType: mime_type_value,
|
|
32
|
+
annotations: annotations_value&.to_h,
|
|
33
|
+
size: size_value,
|
|
34
|
+
_meta: meta_value,
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inherited(subclass)
|
|
39
|
+
super
|
|
40
|
+
subclass.instance_variable_set(:@uri_value, nil)
|
|
41
|
+
subclass.instance_variable_set(:@name_value, nil)
|
|
42
|
+
subclass.instance_variable_set(:@title_value, nil)
|
|
43
|
+
subclass.instance_variable_set(:@description_value, nil)
|
|
44
|
+
subclass.instance_variable_set(:@icons_value, nil)
|
|
45
|
+
subclass.instance_variable_set(:@mime_type_value, nil)
|
|
46
|
+
subclass.instance_variable_set(:@annotations_value, nil)
|
|
47
|
+
subclass.instance_variable_set(:@size_value, nil)
|
|
48
|
+
subclass.instance_variable_set(:@meta_value, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uri(value = NOT_SET)
|
|
52
|
+
if value == NOT_SET
|
|
53
|
+
@uri_value
|
|
54
|
+
else
|
|
55
|
+
@uri_value = value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resource_name(value = NOT_SET)
|
|
60
|
+
if value == NOT_SET
|
|
61
|
+
name_value
|
|
62
|
+
else
|
|
63
|
+
@name_value = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def name_value
|
|
68
|
+
@name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def title(value = NOT_SET)
|
|
72
|
+
if value == NOT_SET
|
|
73
|
+
@title_value
|
|
74
|
+
else
|
|
75
|
+
@title_value = value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def description(value = NOT_SET)
|
|
80
|
+
if value == NOT_SET
|
|
81
|
+
@description_value
|
|
82
|
+
else
|
|
83
|
+
@description_value = value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def icons(value = NOT_SET)
|
|
88
|
+
if value == NOT_SET
|
|
89
|
+
@icons_value
|
|
90
|
+
else
|
|
91
|
+
@icons_value = value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def mime_type(value = NOT_SET)
|
|
96
|
+
if value == NOT_SET
|
|
97
|
+
@mime_type_value
|
|
98
|
+
else
|
|
99
|
+
@mime_type_value = value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def annotations(value = NOT_SET)
|
|
104
|
+
if value == NOT_SET
|
|
105
|
+
@annotations_value
|
|
106
|
+
else
|
|
107
|
+
@annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def size(value = NOT_SET)
|
|
112
|
+
if value == NOT_SET
|
|
113
|
+
@size_value
|
|
114
|
+
else
|
|
115
|
+
@size_value = value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def meta(value = NOT_SET)
|
|
120
|
+
if value == NOT_SET
|
|
121
|
+
@meta_value
|
|
122
|
+
else
|
|
123
|
+
@meta_value = value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
|
|
128
|
+
Class.new(self) do
|
|
129
|
+
uri uri
|
|
130
|
+
resource_name name
|
|
131
|
+
title title
|
|
132
|
+
description description
|
|
133
|
+
icons icons
|
|
134
|
+
mime_type mime_type
|
|
135
|
+
self.annotations(annotations) if annotations
|
|
136
|
+
size size
|
|
137
|
+
meta meta
|
|
138
|
+
define_singleton_method(:contents, &block) if block
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
8
143
|
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
|
|
9
144
|
|
|
10
145
|
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
|
|
@@ -2,6 +2,155 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class ResourceTemplate
|
|
5
|
+
class << self
|
|
6
|
+
NOT_SET = Object.new
|
|
7
|
+
|
|
8
|
+
# Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
|
|
9
|
+
# Variable names are restricted to valid Regexp named-group names,
|
|
10
|
+
# so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
|
|
11
|
+
VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/
|
|
12
|
+
|
|
13
|
+
attr_reader :uri_template_value
|
|
14
|
+
attr_reader :title_value
|
|
15
|
+
attr_reader :description_value
|
|
16
|
+
attr_reader :icons_value
|
|
17
|
+
attr_reader :mime_type_value
|
|
18
|
+
attr_reader :annotations_value
|
|
19
|
+
attr_reader :meta_value
|
|
20
|
+
|
|
21
|
+
def contents(server_context: nil, **params)
|
|
22
|
+
raise NotImplementedError, "Subclasses must implement contents"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
uriTemplate: uri_template_value,
|
|
28
|
+
name: name_value,
|
|
29
|
+
title: title_value,
|
|
30
|
+
description: description_value,
|
|
31
|
+
icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
32
|
+
mimeType: mime_type_value,
|
|
33
|
+
annotations: annotations_value&.to_h,
|
|
34
|
+
_meta: meta_value,
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inherited(subclass)
|
|
39
|
+
super
|
|
40
|
+
subclass.instance_variable_set(:@uri_template_value, nil)
|
|
41
|
+
subclass.instance_variable_set(:@uri_template_pattern, nil)
|
|
42
|
+
subclass.instance_variable_set(:@name_value, nil)
|
|
43
|
+
subclass.instance_variable_set(:@title_value, nil)
|
|
44
|
+
subclass.instance_variable_set(:@description_value, nil)
|
|
45
|
+
subclass.instance_variable_set(:@icons_value, nil)
|
|
46
|
+
subclass.instance_variable_set(:@mime_type_value, nil)
|
|
47
|
+
subclass.instance_variable_set(:@annotations_value, nil)
|
|
48
|
+
subclass.instance_variable_set(:@meta_value, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uri_template(value = NOT_SET)
|
|
52
|
+
if value == NOT_SET
|
|
53
|
+
@uri_template_value
|
|
54
|
+
else
|
|
55
|
+
@uri_template_pattern = nil
|
|
56
|
+
@uri_template_value = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resource_template_name(value = NOT_SET)
|
|
61
|
+
if value == NOT_SET
|
|
62
|
+
name_value
|
|
63
|
+
else
|
|
64
|
+
@name_value = value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def name_value
|
|
69
|
+
@name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def title(value = NOT_SET)
|
|
73
|
+
if value == NOT_SET
|
|
74
|
+
@title_value
|
|
75
|
+
else
|
|
76
|
+
@title_value = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def description(value = NOT_SET)
|
|
81
|
+
if value == NOT_SET
|
|
82
|
+
@description_value
|
|
83
|
+
else
|
|
84
|
+
@description_value = value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def icons(value = NOT_SET)
|
|
89
|
+
if value == NOT_SET
|
|
90
|
+
@icons_value
|
|
91
|
+
else
|
|
92
|
+
@icons_value = value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def mime_type(value = NOT_SET)
|
|
97
|
+
if value == NOT_SET
|
|
98
|
+
@mime_type_value
|
|
99
|
+
else
|
|
100
|
+
@mime_type_value = value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def annotations(value = NOT_SET)
|
|
105
|
+
if value == NOT_SET
|
|
106
|
+
@annotations_value
|
|
107
|
+
else
|
|
108
|
+
@annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def meta(value = NOT_SET)
|
|
113
|
+
if value == NOT_SET
|
|
114
|
+
@meta_value
|
|
115
|
+
else
|
|
116
|
+
@meta_value = value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions.
|
|
121
|
+
# Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match.
|
|
122
|
+
# Variables match one or more characters excluding `/`, and values are not percent-decoded.
|
|
123
|
+
def match_uri(uri)
|
|
124
|
+
match = uri_template_pattern&.match(uri)
|
|
125
|
+
match&.named_captures&.transform_keys(&:to_sym)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
|
|
129
|
+
Class.new(self) do
|
|
130
|
+
uri_template uri_template
|
|
131
|
+
resource_template_name name
|
|
132
|
+
title title
|
|
133
|
+
description description
|
|
134
|
+
icons icons
|
|
135
|
+
mime_type mime_type
|
|
136
|
+
self.annotations(annotations) if annotations
|
|
137
|
+
meta meta
|
|
138
|
+
define_singleton_method(:contents, &block) if block
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def uri_template_pattern
|
|
145
|
+
return if uri_template.nil?
|
|
146
|
+
|
|
147
|
+
@uri_template_pattern ||= begin
|
|
148
|
+
pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
|
|
149
|
+
Regexp.new("\\A#{pattern}\\z")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
5
154
|
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
|
|
6
155
|
|
|
7
156
|
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
# Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
|
|
5
|
+
# for the MCP 2026-07-28 draft.
|
|
6
|
+
#
|
|
7
|
+
# A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
|
|
8
|
+
# of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
|
|
9
|
+
# an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
|
|
10
|
+
# the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
|
|
11
|
+
# or `"complete"` is a final result.
|
|
12
|
+
#
|
|
13
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
14
|
+
module ResultType
|
|
15
|
+
COMPLETE = "complete"
|
|
16
|
+
INPUT_REQUIRED = "input_required"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -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
|