claude-agent-sdk 0.1.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.
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'async'
5
+ require 'async/queue'
6
+ require 'async/condition'
7
+ require 'securerandom'
8
+ require_relative 'transport'
9
+
10
+ module ClaudeAgentSDK
11
+ # Handles bidirectional control protocol on top of Transport
12
+ #
13
+ # This class manages:
14
+ # - Control request/response routing
15
+ # - Hook callbacks
16
+ # - Tool permission callbacks
17
+ # - Message streaming
18
+ # - Initialization handshake
19
+ class Query
20
+ attr_reader :transport, :is_streaming_mode, :sdk_mcp_servers
21
+
22
+ def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil)
23
+ @transport = transport
24
+ @is_streaming_mode = is_streaming_mode
25
+ @can_use_tool = can_use_tool
26
+ @hooks = hooks || {}
27
+ @sdk_mcp_servers = sdk_mcp_servers || {}
28
+
29
+ # Control protocol state
30
+ @pending_control_responses = {}
31
+ @pending_control_results = {}
32
+ @hook_callbacks = {}
33
+ @next_callback_id = 0
34
+ @request_counter = 0
35
+
36
+ # Message stream
37
+ @message_queue = Async::Queue.new
38
+ @task = nil
39
+ @initialized = false
40
+ @closed = false
41
+ @initialization_result = nil
42
+ end
43
+
44
+ # Initialize control protocol if in streaming mode
45
+ # @return [Hash, nil] Initialize response with supported commands, or nil if not streaming
46
+ def initialize_protocol
47
+ return nil unless @is_streaming_mode
48
+
49
+ # Build hooks configuration for initialization
50
+ hooks_config = {}
51
+ if @hooks && !@hooks.empty?
52
+ @hooks.each do |event, matchers|
53
+ next if matchers.nil? || matchers.empty?
54
+
55
+ hooks_config[event] = []
56
+ matchers.each do |matcher|
57
+ callback_ids = []
58
+ (matcher[:hooks] || []).each do |callback|
59
+ callback_id = "hook_#{@next_callback_id}"
60
+ @next_callback_id += 1
61
+ @hook_callbacks[callback_id] = callback
62
+ callback_ids << callback_id
63
+ end
64
+ hooks_config[event] << {
65
+ matcher: matcher[:matcher],
66
+ hookCallbackIds: callback_ids
67
+ }
68
+ end
69
+ end
70
+ end
71
+
72
+ # Send initialize request
73
+ request = {
74
+ subtype: 'initialize',
75
+ hooks: hooks_config.empty? ? nil : hooks_config
76
+ }
77
+
78
+ response = send_control_request(request)
79
+ @initialized = true
80
+ @initialization_result = response
81
+ response
82
+ end
83
+
84
+ # Start reading messages from transport
85
+ def start
86
+ return if @task
87
+
88
+ @task = Async do |task|
89
+ task.async { read_messages }
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def read_messages
96
+ @transport.read_messages do |message|
97
+ break if @closed
98
+
99
+ msg_type = message[:type]
100
+
101
+ # Route control messages
102
+ case msg_type
103
+ when 'control_response'
104
+ handle_control_response(message)
105
+ when 'control_request'
106
+ Async { handle_control_request(message) }
107
+ when 'control_cancel_request'
108
+ # TODO: Implement cancellation support
109
+ next
110
+ else
111
+ # Regular SDK messages go to the queue
112
+ @message_queue.enqueue(message)
113
+ end
114
+ end
115
+ rescue StandardError => e
116
+ # Put error in queue so iterators can handle it
117
+ @message_queue.enqueue({ type: 'error', error: e.message })
118
+ ensure
119
+ # Always signal end of stream
120
+ @message_queue.enqueue({ type: 'end' })
121
+ end
122
+
123
+ def handle_control_response(message)
124
+ response = message[:response] || {}
125
+ request_id = response[:request_id]
126
+ return unless @pending_control_responses.key?(request_id)
127
+
128
+ if response[:subtype] == 'error'
129
+ @pending_control_results[request_id] = StandardError.new(response[:error] || 'Unknown error')
130
+ else
131
+ @pending_control_results[request_id] = response
132
+ end
133
+
134
+ # Signal that response is ready
135
+ @pending_control_responses[request_id].signal
136
+ end
137
+
138
+ def handle_control_request(request)
139
+ request_id = request[:request_id]
140
+ request_data = request[:request]
141
+ subtype = request_data[:subtype]
142
+
143
+ response_data = {}
144
+
145
+ case subtype
146
+ when 'can_use_tool'
147
+ response_data = handle_permission_request(request_data)
148
+ when 'hook_callback'
149
+ response_data = handle_hook_callback(request_data)
150
+ when 'mcp_message'
151
+ response_data = handle_mcp_message(request_data)
152
+ else
153
+ raise "Unsupported control request subtype: #{subtype}"
154
+ end
155
+
156
+ # Send success response
157
+ success_response = {
158
+ type: 'control_response',
159
+ response: {
160
+ subtype: 'success',
161
+ request_id: request_id,
162
+ response: response_data
163
+ }
164
+ }
165
+ @transport.write(JSON.generate(success_response) + "\n")
166
+ rescue StandardError => e
167
+ # Send error response
168
+ error_response = {
169
+ type: 'control_response',
170
+ response: {
171
+ subtype: 'error',
172
+ request_id: request_id,
173
+ error: e.message
174
+ }
175
+ }
176
+ @transport.write(JSON.generate(error_response) + "\n")
177
+ end
178
+
179
+ def handle_permission_request(request_data)
180
+ raise 'canUseTool callback is not provided' unless @can_use_tool
181
+
182
+ original_input = request_data[:input]
183
+
184
+ context = ToolPermissionContext.new(
185
+ signal: nil,
186
+ suggestions: request_data[:permission_suggestions] || []
187
+ )
188
+
189
+ response = @can_use_tool.call(
190
+ request_data[:tool_name],
191
+ request_data[:input],
192
+ context
193
+ )
194
+
195
+ # Convert PermissionResult to expected format
196
+ case response
197
+ when PermissionResultAllow
198
+ result = {
199
+ behavior: 'allow',
200
+ updatedInput: response.updated_input || original_input
201
+ }
202
+ if response.updated_permissions
203
+ result[:updatedPermissions] = response.updated_permissions.map(&:to_h)
204
+ end
205
+ result
206
+ when PermissionResultDeny
207
+ result = { behavior: 'deny', message: response.message }
208
+ result[:interrupt] = response.interrupt if response.interrupt
209
+ result
210
+ else
211
+ raise "Tool permission callback must return PermissionResult, got #{response.class}"
212
+ end
213
+ end
214
+
215
+ def handle_hook_callback(request_data)
216
+ callback_id = request_data[:callback_id]
217
+ callback = @hook_callbacks[callback_id]
218
+ raise "No hook callback found for ID: #{callback_id}" unless callback
219
+
220
+ hook_output = callback.call(
221
+ request_data[:input],
222
+ request_data[:tool_use_id],
223
+ { signal: nil }
224
+ )
225
+
226
+ # Convert Ruby-safe field names to CLI-expected names
227
+ convert_hook_output_for_cli(hook_output)
228
+ end
229
+
230
+ def handle_mcp_message(request_data)
231
+ server_name = request_data[:server_name]
232
+ mcp_message = request_data[:message]
233
+
234
+ raise 'Missing server_name or message for MCP request' unless server_name && mcp_message
235
+
236
+ mcp_response = handle_sdk_mcp_request(server_name, mcp_message)
237
+ { mcp_response: mcp_response }
238
+ end
239
+
240
+ def convert_hook_output_for_cli(hook_output)
241
+ # Convert Ruby hash with symbol keys to CLI format
242
+ # Handle special keywords that might be Ruby-safe versions
243
+ converted = {}
244
+ hook_output.each do |key, value|
245
+ converted_key = case key
246
+ when :async_, 'async_' then 'async'
247
+ when :continue_, 'continue_' then 'continue'
248
+ else key.to_s.gsub('_', '')
249
+ end
250
+ converted[converted_key] = value
251
+ end
252
+ converted
253
+ end
254
+
255
+ def send_control_request(request)
256
+ raise 'Control requests require streaming mode' unless @is_streaming_mode
257
+
258
+ # Generate unique request ID
259
+ @request_counter += 1
260
+ request_id = "req_#{@request_counter}_#{SecureRandom.hex(4)}"
261
+
262
+ # Create condition for response
263
+ condition = Async::Condition.new
264
+ @pending_control_responses[request_id] = condition
265
+
266
+ # Build and send request
267
+ control_request = {
268
+ type: 'control_request',
269
+ request_id: request_id,
270
+ request: request
271
+ }
272
+
273
+ @transport.write(JSON.generate(control_request) + "\n")
274
+
275
+ # Wait for response with timeout
276
+ Async do |task|
277
+ task.with_timeout(60.0) do
278
+ condition.wait
279
+ end
280
+
281
+ result = @pending_control_results.delete(request_id)
282
+ @pending_control_responses.delete(request_id)
283
+
284
+ raise result if result.is_a?(Exception)
285
+
286
+ result[:response] || {}
287
+ end.wait
288
+ rescue Async::TimeoutError
289
+ @pending_control_responses.delete(request_id)
290
+ @pending_control_results.delete(request_id)
291
+ raise "Control request timeout: #{request[:subtype]}"
292
+ end
293
+
294
+ def handle_sdk_mcp_request(server_name, message)
295
+ unless @sdk_mcp_servers.key?(server_name)
296
+ return {
297
+ jsonrpc: '2.0',
298
+ id: message[:id],
299
+ error: {
300
+ code: -32601,
301
+ message: "Server '#{server_name}' not found"
302
+ }
303
+ }
304
+ end
305
+
306
+ server = @sdk_mcp_servers[server_name]
307
+ method = message[:method]
308
+ params = message[:params] || {}
309
+
310
+ case method
311
+ when 'initialize'
312
+ handle_mcp_initialize(server, message)
313
+ when 'tools/list'
314
+ handle_mcp_tools_list(server, message)
315
+ when 'tools/call'
316
+ handle_mcp_tools_call(server, message, params)
317
+ when 'notifications/initialized'
318
+ { jsonrpc: '2.0', result: {} }
319
+ else
320
+ {
321
+ jsonrpc: '2.0',
322
+ id: message[:id],
323
+ error: { code: -32601, message: "Method '#{method}' not found" }
324
+ }
325
+ end
326
+ rescue StandardError => e
327
+ {
328
+ jsonrpc: '2.0',
329
+ id: message[:id],
330
+ error: { code: -32603, message: e.message }
331
+ }
332
+ end
333
+
334
+ def handle_mcp_initialize(server, message)
335
+ {
336
+ jsonrpc: '2.0',
337
+ id: message[:id],
338
+ result: {
339
+ protocolVersion: '2024-11-05',
340
+ capabilities: { tools: {} },
341
+ serverInfo: {
342
+ name: server.name,
343
+ version: server.version || '1.0.0'
344
+ }
345
+ }
346
+ }
347
+ end
348
+
349
+ def handle_mcp_tools_list(server, message)
350
+ # List tools from the SDK MCP server
351
+ tools_data = server.list_tools
352
+ {
353
+ jsonrpc: '2.0',
354
+ id: message[:id],
355
+ result: { tools: tools_data }
356
+ }
357
+ end
358
+
359
+ def handle_mcp_tools_call(server, message, params)
360
+ # Execute tool on the SDK MCP server
361
+ tool_name = params[:name]
362
+ arguments = params[:arguments] || {}
363
+
364
+ # Call the tool
365
+ result = server.call_tool(tool_name, arguments)
366
+
367
+ # Format response
368
+ content = []
369
+ if result[:content]
370
+ result[:content].each do |item|
371
+ if item[:type] == 'text'
372
+ content << { type: 'text', text: item[:text] }
373
+ end
374
+ end
375
+ end
376
+
377
+ response_data = { content: content }
378
+ response_data[:is_error] = true if result[:is_error]
379
+
380
+ {
381
+ jsonrpc: '2.0',
382
+ id: message[:id],
383
+ result: response_data
384
+ }
385
+ end
386
+
387
+ public
388
+
389
+ # Send interrupt control request
390
+ def interrupt
391
+ send_control_request({ subtype: 'interrupt' })
392
+ end
393
+
394
+ # Change permission mode
395
+ def set_permission_mode(mode)
396
+ send_control_request({
397
+ subtype: 'set_permission_mode',
398
+ mode: mode
399
+ })
400
+ end
401
+
402
+ # Change the AI model
403
+ def set_model(model)
404
+ send_control_request({
405
+ subtype: 'set_model',
406
+ model: model
407
+ })
408
+ end
409
+
410
+ # Stream input messages to transport
411
+ def stream_input(stream)
412
+ stream.each do |message|
413
+ break if @closed
414
+ @transport.write(JSON.generate(message) + "\n")
415
+ end
416
+ @transport.end_input
417
+ rescue StandardError => e
418
+ # Log error but don't raise
419
+ warn "Error streaming input: #{e.message}"
420
+ end
421
+
422
+ # Receive SDK messages (not control messages)
423
+ def receive_messages(&block)
424
+ return enum_for(:receive_messages) unless block
425
+
426
+ loop do
427
+ message = @message_queue.dequeue
428
+ break if message[:type] == 'end'
429
+ raise message[:error] if message[:type] == 'error'
430
+
431
+ block.call(message)
432
+ end
433
+ end
434
+
435
+ # Close the query and transport
436
+ def close
437
+ @closed = true
438
+ @task&.stop
439
+ @transport.close
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # SDK MCP Server - runs in-process within your Ruby application
5
+ #
6
+ # Unlike external MCP servers that run as separate processes, SDK MCP servers
7
+ # run directly in your application's process, providing better performance
8
+ # and simpler deployment.
9
+ class SdkMcpServer
10
+ attr_reader :name, :version, :tools
11
+
12
+ def initialize(name:, version: '1.0.0', tools: [])
13
+ @name = name
14
+ @version = version
15
+ @tools = tools
16
+ @tool_map = tools.each_with_object({}) { |tool, hash| hash[tool.name] = tool }
17
+ end
18
+
19
+ # List all available tools
20
+ # @return [Array<Hash>] Array of tool definitions
21
+ def list_tools
22
+ @tools.map do |tool|
23
+ {
24
+ name: tool.name,
25
+ description: tool.description,
26
+ inputSchema: convert_input_schema(tool.input_schema)
27
+ }
28
+ end
29
+ end
30
+
31
+ # Execute a tool by name
32
+ # @param name [String] Tool name
33
+ # @param arguments [Hash] Tool arguments
34
+ # @return [Hash] Tool result
35
+ def call_tool(name, arguments)
36
+ tool = @tool_map[name]
37
+ raise "Tool '#{name}' not found" unless tool
38
+
39
+ # Call the tool's handler
40
+ result = tool.handler.call(arguments)
41
+
42
+ # Ensure result has the expected format
43
+ unless result.is_a?(Hash) && result[:content]
44
+ raise "Tool '#{name}' must return a hash with :content key"
45
+ end
46
+
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ def convert_input_schema(schema)
53
+ # If it's already a proper JSON schema, return it
54
+ if schema.is_a?(Hash) && schema[:type] && schema[:properties]
55
+ return schema
56
+ end
57
+
58
+ # Simple schema: hash mapping parameter names to types
59
+ if schema.is_a?(Hash)
60
+ properties = {}
61
+ schema.each do |param_name, param_type|
62
+ properties[param_name] = type_to_json_schema(param_type)
63
+ end
64
+
65
+ return {
66
+ type: 'object',
67
+ properties: properties,
68
+ required: properties.keys
69
+ }
70
+ end
71
+
72
+ # Default fallback
73
+ { type: 'object', properties: {} }
74
+ end
75
+
76
+ def type_to_json_schema(type)
77
+ case type
78
+ when :string, String
79
+ { type: 'string' }
80
+ when :integer, Integer
81
+ { type: 'integer' }
82
+ when :float, Float
83
+ { type: 'number' }
84
+ when :boolean, TrueClass, FalseClass
85
+ { type: 'boolean' }
86
+ when :number
87
+ { type: 'number' }
88
+ else
89
+ { type: 'string' } # Default fallback
90
+ end
91
+ end
92
+ end
93
+
94
+ # Helper function to create a tool definition
95
+ #
96
+ # @param name [String] Unique identifier for the tool
97
+ # @param description [String] Human-readable description
98
+ # @param input_schema [Hash] Schema defining input parameters
99
+ # @param handler [Proc] Block that implements the tool logic
100
+ # @return [SdkMcpTool] Tool definition
101
+ #
102
+ # @example Simple tool
103
+ # tool = create_tool('greet', 'Greet a user', { name: :string }) do |args|
104
+ # { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
105
+ # end
106
+ #
107
+ # @example Tool with multiple parameters
108
+ # tool = create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
109
+ # result = args[:a] + args[:b]
110
+ # { content: [{ type: 'text', text: "Result: #{result}" }] }
111
+ # end
112
+ #
113
+ # @example Tool with error handling
114
+ # tool = create_tool('divide', 'Divide numbers', { a: :number, b: :number }) do |args|
115
+ # if args[:b] == 0
116
+ # { content: [{ type: 'text', text: 'Error: Division by zero' }], is_error: true }
117
+ # else
118
+ # result = args[:a] / args[:b]
119
+ # { content: [{ type: 'text', text: "Result: #{result}" }] }
120
+ # end
121
+ # end
122
+ def self.create_tool(name, description, input_schema, &handler)
123
+ raise ArgumentError, 'Block required for tool handler' unless handler
124
+
125
+ SdkMcpTool.new(
126
+ name: name,
127
+ description: description,
128
+ input_schema: input_schema,
129
+ handler: handler
130
+ )
131
+ end
132
+
133
+ # Create an SDK MCP server
134
+ #
135
+ # @param name [String] Unique identifier for the server
136
+ # @param version [String] Server version (default: '1.0.0')
137
+ # @param tools [Array<SdkMcpTool>] List of tool definitions
138
+ # @return [Hash] MCP server configuration for ClaudeAgentOptions
139
+ #
140
+ # @example Simple calculator server
141
+ # add_tool = ClaudeAgentSDK.create_tool('add', 'Add numbers', { a: :number, b: :number }) do |args|
142
+ # { content: [{ type: 'text', text: "Sum: #{args[:a] + args[:b]}" }] }
143
+ # end
144
+ #
145
+ # calculator = ClaudeAgentSDK.create_sdk_mcp_server(
146
+ # name: 'calculator',
147
+ # version: '2.0.0',
148
+ # tools: [add_tool]
149
+ # )
150
+ #
151
+ # options = ClaudeAgentOptions.new(
152
+ # mcp_servers: { calc: calculator },
153
+ # allowed_tools: ['mcp__calc__add']
154
+ # )
155
+ def self.create_sdk_mcp_server(name:, version: '1.0.0', tools: [])
156
+ server = SdkMcpServer.new(name: name, version: version, tools: tools)
157
+
158
+ # Return configuration for ClaudeAgentOptions
159
+ {
160
+ type: 'sdk',
161
+ name: name,
162
+ instance: server
163
+ }
164
+ end
165
+ end