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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ class Client
5
+ # What the client does with a notification a transport delivers: the
6
+ # caches it drops, the host callbacks it runs, and how a transport's
7
+ # notifications are hooked up in the first place. A listener is host
8
+ # code, run after the client's own bookkeeping; its failures are the
9
+ # transport's to isolate.
10
+ module NotificationRouting
11
+ private
12
+
13
+ # Wire this client's own notification processing and the host's listeners
14
+ # onto the transport.
15
+ #
16
+ # The cache invalidation goes on the transport's own invalidation hook,
17
+ # which runs *before* a notification is delivered to a subscription's
18
+ # listeners — so a listener reacting to a `list_changed` notification
19
+ # re-fetches instead of reading the entry the notification just
20
+ # invalidated. Everything else this client does with a notification is host
21
+ # code or leads to it (logging, progress callbacks, task status), and stays
22
+ # on the callback that runs last, behind the delivery. A transport that
23
+ # emits no such hook — a host-supplied adapter written against the older
24
+ # interface, say — keeps the invalidation on `on_notification`, ahead of
25
+ # everything else there: it routes no subscriptions, so there is no
26
+ # delivery for it to be ahead of.
27
+ #
28
+ # Which of the two it is cannot be answered by whether the transport *has*
29
+ # the hook: every {MCPClient::ServerBase} subclass inherits it, so the
30
+ # answer was yes for every custom adapter as well, and one that fans its
31
+ # notifications out through `@notification_callback` alone — exactly what
32
+ # the interface used to be — silently stopped invalidating anything. The
33
+ # question is whether the hook actually *ran* for the notification in
34
+ # hand, and the hook answers it itself: every path that emits it does so
35
+ # immediately before the host callback and on the same thread
36
+ # ({MCPClient::JsonRpcCommon#notify_cache_invalidation}), so a callback
37
+ # that arrives without that mark is one nothing invalidated for. Having
38
+ # the hook still decides whether one is *registered* — a host may supply
39
+ # an object that is no ServerBase at all — but no longer decides who
40
+ # invalidates.
41
+ # @param server [MCPClient::ServerBase] the server to wire
42
+ # @return [void]
43
+ def register_notification_handlers(server)
44
+ if server.class.method_defined?(:on_cache_invalidation)
45
+ server.on_cache_invalidation do |method, _params|
46
+ invalidate_caches_for_notification(server, method)
47
+ Thread.current[CACHE_INVALIDATION_MARK] = [server, method]
48
+ end
49
+ end
50
+ server.on_notification do |method, params|
51
+ mark = Thread.current[CACHE_INVALIDATION_MARK]
52
+ Thread.current[CACHE_INVALIDATION_MARK] = nil
53
+ invalidate_caches_for_notification(server, method) unless mark_covers?(mark, server, method)
54
+ # Default notification processing (e.g., logging, progress)
55
+ process_notification(server, method, params)
56
+ # Invoke user-defined listeners
57
+ @notification_listeners.each { |cb| cb.call(server, method, params) }
58
+ end
59
+ end
60
+
61
+ # Drop the caches a notification invalidates.
62
+ #
63
+ # Registered on the transport's `on_cache_invalidation` hook, which runs
64
+ # before the notification is delivered to a subscription's listeners — so a
65
+ # listener that reacts to a `list_changed` notification by calling
66
+ # `list_tools` (or the prompt/resource equivalents) re-fetches instead of
67
+ # reading the entry the notification just invalidated. It used to ride on
68
+ # `on_notification`, which round 10 moved to the end of the routing order
69
+ # for good reason: that callback is host code and may block the very reader
70
+ # the delivery came from. Only the cache drops moved forward; everything
71
+ # else {#process_notification} does still runs behind the delivery.
72
+ # @param server [MCPClient::ServerBase] the server that emitted it
73
+ # @param method [String] JSON-RPC notification method
74
+ # @return [void]
75
+ def invalidate_caches_for_notification(server, method)
76
+ server_id = notification_server_id(server)
77
+ case method
78
+ when 'notifications/tools/list_changed'
79
+ logger.warn("[#{server_id}] Tool list has changed, clearing tool cache")
80
+ clear_tool_cache
81
+ when 'notifications/prompts/list_changed'
82
+ logger.warn("[#{server_id}] Prompt list has changed, clearing prompt cache")
83
+ @cache_mutex.synchronize do
84
+ @cache_version += 1
85
+ @prompt_cache.clear
86
+ @cache_params.delete(:prompts)
87
+ @cache_filled.delete(:prompts)
88
+ end
89
+ when 'notifications/resources/list_changed'
90
+ logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
91
+ @cache_mutex.synchronize do
92
+ @cache_version += 1
93
+ @resource_cache.clear
94
+ @cache_params.delete(:resources)
95
+ @cache_filled.delete(:resources)
96
+ end
97
+ end
98
+ end
99
+
100
+ # @param server [MCPClient::ServerBase] the server that emitted a notification
101
+ # @return [String] the identity used to prefix its log lines
102
+ def notification_server_id(server)
103
+ server.name ? "#{server.class}[#{server.name}]" : server.class.to_s
104
+ end
105
+
106
+ # Process incoming JSON-RPC notifications with default handlers
107
+ # @param server [MCPClient::ServerBase] the server that emitted the notification
108
+ # @param method [String] JSON-RPC notification method
109
+ # @param params [Hash] parameters for the notification
110
+ # @return [void]
111
+ def process_notification(server, method, params)
112
+ server_id = notification_server_id(server)
113
+ case method
114
+ when 'notifications/tools/list_changed', 'notifications/prompts/list_changed',
115
+ 'notifications/resources/list_changed'
116
+ # Already handled, ahead of the delivery to any subscription listener
117
+ # (see {#invalidate_caches_for_notification}).
118
+ nil
119
+ when 'notifications/resources/updated'
120
+ logger.warn("[#{server_id}] Resource #{params['uri']} updated")
121
+ when 'notifications/message'
122
+ # MCP 2025-06-18: Handle logging messages from server
123
+ handle_log_message(server_id, params)
124
+ when 'notifications/tasks/status', 'notifications/tasks'
125
+ # (both handled below; the legacy method carries the flat 2025 shape)
126
+ # MCP 2025-11-25: task status update (params are a flat Task);
127
+ # MCP 2026-07-28 tasks extension: notifications/tasks carries a
128
+ # DetailedTask (only ever on a subscriptions/listen stream).
129
+ handle_task_status_notification(server_id, params, method)
130
+ when 'notifications/subscriptions/acknowledged'
131
+ # MCP 2026-07-28: the transport already recorded the acknowledged
132
+ # filter on the Subscription; log for observability.
133
+ sub_id = params&.dig('_meta', 'io.modelcontextprotocol/subscriptionId')
134
+ logger.debug("[#{server_id}] Subscription #{sanitize_peer_log_text(sub_id.to_s)} acknowledged")
135
+ when 'notifications/cancelled'
136
+ # MCP 2025-11-25 cancellation utility: the server cancelled one of its
137
+ # own in-flight requests (sampling/elicitation). Server-request
138
+ # dispatch is synchronous per transport, so by the time this arrives
139
+ # the handler has usually completed; receivers MAY ignore
140
+ # cancellations they cannot honor — log for observability. On MCP
141
+ # 2026-07-28 it only ever tears down a subscriptions/listen stream,
142
+ # which the transport handled before this point.
143
+ request_id = sanitize_peer_log_text(params&.dig('requestId').to_s)
144
+ reason = sanitize_peer_log_text((params&.dig('reason') || 'no reason given').to_s)
145
+ logger.debug("[#{server_id}] Server cancelled request #{request_id}: #{reason}")
146
+ when 'notifications/progress'
147
+ handle_progress_notification(server_id, params)
148
+ else
149
+ # Log unknown notification types for debugging purposes
150
+ logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ class Client
5
+ # The client's own handling of a server's sampling request: the shape the
6
+ # 2026-07-28 specification requires of a message history before the host's
7
+ # sampler is asked for a completion, and the refusal when it is not met.
8
+ # Mixed into {MCPClient::Client}; every method is private there.
9
+ module SamplingValidation
10
+ private
11
+
12
+ # Handle sampling/createMessage request from server (MCP 2025-11-25)
13
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
14
+ # @param params [Hash] the sampling parameters
15
+ # @return [Hash] the sampling response (role, content, model, stopReason)
16
+ def handle_sampling_request(_request_id, params)
17
+ # Without a handler the sampling capability was never declared, so the
18
+ # request targets an unsupported method: answer -32601 (Method not
19
+ # found) rather than -1, which sampling.mdx § Error Handling reserves
20
+ # for "User rejected sampling request".
21
+ unless @sampling_handler
22
+ @logger.warn('Received sampling request but no sampling handler is configured')
23
+ return jsonrpc_error_result(-32_601, 'Sampling not supported: no sampling handler configured')
24
+ end
25
+
26
+ # SEP-1577 (schema.ts CreateMessageRequestParams.tools/.toolChoice):
27
+ # "The client MUST return an error if this field is provided but
28
+ # ClientCapabilities.sampling.tools is not declared." -32602 is the
29
+ # Invalid params code used by sampling.mdx § Error Handling.
30
+ if (params.key?('tools') || params.key?('toolChoice')) && !@sampling_supports_tools
31
+ @logger.warn('Rejecting tool-enabled sampling request: sampling.tools capability not declared')
32
+ return jsonrpc_error_result(-32_602,
33
+ 'Invalid params: tools/toolChoice provided but the sampling.tools ' \
34
+ 'capability was not declared')
35
+ end
36
+
37
+ # SEP-2596: "thisServer" / "allServers" are deprecated (omit the field
38
+ # or send "none"); the request is still served as before.
39
+ if %w[thisServer allServers].include?(params['includeContext'])
40
+ MCPClient::Deprecations.warn(:include_context, @logger, detail: "includeContext #{params['includeContext']}")
41
+ end
42
+
43
+ messages = params['messages'] || []
44
+ # Both parties SHOULD validate message content (sampling.mdx
45
+ # "Security Considerations"): the role, the content, a user message of
46
+ # tool results carrying nothing else, and every assistant tool use
47
+ # answered by the message that follows it.
48
+ if (problem = sampling_history_problem(messages))
49
+ @logger.warn("Rejecting sampling request with a malformed history: #{problem}")
50
+ return jsonrpc_error_result(-32_602, "Invalid params: #{problem}")
51
+ end
52
+
53
+ model_preferences = normalize_model_preferences(params['modelPreferences'])
54
+ system_prompt = params['systemPrompt']
55
+ max_tokens = params['maxTokens']
56
+
57
+ begin
58
+ # Call the user-defined handler with parameters based on arity
59
+ result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens, params)
60
+
61
+ # Validate and format response
62
+ validate_sampling_response(result)
63
+ rescue StandardError => e
64
+ @logger.error("Sampling handler error: #{e.message}")
65
+ @logger.debug(e.backtrace.join("\n"))
66
+ # A handler exception is an internal client failure (-32603), not a
67
+ # user rejection: sampling.mdx § Error Handling reserves -1 for
68
+ # "User rejected sampling request". The exception message itself is
69
+ # host-internal (file paths, connection strings, library internals)
70
+ # and stays in the local log rather than crossing to the server.
71
+ jsonrpc_error_result(-32_603, 'Sampling error')
72
+ end
73
+ end
74
+
75
+ # What is wrong with a sampling history, if anything, by the rules of
76
+ # MCP 2026-07-28 client/sampling: every message has a role of "user" or
77
+ # "assistant" and content; a user message containing tool results
78
+ # contains only tool results; every assistant message with tool uses is
79
+ # followed by a user message consisting entirely of the matching tool
80
+ # results before any other message.
81
+ # @param messages [Array<Hash>] the sampling messages
82
+ # @return [String, nil] the problem, nil when the history is well formed
83
+ def sampling_history_problem(messages)
84
+ return 'messages must be an array' unless messages.is_a?(Array)
85
+
86
+ pending = nil
87
+ messages.each_with_index do |message, index|
88
+ problem, pending = sampling_message_problem(message, index, pending)
89
+ return problem if problem
90
+ end
91
+ return "the last message leaves its tool uses (#{pending.join(', ')}) unanswered" if pending
92
+
93
+ nil
94
+ end
95
+
96
+ # @param message [Object] a sampling message
97
+ # @param index [Integer] its position
98
+ # @param pending [Array<String>, nil] the tool use ids the previous message left to answer
99
+ # @return [Array(String, nil), Array(nil, Array<String>)] the problem, or the tool uses now pending
100
+ def sampling_message_problem(message, index, pending)
101
+ blocks = sampling_message_blocks(message)
102
+ return [sampling_shape_problem(message, index), nil] unless blocks
103
+
104
+ role = message['role'] || message[:role]
105
+ uses = blocks.select { |block| sampling_block_type(block) == 'tool_use' }
106
+ results = blocks.select { |block| sampling_block_type(block) == 'tool_result' }
107
+ return ["message #{index} carries tool uses in a #{role} message", nil] if uses.any? && role != 'assistant'
108
+
109
+ if pending
110
+ problem = sampling_tool_results_problem(index, role, blocks, results, pending)
111
+ return [problem, nil] if problem
112
+ elsif results.any? && results.size != blocks.size
113
+ # The spec forbids the mixing, not a results-only message on its own:
114
+ # a server may hand over the results without the history before them.
115
+ return ["message #{index} mixes tool results with other content", nil]
116
+ end
117
+ [nil, (uses.map { |block| (block['id'] || block[:id]).to_s } if uses.any?)]
118
+ end
119
+
120
+ # @param index [Integer] the position of the message answering the tool uses
121
+ # @param role [String] its role
122
+ # @param blocks [Array<Hash>] its content blocks
123
+ # @param results [Array<Hash>] the tool results among them
124
+ # @param pending [Array<String>] the tool use ids to answer
125
+ # @return [String, nil] the problem, nil when the message answers exactly those uses
126
+ def sampling_tool_results_problem(index, role, blocks, results, pending)
127
+ unless role == 'user' && results.size == blocks.size
128
+ return "message #{index} must consist only of the tool results answering message #{index - 1}"
129
+ end
130
+
131
+ ids = results.map { |block| (block['toolUseId'] || block[:toolUseId]).to_s }
132
+ return nil if ids.sort == pending.sort
133
+
134
+ "message #{index} tool results do not match the tool uses of message #{index - 1}"
135
+ end
136
+
137
+ # Which half of the message's shape is wrong, so the host is told what to
138
+ # look at: the envelope, or a content block that carries no meaning.
139
+ # @param message [Object] a sampling message
140
+ # @param index [Integer] its position
141
+ # @return [String] the problem
142
+ def sampling_shape_problem(message, index)
143
+ blocks = message.is_a?(Hash) ? (message['content'] || message[:content]) : nil
144
+ blocks = [blocks] if blocks.is_a?(Hash)
145
+ if blocks.is_a?(Array)
146
+ bad = blocks.find { |block| !sampling_block_well_formed?(block) }
147
+ if bad
148
+ type = sampling_block_type(bad)
149
+ named = type ? "a #{type.inspect} content block" : 'a content block'
150
+ return "message #{index} carries #{named} without the fields its type requires"
151
+ end
152
+ end
153
+ "message #{index} must be an object with a role of \"user\" or \"assistant\" and content"
154
+ end
155
+
156
+ # @param message [Object] a sampling message
157
+ # @return [Array<Hash>, nil] its content blocks, nil unless the message is well formed
158
+ def sampling_message_blocks(message)
159
+ return nil unless message.is_a?(Hash) && %w[user assistant].include?(message['role'] || message[:role])
160
+
161
+ blocks = message['content'] || message[:content]
162
+ blocks = [blocks] if blocks.is_a?(Hash)
163
+ return nil unless blocks.is_a?(Array) && !blocks.empty?
164
+ return nil unless blocks.all? { |block| sampling_block_well_formed?(block) }
165
+
166
+ blocks
167
+ end
168
+
169
+ # The fields a content block of a known type must carry for the message
170
+ # rules to mean anything: the text of a text block, the payload of an
171
+ # image or audio block, and — the reason the correlation rules can be
172
+ # checked at all — the identifier of a tool use and of the tool result
173
+ # answering it. A type this client does not know is the host's to read,
174
+ # not this client's to refuse: refusing it would break a session with a
175
+ # server using a content type added after this release.
176
+ # @param block [Object] a content block
177
+ # @return [Boolean] whether the block can be handed to the host
178
+ def sampling_block_well_formed?(block)
179
+ type = sampling_block_type(block)
180
+ return false unless type
181
+
182
+ case type
183
+ when 'text' then sampling_block_string?(block, 'text')
184
+ when 'image', 'audio' then sampling_block_string?(block, 'data') && sampling_block_string?(block, 'mimeType')
185
+ when 'tool_use' then sampling_block_string?(block, 'id')
186
+ when 'tool_result' then sampling_block_string?(block, 'toolUseId')
187
+ else true
188
+ end
189
+ end
190
+
191
+ # @param block [Hash] a content block
192
+ # @param field [String] the field it must carry
193
+ # @return [Boolean] whether the field is a non-empty String
194
+ def sampling_block_string?(block, field)
195
+ value = block[field] || block[field.to_sym]
196
+ value.is_a?(String) && !value.empty?
197
+ end
198
+ end
199
+ end
200
+ end