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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +4 -2
- data/docs/configuration.md +13 -2
- data/docs/observability.md +28 -4
- data/docs/sessions.md +15 -2
- data/lib/claude_agent_sdk/command_builder.rb +69 -22
- data/lib/claude_agent_sdk/fiber_boundary.rb +39 -1
- data/lib/claude_agent_sdk/instrumentation/otel.rb +97 -23
- data/lib/claude_agent_sdk/message_parser.rb +4 -1
- data/lib/claude_agent_sdk/observer.rb +23 -3
- data/lib/claude_agent_sdk/query.rb +223 -88
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +232 -181
- data/lib/claude_agent_sdk/session_store.rb +4 -0
- data/lib/claude_agent_sdk/sessions.rb +144 -24
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +184 -50
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -1
- data/lib/claude_agent_sdk/types.rb +43 -5
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +359 -93
- metadata +12 -6
|
@@ -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
|
-
#
|
|
37
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
367
|
-
|
|
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
|
|