ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # MCP 2026-07-28 Streamable HTTP "Custom Headers from Tool Parameters"
5
+ # (SEP-2243). A tool's inputSchema may annotate a property with
6
+ # `x-mcp-header`; on the Streamable HTTP transport the client mirrors the
7
+ # argument value into an `Mcp-Param-{name}` request header so that
8
+ # intermediaries can route on it without parsing the body.
9
+ #
10
+ # This module validates the annotations (clients MUST reject tool
11
+ # definitions that violate the constraints) and extracts the header values
12
+ # for a call (clients MUST mirror the designated values, omitting a header
13
+ # whose argument is absent or null).
14
+ module HeaderParams
15
+ ANNOTATION = 'x-mcp-header'
16
+ HEADER_PREFIX = 'Mcp-Param-'
17
+ # HTTP field names are case-insensitive, so membership of the mirrored
18
+ # namespace is decided on the lower-cased name.
19
+ HEADER_PREFIX_DOWNCASE = HEADER_PREFIX.downcase.freeze
20
+
21
+ # HTTP field-name token: 1*tchar (RFC 9110 Section 5.6.2)
22
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
23
+
24
+ # The only JSON Schema types an annotated property may have.
25
+ PRIMITIVE_TYPES = %w[string integer boolean].freeze
26
+
27
+ # Integer values must fit IEEE754 double precision exactly.
28
+ SAFE_INTEGER_MAX = (2**53) - 1
29
+ SAFE_INTEGER_MIN = -SAFE_INTEGER_MAX
30
+
31
+ # Header value that may travel as-is: visible ASCII (0x21-0x7E), spaces
32
+ # and tabs only in the interior (RFC 9110 field values; MCP 2026-07-28
33
+ # Streamable HTTP "Value Encoding"). RFC 9110 field values may also be
34
+ # empty, so an empty string travels as an empty field value rather than
35
+ # as an encoding of nothing.
36
+ HEADER_SAFE_VALUE = /\A(?:[\x21-\x7E](?:[\x20-\x7E\t]*[\x21-\x7E])?)?\z/
37
+
38
+ # The Base64 sentinel markers; a plain value that starts with the one and
39
+ # ends with the other must itself be encoded to avoid ambiguity. The rule
40
+ # is on the start and the end alone: "=?base64?=" is sentinel-shaped even
41
+ # though its two markers overlap.
42
+ BASE64_SENTINEL_START = '=?base64?'
43
+ BASE64_SENTINEL_END = '?='
44
+
45
+ module_function
46
+
47
+ # Check every `x-mcp-header` annotation in an inputSchema against the
48
+ # transport's constraints.
49
+ # @param schema [Hash, nil] the tool's inputSchema
50
+ # @return [Array<String>] violations (empty when the schema is acceptable)
51
+ def validate_schema(schema)
52
+ return [] unless schema.is_a?(Hash)
53
+
54
+ errors = []
55
+ walk(schema, [], root: true, reachable: false, document: schema, errors: errors, seen: {}, found: [])
56
+ errors
57
+ end
58
+
59
+ # The statically reachable annotated properties of an inputSchema.
60
+ # @param schema [Hash, nil] the tool's inputSchema
61
+ # @return [Array<Array(Array<String>, String)>] [property path, header name] pairs
62
+ def annotations(schema)
63
+ return [] unless schema.is_a?(Hash)
64
+
65
+ found = []
66
+ walk(schema, [], root: true, reachable: false, document: schema, errors: [], seen: {}, found: found)
67
+ found
68
+ end
69
+
70
+ # Whether an HTTP header name belongs to the mirrored namespace, which
71
+ # the client owns on a modern session: its members are derived from the
72
+ # call's arguments and from nothing else.
73
+ # @param name [String, Symbol] an HTTP header name
74
+ # @return [Boolean]
75
+ def mirrored_header?(name)
76
+ name.to_s.downcase.start_with?(HEADER_PREFIX_DOWNCASE)
77
+ end
78
+
79
+ # The `Mcp-Param-*` headers for one tools/call.
80
+ # @param schema [Hash, nil] the tool's inputSchema
81
+ # @param arguments [Hash, nil] the call arguments (String or Symbol keys)
82
+ # @return [Hash{String => String}] header name => encoded value
83
+ # @raise [MCPClient::Errors::ValidationError] when an annotated argument cannot be mirrored
84
+ def headers_for(schema, arguments)
85
+ annotations(schema).each_with_object({}) do |(path, name), headers|
86
+ value = dig_argument(arguments, path)
87
+ next if value.nil?
88
+
89
+ headers["#{HEADER_PREFIX}#{name}"] = encode_value(value, path)
90
+ end
91
+ end
92
+
93
+ # Encode a parameter value for an MCP request header (Mcp-Name,
94
+ # Mcp-Param-*): strings as-is when header-safe, integers in decimal,
95
+ # booleans lowercase; anything not safely representable — non-ASCII,
96
+ # control characters, leading/trailing whitespace, or a value that looks
97
+ # like the sentinel — as `=?base64?<b64 of UTF-8>?=`.
98
+ #
99
+ # A Ruby String carries an encoding of its own, and the value being
100
+ # mirrored is the one the JSON body carries: UTF-8. The conversion
101
+ # therefore comes first — deciding header safety on, say, UTF-16 bytes
102
+ # would be deciding it on a different string (and an ASCII pattern cannot
103
+ # even be matched against one).
104
+ # @param value [String, Integer, true, false] the parameter value
105
+ # @return [String] the header value
106
+ def encode_header_value(value)
107
+ text = value.to_s.encode('UTF-8')
108
+ return text if text.match?(HEADER_SAFE_VALUE) && !sentinel_shaped?(text)
109
+
110
+ "=?base64?#{[text].pack('m0')}?="
111
+ end
112
+
113
+ # Encode one mirrored argument, enforcing the primitive-type and safe
114
+ # integer constraints.
115
+ # @param value [Object] the argument value
116
+ # @param path [Array<String>] the property path (for messages)
117
+ # @return [String]
118
+ # @raise [MCPClient::Errors::ValidationError]
119
+ def encode_value(value, path)
120
+ # JSON has no integer type: a schema integer may be parsed as 42.0.
121
+ value = value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
122
+ case value
123
+ when Integer
124
+ unless value.between?(SAFE_INTEGER_MIN, SAFE_INTEGER_MAX)
125
+ raise MCPClient::Errors::ValidationError,
126
+ "Argument #{path.join('.')} is mirrored into an HTTP header and must be within the safe " \
127
+ "integer range (#{SAFE_INTEGER_MIN}..#{SAFE_INTEGER_MAX})"
128
+ end
129
+ value.to_s
130
+ when String, true, false
131
+ encode_header_value(value)
132
+ else
133
+ raise MCPClient::Errors::ValidationError,
134
+ "Argument #{path.join('.')} is mirrored into an HTTP header and must be a primitive " \
135
+ "(string, integer or boolean), got #{value.class}"
136
+ end
137
+ end
138
+
139
+ # @param text [String] a UTF-8 header value
140
+ # @return [Boolean] whether the value would read as a Base64 sentinel
141
+ def sentinel_shaped?(text)
142
+ text.start_with?(BASE64_SENTINEL_START) && text.end_with?(BASE64_SENTINEL_END)
143
+ end
144
+
145
+ # Read the argument at an exact property path, accepting String or
146
+ # Symbol keys at each step. A step given under both kinds of key with
147
+ # different values is rejected: the JSON body serializes both, which one
148
+ # the server reads is its business, and no header can agree with an
149
+ # argument that is two values.
150
+ # @return [Object, nil] the value, nil when absent (or explicitly null)
151
+ # @raise [MCPClient::Errors::ValidationError] on conflicting String/Symbol keys
152
+ def dig_argument(arguments, path)
153
+ path.reduce(arguments) do |node, key|
154
+ return nil unless node.is_a?(Hash)
155
+
156
+ if node.key?(key) && node.key?(key.to_sym) && node[key] != node[key.to_sym]
157
+ raise MCPClient::Errors::ValidationError,
158
+ "Argument #{path.join('.')} is given under both a String and a Symbol key with different values"
159
+ end
160
+ node.key?(key) ? node[key] : node[key.to_sym]
161
+ end
162
+ end
163
+
164
+ # JSON Schema 2020-12 keywords whose value is one subschema.
165
+ SCHEMA_KEYWORDS = %w[additionalProperties items contains not if then else propertyNames
166
+ unevaluatedProperties unevaluatedItems additionalItems contentSchema].freeze
167
+ # Keywords whose value is a map of subschemas (draft-07 `dependencies`
168
+ # may hold schemas too).
169
+ SCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
170
+ # Keywords whose value is an array of subschemas.
171
+ SCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
172
+
173
+ # Recursive schema walk over schema-bearing keywords only (instance data
174
+ # such as `default`, `examples`, `enum` or `const` is never a schema). A
175
+ # node is a *reachable property* when the chain from the root to it
176
+ # consists solely of `properties` keys; annotations anywhere else (items,
177
+ # composition and conditional keywords, $defs, $ref targets, the root
178
+ # itself) invalidate the tool.
179
+ # @api private
180
+ def walk(node, path, root:, reachable:, document:, errors:, seen:, found:)
181
+ return unless node.is_a?(Hash)
182
+
183
+ ctx = { document: document, errors: errors, seen: seen, found: found }
184
+ check_annotation(node, path, reachable, document, errors, seen, found) if annotated?(node)
185
+ node.each do |key, value|
186
+ key_name = key.to_s
187
+ if key_name == 'properties' && value.is_a?(Hash)
188
+ value.each { |name, prop| walk(prop, path + [name.to_s], root: false, reachable: root || reachable, **ctx) }
189
+ else
190
+ subschemas(key_name, value).each { |sub| walk(sub, path + [key_name], root: false, reachable: false, **ctx) }
191
+ end
192
+ end
193
+ end
194
+
195
+ # The subschemas held by a keyword's value (none for instance data such
196
+ # as default, examples, enum or const).
197
+ # @api private
198
+ def subschemas(key_name, value)
199
+ if SCHEMA_MAP_KEYWORDS.include?(key_name)
200
+ value.is_a?(Hash) ? value.values : []
201
+ elsif SCHEMA_ARRAY_KEYWORDS.include?(key_name) || (key_name == 'items' && value.is_a?(Array))
202
+ Array(value)
203
+ elsif SCHEMA_KEYWORDS.include?(key_name)
204
+ [value]
205
+ else
206
+ []
207
+ end
208
+ end
209
+
210
+ # Whether a property schema declares exactly one of the primitive types.
211
+ #
212
+ # A type array naming the one type is the same declaration as the bare
213
+ # string, and JSON Schema has no other way to spell a nullable primitive
214
+ # than to union it with "null": the constraint is on the property's type,
215
+ # while a null *value* has its own rule -- the header is omitted -- so
216
+ # dropping the whole tool over `["string", "null"]` would reject a schema
217
+ # the transport can mirror perfectly well.
218
+ #
219
+ # A property that states its type through a reference states it all the
220
+ # same: JSON Schema 2020-12 evaluates `$ref` beside its siblings (Core
221
+ # 8.2.3.1), so `{"$ref": "#/$defs/r", "x-mcp-header": "Region"}` is a
222
+ # primitive property whenever the target is one. That is separate from
223
+ # the reachability rule, which is about where the ANNOTATION sits and
224
+ # still never passes through a reference.
225
+ # @api private
226
+ def primitive_type?(node, document = nil)
227
+ node = typed_node(node, document)
228
+ return false unless node.is_a?(Hash)
229
+
230
+ type = node.key?('type') ? node['type'] : node[:type]
231
+ declared = Array(type) - ['null']
232
+ declared.size == 1 && declared.first.is_a?(String) && PRIMITIVE_TYPES.include?(declared.first)
233
+ end
234
+
235
+ # How many references the type lookup will follow before giving up.
236
+ MAX_REF_HOPS = 8
237
+
238
+ # The schema object that states this property's type: the node itself, or
239
+ # what its local `$ref` chain leads to. A reference this client cannot
240
+ # resolve on its own -- an external URI, a pointer into nothing, a cycle,
241
+ # or a name whose pointer escapes are percent-encoded -- resolves to
242
+ # nothing, and the property is treated as one whose type is unstated: the
243
+ # tool is excluded rather than mirrored on a guess.
244
+ # @api private
245
+ def typed_node(node, document)
246
+ hops = 0
247
+ seen = []
248
+ while node.is_a?(Hash) && !node.key?('type') && !node.key?(:type)
249
+ ref = node.key?('$ref') ? node['$ref'] : node[:$ref]
250
+ return nil unless ref.is_a?(String) && !seen.include?(ref) && (hops += 1) <= MAX_REF_HOPS
251
+
252
+ seen << ref
253
+ node = resolve_local_pointer(document, ref)
254
+ end
255
+ node
256
+ end
257
+
258
+ # Resolve a same-document JSON pointer (RFC 6901) given as a URI fragment.
259
+ # @api private
260
+ def resolve_local_pointer(document, ref)
261
+ return nil unless document.is_a?(Hash) && ref.start_with?('#')
262
+
263
+ pointer = ref[1..]
264
+ return document if pointer.empty?
265
+ return nil unless pointer.start_with?('/')
266
+
267
+ pointer.split('/', -1).drop(1).reduce(document) do |node, token|
268
+ return nil if token.include?('%')
269
+
270
+ pointer_step(node, token.gsub('~1', '/').gsub('~0', '~'))
271
+ end
272
+ end
273
+
274
+ # @api private
275
+ def pointer_step(node, token)
276
+ case node
277
+ when Hash then node.key?(token) ? node[token] : node[token.to_sym]
278
+ when Array then token.match?(/\A(?:0|[1-9][0-9]*)\z/) ? node[token.to_i] : nil
279
+ end
280
+ end
281
+
282
+ # @api private
283
+ def annotated?(node)
284
+ node.key?(ANNOTATION) || node.key?(ANNOTATION.to_sym)
285
+ end
286
+
287
+ # @api private
288
+ def check_annotation(node, path, reachable, document, errors, seen, found)
289
+ value = node.key?(ANNOTATION) ? node[ANNOTATION] : node[ANNOTATION.to_sym]
290
+ # Property names are peer-controlled: inspect escapes control characters.
291
+ where = path.empty? ? 'the schema root' : path.join('.').inspect
292
+
293
+ unless value.is_a?(String)
294
+ errors << "#{ANNOTATION} at #{where} must be a string"
295
+ return
296
+ end
297
+ errors << "#{ANNOTATION} at #{where} must not be empty" if value.empty?
298
+ if !value.empty? && !value.match?(TOKEN)
299
+ errors << "#{ANNOTATION} at #{where} must be an HTTP field-name token (#{value.inspect})"
300
+ end
301
+ unless reachable
302
+ errors << "#{ANNOTATION} at #{where} is not statically reachable via properties keys from the schema root"
303
+ end
304
+
305
+ unless primitive_type?(node, document)
306
+ errors << "#{ANNOTATION} at #{where} must be on a primitive property (integer, string or boolean)"
307
+ end
308
+
309
+ key = value.downcase
310
+ if seen.key?(key)
311
+ errors << "#{ANNOTATION} values must be case-insensitively unique: #{value.inspect} at #{where} " \
312
+ "duplicates #{seen[key]}"
313
+ else
314
+ seen[key] = where
315
+ end
316
+
317
+ found << [path, value] if reachable && errors.empty?
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module MCPClient
6
+ module HttpTransportBase
7
+ # Inflation of peer-supplied gzip bytes under an expansion bound. The peer
8
+ # controls the compression ratio, so the bound is applied to the pieces
9
+ # zlib produces as it produces them: a body that expands far past the
10
+ # ceiling is stopped at the first piece that crosses it, never allocated
11
+ # in full and then measured.
12
+ module BoundedInflate
13
+ module_function
14
+
15
+ # @param inflater [Zlib::Inflate] the stream the bytes belong to
16
+ # @param bytes [String] gzip bytes as they arrived
17
+ # @param limit [Integer, nil] ceiling on the inflated size, nil for none
18
+ # @param inflated_so_far [Integer] bytes this stream already produced
19
+ # @return [String, nil] the text these bytes expand to; nil once the
20
+ # expansion would cross the limit
21
+ def inflate(inflater, bytes, limit, inflated_so_far = 0)
22
+ text = +''.b
23
+ over = ->(piece) { limit && inflated_so_far + text.bytesize + piece.bytesize > limit }
24
+ catch(:over_the_bound) do
25
+ inflater.inflate(bytes) do |piece|
26
+ throw :over_the_bound if over.call(piece)
27
+
28
+ text << piece
29
+ end
30
+ # zlib hands over full pieces as it fills them; what is left of a
31
+ # stream that has not ended stays in its buffer until asked for.
32
+ tail = inflater.flush_next_out
33
+ throw :over_the_bound if over.call(tail)
34
+
35
+ return text << tail
36
+ end
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end