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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|