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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.9.0"
4
+ VERSION = "1.10.0"
5
5
  end
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.9.0
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.9.0
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