claude-agent-sdk 0.18.0 → 0.19.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ca7129acd0fb9330d1a49307fde85efff7febaf503bcbda5dbe63d82b3f6476
4
- data.tar.gz: 49568fac24a25b129c9f97e5a8ef6c2cd0a3f4db6be6f721c69be7ea31261da5
3
+ metadata.gz: fc9c6b243b2a7ac982141db36c55d510d9a08cb18caeffb5dd2d9ed59fefc42d
4
+ data.tar.gz: 7ce8124f1cebd923857fda75532caa8332a89d930564ff9c474e847e57fcb1d8
5
5
  SHA512:
6
- metadata.gz: 54a7f40056f3b97db66a31e34dab0d659432731bef991e7262b32d0a09efd419248ea6f150fe2906292b281f293b964dde85cb0e7b9bdd7d220b0d8fb57a8441
7
- data.tar.gz: 430e1f1d708a2b75b425523fd87d1a0aab8ef68850def2c66205fcb0d20552f374d2f92feb03217a301410d5b016d7c465eeac97b0aacfc497bafea0412c69ff
6
+ metadata.gz: 7c548159588586dd908b4a51173bdf89abac417ceab753d8eb8b313095463770dbf5fc44e7af9c90dce26df651f11ae5b25eca28d755a17e2c5b68984eff1ccb
7
+ data.tar.gz: 0a46ae5342425411af912c98ab5f96607bdf4d8ec76f027a7388b5519c3dc7d425a79e03a4b82c7317e9f983dcd232c7375d91f23feb33ecb758289f57dcce3b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.1] - 2026-07-03
11
+
12
+ Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
13
+
14
+ ### Added
15
+ - `lib/claude-agent-sdk.rb` require shim: `Bundler.require` now loads the SDK in default Rails/Bundler apps. Previously Bundler tried `claude-agent-sdk` and `claude/agent/sdk`, silently swallowed both LoadErrors, and left `ClaudeAgentSDK` undefined until a confusing `NameError` at first use.
16
+
17
+ ### Fixed
18
+ - `require 'claude_agent_sdk/instrumentation'` now loads the SDK core, so the documented Rails observability initializer (and `OTelObserver`'s own `@example`) works as the only require.
19
+ - Typed `McpStdioServerConfig` / `McpSSEServerConfig` / `McpHttpServerConfig` / `McpSdkServerConfig` objects passed directly in `mcp_servers` (without `.to_h`) now serialize to their wire hash instead of a `#<...>` string the CLI cannot parse — and `McpSdkServerConfig` instances now actually register their in-process server, so its tools connect.
20
+ - `create_tool` with a Symbol name produced a tool that was advertised in `tools/list` but failed every invocation with 'Tool not found'; names are now coerced to String.
21
+ - One stdout line carrying invalid UTF-8 bytes no longer aborts the whole message stream (which dropped all buffered valid frames, including a trailing `result`); the bad line is scrubbed, matching the version-probe path.
22
+ - `fork_session` no longer hangs forever on a transcript with a `parentUuid` cycle among progress entries.
23
+ - `fork_session` / `delete_session` no longer stop at a 0-byte transcript stub: they fall through to the worktree project dir holding the real transcript, mirroring the read path. A session whose *only* copy is a 0-byte stub now raises `Errno::ENOENT`, consistent with the read paths that already hide it.
24
+ - Long-lived `Client`s using SDK MCP servers no longer leak one finished internal task per synchronously-handled control request (MCP metadata requests, unsupported subtypes).
25
+ - An observer implementing none of the observer interface — most commonly a Class passed instead of an instance (`observers: [OTelObserver]`) — is now warned about and skipped instead of producing silent zero instrumentation.
26
+
27
+ ### Changed
28
+ - The gem packages git-tracked files only (previously a working-tree glob, which shipped stray untracked files under `lib/`/`docs/` in locally built gems). `gem build` from a tree with tracked-but-deleted files now fails fast instead of silently packaging whatever was on disk.
29
+
30
+ ## [0.19.0] - 2026-06-29
31
+
32
+ ### Added
33
+ - `TaskUpdatedMessage` — typed `system`/`task_updated` lifecycle events (Python SDK 0.2.101 / #1016 parity). A background task's terminal state can arrive *only* as a `task_updated` patch with no accompanying `TaskNotificationMessage` (e.g. a `TaskStop`-killed task reports `status: 'killed'` here), so consumers tracking active task IDs no longer hang waiting for a notification that never comes. `status` is derived from `patch['status']` (parsed defensively — a non-Hash/absent patch falls back to `{}`, `task_id` defaults to `''` so it is never nil, and parsing never raises). New `TASK_UPDATED_STATUSES` and `TERMINAL_TASK_STATUSES` constants; the latter spans both lifecycle vocabularies (`task_notification` reports `stopped`, `task_updated` reports the raw `killed`) so a terminal status from *either* message clears active-task tracking.
34
+
35
+ ### Fixed
36
+ - Malformed CLI message content now raises a descriptive `MessageParseError` instead of an opaque `TypeError`/`NoMethodError` (Python SDK #1058 parity): a non-Hash content block (e.g. a bare String) and an assistant `content` that is not an Array are both caught with a clear message carrying the full payload, rather than crashing deep inside block parsing on `block[:type]`.
37
+
10
38
  ## [0.18.0] - 2026-06-12
11
39
 
12
40
  ### Added
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.18.0'
71
+ gem 'claude-agent-sdk', '~> 0.19.1'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/types.md CHANGED
@@ -42,22 +42,30 @@ System message with metadata. Task lifecycle events are typed subclasses.
42
42
 
43
43
  ```ruby
44
44
  class SystemMessage
45
- attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', etc.)
45
+ attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', 'task_updated', etc.)
46
46
  :data # Hash
47
47
  end
48
48
 
49
49
  # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
50
50
  class TaskStartedMessage < SystemMessage
51
- attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type
51
+ attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type, :workflow_name, :prompt
52
52
  end
53
53
 
54
54
  class TaskProgressMessage < SystemMessage
55
- attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name
55
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary
56
56
  end
57
57
 
58
58
  class TaskNotificationMessage < SystemMessage
59
59
  attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
60
60
  end
61
+
62
+ # Background task lifecycle state change. `status` is derived from patch["status"].
63
+ # A terminal task can arrive *only* as a TaskUpdatedMessage (no TaskNotificationMessage) —
64
+ # e.g. a TaskStop-killed task reports status "killed" here. Clear tracked task IDs on a
65
+ # terminal status (see TERMINAL_TASK_STATUSES) from *either* message.
66
+ class TaskUpdatedMessage < SystemMessage
67
+ attr_accessor :task_id, :patch, :status, :uuid, :session_id
68
+ end
61
69
  ```
62
70
 
63
71
  ### ResultMessage
@@ -183,5 +191,7 @@ end
183
191
  | `HOOK_EVENTS` | Available hook events |
184
192
  | `ASSISTANT_MESSAGE_ERRORS` | Possible error types in AssistantMessage |
185
193
  | `TASK_NOTIFICATION_STATUSES` | Task lifecycle notification statuses (`completed`, `failed`, `stopped`) |
194
+ | `TASK_UPDATED_STATUSES` | `task_updated` patch statuses (`pending`, `running`, `paused`, `completed`, `failed`, `killed`) |
195
+ | `TERMINAL_TASK_STATUSES` | Statuses meaning a task has finished — spans both vocabularies (`completed`, `failed`, `stopped`, `killed`); clear active-task tracking on any of these |
186
196
  | `MCP_SERVER_CONNECTION_STATUSES` | MCP server connection states (`connected`, `failed`, `needs-auth`, `pending`, `disabled`) |
187
197
  | `EFFORT_LEVELS` | Effort levels (`low`, `medium`, `high`, `xhigh`, `max`) |
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler autorequire shim. The gem is named `claude-agent-sdk`, so
4
+ # `Bundler.require` tries `require 'claude-agent-sdk'` and then
5
+ # `require 'claude/agent/sdk'` — and silently swallows both LoadErrors when
6
+ # neither file exists, leaving the SDK unloaded in default Rails/Bundler
7
+ # apps. This file makes the gem-named require resolve to the real entry point.
8
+ require_relative 'claude_agent_sdk'
@@ -306,6 +306,9 @@ module ClaudeAgentSDK
306
306
  if @options.mcp_servers.is_a?(Hash)
307
307
  servers_for_cli = {}
308
308
  @options.mcp_servers.each do |name, config|
309
+ # Typed Mcp*ServerConfig objects serialize via their wire hash —
310
+ # without this they'd JSON-stringify as "#<...>" via to_s.
311
+ config = config.to_h if config.is_a?(Type)
309
312
  servers_for_cli[name] = if config.is_a?(Hash) && config[:type] == "sdk"
310
313
  config.except(:instance)
311
314
  else
@@ -3,4 +3,10 @@
3
3
  # Instrumentation adapters for ClaudeAgentSDK.
4
4
  # Each adapter lazy-requires its external gem, so loading this file has zero cost
5
5
  # unless you instantiate a specific observer.
6
+ #
7
+ # This entry point is documented as a standalone require (docs/rails.md's
8
+ # initializer, OTelObserver's @example), so it must load the SDK core too —
9
+ # otherwise ClaudeAgentSDK exists (observer.rb reopens the module) but has
10
+ # no .configure/.query/ClaudeAgentOptions.
11
+ require_relative '../claude_agent_sdk'
6
12
  require_relative 'instrumentation/otel'
@@ -51,7 +51,7 @@ module ClaudeAgentSDK
51
51
  raise MessageParseError.new("Missing content in user message", data: data) unless content
52
52
 
53
53
  if content.is_a?(Array)
54
- content_blocks = content.map { |block| parse_content_block(block) }
54
+ content_blocks = parse_content_blocks(content, data)
55
55
  UserMessage.new(content: content_blocks, uuid: uuid, parent_tool_use_id: parent_tool_use_id,
56
56
  tool_use_result: tool_use_result)
57
57
  else
@@ -63,8 +63,9 @@ module ClaudeAgentSDK
63
63
  def self.parse_assistant_message(data)
64
64
  content = data.dig(:message, :content)
65
65
  raise MessageParseError.new("Missing content in assistant message", data: data) unless content
66
+ raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
66
67
 
67
- content_blocks = content.map { |block| parse_content_block(block) }
68
+ content_blocks = parse_content_blocks(content, data)
68
69
  AssistantMessage.new(
69
70
  content: content_blocks,
70
71
  model: data.dig(:message, :model),
@@ -96,7 +97,14 @@ module ClaudeAgentSDK
96
97
  'elicitation_complete' => ElicitationCompleteMessage,
97
98
  'task_started' => TaskStartedMessage,
98
99
  'task_progress' => TaskProgressMessage,
99
- 'task_notification' => TaskNotificationMessage
100
+ 'task_notification' => TaskNotificationMessage,
101
+ # task_updated carries `status` inside `patch` (not at the top level) and
102
+ # defaults task_id to "" — it derives those defensively in its own
103
+ # constructor (see TaskUpdatedMessage), so it dispatches through the table
104
+ # like every other system subtype. `data` is always symbol-keyed here:
105
+ # `parse` rejects any message lacking a `:type` symbol key, so a
106
+ # string-keyed hash never reaches these classes.
107
+ 'task_updated' => TaskUpdatedMessage
100
108
  }.freeze
101
109
 
102
110
  def self.parse_system_message(data)
@@ -132,6 +140,18 @@ module ClaudeAgentSDK
132
140
  PromptSuggestionMessage.new(data)
133
141
  end
134
142
 
143
+ # Maps a content Array to typed blocks, guarding each element. A non-Hash
144
+ # block (e.g. a bare String or nil from a malformed CLI message) raises a
145
+ # descriptive MessageParseError carrying the full message rather than an
146
+ # opaque TypeError/NoMethodError from `block[:type]` deep in parsing.
147
+ def self.parse_content_blocks(content, data)
148
+ content.map do |block|
149
+ raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data) unless block.is_a?(Hash)
150
+
151
+ parse_content_block(block)
152
+ end
153
+ end
154
+
135
155
  # Accepts blocks with either symbol or string keys — live CLI messages
136
156
  # arrive symbol-keyed (parsed via `symbolize_names: true`), session
137
157
  # transcripts arrive string-keyed (parsed via `symbolize_names: false`).
@@ -256,7 +256,11 @@ module ClaudeAgentSDK
256
256
  @inflight_control_request_tasks.delete(request_id) if request_id
257
257
  end
258
258
  end
259
- @inflight_control_request_tasks[request_id] = handler_task if request_id
259
+ # A handler that never suspends (MCP metadata, unsupported-subtype
260
+ # error path) already ran to completion inside the async{} above —
261
+ # its ensure-delete fired before this insert, so registering it here
262
+ # would leak a finished task in the map forever.
263
+ @inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
260
264
  when 'control_cancel_request'
261
265
  request_id = message[:request_id] || message[:requestId]
262
266
  task = request_id ? @inflight_control_request_tasks[request_id] : nil
@@ -470,6 +470,11 @@ module ClaudeAgentSDK
470
470
  resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars
471
471
  end
472
472
 
473
+ # tools/call arrives from the CLI with the name as a JSON String; the mcp
474
+ # gem keys its lookup on the value stored here, so a Symbol name would be
475
+ # advertised in tools/list yet miss on every invocation ('Tool not found').
476
+ name = name.to_s if name.is_a?(Symbol)
477
+
473
478
  SdkMcpTool.new(
474
479
  name: name,
475
480
  description: description,
@@ -280,11 +280,17 @@ module ClaudeAgentSDK
280
280
  nil
281
281
  end
282
282
 
283
+ # A candidate counts only when it exists AND is non-empty — a 0-byte stub
284
+ # in one project dir must not stop the search when the real transcript
285
+ # lives under another (worktree) project dir. Mirrors the read path
286
+ # (Sessions.stat_candidate) and the append path (try_append).
283
287
  def try_project_dir(file_name, project_dir)
284
288
  return nil unless project_dir
285
289
 
286
290
  candidate = File.join(project_dir, file_name)
287
- File.exist?(candidate) ? [candidate, project_dir] : nil
291
+ File.size(candidate).positive? ? [candidate, project_dir] : nil
292
+ rescue SystemCallError
293
+ nil
288
294
  end
289
295
 
290
296
  def find_in_all_projects(file_name)
@@ -295,8 +301,8 @@ module ClaudeAgentSDK
295
301
  pd = File.join(projects_dir, child)
296
302
  next unless File.directory?(pd)
297
303
 
298
- candidate = File.join(pd, file_name)
299
- return [candidate, pd] if File.exist?(candidate)
304
+ result = try_project_dir(file_name, pd)
305
+ return result if result
300
306
  end
301
307
  nil
302
308
  end
@@ -523,9 +529,13 @@ module ClaudeAgentSDK
523
529
  JSON.generate(forked)
524
530
  end
525
531
 
526
- # Walk up parentUuid chain skipping progress entries.
532
+ # Walk up parentUuid chain skipping progress entries. The visited set
533
+ # guards against parentUuid cycles among progress entries (corrupt
534
+ # transcripts) like every other chain walker — an unguarded walk spun
535
+ # forever and hung both fork paths.
527
536
  def resolve_parent_uuid(parent_id, by_uuid, uuid_mapping)
528
- while parent_id
537
+ visited = Set.new
538
+ while parent_id && visited.add?(parent_id)
529
539
  parent = by_uuid[parent_id]
530
540
  break unless parent
531
541
  return uuid_mapping[parent_id] if parent['type'] != 'progress'
@@ -494,6 +494,13 @@ module ClaudeAgentSDK
494
494
  # an oversized line was fully allocated BEFORE the 1MB cap could
495
495
  # fire — unbounded memory on hostile/buggy stdout.
496
496
  @stdout.each_line("\n", @max_buffer_size + 1) do |line|
497
+ # stdout is UTF-8-tagged but the CLI can emit invalid bytes (echoed
498
+ # binary/latin-1 output). strip/lstrip below raise on invalid
499
+ # encoding, which would abort the whole stream and drop buffered
500
+ # valid frames — scrub the one bad line instead (the version-probe
501
+ # path guards the same way).
502
+ line = line.scrub unless line.valid_encoding?
503
+
497
504
  # Position-aware whitespace handling: a chunk of an over-limit line
498
505
  # must keep its interior whitespace — a blanket per-chunk strip
499
506
  # deleted spaces inside JSON strings straddling the chunk boundary
@@ -333,6 +333,19 @@ module ClaudeAgentSDK
333
333
  # Task lifecycle notification statuses
334
334
  TASK_NOTIFICATION_STATUSES = %w[completed failed stopped].freeze
335
335
 
336
+ # Possible status values reported inside a `task_updated` patch.
337
+ # pending/running/paused are non-terminal; completed/failed/killed are
338
+ # terminal. Note: task_updated reports the raw "killed"; the CLI maps that to
339
+ # "stopped" only when it emits a task_notification.
340
+ TASK_UPDATED_STATUSES = %w[pending running paused completed failed killed].freeze
341
+
342
+ # Task statuses that mean the task has finished and should be cleared from any
343
+ # "active task" tracking. Spans both lifecycle vocabularies: task_notification
344
+ # reports "stopped" (the CLI's mapped form of a killed task) while task_updated
345
+ # reports the raw "killed". Treat the status of a TaskNotificationMessage and a
346
+ # TaskUpdatedMessage the same way.
347
+ TERMINAL_TASK_STATUSES = %w[completed failed stopped killed].freeze
348
+
336
349
  # Typed usage data for task progress and notifications
337
350
  class TaskUsage < Type
338
351
  attr_accessor :total_tokens, :tool_uses, :duration_ms
@@ -356,11 +369,44 @@ module ClaudeAgentSDK
356
369
  attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary
357
370
  end
358
371
 
359
- # Task notification system message (task completed/failed/stopped)
372
+ # Task notification system message (task completed/failed/stopped).
373
+ #
374
+ # Note: not every terminal task emits this message. Background tasks may
375
+ # instead report completion only via a TaskUpdatedMessage whose patch["status"]
376
+ # is terminal (see TERMINAL_TASK_STATUSES). Consumers tracking active task IDs
377
+ # should clear them on a terminal status from *either* message.
360
378
  class TaskNotificationMessage < SystemMessage
361
379
  attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
362
380
  end
363
381
 
382
+ # Task updated system message (background task lifecycle state change).
383
+ #
384
+ # The CLI emits system/task_updated events as a task moves through its
385
+ # lifecycle. `patch` carries the changed fields (e.g. status, end_time); when
386
+ # patch["status"] is terminal (see TERMINAL_TASK_STATUSES) the task has
387
+ # finished. A background task's terminal state can arrive *only* as a
388
+ # TaskUpdatedMessage with no accompanying TaskNotificationMessage — e.g. a task
389
+ # stopped via TaskStop reports status "killed" here and the matching
390
+ # notification is sometimes suppressed. Consumers tracking active task IDs
391
+ # should clear them on a terminal status from *either* message.
392
+ #
393
+ # Parsed defensively in the constructor — a lifecycle event must never raise:
394
+ # `status` is derived from patch["status"] (not a top-level field); a non-Hash
395
+ # or absent patch falls back to {}; and `task_id` defaults to "" (never nil,
396
+ # matching the Python SDK) so consumers can rely on it always being a String.
397
+ # The full patch is preserved on `#patch` for callers that need more than the
398
+ # status.
399
+ class TaskUpdatedMessage < SystemMessage
400
+ attr_accessor :task_id, :patch, :status, :uuid, :session_id
401
+
402
+ def initialize(attributes = {})
403
+ super
404
+ @task_id ||= ''
405
+ @patch = {} unless @patch.is_a?(Hash)
406
+ @status = @patch[:status]
407
+ end
408
+ end
409
+
364
410
  # Result message with cost and usage information
365
411
  class ResultMessage < Type
366
412
  attr_accessor :subtype, :duration_ms, :duration_api_ms, :is_error,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.18.0'
4
+ VERSION = '0.19.1'
5
5
  end
@@ -23,13 +23,43 @@ require 'securerandom'
23
23
 
24
24
  # Claude Agent SDK for Ruby
25
25
  module ClaudeAgentSDK
26
+ # The duck-typed observer surface probed by resolve_observers — implementing
27
+ # any one of these counts as an observer (see Observer's no-op defaults).
28
+ OBSERVER_INTERFACE = %i[on_user_prompt on_message on_error on_close].freeze
29
+
26
30
  # Resolve observers array: callables (Proc/lambda) are invoked to produce
27
31
  # a fresh instance per query/session (thread-safe); plain objects are used as-is.
28
32
  # Array() guards against nil (e.g., when observers: nil is passed explicitly).
33
+ # Anything implementing none of the observer methods is warned about and
34
+ # skipped — most commonly a Class passed instead of an instance, which
35
+ # previously produced silent zero instrumentation (every notify raised
36
+ # NoMethodError, swallowed by notify_observers' error containment).
29
37
  def self.resolve_observers(observers)
30
- Array(observers).map do |obs|
31
- obs.respond_to?(:call) ? obs.call : obs
38
+ Array(observers).filter_map do |obs|
39
+ resolved = obs.respond_to?(:call) ? obs.call : obs
40
+ if OBSERVER_INTERFACE.none? { |m| resolved.respond_to?(m) }
41
+ label = resolved.is_a?(Module) ? resolved : resolved.class
42
+ hint = resolved.is_a?(Module) ? " — pass an instance (#{resolved}.new) or a factory lambda" : ''
43
+ warn "ClaudeAgentSDK: ignoring observer #{label}: it implements none of #{OBSERVER_INTERFACE.join('/')}#{hint}"
44
+ next nil
45
+ end
46
+ resolved
47
+ end
48
+ end
49
+
50
+ # Internal: pull live SDK MCP server instances out of an mcp_servers Hash.
51
+ # Accepts both raw Hash configs and typed Mcp*ServerConfig objects — a
52
+ # McpSdkServerConfig passed without .to_h previously failed the Hash-only
53
+ # guard, so its in-process server was silently never registered.
54
+ def self.extract_sdk_mcp_servers(mcp_servers)
55
+ return {} unless mcp_servers.is_a?(Hash)
56
+
57
+ servers = {}
58
+ mcp_servers.each do |name, config|
59
+ config = config.to_h if config.is_a?(Type)
60
+ servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
32
61
  end
62
+ servers
33
63
  end
34
64
 
35
65
  # Safely call a method on each observer, suppressing any errors.
@@ -391,12 +421,7 @@ module ClaudeAgentSDK
391
421
  transport.connect
392
422
 
393
423
  # Extract SDK MCP servers
394
- sdk_mcp_servers = {}
395
- if configured_options.mcp_servers.is_a?(Hash)
396
- configured_options.mcp_servers.each do |name, config|
397
- sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
398
- end
399
- end
424
+ sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
400
425
 
401
426
  hooks = nil
402
427
  if configured_options.hooks
@@ -903,12 +928,7 @@ module ClaudeAgentSDK
903
928
  @transport.connect
904
929
 
905
930
  # Extract SDK MCP servers
906
- sdk_mcp_servers = {}
907
- if configured_options.mcp_servers.is_a?(Hash)
908
- configured_options.mcp_servers.each do |name, config|
909
- sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
910
- end
911
- end
931
+ sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
912
932
 
913
933
  # Convert hooks to internal format
914
934
  hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-12 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -119,6 +119,7 @@ files:
119
119
  - docs/rails.md
120
120
  - docs/sessions.md
121
121
  - docs/types.md
122
+ - lib/claude-agent-sdk.rb
122
123
  - lib/claude_agent_sdk.rb
123
124
  - lib/claude_agent_sdk/command_builder.rb
124
125
  - lib/claude_agent_sdk/configuration.rb