claude-agent-sdk 0.36.0 → 1.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/CHANGELOG.md +34 -0
- data/README.md +7 -3
- data/UPGRADING-1.0.md +151 -0
- data/docs/client.md +26 -1
- data/docs/errors.md +6 -0
- data/docs/hooks-and-permissions.md +5 -3
- data/docs/mcp-servers.md +1 -2
- data/docs/sessions.md +101 -3
- data/docs/types.md +109 -4
- data/lib/claude_agent_sdk/cli_installer.rb +30 -3
- data/lib/claude_agent_sdk/command_builder.rb +109 -98
- data/lib/claude_agent_sdk/deprecation.rb +1 -1
- data/lib/claude_agent_sdk/errors.rb +10 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
- data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
- data/lib/claude_agent_sdk/message_parser.rb +23 -9
- data/lib/claude_agent_sdk/observer.rb +2 -1
- data/lib/claude_agent_sdk/option_warnings.rb +2 -0
- data/lib/claude_agent_sdk/query.rb +50 -43
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
- data/lib/claude_agent_sdk/session_mutations.rb +20 -8
- data/lib/claude_agent_sdk/session_resume.rb +31 -16
- data/lib/claude_agent_sdk/session_store.rb +7 -3
- data/lib/claude_agent_sdk/session_summary.rb +4 -2
- data/lib/claude_agent_sdk/sessions.rb +8 -6
- data/lib/claude_agent_sdk/streaming.rb +1 -1
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
- data/lib/claude_agent_sdk/types/attributes.rb +236 -0
- data/lib/claude_agent_sdk/types/base.rb +322 -0
- data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
- data/lib/claude_agent_sdk/types/hooks.rb +640 -0
- data/lib/claude_agent_sdk/types/mcp.rb +232 -0
- data/lib/claude_agent_sdk/types/messages.rb +614 -0
- data/lib/claude_agent_sdk/types/option_values.rb +302 -0
- data/lib/claude_agent_sdk/types/options.rb +352 -0
- data/lib/claude_agent_sdk/types/permissions.rb +107 -0
- data/lib/claude_agent_sdk/types/sessions.rb +10 -0
- data/lib/claude_agent_sdk/types.rb +13 -2534
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +62 -28
- data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
- data/sig/claude_agent_sdk/configuration.rbs +14 -0
- data/sig/claude_agent_sdk/errors.rbs +86 -0
- data/sig/claude_agent_sdk/observer.rbs +42 -0
- data/sig/claude_agent_sdk/railtie.rbs +10 -0
- data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
- data/sig/claude_agent_sdk/session_store.rbs +105 -0
- data/sig/claude_agent_sdk/streaming.rbs +15 -0
- data/sig/claude_agent_sdk/transport.rbs +98 -0
- data/sig/claude_agent_sdk/types/base.rbs +39 -0
- data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
- data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
- data/sig/claude_agent_sdk/types/messages.rbs +586 -0
- data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
- data/sig/claude_agent_sdk/types/options.rbs +288 -0
- data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
- data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
- data/sig/claude_agent_sdk.rbs +231 -0
- data/sig/manifest.yaml +5 -0
- metadata +32 -1
|
@@ -5,9 +5,11 @@ require_relative 'errors'
|
|
|
5
5
|
|
|
6
6
|
module ClaudeAgentSDK
|
|
7
7
|
# Parse message from CLI output into typed Message objects
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
8
10
|
class MessageParser
|
|
9
|
-
def self.parse(data)
|
|
10
|
-
raise MessageParseError.new(
|
|
11
|
+
def self.parse(data) # rubocop:disable Metrics/CyclomaticComplexity -- flat dispatch over CLI message types
|
|
12
|
+
raise MessageParseError.new('Invalid message data type', data: data) unless data.is_a?(Hash)
|
|
11
13
|
|
|
12
14
|
message_type = data[:type]
|
|
13
15
|
raise MessageParseError.new("Message missing 'type' field", data: data) unless message_type
|
|
@@ -47,13 +49,17 @@ module ClaudeAgentSDK
|
|
|
47
49
|
uuid = data[:uuid] # UUID for rewind support
|
|
48
50
|
tool_use_result = data[:tool_use_result]
|
|
49
51
|
message_data = data[:message]
|
|
50
|
-
raise MessageParseError.new(
|
|
52
|
+
raise MessageParseError.new('Missing message field in user message', data: data) unless message_data
|
|
51
53
|
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
52
54
|
# message_data[:content] instead of the documented MessageParseError.
|
|
53
|
-
|
|
55
|
+
unless message_data.is_a?(Hash)
|
|
56
|
+
raise MessageParseError.new(
|
|
57
|
+
"Invalid message field in user message (expected Hash, got #{message_data.class})", data: data
|
|
58
|
+
)
|
|
59
|
+
end
|
|
54
60
|
|
|
55
61
|
content = message_data[:content]
|
|
56
|
-
raise MessageParseError.new(
|
|
62
|
+
raise MessageParseError.new('Missing content in user message', data: data) unless content
|
|
57
63
|
|
|
58
64
|
origin = parse_origin(data)
|
|
59
65
|
|
|
@@ -85,11 +91,17 @@ module ClaudeAgentSDK
|
|
|
85
91
|
message_data = data[:message]
|
|
86
92
|
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
87
93
|
# dig instead of the documented MessageParseError.
|
|
88
|
-
|
|
94
|
+
unless message_data.is_a?(Hash)
|
|
95
|
+
raise MessageParseError.new(
|
|
96
|
+
"Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data
|
|
97
|
+
)
|
|
98
|
+
end
|
|
89
99
|
|
|
90
100
|
content = message_data[:content]
|
|
91
|
-
raise MessageParseError.new(
|
|
92
|
-
|
|
101
|
+
raise MessageParseError.new('Missing content in assistant message', data: data) unless content
|
|
102
|
+
unless content.is_a?(Array)
|
|
103
|
+
raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data)
|
|
104
|
+
end
|
|
93
105
|
|
|
94
106
|
content_blocks = parse_content_blocks(content, data)
|
|
95
107
|
AssistantMessage.new(
|
|
@@ -194,7 +206,9 @@ module ClaudeAgentSDK
|
|
|
194
206
|
# opaque TypeError/NoMethodError from `block[:type]` deep in parsing.
|
|
195
207
|
def self.parse_content_blocks(content, data)
|
|
196
208
|
content.map do |block|
|
|
197
|
-
|
|
209
|
+
unless block.is_a?(Hash)
|
|
210
|
+
raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data)
|
|
211
|
+
end
|
|
198
212
|
|
|
199
213
|
parse_content_block(block)
|
|
200
214
|
end
|
|
@@ -39,7 +39,8 @@ module ClaudeAgentSDK
|
|
|
39
39
|
# Client#query/#receive_messages/#receive_response/#connect (after
|
|
40
40
|
# argument/configuration validation — usage errors such as 'Not
|
|
41
41
|
# connected' or invalid options do not notify) — including errors raised
|
|
42
|
-
# by the user's own message block — before on_close where both fire.
|
|
42
|
+
# by the user's own message block — before on_close where both fire.
|
|
43
|
+
# query() fires on_close even for connect-phase failures (its
|
|
43
44
|
# ensure always runs); a Client#connect failure before the handshake
|
|
44
45
|
# completes fires on_error WITHOUT on_close (the session never opened).
|
|
45
46
|
# Not notified (by design): errors raised by control-request methods
|
|
@@ -19,9 +19,17 @@ module ClaudeAgentSDK
|
|
|
19
19
|
# - Tool permission callbacks
|
|
20
20
|
# - Message streaming
|
|
21
21
|
# - Initialization handshake
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class Query # rubocop:disable Metrics/ClassLength -- control-protocol hub: routing, hooks, permissions, MCP bridge
|
|
23
25
|
attr_reader :transport, :is_streaming_mode, :sdk_mcp_servers
|
|
24
26
|
|
|
27
|
+
# The CLI's response to the initialize control request (nil before
|
|
28
|
+
# #initialize_protocol completes). Read by Client#server_info.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
attr_reader :initialization_result
|
|
32
|
+
|
|
25
33
|
CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
|
|
26
34
|
DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
|
|
27
35
|
|
|
@@ -63,7 +71,7 @@ module ClaudeAgentSDK
|
|
|
63
71
|
end
|
|
64
72
|
end
|
|
65
73
|
|
|
66
|
-
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
74
|
+
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
|
|
67
75
|
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
68
76
|
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
69
77
|
callback_scheduling: :thread, callback_wrapper: nil)
|
|
@@ -132,7 +140,7 @@ module ClaudeAgentSDK
|
|
|
132
140
|
|
|
133
141
|
# Initialize control protocol if in streaming mode
|
|
134
142
|
# @return [Hash, nil] Initialize response with supported commands, or nil if not streaming
|
|
135
|
-
def initialize_protocol
|
|
143
|
+
def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds the initialize request from every optional option
|
|
136
144
|
return nil unless @is_streaming_mode
|
|
137
145
|
|
|
138
146
|
# Build hooks configuration for initialization
|
|
@@ -237,7 +245,10 @@ module ClaudeAgentSDK
|
|
|
237
245
|
return if @task
|
|
238
246
|
|
|
239
247
|
parent = Async::Task.current?
|
|
240
|
-
|
|
248
|
+
unless parent
|
|
249
|
+
raise CLIConnectionError,
|
|
250
|
+
'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})'
|
|
251
|
+
end
|
|
241
252
|
|
|
242
253
|
@owning_scheduler = Fiber.scheduler
|
|
243
254
|
# Async child fibers do not inherit OTel's fiber-local current context.
|
|
@@ -322,8 +333,8 @@ module ClaudeAgentSDK
|
|
|
322
333
|
DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS
|
|
323
334
|
end
|
|
324
335
|
|
|
325
|
-
def read_messages
|
|
326
|
-
@transport.read_messages do |message|
|
|
336
|
+
def read_messages # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
|
|
337
|
+
@transport.read_messages do |message| # rubocop:disable Metrics/BlockLength -- see read_messages
|
|
327
338
|
break if @closed
|
|
328
339
|
|
|
329
340
|
msg_type = message[:type]
|
|
@@ -338,14 +349,12 @@ module ClaudeAgentSDK
|
|
|
338
349
|
# nothing keeps running after close; bare Async do may root at the
|
|
339
350
|
# reactor and leak past shutdown.
|
|
340
351
|
handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
@inflight_control_request_tasks.delete(request_id)
|
|
348
|
-
end
|
|
352
|
+
handle_control_request(message)
|
|
353
|
+
ensure
|
|
354
|
+
# Identity-guarded: if the CLI ever reused an in-flight request
|
|
355
|
+
# id, the later handler owns the slot and must stay cancellable.
|
|
356
|
+
if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
|
|
357
|
+
@inflight_control_request_tasks.delete(request_id)
|
|
349
358
|
end
|
|
350
359
|
end)
|
|
351
360
|
# A handler that never suspends (MCP metadata, unsupported-subtype
|
|
@@ -383,11 +392,7 @@ module ClaudeAgentSDK
|
|
|
383
392
|
@first_result_received = true
|
|
384
393
|
@first_result_condition.signal
|
|
385
394
|
end
|
|
386
|
-
|
|
387
|
-
@last_error_result = message
|
|
388
|
-
else
|
|
389
|
-
@last_error_result = nil
|
|
390
|
-
end
|
|
395
|
+
@last_error_result = message[:is_error] ? message : nil
|
|
391
396
|
elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
|
|
392
397
|
# Anything other than the post-turn session_state_changed marker
|
|
393
398
|
# means the conversation moved on; a ProcessError now is a fresh
|
|
@@ -533,11 +538,12 @@ module ClaudeAgentSDK
|
|
|
533
538
|
waiter = @pending_control_responses[request_id]
|
|
534
539
|
return unless waiter
|
|
535
540
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
+
@pending_control_results[request_id] =
|
|
542
|
+
if response[:subtype] == 'error'
|
|
543
|
+
StandardError.new(response[:error] || 'Unknown error')
|
|
544
|
+
else
|
|
545
|
+
response
|
|
546
|
+
end
|
|
541
547
|
|
|
542
548
|
# Signal that response is ready. INVARIANT: the result slot above
|
|
543
549
|
# MUST be written before this signal — senders check the slot before
|
|
@@ -545,7 +551,7 @@ module ClaudeAgentSDK
|
|
|
545
551
|
waiter.signal
|
|
546
552
|
end
|
|
547
553
|
|
|
548
|
-
def handle_control_request(request)
|
|
554
|
+
def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- subtype dispatch plus the shared error response
|
|
549
555
|
request_id = request[:request_id] || request[:requestId]
|
|
550
556
|
request_data = request[:request]
|
|
551
557
|
subtype = request_data[:subtype]
|
|
@@ -636,7 +642,7 @@ module ClaudeAgentSDK
|
|
|
636
642
|
nil
|
|
637
643
|
end
|
|
638
644
|
|
|
639
|
-
def handle_permission_request(request_data, request_id: nil)
|
|
645
|
+
def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
|
|
640
646
|
raise 'canUseTool callback is not provided' unless @can_use_tool
|
|
641
647
|
|
|
642
648
|
signal = CancellationSignal.new
|
|
@@ -644,13 +650,17 @@ module ClaudeAgentSDK
|
|
|
644
650
|
original_input = request_data[:input]
|
|
645
651
|
|
|
646
652
|
# Field order mirrors Python _internal/query.py's can_use_tool branch.
|
|
647
|
-
# Suggestions are hydrated into PermissionUpdate (Python #920)
|
|
648
|
-
#
|
|
649
|
-
#
|
|
653
|
+
# Suggestions are hydrated into PermissionUpdate (Python #920) through
|
|
654
|
+
# the lenient .wrap, so fields a newer CLI adds never trip the
|
|
655
|
+
# strict-attribute warning meant for user-built updates. A nil (or
|
|
656
|
+
# false) entry becomes an empty PermissionUpdate, as PermissionUpdate.new
|
|
657
|
+
# made it before; any other non-Hash entry raises here, on the reactor,
|
|
658
|
+
# and becomes an error control_response — same observable behavior as
|
|
659
|
+
# Python.
|
|
650
660
|
context = ToolPermissionContext.new(
|
|
651
661
|
signal: signal,
|
|
652
662
|
request_id: request_id,
|
|
653
|
-
suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.
|
|
663
|
+
suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.wrap(s || {}) },
|
|
654
664
|
tool_use_id: request_data[:tool_use_id],
|
|
655
665
|
agent_id: request_data[:agent_id],
|
|
656
666
|
blocked_path: request_data[:blocked_path],
|
|
@@ -681,9 +691,7 @@ module ClaudeAgentSDK
|
|
|
681
691
|
behavior: 'allow',
|
|
682
692
|
updatedInput: response.updated_input || original_input
|
|
683
693
|
}
|
|
684
|
-
if response.updated_permissions
|
|
685
|
-
result[:updatedPermissions] = response.updated_permissions.map(&:to_h)
|
|
686
|
-
end
|
|
694
|
+
result[:updatedPermissions] = response.updated_permissions.map(&:to_h) if response.updated_permissions
|
|
687
695
|
result
|
|
688
696
|
when PermissionResultDeny
|
|
689
697
|
result = { behavior: 'deny', message: response.message }
|
|
@@ -699,7 +707,7 @@ module ClaudeAgentSDK
|
|
|
699
707
|
untrack_callback_signal(request_id, signal)
|
|
700
708
|
end
|
|
701
709
|
|
|
702
|
-
def handle_hook_callback(request_data, request_id: nil)
|
|
710
|
+
def handle_hook_callback(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- hook round-trip: timeout, callback, output conversion
|
|
703
711
|
callback_id = request_data[:callback_id]
|
|
704
712
|
callback = @hook_callbacks[callback_id]
|
|
705
713
|
raise "No hook callback found for ID: #{callback_id}" unless callback
|
|
@@ -784,7 +792,7 @@ module ClaudeAgentSDK
|
|
|
784
792
|
@callback_request_signals.delete(request_id)
|
|
785
793
|
end
|
|
786
794
|
|
|
787
|
-
def parse_hook_input(input_data)
|
|
795
|
+
def parse_hook_input(input_data) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one branch per hook event type
|
|
788
796
|
event_name = input_data[:hook_event_name] || input_data['hook_event_name']
|
|
789
797
|
fetch = lambda do |key|
|
|
790
798
|
if input_data.key?(key)
|
|
@@ -1017,11 +1025,9 @@ module ClaudeAgentSDK
|
|
|
1017
1025
|
{ mcp_response: mcp_response }
|
|
1018
1026
|
end
|
|
1019
1027
|
|
|
1020
|
-
def convert_hook_output_for_cli(hook_output)
|
|
1028
|
+
def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
|
|
1021
1029
|
# Handle typed output objects
|
|
1022
|
-
if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
|
|
1023
|
-
return hook_output.to_h
|
|
1024
|
-
end
|
|
1030
|
+
return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
|
|
1025
1031
|
|
|
1026
1032
|
return {} unless hook_output.is_a?(Hash)
|
|
1027
1033
|
|
|
@@ -1135,7 +1141,7 @@ module ClaudeAgentSDK
|
|
|
1135
1141
|
end
|
|
1136
1142
|
end
|
|
1137
1143
|
|
|
1138
|
-
def handle_sdk_mcp_request(server_name, message)
|
|
1144
|
+
def handle_sdk_mcp_request(server_name, message) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength -- JSON-RPC method dispatch for SDK MCP servers
|
|
1139
1145
|
# Carry this session's scheduling mode and callback wrapper across the
|
|
1140
1146
|
# dispatch into the (possibly session-shared) SdkMcpServer via fiber
|
|
1141
1147
|
# storage — set on the dispatching fiber, read back by the server's
|
|
@@ -1162,7 +1168,7 @@ module ClaudeAgentSDK
|
|
|
1162
1168
|
jsonrpc: '2.0',
|
|
1163
1169
|
id: message[:id],
|
|
1164
1170
|
error: {
|
|
1165
|
-
code: -
|
|
1171
|
+
code: -32_601,
|
|
1166
1172
|
message: "Server '#{server_name}' not found"
|
|
1167
1173
|
}
|
|
1168
1174
|
}
|
|
@@ -1193,14 +1199,14 @@ module ClaudeAgentSDK
|
|
|
1193
1199
|
{
|
|
1194
1200
|
jsonrpc: '2.0',
|
|
1195
1201
|
id: message[:id],
|
|
1196
|
-
error: { code: -
|
|
1202
|
+
error: { code: -32_601, message: "Method '#{method}' not found" }
|
|
1197
1203
|
}
|
|
1198
1204
|
end
|
|
1199
1205
|
rescue StandardError => e
|
|
1200
1206
|
{
|
|
1201
1207
|
jsonrpc: '2.0',
|
|
1202
1208
|
id: message[:id],
|
|
1203
|
-
error: { code: -
|
|
1209
|
+
error: { code: -32_603, message: e.message }
|
|
1204
1210
|
}
|
|
1205
1211
|
ensure
|
|
1206
1212
|
dispatch_scope&.close
|
|
@@ -1429,6 +1435,7 @@ module ClaudeAgentSDK
|
|
|
1429
1435
|
wrote_message = false
|
|
1430
1436
|
stream.each do |message|
|
|
1431
1437
|
break if @closed
|
|
1438
|
+
|
|
1432
1439
|
serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
|
|
1433
1440
|
writeln(serialized)
|
|
1434
1441
|
wrote_message = true
|
|
@@ -67,13 +67,14 @@ module ClaudeAgentSDK
|
|
|
67
67
|
# @api private
|
|
68
68
|
def self.ruby_type_to_json_schema(type)
|
|
69
69
|
# Class#=== matches instances, not the class object used in { id: Integer }.
|
|
70
|
-
type = { String => :string, Integer => :integer, Float => :float,
|
|
70
|
+
type = { String => :string, Integer => :integer, Float => :float,
|
|
71
|
+
TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
|
|
71
72
|
case type
|
|
72
73
|
when :string, String then { type: 'string' }
|
|
73
74
|
when :integer, Integer then { type: 'integer' }
|
|
74
75
|
when :float, Float, :number then { type: 'number' }
|
|
75
76
|
when :boolean, TrueClass, FalseClass then { type: 'boolean' }
|
|
76
|
-
else { type: 'string' } #
|
|
77
|
+
else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
|
|
77
78
|
end
|
|
78
79
|
end
|
|
79
80
|
|
|
@@ -96,14 +97,17 @@ module ClaudeAgentSDK
|
|
|
96
97
|
#
|
|
97
98
|
# This class wraps the official MCP Ruby SDK and provides a simpler block-based
|
|
98
99
|
# API for defining tools, resources, and prompts.
|
|
99
|
-
class SdkMcpServer
|
|
100
|
+
class SdkMcpServer # rubocop:disable Metrics/ClassLength -- one facade over MCP::Server tools, resources and prompts
|
|
100
101
|
# The gem validates arguments before injecting its server_context keyword.
|
|
101
102
|
# Guard actual keys here, independent of schema composition/$ref support,
|
|
102
103
|
# and retain this guard even when schema validation falls back to permissive.
|
|
104
|
+
#
|
|
105
|
+
# @api private
|
|
103
106
|
class ToolInputSchema < MCP::Tool::InputSchema
|
|
104
107
|
def validate_arguments(arguments)
|
|
105
108
|
if arguments.is_a?(Hash) && (arguments.key?(:server_context) || arguments.key?('server_context'))
|
|
106
|
-
raise ValidationError,
|
|
109
|
+
raise ValidationError,
|
|
110
|
+
"Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
super
|
|
@@ -150,7 +154,9 @@ module ClaudeAgentSDK
|
|
|
150
154
|
# Validated at set time so a non-callable fails here, not later as a
|
|
151
155
|
# NoMethodError inside a tool dispatch.
|
|
152
156
|
def callback_wrapper=(value)
|
|
153
|
-
|
|
157
|
+
unless value.nil? || value.respond_to?(:call)
|
|
158
|
+
raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})"
|
|
159
|
+
end
|
|
154
160
|
|
|
155
161
|
@callback_wrapper = value
|
|
156
162
|
end
|
|
@@ -224,6 +230,7 @@ module ClaudeAgentSDK
|
|
|
224
230
|
# Handle a JSON-RPC request
|
|
225
231
|
# @param json_string [String] JSON-RPC request
|
|
226
232
|
# @return [String] JSON-RPC response
|
|
233
|
+
# @api private
|
|
227
234
|
def handle_json(json_string)
|
|
228
235
|
@mcp_server.handle_json(json_string)
|
|
229
236
|
end
|
|
@@ -245,6 +252,7 @@ module ClaudeAgentSDK
|
|
|
245
252
|
# Responses are built from per-call locals (safe), but the gem's
|
|
246
253
|
# instrumentation_callback attribution (@instrumentation_data ivar) can
|
|
247
254
|
# cross-contaminate under concurrency — harmless with the default no-op.
|
|
255
|
+
# @api private
|
|
248
256
|
def handle_message(message)
|
|
249
257
|
original_id = message[:id]
|
|
250
258
|
response = @mcp_server.handle(message.merge(jsonrpc: '2.0', id: 0))
|
|
@@ -294,7 +302,7 @@ module ClaudeAgentSDK
|
|
|
294
302
|
end
|
|
295
303
|
|
|
296
304
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
297
|
-
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result,
|
|
305
|
+
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil
|
|
298
306
|
return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
|
|
299
307
|
|
|
300
308
|
result
|
|
@@ -333,7 +341,7 @@ module ClaudeAgentSDK
|
|
|
333
341
|
|
|
334
342
|
# Ensure content has the expected format (symbol or string keys; guard
|
|
335
343
|
# before flexible_fetch — it raises on non-Hash inputs)
|
|
336
|
-
contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content,
|
|
344
|
+
contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, 'contents', 'contents') : nil
|
|
337
345
|
raise "Resource '#{uri}' must return a hash with :contents key" if contents.nil?
|
|
338
346
|
|
|
339
347
|
content
|
|
@@ -367,7 +375,7 @@ module ClaudeAgentSDK
|
|
|
367
375
|
end
|
|
368
376
|
|
|
369
377
|
# Ensure result has the expected format (symbol or string keys)
|
|
370
|
-
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result,
|
|
378
|
+
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'messages', 'messages') : nil
|
|
371
379
|
raise "Prompt '#{name}' must return a hash with :messages key" if messages.nil?
|
|
372
380
|
|
|
373
381
|
result
|
|
@@ -379,7 +387,7 @@ module ClaudeAgentSDK
|
|
|
379
387
|
# in content with isError: true, returned as a *successful* JSON-RPC
|
|
380
388
|
# result.
|
|
381
389
|
def error_tool_result(text)
|
|
382
|
-
{ content: [{ type:
|
|
390
|
+
{ content: [{ type: 'text', text: text }], isError: true }
|
|
383
391
|
end
|
|
384
392
|
|
|
385
393
|
# The mcp gem's tools/call error behavior swung across 0.x releases:
|
|
@@ -404,18 +412,19 @@ module ClaudeAgentSDK
|
|
|
404
412
|
end
|
|
405
413
|
|
|
406
414
|
# Create dynamic Tool classes from tool definitions
|
|
407
|
-
def create_tool_classes(tools)
|
|
415
|
+
def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
|
|
408
416
|
# Captured so the dynamic class can resolve the effective scheduling
|
|
409
417
|
# mode at call time — same pattern as prompt classes.
|
|
410
418
|
sdk_server = self
|
|
411
|
-
tools.map do |tool_def|
|
|
419
|
+
tools.map do |tool_def| # rubocop:disable Metrics/BlockLength -- see create_tool_classes
|
|
412
420
|
# The gem injects server_context AFTER expanding the tool arguments,
|
|
413
421
|
# overwriting a user value before our call method can recover it.
|
|
414
422
|
# Check at registration (including raw SdkMcpTool definitions), not in
|
|
415
423
|
# input_schema_value's permissive schema-error fallback.
|
|
416
424
|
schema = ClaudeAgentSDK.normalize_tool_schema(tool_def.input_schema)
|
|
417
425
|
if schema[:properties]&.key?(:server_context)
|
|
418
|
-
raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK;
|
|
426
|
+
raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; " \
|
|
427
|
+
"rename it (e.g. 'request_context')"
|
|
419
428
|
end
|
|
420
429
|
|
|
421
430
|
# Create a new class that extends MCP::Tool
|
|
@@ -470,7 +479,7 @@ module ClaudeAgentSDK
|
|
|
470
479
|
@tool_def.meta
|
|
471
480
|
end
|
|
472
481
|
|
|
473
|
-
def call(server_context: nil, **args)
|
|
482
|
+
def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgument -- declared to strip it from args
|
|
474
483
|
# Filter out server_context and pass remaining args to handler.
|
|
475
484
|
# Hop to a plain thread (default) so user handlers don't see
|
|
476
485
|
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
@@ -13,7 +13,9 @@ module ClaudeAgentSDK
|
|
|
13
13
|
# Ported from Python SDK's _internal/session_mutations.py.
|
|
14
14
|
# Appends typed metadata entries to the session's JSONL file,
|
|
15
15
|
# matching the CLI pattern. Safe to call from any SDK host process.
|
|
16
|
-
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/delete/fork share transcript helpers
|
|
17
19
|
module_function
|
|
18
20
|
|
|
19
21
|
# Transcript entry types kept in fork output. Mirrors Python's
|
|
@@ -82,7 +84,9 @@ module ClaudeAgentSDK
|
|
|
82
84
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
|
|
83
85
|
|
|
84
86
|
result = find_session_file_with_dir(session_id, directory)
|
|
85
|
-
|
|
87
|
+
unless result
|
|
88
|
+
raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
|
|
89
|
+
end
|
|
86
90
|
|
|
87
91
|
path = result[0]
|
|
88
92
|
|
|
@@ -117,10 +121,14 @@ module ClaudeAgentSDK
|
|
|
117
121
|
def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
|
|
118
122
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
|
|
119
123
|
|
|
120
|
-
|
|
124
|
+
if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
|
|
125
|
+
raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
|
|
126
|
+
end
|
|
121
127
|
|
|
122
128
|
result = find_session_file_with_dir(session_id, directory)
|
|
123
|
-
|
|
129
|
+
unless result
|
|
130
|
+
raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
|
|
131
|
+
end
|
|
124
132
|
|
|
125
133
|
file_path, project_dir = result
|
|
126
134
|
file_size = File.size(file_path)
|
|
@@ -232,7 +240,9 @@ module ClaudeAgentSDK
|
|
|
232
240
|
# @raise [Errno::ENOENT] if the source session is not found in the store
|
|
233
241
|
def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
|
|
234
242
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
|
|
235
|
-
|
|
243
|
+
if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
|
|
244
|
+
raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
|
|
245
|
+
end
|
|
236
246
|
|
|
237
247
|
project_key = Sessions.project_key_for_directory(directory)
|
|
238
248
|
raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
|
|
@@ -382,7 +392,7 @@ module ClaudeAgentSDK
|
|
|
382
392
|
# +derive_title+ is a callable invoked ONLY when no explicit +title+ is
|
|
383
393
|
# given, so the disk path's head/tail byte scan and the store path's
|
|
384
394
|
# entry-object scan each run only when needed.
|
|
385
|
-
def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
|
|
395
|
+
def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- single fork rewrite pass: UUID remap, truncation, title
|
|
386
396
|
transcript = transcript.reject { |e| e['isSidechain'] }
|
|
387
397
|
raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
|
|
388
398
|
|
|
@@ -520,7 +530,7 @@ module ClaudeAgentSDK
|
|
|
520
530
|
end
|
|
521
531
|
|
|
522
532
|
# Build a single forked entry with remapped UUIDs.
|
|
523
|
-
def build_forked_entry(original, index, total, uuid_mapping, by_uuid,
|
|
533
|
+
def build_forked_entry(original, index, total, uuid_mapping, by_uuid, # rubocop:disable Metrics/ParameterLists -- per-entry step of build_fork_lines; its state is threaded explicitly
|
|
524
534
|
forked_session_id, source_session_id, now)
|
|
525
535
|
new_uuid = uuid_mapping[original['uuid']]
|
|
526
536
|
|
|
@@ -603,7 +613,9 @@ module ClaudeAgentSDK
|
|
|
603
613
|
|
|
604
614
|
def append_to_session_global(session_id, data, file_name)
|
|
605
615
|
projects_dir = File.join(Sessions.config_dir, 'projects')
|
|
606
|
-
|
|
616
|
+
unless File.directory?(projects_dir)
|
|
617
|
+
raise Errno::ENOENT, "Session #{session_id} not found (no projects directory)"
|
|
618
|
+
end
|
|
607
619
|
|
|
608
620
|
found = Dir.children(projects_dir).any? do |child|
|
|
609
621
|
candidate = File.join(projects_dir, child, file_name)
|