mcp 1.9.0 → 1.10.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 +3 -0
- data/lib/json_rpc_handler.rb +1 -1
- data/lib/mcp/client/http.rb +29 -5
- data/lib/mcp/client/oauth/client_credentials_provider.rb +16 -0
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +19 -2
- data/lib/mcp/client/oauth/flow.rb +11 -3
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +3 -2
- data/lib/mcp/client/oauth/pkce.rb +3 -2
- data/lib/mcp/client/stdio.rb +15 -3
- data/lib/mcp/client.rb +5 -1
- data/lib/mcp/instrumentation.rb +12 -7
- data/lib/mcp/mcp_param_headers.rb +332 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +34 -5
- data/lib/mcp/server/transports/stdio_transport.rb +12 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +168 -19
- data/lib/mcp/server.rb +60 -35
- data/lib/mcp/server_session.rb +23 -1
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +3 -3
- data/lib/mcp/client/mcp_param_headers.rb +0 -242
data/lib/mcp/server_session.rb
CHANGED
|
@@ -359,7 +359,29 @@ module MCP
|
|
|
359
359
|
timeout: timeout,
|
|
360
360
|
}.compact
|
|
361
361
|
|
|
362
|
-
forward_to_transport(@transport.method(:send_request), method, params, kwargs)
|
|
362
|
+
result = forward_to_transport(@transport.method(:send_request), method, params, kwargs)
|
|
363
|
+
validate_elicitation_result!(result, params || {}) if method == Methods::ELICITATION_CREATE
|
|
364
|
+
result
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Validate before returning to a handler or replaying a legacy input_required result.
|
|
368
|
+
def validate_elicitation_result!(result, params)
|
|
369
|
+
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
|
|
370
|
+
|
|
371
|
+
action = result[:action] || result["action"]
|
|
372
|
+
raise Server::ValidationError, "Response validation failed: invalid `action`" unless ["accept", "decline", "cancel"].include?(action)
|
|
373
|
+
return unless action == "accept"
|
|
374
|
+
return if (params[:mode] || params["mode"]) == "url"
|
|
375
|
+
|
|
376
|
+
requested_schema = params[:requestedSchema] || params["requestedSchema"]
|
|
377
|
+
return unless requested_schema
|
|
378
|
+
|
|
379
|
+
content = result.fetch(:content) { result["content"] }
|
|
380
|
+
return if content.nil?
|
|
381
|
+
|
|
382
|
+
Tool::OutputSchema.new(requested_schema).validate_result(content)
|
|
383
|
+
rescue Tool::OutputSchema::ValidationError => e
|
|
384
|
+
raise Server::ValidationError, "Response validation failed: #{e.message}"
|
|
363
385
|
end
|
|
364
386
|
|
|
365
387
|
# Calls `transport_method(method, params, **supported)` where `supported` contains only the keys
|
data/lib/mcp/version.rb
CHANGED
data/lib/mcp.rb
CHANGED
|
@@ -17,12 +17,14 @@ module MCP
|
|
|
17
17
|
autoload :Elicitation, "mcp/elicitation"
|
|
18
18
|
autoload :ErrorCodes, "mcp/error_codes"
|
|
19
19
|
autoload :Icon, "mcp/icon"
|
|
20
|
+
autoload :McpParamHeaders, "mcp/mcp_param_headers"
|
|
20
21
|
autoload :Prompt, "mcp/prompt"
|
|
21
22
|
autoload :RequestEnvelope, "mcp/request_envelope"
|
|
22
23
|
autoload :Resource, "mcp/resource"
|
|
23
24
|
autoload :ResourceTemplate, "mcp/resource_template"
|
|
24
25
|
autoload :ResultType, "mcp/result_type"
|
|
25
26
|
autoload :Server, "mcp/server"
|
|
27
|
+
autoload :ServerContext, "mcp/server_context"
|
|
26
28
|
autoload :ServerSession, "mcp/server_session"
|
|
27
29
|
autoload :Skill, "mcp/skill"
|
|
28
30
|
autoload :Tool, "mcp/tool"
|
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: 1.
|
|
4
|
+
version: 1.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -43,7 +43,6 @@ files:
|
|
|
43
43
|
- lib/mcp/client.rb
|
|
44
44
|
- lib/mcp/client/elicitation.rb
|
|
45
45
|
- lib/mcp/client/http.rb
|
|
46
|
-
- lib/mcp/client/mcp_param_headers.rb
|
|
47
46
|
- lib/mcp/client/modern_envelope.rb
|
|
48
47
|
- lib/mcp/client/oauth.rb
|
|
49
48
|
- lib/mcp/client/oauth/authorization_request.rb
|
|
@@ -71,6 +70,7 @@ files:
|
|
|
71
70
|
- lib/mcp/icon.rb
|
|
72
71
|
- lib/mcp/instrumentation.rb
|
|
73
72
|
- lib/mcp/logging_message_notification.rb
|
|
73
|
+
- lib/mcp/mcp_param_headers.rb
|
|
74
74
|
- lib/mcp/methods.rb
|
|
75
75
|
- lib/mcp/progress.rb
|
|
76
76
|
- lib/mcp/prompt.rb
|
|
@@ -125,7 +125,7 @@ licenses:
|
|
|
125
125
|
- Apache-2.0
|
|
126
126
|
metadata:
|
|
127
127
|
allowed_push_host: https://rubygems.org
|
|
128
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
128
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.10.0
|
|
129
129
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
130
130
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
131
131
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module MCP
|
|
4
|
-
class Client
|
|
5
|
-
# The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for
|
|
6
|
-
# `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers,
|
|
7
|
-
# with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values.
|
|
8
|
-
# Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`)
|
|
9
|
-
# lives with the transport.
|
|
10
|
-
#
|
|
11
|
-
# https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters
|
|
12
|
-
module McpParamHeaders
|
|
13
|
-
# The fixed prefix every custom-parameter header carries.
|
|
14
|
-
HEADER_PREFIX = "Mcp-Param-"
|
|
15
|
-
|
|
16
|
-
# The schema-extension property name a tool's `inputSchema` carries.
|
|
17
|
-
X_MCP_HEADER_KEY = "x-mcp-header"
|
|
18
|
-
|
|
19
|
-
# RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces,
|
|
20
|
-
# control characters (including CR/LF), and the HTTP delimiters.
|
|
21
|
-
RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
|
|
22
|
-
|
|
23
|
-
# The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because
|
|
24
|
-
# the published conformance referee annotates `type: "number"` parameters and expects them
|
|
25
|
-
# mirrored; the TypeScript SDK makes the same accommodation.
|
|
26
|
-
PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze
|
|
27
|
-
|
|
28
|
-
# JSON Schema keywords the SEP-2243 static-reachability constraint excludes from
|
|
29
|
-
# the `properties`-only chain. An `x-mcp-header` under any of these invalidates
|
|
30
|
-
# the tool definition rather than being silently ignored.
|
|
31
|
-
NON_REACHABLE_SUBSCHEMA_KEYWORDS = [
|
|
32
|
-
"items",
|
|
33
|
-
"prefixItems",
|
|
34
|
-
"contains",
|
|
35
|
-
"additionalProperties",
|
|
36
|
-
"unevaluatedProperties",
|
|
37
|
-
"unevaluatedItems",
|
|
38
|
-
"propertyNames",
|
|
39
|
-
"patternProperties",
|
|
40
|
-
"dependentSchemas",
|
|
41
|
-
"oneOf",
|
|
42
|
-
"anyOf",
|
|
43
|
-
"allOf",
|
|
44
|
-
"not",
|
|
45
|
-
"if",
|
|
46
|
-
"then",
|
|
47
|
-
"else",
|
|
48
|
-
"$defs",
|
|
49
|
-
"definitions",
|
|
50
|
-
].freeze
|
|
51
|
-
|
|
52
|
-
# Keywords whose value maps names to subschemas rather than being one subschema or a list of them.
|
|
53
|
-
OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze
|
|
54
|
-
|
|
55
|
-
# Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored;
|
|
56
|
-
# the TypeScript SDK refuses unsafe integers the same way.
|
|
57
|
-
MAX_SAFE_INTEGER = (2**53) - 1
|
|
58
|
-
|
|
59
|
-
BASE64_SENTINEL_PREFIX = "=?base64?"
|
|
60
|
-
BASE64_SENTINEL_SUFFIX = "?="
|
|
61
|
-
|
|
62
|
-
class << self
|
|
63
|
-
# Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint
|
|
64
|
-
# the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed
|
|
65
|
-
# declaring properties, and static reachability through a chain of `properties` keys only.
|
|
66
|
-
# Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`,
|
|
67
|
-
# or `{ valid: false, reason: "..." }` on the first violation.
|
|
68
|
-
def scan(input_schema)
|
|
69
|
-
declarations = []
|
|
70
|
-
fault = visit(input_schema, [], true, declarations, {})
|
|
71
|
-
|
|
72
|
-
fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and
|
|
76
|
-
# the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows);
|
|
77
|
-
# a non-primitive or non-representable value is omitted rather than emitted malformed.
|
|
78
|
-
def build(declarations, arguments)
|
|
79
|
-
declarations.each_with_object({}) do |declaration, headers|
|
|
80
|
-
value = value_at_path(arguments, declaration[:path])
|
|
81
|
-
next if value.nil?
|
|
82
|
-
|
|
83
|
-
string_value = primitive_to_string(value)
|
|
84
|
-
next unless string_value
|
|
85
|
-
|
|
86
|
-
encoded = begin
|
|
87
|
-
encode_value(string_value)
|
|
88
|
-
rescue EncodingError
|
|
89
|
-
# A string that cannot be represented as UTF-8 (e.g. binary data) has no header
|
|
90
|
-
# representation; omit it like the other non-representable values.
|
|
91
|
-
next
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Converts a primitive argument to its header string per the spec's type-conversion rules:
|
|
99
|
-
# strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become
|
|
100
|
-
# their decimal string. `nil` means "not representable: do not emit a header".
|
|
101
|
-
def primitive_to_string(value)
|
|
102
|
-
case value
|
|
103
|
-
when String
|
|
104
|
-
value
|
|
105
|
-
when true
|
|
106
|
-
"true"
|
|
107
|
-
when false
|
|
108
|
-
"false"
|
|
109
|
-
when Integer
|
|
110
|
-
value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil
|
|
111
|
-
when Float
|
|
112
|
-
return unless value.finite?
|
|
113
|
-
|
|
114
|
-
# JSON has one number type: an integral float serializes without the fractional part,
|
|
115
|
-
# matching the `String(42.0)` the JavaScript reference emits.
|
|
116
|
-
value == value.truncate ? value.truncate.to_s : value.to_s
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value
|
|
121
|
-
# passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`.
|
|
122
|
-
def encode_value(value)
|
|
123
|
-
return value unless needs_base64?(value)
|
|
124
|
-
|
|
125
|
-
"#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
def visit(node, path, reachable, declarations, seen_lower)
|
|
131
|
-
return unless node.is_a?(Hash)
|
|
132
|
-
|
|
133
|
-
if key?(node, X_MCP_HEADER_KEY)
|
|
134
|
-
fault = validate_declaration(node, path, reachable, declarations, seen_lower)
|
|
135
|
-
|
|
136
|
-
return fault if fault
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
properties = read(node, "properties")
|
|
140
|
-
if properties.is_a?(Hash)
|
|
141
|
-
properties.each do |key, child|
|
|
142
|
-
fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower)
|
|
143
|
-
|
|
144
|
-
return fault if fault
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass
|
|
149
|
-
# through with `reachable: false`, so an annotation under any of them is reported.
|
|
150
|
-
# `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope.
|
|
151
|
-
NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword|
|
|
152
|
-
next unless (sub = read(node, keyword))
|
|
153
|
-
|
|
154
|
-
branches = if sub.is_a?(Array)
|
|
155
|
-
sub
|
|
156
|
-
elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword)
|
|
157
|
-
sub.values
|
|
158
|
-
else
|
|
159
|
-
[sub]
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
branches.each do |branch|
|
|
163
|
-
fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower)
|
|
164
|
-
|
|
165
|
-
return fault if fault
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
nil
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def validate_declaration(node, path, reachable, declarations, seen_lower)
|
|
173
|
-
if !reachable || path.empty?
|
|
174
|
-
return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys"
|
|
175
|
-
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
annotation = read(node, X_MCP_HEADER_KEY)
|
|
179
|
-
|
|
180
|
-
unless annotation.is_a?(String) && !annotation.empty?
|
|
181
|
-
return "#{path_name(path)}: x-mcp-header MUST be a non-empty string"
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
unless RFC9110_TOKEN.match?(annotation)
|
|
185
|
-
return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token"
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
type = read(node, "type")
|
|
189
|
-
unless type.is_a?(String) && PERMITTED_TYPES.include?(type)
|
|
190
|
-
return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \
|
|
191
|
-
"(got `#{type.inspect}`)"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
lower = annotation.downcase
|
|
195
|
-
prior = seen_lower[lower]
|
|
196
|
-
if prior
|
|
197
|
-
return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
seen_lower[lower] = annotation
|
|
201
|
-
declarations << { path: path, header_name: annotation, type: type }
|
|
202
|
-
nil
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# A value cannot ride as a plain ASCII field value when it is empty, already shaped like
|
|
206
|
-
# the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing
|
|
207
|
-
# would strip, or contains a byte outside visible ASCII plus interior tab.
|
|
208
|
-
def needs_base64?(value)
|
|
209
|
-
return true if value.empty?
|
|
210
|
-
return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX)
|
|
211
|
-
return true if value != value.strip
|
|
212
|
-
|
|
213
|
-
value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) }
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def value_at_path(root, path)
|
|
217
|
-
path.reduce(root) do |node, key|
|
|
218
|
-
break unless node.is_a?(Hash)
|
|
219
|
-
|
|
220
|
-
read(node, key)
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Schemas and arguments arrive with string keys off the wire but may carry symbol keys
|
|
225
|
-
# when constructed in Ruby; read both forms like the SDK's other readers.
|
|
226
|
-
def read(hash, key)
|
|
227
|
-
value = hash[key]
|
|
228
|
-
|
|
229
|
-
value.nil? ? hash[key.to_sym] : value
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def key?(hash, key)
|
|
233
|
-
hash.key?(key) || hash.key?(key.to_sym)
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def path_name(path)
|
|
237
|
-
path.empty? ? "<root>" : path.join(".")
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|