claude-agent-sdk 0.17.0 → 0.18.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.
@@ -12,6 +12,57 @@ module ClaudeAgentSDK
12
12
  end
13
13
  end
14
14
 
15
+ # Like deep_symbolize_keys, but also converts Symbol VALUES to strings so a
16
+ # prebuilt schema written with symbols ({ type: :object, ... }) emits clean
17
+ # wire-format JSON Schema.
18
+ def self.deep_normalize_schema(obj)
19
+ case obj
20
+ when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_normalize_schema(v) }
21
+ when Array then obj.map { |v| deep_normalize_schema(v) }
22
+ when Symbol then obj.to_s
23
+ else obj
24
+ end
25
+ end
26
+
27
+ # A prebuilt JSON Schema is detected by type == 'object' (String or Symbol)
28
+ # AND a Hash properties value. Deliberately stricter than Python's rule:
29
+ # Ruby's simple-schema idiom uses Symbols as type VALUES, so
30
+ # { type: :string, properties: :string } is a legal simple schema with
31
+ # params literally named type/properties.
32
+ def self.prebuilt_json_schema?(schema)
33
+ return false unless schema.is_a?(Hash)
34
+
35
+ type_val = schema[:type] || schema['type']
36
+ props_val = schema[:properties] || schema['properties']
37
+ (type_val.is_a?(String) || type_val.is_a?(Symbol)) && type_val.to_s == 'object' && props_val.is_a?(Hash)
38
+ end
39
+
40
+ # Single source of truth for tool input schemas: prebuilt schemas are
41
+ # normalized (symbol keys, string values); simple { name: :type } hashes
42
+ # become a full JSON Schema with every param required (string keys).
43
+ def self.normalize_tool_schema(schema)
44
+ return deep_normalize_schema(schema) if prebuilt_json_schema?(schema)
45
+
46
+ if schema.is_a?(Hash)
47
+ properties = schema.to_h { |param, type| [param.to_sym, ruby_type_to_json_schema(type)] }
48
+ result = { type: 'object', properties: properties }
49
+ result[:required] = properties.keys.map(&:to_s) unless properties.empty?
50
+ return result
51
+ end
52
+
53
+ { type: 'object', properties: {} }
54
+ end
55
+
56
+ def self.ruby_type_to_json_schema(type)
57
+ case type
58
+ when :string, String then { type: 'string' }
59
+ when :integer, Integer then { type: 'integer' }
60
+ when :float, Float, :number then { type: 'number' }
61
+ when :boolean, TrueClass, FalseClass then { type: 'boolean' }
62
+ else { type: 'string' } # Default fallback
63
+ end
64
+ end
65
+
15
66
  # SDK MCP Server - wraps official MCP::Server with block-based API
16
67
  #
17
68
  # Unlike external MCP servers that run as separate processes, SDK MCP servers
@@ -33,8 +84,9 @@ module ClaudeAgentSDK
33
84
  # Create dynamic Tool classes from tool definitions
34
85
  tool_classes = create_tool_classes(tools)
35
86
 
36
- # Create dynamic Resource classes from resource definitions
37
- resource_classes = create_resource_classes(resources)
87
+ # Resources are served as MCP::Resource instances; reads go through
88
+ # the gem's registerable handler (see register_resources_read_handler).
89
+ resource_instances = create_resource_instances(resources)
38
90
 
39
91
  # Create dynamic Prompt classes from prompt definitions
40
92
  prompt_classes = create_prompt_classes(prompts)
@@ -44,9 +96,10 @@ module ClaudeAgentSDK
44
96
  name: name,
45
97
  version: version,
46
98
  tools: tool_classes,
47
- resources: resource_classes,
99
+ resources: resource_instances,
48
100
  prompts: prompt_classes
49
101
  )
102
+ register_resources_read_handler
50
103
  end
51
104
 
52
105
  # Handle a JSON-RPC request
@@ -56,6 +109,30 @@ module ClaudeAgentSDK
56
109
  @mcp_server.handle_json(json_string)
57
110
  end
58
111
 
112
+ # Route one JSON-RPC request hash (symbol keys, as produced by the
113
+ # transport) through the official MCP::Server. Two sanitations, both
114
+ # empirically required:
115
+ # 1. The gem's JsonRpcHandler rejects string ids not matching
116
+ # /\A[a-zA-Z0-9_-]+\z/ with {id: nil, error: -32600} (Python echoes
117
+ # any id verbatim) — swap in a safe id and re-stamp the original on
118
+ # the response (error envelopes too).
119
+ # 2. The gem rejects messages lacking jsonrpc: '2.0' with -32600; Python
120
+ # never inspects this field and the CLI's embedded mcp_message shape
121
+ # is not guaranteed — force it.
122
+ # @param message [Hash] JSON-RPC request hash
123
+ # @return [Hash] JSON-RPC response hash
124
+ # NOTE on concurrency: Query runs each control_request in its own async
125
+ # task, so two tools/call can interleave inside the gem's Server#handle.
126
+ # Responses are built from per-call locals (safe), but the gem's
127
+ # instrumentation_callback attribution (@instrumentation_data ivar) can
128
+ # cross-contaminate under concurrency — harmless with the default no-op.
129
+ def handle_message(message)
130
+ original_id = message[:id]
131
+ response = @mcp_server.handle(message.merge(jsonrpc: '2.0', id: 0))
132
+ response[:id] = original_id if response.is_a?(Hash) && response.key?(:id)
133
+ normalize_tools_call_errors(message, response)
134
+ end
135
+
59
136
  # List all available tools (for backward compatibility)
60
137
  # @return [Array<Hash>] Array of tool definitions
61
138
  def list_tools
@@ -71,24 +148,34 @@ module ClaudeAgentSDK
71
148
  end
72
149
  end
73
150
 
74
- # Execute a tool by name (for backward compatibility)
151
+ # Execute a tool by name (backward-compat public API; Query's tools/call
152
+ # dispatch routes through handle_message/the official MCP::Server, which
153
+ # also validates arguments against the tool's inputSchema — this direct
154
+ # path bypasses that validation).
155
+ # Tool-execution failures are reported in-band (isError: true) per the
156
+ # MCP spec and Python parity (the mcp lowlevel server converts handler
157
+ # exceptions to CallToolResult(isError=True)); they must NOT become
158
+ # JSON-RPC protocol errors — the model needs the error text to
159
+ # self-correct.
75
160
  # @param name [String] Tool name
76
161
  # @param arguments [Hash] Tool arguments
77
- # @return [Hash] Tool result
162
+ # @return [Hash] Tool result (with isError: true on failure)
78
163
  def call_tool(name, arguments)
79
164
  tool = @tools.find { |t| t.name == name }
80
- raise "Tool '#{name}' not found" unless tool
165
+ return error_tool_result("Tool '#{name}' not found") unless tool
81
166
 
82
167
  # Call the tool's handler on a plain thread so the async gem's
83
168
  # Fiber scheduler is not visible to user code (which may hit AR/PG).
84
169
  result = FiberBoundary.invoke { tool.handler.call(arguments) }
85
170
 
86
- # Ensure result has the expected format
87
- unless result.is_a?(Hash) && result[:content]
88
- raise "Tool '#{name}' must return a hash with :content key"
89
- end
171
+ # Guard before flexible_fetch: it raises on non-Hash inputs.
172
+ content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
173
+ return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
90
174
 
91
175
  result
176
+ rescue StandardError => e
177
+ # Bare e.message like Python's str(e) — no prefix.
178
+ error_tool_result(e.message)
92
179
  end
93
180
 
94
181
  # List all available resources (for backward compatibility)
@@ -116,10 +203,10 @@ module ClaudeAgentSDK
116
203
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
117
204
  content = FiberBoundary.invoke { resource.reader.call }
118
205
 
119
- # Ensure content has the expected format
120
- unless content.is_a?(Hash) && content[:contents]
121
- raise "Resource '#{uri}' must return a hash with :contents key"
122
- end
206
+ # Ensure content has the expected format (symbol or string keys; guard
207
+ # before flexible_fetch — it raises on non-Hash inputs)
208
+ contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, "contents", "contents") : nil
209
+ raise "Resource '#{uri}' must return a hash with :contents key" if contents.nil?
123
210
 
124
211
  content
125
212
  end
@@ -148,16 +235,43 @@ module ClaudeAgentSDK
148
235
  # as `call_tool` above.
149
236
  result = FiberBoundary.invoke { prompt.generator.call(arguments) }
150
237
 
151
- # Ensure result has the expected format
152
- unless result.is_a?(Hash) && result[:messages]
153
- raise "Prompt '#{name}' must return a hash with :messages key"
154
- end
238
+ # Ensure result has the expected format (symbol or string keys)
239
+ messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
240
+ raise "Prompt '#{name}' must return a hash with :messages key" if messages.nil?
155
241
 
156
242
  result
157
243
  end
158
244
 
159
245
  private
160
246
 
247
+ # Mirrors Python mcp lowlevel Server._make_error_result: error text goes
248
+ # in content with isError: true, returned as a *successful* JSON-RPC
249
+ # result.
250
+ def error_tool_result(text)
251
+ { content: [{ type: "text", text: text }], isError: true }
252
+ end
253
+
254
+ # The mcp gem's tools/call error behavior swung across 0.x releases:
255
+ # 0.5-0.7.0 return validation/unknown-tool/handler failures in-band,
256
+ # 0.7.1+ progressively turned them back into JSON-RPC protocol errors
257
+ # (0.18 raises for handler exceptions and unknown tools). The in-band
258
+ # contract is load-bearing — the model must see the error text to
259
+ # self-correct — so normalize ANY tools/call error envelope to an
260
+ # in-band isError result, version-independently. Protocol-level errors
261
+ # cannot legitimately occur here: the method name is fixed and the
262
+ # envelope is sanitized by handle_message.
263
+ def normalize_tools_call_errors(message, response)
264
+ return response unless message[:method] == 'tools/call'
265
+ return response unless response.is_a?(Hash) && response[:error].is_a?(Hash)
266
+
267
+ error = response[:error]
268
+ {
269
+ jsonrpc: '2.0',
270
+ id: response[:id],
271
+ result: error_tool_result((error[:data] || error[:message]).to_s)
272
+ }
273
+ end
274
+
161
275
  # Create dynamic Tool classes from tool definitions
162
276
  def create_tool_classes(tools)
163
277
  tools.map do |tool_def|
@@ -177,10 +291,39 @@ module ClaudeAgentSDK
177
291
  end
178
292
 
179
293
  def input_schema_value
180
- schema = convert_schema(@tool_def.input_schema)
181
- opts = { properties: schema[:properties] || {} }
182
- opts[:required] = schema[:required] if schema[:required]&.any?
183
- MCP::Tool::InputSchema.new(**opts)
294
+ # Full-schema construction: the gem JSON-round-trips and
295
+ # validates against the draft4 metaschema. additionalProperties/
296
+ # enum/description survive. Empty required arrays are stripped —
297
+ # draft4's metaschema mandates non-empty required (Python's
298
+ # modern jsonschema accepts []). Schemas the draft4 metaschema
299
+ # rejects (numeric exclusiveMinimum, $ref/$defs — valid modern
300
+ # JSON Schema that Python accepts) fall back to a permissive
301
+ # schema with a one-time warning: the tool keeps working with
302
+ # argument validation disabled instead of being permanently
303
+ # uncallable while tools/list advertises it as healthy.
304
+ @input_schema_value ||= begin
305
+ schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
306
+ schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
307
+ begin
308
+ MCP::Tool::InputSchema.new(schema)
309
+ rescue ArgumentError => e
310
+ warn "Claude SDK: tool '#{@tool_def.name}' schema not draft4-compatible " \
311
+ "(#{e.message.lines.first&.strip}); argument validation disabled for this tool"
312
+ MCP::Tool::InputSchema.new({ type: 'object', properties: {} })
313
+ end
314
+ end
315
+ end
316
+
317
+ def annotations_value
318
+ # Raw hash, not MCP::Annotations: the gem's class only accepts
319
+ # audience/priority/last_modified, but SDK annotations carry
320
+ # arbitrary keys (e.g. maxResultSizeChars). Hash#to_h is
321
+ # identity, so Tool.to_h serializes it unchanged.
322
+ @tool_def.annotations
323
+ end
324
+
325
+ def meta_value
326
+ @tool_def.meta
184
327
  end
185
328
 
186
329
  def call(server_context: nil, **args)
@@ -188,10 +331,13 @@ module ClaudeAgentSDK
188
331
  # Hop to a plain thread so user handlers don't see the Fiber scheduler.
189
332
  result = FiberBoundary.invoke { @tool_def.handler.call(args) }
190
333
 
334
+ # Guard BEFORE flexible_fetch: on a non-Hash it raises
335
+ # TypeError/NoMethodError, surfacing garbage instead of the
336
+ # friendly message.
337
+ raise "Tool '#{@tool_def.name}' must return a hash with :content key" unless result.is_a?(Hash)
338
+
191
339
  content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
192
- unless result.is_a?(Hash) && content
193
- raise "Tool '#{@tool_def.name}' must return a hash with :content key"
194
- end
340
+ raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil?
195
341
 
196
342
  is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
197
343
  structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
@@ -202,182 +348,87 @@ module ClaudeAgentSDK
202
348
  structured_content: structured_content
203
349
  )
204
350
  end
205
-
206
- private
207
-
208
- def convert_schema(schema)
209
- # If it's already a proper JSON schema (symbol or string keys), normalize
210
- # to symbol keys so downstream code (schema[:properties]) works uniformly.
211
- if schema.is_a?(Hash)
212
- type_val = schema[:type] || schema['type']
213
- props_val = schema[:properties] || schema['properties']
214
- return ClaudeAgentSDK.deep_symbolize_keys(schema) if type_val == 'object' && props_val.is_a?(Hash)
215
- end
216
-
217
- # Simple schema: hash mapping parameter names to types
218
- if schema.is_a?(Hash)
219
- properties = {}
220
- schema.each do |param_name, param_type|
221
- properties[param_name] = type_to_json_schema(param_type)
222
- end
223
-
224
- result = { type: 'object', properties: properties }
225
- required_keys = properties.keys.map(&:to_s)
226
- result[:required] = required_keys unless required_keys.empty?
227
- return result
228
- end
229
-
230
- # Default fallback
231
- { type: 'object', properties: {} }
232
- end
233
-
234
- def type_to_json_schema(type)
235
- case type
236
- when :string, String
237
- { type: 'string' }
238
- when :integer, Integer
239
- { type: 'integer' }
240
- when :float, Float
241
- { type: 'number' }
242
- when :boolean, TrueClass, FalseClass
243
- { type: 'boolean' }
244
- when :number
245
- { type: 'number' }
246
- else
247
- { type: 'string' } # Default fallback
248
- end
249
- end
250
351
  end
251
352
  end
252
353
  end
253
354
  end
254
355
 
255
- # Create dynamic Resource classes from resource definitions
256
- def create_resource_classes(resources)
356
+ # The mcp gem serves resources as MCP::Resource INSTANCES (Resource#to_h
357
+ # drives resources/list) and reads exclusively through the registerable
358
+ # resources_read_handler — the old Class.new(MCP::Resource) approach broke
359
+ # resources/list (Class has no #to_h) and its read method referenced
360
+ # MCP::ResourceContents, a constant that has never existed in any gem
361
+ # version.
362
+ def create_resource_instances(resources)
257
363
  resources.map do |resource_def|
258
- # Create a new class that extends MCP::Resource
259
- Class.new(MCP::Resource) do
260
- @resource_def = resource_def
261
-
262
- class << self
263
- attr_reader :resource_def
264
-
265
- def uri
266
- @resource_def.uri
267
- end
268
-
269
- def name
270
- @resource_def.name
271
- end
272
-
273
- def description
274
- @resource_def.description
275
- end
276
-
277
- def mime_type
278
- @resource_def.mime_type
279
- end
364
+ MCP::Resource.new(
365
+ uri: resource_def.uri,
366
+ name: resource_def.name,
367
+ description: resource_def.description,
368
+ mime_type: resource_def.mime_type
369
+ )
370
+ end
371
+ end
280
372
 
281
- def read
282
- # Hop off the Fiber scheduler before invoking user code so the
283
- # async gem's scheduler is not visible to ActiveRecord / pg.
284
- result = ClaudeAgentSDK::FiberBoundary.invoke { @resource_def.reader.call }
285
-
286
- # Convert to MCP format
287
- result[:contents].map do |content|
288
- MCP::ResourceContents.new(
289
- uri: content[:uri],
290
- mime_type: content[:mimeType] || content[:mime_type],
291
- text: content[:text]
292
- )
293
- end
294
- end
295
- end
373
+ # Register the gem's read hook, delegating to read_resource so the
374
+ # FiberBoundary hop and result validation apply on the handle_json path
375
+ # too. The handler must return the INNER contents array (the gem wraps
376
+ # {contents: ...}); RequestHandlerError keeps the human-readable detail
377
+ # in error.data (a plain raise is swallowed into 'Internal error ...').
378
+ def register_resources_read_handler
379
+ sdk_server = self
380
+ @mcp_server.resources_read_handler do |params|
381
+ uri = params[:uri] || params['uri']
382
+ unless sdk_server.resources.any? { |r| r.uri == uri }
383
+ raise MCP::Server::RequestHandlerError.new(
384
+ "Resource '#{uri}' not found", params, error_type: :internal_error
385
+ )
296
386
  end
387
+
388
+ ClaudeAgentSDK.flexible_fetch(sdk_server.read_resource(uri), 'contents', 'contents')
297
389
  end
298
390
  end
299
391
 
300
- # Create dynamic Prompt classes from prompt definitions
392
+ # Prompts via the gem's canonical factory: Prompt.define sets
393
+ # @name_value (exact name, no class-name mangling), @description_value
394
+ # and @arguments_value, so prompts/list and prompts/get work. The
395
+ # template block delegates to get_prompt, preserving the FiberBoundary
396
+ # hop and :messages validation. Inside the block self is the prompt
397
+ # class (instance_exec), so capture the server first.
301
398
  def create_prompt_classes(prompts)
399
+ sdk_server = self
302
400
  prompts.map do |prompt_def|
303
- # Create a new class that extends MCP::Prompt
304
- Class.new(MCP::Prompt) do
305
- @prompt_def = prompt_def
306
-
307
- class << self
308
- attr_reader :prompt_def
309
-
310
- def name
311
- @prompt_def.name
312
- end
313
-
314
- def description
315
- @prompt_def.description
316
- end
317
-
318
- def arguments
319
- @prompt_def.arguments || []
320
- end
321
-
322
- def get(**args)
323
- # Hop off the Fiber scheduler before invoking user code (see read above).
324
- result = ClaudeAgentSDK::FiberBoundary.invoke { @prompt_def.generator.call(args) }
325
-
326
- # Convert to MCP format
327
- {
328
- messages: result[:messages].map do |msg|
329
- {
330
- role: msg[:role],
331
- content: msg[:content]
332
- }
333
- end
334
- }
335
- end
336
- end
401
+ klass = MCP::Prompt.define(
402
+ name: prompt_def.name,
403
+ description: prompt_def.description,
404
+ arguments: build_prompt_arguments(prompt_def.arguments)
405
+ ) do |args|
406
+ sdk_server.get_prompt(prompt_def.name, args || {})
337
407
  end
408
+ # The gem passes request[:arguments] (nil when omitted) straight into
409
+ # validate_arguments! -> nil.keys NoMethodError; default it.
410
+ klass.define_singleton_method(:validate_arguments!) { |args| super(args || {}) }
411
+ klass
338
412
  end
339
413
  end
340
414
 
341
- def convert_input_schema(schema)
342
- # If it's already a proper JSON schema (symbol or string keys), normalize
343
- # to symbol keys for consistent output.
344
- if schema.is_a?(Hash)
345
- type_val = schema[:type] || schema['type']
346
- props_val = schema[:properties] || schema['properties']
347
- return ClaudeAgentSDK.deep_symbolize_keys(schema) if type_val == 'object' && props_val.is_a?(Hash)
415
+ # Arguments must be MCP::Prompt::Argument instances: the gem's
416
+ # required-args check calls arg.name/arg.required on each entry.
417
+ def build_prompt_arguments(arguments)
418
+ (arguments || []).map do |arg|
419
+ next arg if arg.is_a?(MCP::Prompt::Argument)
420
+
421
+ MCP::Prompt::Argument.new(
422
+ name: ClaudeAgentSDK.flexible_fetch(arg, 'name', 'name').to_s,
423
+ description: ClaudeAgentSDK.flexible_fetch(arg, 'description', 'description'),
424
+ required: !ClaudeAgentSDK.flexible_fetch(arg, 'required', 'required').nil? &&
425
+ ClaudeAgentSDK.flexible_fetch(arg, 'required', 'required') != false
426
+ )
348
427
  end
349
-
350
- # Simple schema: hash mapping parameter names to types
351
- if schema.is_a?(Hash)
352
- properties = {}
353
- schema.each do |param_name, param_type|
354
- properties[param_name] = type_to_json_schema(param_type)
355
- end
356
-
357
- result = { type: 'object', properties: properties }
358
- result[:required] = properties.keys unless properties.empty?
359
- return result
360
- end
361
-
362
- # Default fallback
363
- { type: 'object', properties: {} }
364
428
  end
365
429
 
366
- def type_to_json_schema(type)
367
- case type
368
- when :string, String
369
- { type: 'string' }
370
- when :integer, Integer
371
- { type: 'integer' }
372
- when :float, Float
373
- { type: 'number' }
374
- when :boolean, TrueClass, FalseClass
375
- { type: 'boolean' }
376
- when :number
377
- { type: 'number' }
378
- else
379
- { type: 'string' } # Default fallback
380
- end
430
+ def convert_input_schema(schema)
431
+ ClaudeAgentSDK.normalize_tool_schema(schema)
381
432
  end
382
433
  end
383
434
 
@@ -344,6 +344,10 @@ module ClaudeAgentSDK
344
344
  (env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
345
345
  override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
346
346
  override = nil if override.respond_to?(:empty?) && override.empty?
347
+ # NFC like Python's _get_projects_dir(env_override) — a decomposed
348
+ # Unicode override would otherwise mismatch the NFC paths used for
349
+ # the mirror's projects-dir prefix comparison and drop every frame.
350
+ override = override.unicode_normalize(:nfc) if override
347
351
  return File.join(override || File.expand_path('~/.claude'), 'projects')
348
352
  end
349
353