schwab_mcp 0.1.0 → 0.2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.json +14 -0
  3. data/CLAUDE.md +124 -0
  4. data/debug_env.rb +46 -0
  5. data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
  6. data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
  7. data/exe/schwab_mcp +14 -3
  8. data/exe/schwab_token_refresh +10 -9
  9. data/lib/schwab_mcp/redactor.rb +4 -0
  10. data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
  11. data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -50
  12. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
  13. data/lib/schwab_mcp/tools/get_order_tool.rb +51 -108
  14. data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
  15. data/lib/schwab_mcp/tools/help_tool.rb +1 -22
  16. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +35 -63
  17. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -72
  18. data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
  19. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +18 -31
  20. data/lib/schwab_mcp/tools/option_chain_tool.rb +130 -82
  21. data/lib/schwab_mcp/tools/place_order_tool.rb +105 -117
  22. data/lib/schwab_mcp/tools/preview_order_tool.rb +100 -48
  23. data/lib/schwab_mcp/tools/quote_tool.rb +33 -26
  24. data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
  25. data/lib/schwab_mcp/tools/replace_order_tool.rb +104 -116
  26. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -72
  27. data/lib/schwab_mcp/version.rb +1 -1
  28. data/lib/schwab_mcp.rb +1 -2
  29. data/orders_example.json +7084 -0
  30. data/spx_option_chain.json +25073 -0
  31. data/test_mcp.rb +16 -0
  32. data/test_server.rb +23 -0
  33. data/trading_brokerage_account_details.json +89 -0
  34. data/transactions_example.json +488 -0
  35. metadata +17 -7
  36. data/lib/schwab_mcp/option_chain_filter.rb +0 -213
  37. data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
  38. data/lib/schwab_mcp/orders/order_factory.rb +0 -40
  39. data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
  40. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
@@ -2,6 +2,7 @@ require "mcp"
2
2
  require "schwab_rb"
3
3
  require "json"
4
4
  require_relative "../loggable"
5
+ require_relative "../schwab_client_factory"
5
6
 
6
7
  module SchwabMCP
7
8
  module Tools
@@ -52,20 +53,8 @@ module SchwabMCP
52
53
  end
53
54
 
54
55
  begin
55
- client = SchwabRb::Auth.init_client_easy(
56
- ENV['SCHWAB_API_KEY'],
57
- ENV['SCHWAB_APP_SECRET'],
58
- ENV['SCHWAB_CALLBACK_URI'],
59
- ENV['TOKEN_PATH']
60
- )
61
-
62
- unless client
63
- log_error("Failed to initialize Schwab client")
64
- return MCP::Tool::Response.new([{
65
- type: "text",
66
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
67
- }])
68
- end
56
+ client = SchwabClientFactory.create_client
57
+ return SchwabClientFactory.client_error_response unless client
69
58
 
70
59
  account_id = ENV[account_name]
71
60
  unless account_id
@@ -80,9 +69,9 @@ module SchwabMCP
80
69
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
81
70
  log_debug("Fetching account numbers mapping")
82
71
 
83
- account_numbers_response = client.get_account_numbers
84
72
 
85
- unless account_numbers_response&.body
73
+ account_numbers = client.get_account_numbers # returns SchwabRb::DataObjects::AccountNumbers
74
+ unless account_numbers && account_numbers.respond_to?(:accounts)
86
75
  log_error("Failed to retrieve account numbers")
87
76
  return MCP::Tool::Response.new([{
88
77
  type: "text",
@@ -90,13 +79,10 @@ module SchwabMCP
90
79
  }])
91
80
  end
92
81
 
93
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
94
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
95
-
96
82
  account_hash = nil
97
- account_mappings.each do |mapping|
98
- if mapping[:accountNumber] == account_id
99
- account_hash = mapping[:hashValue]
83
+ account_numbers.accounts.each do |acct|
84
+ if acct.account_number.to_s == account_id.to_s
85
+ account_hash = acct.hash_value
100
86
  break
101
87
  end
102
88
  end
@@ -105,16 +91,16 @@ module SchwabMCP
105
91
  log_error("Account ID not found in available accounts")
106
92
  return MCP::Tool::Response.new([{
107
93
  type: "text",
108
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
94
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.accounts.length} accounts available."
109
95
  }])
110
96
  end
111
97
 
112
98
  log_debug("Found account hash for account ID: #{account_name}")
113
99
  log_debug("Verifying order exists before attempting cancellation")
114
100
 
115
- order_response = client.get_order(order_id, account_hash)
116
101
 
117
- unless order_response&.body
102
+ order = client.get_order(order_id, account_hash) # returns SchwabRb::DataObjects::Order
103
+ unless order
118
104
  log_warn("Order not found or empty response for order ID: #{order_id}")
119
105
  return MCP::Tool::Response.new([{
120
106
  type: "text",
@@ -122,9 +108,8 @@ module SchwabMCP
122
108
  }])
123
109
  end
124
110
 
125
- order_data = JSON.parse(order_response.body)
126
- order_status = order_data['status']
127
- cancelable = order_data['cancelable']
111
+ order_status = order.status
112
+ cancelable = order.respond_to?(:cancelable) ? order.cancelable : true # fallback if attribute not present
128
113
 
129
114
  log_debug("Order found - Status: #{order_status}, Cancelable: #{cancelable}")
130
115
  if cancelable == false
@@ -140,7 +125,7 @@ module SchwabMCP
140
125
 
141
126
  if cancel_response.respond_to?(:status) && cancel_response.status == 200
142
127
  log_info("Successfully cancelled order ID: #{order_id}")
143
- formatted_response = format_cancellation_success(order_id, account_name, order_data)
128
+ formatted_response = format_cancellation_success(order_id, account_name, order)
144
129
  elsif cancel_response.respond_to?(:status) && cancel_response.status == 404
145
130
  log_warn("Order not found during cancellation: #{order_id}")
146
131
  return MCP::Tool::Response.new([{
@@ -149,7 +134,7 @@ module SchwabMCP
149
134
  }])
150
135
  else
151
136
  log_info("Order cancellation request submitted for order ID: #{order_id}")
152
- formatted_response = format_cancellation_success(order_id, account_name, order_data)
137
+ formatted_response = format_cancellation_success(order_id, account_name, order)
153
138
  end
154
139
 
155
140
  MCP::Tool::Response.new([{
@@ -157,12 +142,7 @@ module SchwabMCP
157
142
  text: formatted_response
158
143
  }])
159
144
 
160
- rescue JSON::ParserError => e
161
- log_error("JSON parsing error: #{e.message}")
162
- MCP::Tool::Response.new([{
163
- type: "text",
164
- text: "**Error**: Failed to parse API response: #{e.message}"
165
- }])
145
+ # No JSON::ParserError rescue needed with data objects
166
146
  rescue => e
167
147
  log_error("Error cancelling order ID #{order_id}: #{e.message}")
168
148
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -186,27 +166,26 @@ module SchwabMCP
186
166
 
187
167
  private
188
168
 
189
- def self.format_cancellation_success(order_id, account_name, order_data)
169
+ def self.format_cancellation_success(order_id, account_name, order)
190
170
  friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
191
171
 
192
172
  formatted = "**✅ Order Cancellation Successful**\n\n"
193
173
  formatted += "**Order ID**: #{order_id}\n"
194
174
  formatted += "**Account**: #{friendly_name} (#{account_name})\n\n"
195
175
  formatted += "**Order Details:**\n"
196
- formatted += "- Original Status: #{order_data['status']}\n" if order_data['status']
197
- formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
198
- formatted += "- Session: #{order_data['session']}\n" if order_data['session']
199
- formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
200
- formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
201
- formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
202
-
203
- if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
176
+ formatted += "- Original Status: #{order.status}\n" if order.status
177
+ formatted += "- Order Type: #{order.order_type}\n" if order.order_type
178
+ formatted += "- Duration: #{order.duration}\n" if order.duration
179
+ formatted += "- Quantity: #{order.quantity}\n" if order.quantity
180
+ formatted += "- Price: $#{format_currency(order.price)}\n" if order.price
181
+
182
+ if order.order_leg_collection && order.order_leg_collection.any?
204
183
  formatted += "\n**Instruments:**\n"
205
- order_data['orderLegCollection'].each do |leg|
206
- if leg['instrument']
207
- symbol = leg['instrument']['symbol']
208
- instruction = leg['instruction']
209
- quantity = leg['quantity']
184
+ order.order_leg_collection.each do |leg|
185
+ if leg.instrument
186
+ symbol = leg.instrument.symbol
187
+ instruction = leg.instruction
188
+ quantity = leg.quantity
210
189
  formatted += "- #{symbol}: #{instruction} #{quantity}\n"
211
190
  end
212
191
  end
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mcp"
2
4
  require "schwab_rb"
3
- require "json"
4
5
  require "date"
5
6
  require_relative "../loggable"
7
+ require_relative "../schwab_client_factory"
6
8
 
7
9
  module SchwabMCP
8
10
  module Tools
@@ -42,22 +44,9 @@ module SchwabMCP
42
44
  log_debug("Date parameter: #{date || 'today'}")
43
45
 
44
46
  begin
45
- client = SchwabRb::Auth.init_client_easy(
46
- ENV['SCHWAB_API_KEY'],
47
- ENV['SCHWAB_APP_SECRET'],
48
- ENV['SCHWAB_CALLBACK_URI'],
49
- ENV['TOKEN_PATH']
50
- )
51
-
52
- unless client
53
- log_error("Failed to initialize Schwab client")
54
- return MCP::Tool::Response.new([{
55
- type: "text",
56
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
57
- }])
58
- end
47
+ client = SchwabClientFactory.create_client
48
+ return SchwabClientFactory.client_error_response unless client
59
49
 
60
- # Parse date if provided
61
50
  parsed_date = nil
62
51
  if date
63
52
  begin
@@ -73,23 +62,24 @@ module SchwabMCP
73
62
  end
74
63
 
75
64
  log_debug("Making API request for markets: #{markets.join(', ')}")
76
- response = client.get_market_hours(markets, date: parsed_date)
65
+ market_hours_obj = client.get_market_hours(markets, date: parsed_date, return_data_objects: true)
77
66
 
78
- if response&.body
79
- log_info("Successfully retrieved market hours for #{markets.join(', ')}")
80
- date_info = date ? " for #{date}" : " for today"
81
- MCP::Tool::Response.new([{
82
- type: "text",
83
- text: "**Market Hours#{date_info}:**\n\n```json\n#{response.body}\n```"
84
- }])
85
- else
86
- log_warn("Empty response from Schwab API for markets: #{markets.join(', ')}")
87
- MCP::Tool::Response.new([{
67
+ unless market_hours_obj
68
+ log_warn("No market hours data object returned for markets: #{markets.join(', ')}")
69
+ return MCP::Tool::Response.new([{
88
70
  type: "text",
89
- text: "**No Data**: Empty response from Schwab API for markets: #{markets.join(', ')}"
71
+ text: "**No Data**: No market hours data returned for markets: #{markets.join(', ')}"
90
72
  }])
91
73
  end
92
74
 
75
+ formatted = format_market_hours_object(market_hours_obj)
76
+ log_info("Successfully retrieved market hours for #{markets.join(', ')}")
77
+ date_info = date ? " for #{date}" : " for today"
78
+ MCP::Tool::Response.new([{
79
+ type: "text",
80
+ text: "**Market Hours#{date_info}:**\n\n#{formatted}"
81
+ }])
82
+
93
83
  rescue => e
94
84
  log_error("Error retrieving market hours for #{markets.join(', ')}: #{e.message}")
95
85
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -99,6 +89,15 @@ module SchwabMCP
99
89
  }])
100
90
  end
101
91
  end
92
+
93
+ # Format the market hours object for display
94
+ def self.format_market_hours_object(obj)
95
+ if obj.respond_to?(:to_h)
96
+ obj.to_h.inspect
97
+ else
98
+ obj.inspect
99
+ end
100
+ end
102
101
  end
103
102
  end
104
103
  end
@@ -3,6 +3,7 @@ require "schwab_rb"
3
3
  require "json"
4
4
  require_relative "../loggable"
5
5
  require_relative "../redactor"
6
+ require_relative "../schwab_client_factory"
6
7
 
7
8
  module SchwabMCP
8
9
  module Tools
@@ -53,20 +54,8 @@ module SchwabMCP
53
54
  end
54
55
 
55
56
  begin
56
- client = SchwabRb::Auth.init_client_easy(
57
- ENV['SCHWAB_API_KEY'],
58
- ENV['SCHWAB_APP_SECRET'],
59
- ENV['SCHWAB_CALLBACK_URI'],
60
- ENV['TOKEN_PATH']
61
- )
62
-
63
- unless client
64
- log_error("Failed to initialize Schwab client")
65
- return MCP::Tool::Response.new([{
66
- type: "text",
67
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
68
- }])
69
- end
57
+ client = SchwabClientFactory.create_client
58
+ return SchwabClientFactory.client_error_response unless client
70
59
 
71
60
  account_id = ENV[account_name]
72
61
  unless account_id
@@ -81,9 +70,9 @@ module SchwabMCP
81
70
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
82
71
  log_debug("Fetching account numbers mapping")
83
72
 
84
- account_numbers_response = client.get_account_numbers
85
73
 
86
- unless account_numbers_response&.body
74
+ account_numbers = client.get_account_numbers # returns SchwabRb::DataObjects::AccountNumbers
75
+ unless account_numbers && account_numbers.respond_to?(:accounts)
87
76
  log_error("Failed to retrieve account numbers")
88
77
  return MCP::Tool::Response.new([{
89
78
  type: "text",
@@ -91,13 +80,10 @@ module SchwabMCP
91
80
  }])
92
81
  end
93
82
 
94
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
95
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
96
-
97
83
  account_hash = nil
98
- account_mappings.each do |mapping|
99
- if mapping[:accountNumber] == account_id
100
- account_hash = mapping[:hashValue]
84
+ account_numbers.accounts.each do |acct|
85
+ if acct.account_number.to_s == account_id.to_s
86
+ account_hash = acct.hash_value
101
87
  break
102
88
  end
103
89
  end
@@ -106,21 +92,17 @@ module SchwabMCP
106
92
  log_error("Account ID not found in available accounts")
107
93
  return MCP::Tool::Response.new([{
108
94
  type: "text",
109
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
95
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.accounts.length} accounts available."
110
96
  }])
111
97
  end
112
98
 
113
99
  log_debug("Found account hash for account ID: #{account_name}")
114
100
  log_debug("Fetching order details for order ID: #{order_id}")
115
101
 
116
- order_response = client.get_order(order_id, account_hash)
117
-
118
- if order_response&.body
102
+ order = client.get_order(order_id, account_hash) # returns SchwabRb::DataObjects::Order
103
+ if order
119
104
  log_info("Successfully retrieved order details for order ID: #{order_id}")
120
- order_data = JSON.parse(order_response.body)
121
-
122
- formatted_response = format_order_data(order_data, order_id, account_name)
123
-
105
+ formatted_response = format_order_object(order, order_id, account_name)
124
106
  MCP::Tool::Response.new([{
125
107
  type: "text",
126
108
  text: formatted_response
@@ -132,13 +114,6 @@ module SchwabMCP
132
114
  text: "**No Data**: Empty response from Schwab API for order ID: #{order_id}. Order may not exist or may be in a different account."
133
115
  }])
134
116
  end
135
-
136
- rescue JSON::ParserError => e
137
- log_error("JSON parsing error: #{e.message}")
138
- MCP::Tool::Response.new([{
139
- type: "text",
140
- text: "**Error**: Failed to parse API response: #{e.message}"
141
- }])
142
117
  rescue => e
143
118
  log_error("Error retrieving order details for order ID #{order_id}: #{e.message}")
144
119
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -151,104 +126,72 @@ module SchwabMCP
151
126
 
152
127
  private
153
128
 
154
- def self.format_order_data(order_data, order_id, account_name)
155
- friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
156
129
 
130
+ def self.format_order_object(order, order_id, account_name)
131
+ friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
157
132
  formatted = "**Order Details for Order ID #{order_id}:**\n\n"
158
133
  formatted += "**Account:** #{friendly_name} (#{account_name})\n\n"
159
134
 
160
135
  formatted += "**Order Information:**\n"
161
- formatted += "- Order ID: #{order_data['orderId']}\n" if order_data['orderId']
162
- formatted += "- Status: #{order_data['status']}\n" if order_data['status']
163
- formatted += "- Order Type: #{order_data['orderType']}\n" if order_data['orderType']
164
- formatted += "- Session: #{order_data['session']}\n" if order_data['session']
165
- formatted += "- Duration: #{order_data['duration']}\n" if order_data['duration']
166
- formatted += "- Complex Order Strategy Type: #{order_data['complexOrderStrategyType']}\n" if order_data['complexOrderStrategyType']
167
- formatted += "- Cancelable: #{order_data['cancelable']}\n" if order_data.key?('cancelable')
168
- formatted += "- Editable: #{order_data['editable']}\n" if order_data.key?('editable')
136
+ formatted += "- Order ID: #{order.order_id}\n" if order.order_id
137
+ formatted += "- Status: #{order.status}\n" if order.status
138
+ formatted += "- Order Type: #{order.order_type}\n" if order.order_type
139
+ formatted += "- Duration: #{order.duration}\n" if order.duration
140
+ formatted += "- Complex Order Strategy Type: #{order.complex_order_strategy_type}\n" if order.complex_order_strategy_type
169
141
 
170
142
  formatted += "\n**Timing:**\n"
171
- formatted += "- Entered Time: #{order_data['enteredTime']}\n" if order_data['enteredTime']
172
- formatted += "- Close Time: #{order_data['closeTime']}\n" if order_data['closeTime']
143
+ formatted += "- Entered Time: #{order.entered_time}\n" if order.entered_time
144
+ formatted += "- Close Time: #{order.close_time}\n" if order.close_time
173
145
 
174
146
  formatted += "\n**Quantity & Pricing:**\n"
175
- formatted += "- Quantity: #{order_data['quantity']}\n" if order_data['quantity']
176
- formatted += "- Filled Quantity: #{order_data['filledQuantity']}\n" if order_data['filledQuantity']
177
- formatted += "- Remaining Quantity: #{order_data['remainingQuantity']}\n" if order_data['remainingQuantity']
178
- formatted += "- Requested Destination: #{order_data['requestedDestination']}\n" if order_data['requestedDestination']
179
- formatted += "- Destination Link Name: #{order_data['destinationLinkName']}\n" if order_data['destinationLinkName']
180
- formatted += "- Price: $#{format_currency(order_data['price'])}\n" if order_data['price']
181
- formatted += "- Stop Price: $#{format_currency(order_data['stopPrice'])}\n" if order_data['stopPrice']
182
- formatted += "- Stop Price Link Basis: #{order_data['stopPriceLinkBasis']}\n" if order_data['stopPriceLinkBasis']
183
- formatted += "- Stop Price Link Type: #{order_data['stopPriceLinkType']}\n" if order_data['stopPriceLinkType']
184
- formatted += "- Stop Price Offset: $#{format_currency(order_data['stopPriceOffset'])}\n" if order_data['stopPriceOffset']
185
- formatted += "- Stop Type: #{order_data['stopType']}\n" if order_data['stopType']
147
+ formatted += "- Quantity: #{order.quantity}\n" if order.quantity
148
+ formatted += "- Filled Quantity: #{order.filled_quantity}\n" if order.filled_quantity
149
+ formatted += "- Remaining Quantity: #{order.remaining_quantity}\n" if order.remaining_quantity
150
+ formatted += "- Price: $#{format_currency(order.price)}\n" if order.price
186
151
 
187
- if order_data['orderLegCollection'] && order_data['orderLegCollection'].any?
152
+ if order.order_leg_collection && order.order_leg_collection.any?
188
153
  formatted += "\n**Order Legs:**\n"
189
- order_data['orderLegCollection'].each_with_index do |leg, index|
154
+ order.order_leg_collection.each_with_index do |leg, index|
190
155
  formatted += "**Leg #{index + 1}:**\n"
191
- formatted += "- Instruction: #{leg['instruction']}\n" if leg['instruction']
192
- formatted += "- Quantity: #{leg['quantity']}\n" if leg['quantity']
193
- formatted += "- Position Effect: #{leg['positionEffect']}\n" if leg['positionEffect']
194
- formatted += "- Quantity Type: #{leg['quantityType']}\n" if leg['quantityType']
156
+ formatted += "- Instruction: #{leg.instruction}\n" if leg.instruction
157
+ formatted += "- Quantity: #{leg.quantity}\n" if leg.quantity
158
+ formatted += "- Position Effect: #{leg.position_effect}\n" if leg.position_effect
195
159
 
196
- if leg['instrument']
197
- instrument = leg['instrument']
160
+ if leg.instrument
161
+ instrument = leg.instrument
198
162
  formatted += "- **Instrument:**\n"
199
- formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
200
- formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
201
- formatted += " * Description: #{instrument['description']}\n" if instrument['description']
202
- formatted += " * CUSIP: #{instrument['cusip']}\n" if instrument['cusip']
203
- formatted += " * Net Change: #{instrument['netChange']}\n" if instrument['netChange']
204
-
205
- if instrument['putCall']
206
- formatted += " * Option Type: #{instrument['putCall']}\n"
207
- formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
208
- formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
209
- formatted += " * Days to Expiration: #{instrument['daysToExpiration']}\n" if instrument['daysToExpiration']
210
- formatted += " * Expiration Type: #{instrument['expirationType']}\n" if instrument['expirationType']
211
- formatted += " * Exercise Type: #{instrument['exerciseType']}\n" if instrument['exerciseType']
212
- formatted += " * Settlement Type: #{instrument['settlementType']}\n" if instrument['settlementType']
213
- formatted += " * Deliverables: #{instrument['deliverables']}\n" if instrument['deliverables']
214
- end
163
+ formatted += " * Asset Type: #{instrument.asset_type}\n" if instrument.asset_type
164
+ formatted += " * Symbol: #{instrument.symbol}\n" if instrument.symbol
165
+ formatted += " * Description: #{instrument.description}\n" if instrument.description
215
166
  end
216
- formatted += "\n" unless index == order_data['orderLegCollection'].length - 1
217
- end
218
- end
219
-
220
- if order_data['childOrderStrategies'] && order_data['childOrderStrategies'].any?
221
- formatted += "\n**Child Order Strategies:**\n"
222
- formatted += "- Number of Child Orders: #{order_data['childOrderStrategies'].length}\n"
223
- order_data['childOrderStrategies'].each_with_index do |child, index|
224
- formatted += "- Child Order #{index + 1}: #{child['orderId']} (Status: #{child['status']})\n" if child['orderId'] && child['status']
167
+ formatted += "\n" unless index == order.order_leg_collection.length - 1
225
168
  end
226
169
  end
227
170
 
228
- if order_data['orderActivityCollection'] && order_data['orderActivityCollection'].any?
171
+ if order.order_activity_collection && order.order_activity_collection.any?
229
172
  formatted += "\n**Order Activities:**\n"
230
- order_data['orderActivityCollection'].each_with_index do |activity, index|
173
+ order.order_activity_collection.each_with_index do |activity, index|
231
174
  formatted += "**Activity #{index + 1}:**\n"
232
- formatted += "- Activity Type: #{activity['activityType']}\n" if activity['activityType']
233
- formatted += "- Execution Type: #{activity['executionType']}\n" if activity['executionType']
234
- formatted += "- Quantity: #{activity['quantity']}\n" if activity['quantity']
235
- formatted += "- Order Remaining Quantity: #{activity['orderRemainingQuantity']}\n" if activity['orderRemainingQuantity']
175
+ formatted += "- Activity Type: #{activity.activity_type}\n" if activity.activity_type
176
+ formatted += "- Execution Type: #{activity.execution_type}\n" if activity.execution_type
177
+ formatted += "- Quantity: #{activity.quantity}\n" if activity.quantity
178
+ formatted += "- Order Remaining Quantity: #{activity.order_remaining_quantity}\n" if activity.order_remaining_quantity
236
179
 
237
- if activity['executionLegs'] && activity['executionLegs'].any?
238
- activity['executionLegs'].each_with_index do |exec_leg, leg_index|
180
+ if activity.execution_legs && activity.execution_legs.any?
181
+ activity.execution_legs.each_with_index do |exec_leg, leg_index|
239
182
  formatted += "- **Execution Leg #{leg_index + 1}:**\n"
240
- formatted += " * Leg ID: #{exec_leg['legId']}\n" if exec_leg['legId']
241
- formatted += " * Price: $#{format_currency(exec_leg['price'])}\n" if exec_leg['price']
242
- formatted += " * Quantity: #{exec_leg['quantity']}\n" if exec_leg['quantity']
243
- formatted += " * Mismarked Quantity: #{exec_leg['mismarkedQuantity']}\n" if exec_leg['mismarkedQuantity']
244
- formatted += " * Time: #{exec_leg['time']}\n" if exec_leg['time']
183
+ formatted += " * Leg ID: #{exec_leg.leg_id}\n" if exec_leg.leg_id
184
+ formatted += " * Price: $#{format_currency(exec_leg.price)}\n" if exec_leg.price
185
+ formatted += " * Quantity: #{exec_leg.quantity}\n" if exec_leg.quantity
186
+ formatted += " * Mismarked Quantity: #{exec_leg.mismarked_quantity}\n" if exec_leg.mismarked_quantity
187
+ formatted += " * Time: #{exec_leg.time}\n" if exec_leg.time
245
188
  end
246
189
  end
247
- formatted += "\n" unless index == order_data['orderActivityCollection'].length - 1
190
+ formatted += "\n" unless index == order.order_activity_collection.length - 1
248
191
  end
249
192
  end
250
193
 
251
- redacted_data = Redactor.redact(order_data)
194
+ redacted_data = Redactor.redact(order.to_h)
252
195
  formatted += "\n**Full Response (Redacted):**\n"
253
196
  formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
254
197
  formatted
@@ -1,8 +1,8 @@
1
1
  require "mcp"
2
2
  require "schwab_rb"
3
- require "json"
4
3
  require "date"
5
4
  require_relative "../loggable"
5
+ require_relative "../schwab_client_factory"
6
6
 
7
7
  module SchwabMCP
8
8
  module Tools
@@ -68,20 +68,8 @@ module SchwabMCP
68
68
  log_info("Getting price history for symbol: #{symbol}")
69
69
 
70
70
  begin
71
- client = SchwabRb::Auth.init_client_easy(
72
- ENV['SCHWAB_API_KEY'],
73
- ENV['SCHWAB_APP_SECRET'],
74
- ENV['SCHWAB_CALLBACK_URI'],
75
- ENV['TOKEN_PATH']
76
- )
77
-
78
- unless client
79
- log_error("Failed to initialize Schwab client")
80
- return MCP::Tool::Response.new([{
81
- type: "text",
82
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
83
- }])
84
- end
71
+ client = SchwabClientFactory.create_client
72
+ return SchwabClientFactory.client_error_response unless client
85
73
 
86
74
  parsed_start = nil
87
75
  parsed_end = nil
@@ -146,7 +134,7 @@ module SchwabMCP
146
134
 
147
135
  log_debug("Making price history API request for symbol: #{symbol}")
148
136
 
149
- response = client.get_price_history(
137
+ price_history = client.get_price_history(
150
138
  symbol.upcase,
151
139
  period_type: period_type_enum,
152
140
  period: period,
@@ -158,29 +146,29 @@ module SchwabMCP
158
146
  need_previous_close: need_previous_close
159
147
  )
160
148
 
161
- if response&.body
149
+ if price_history
162
150
  log_info("Successfully retrieved price history for #{symbol}")
163
151
 
164
- begin
165
- data = JSON.parse(response.body)
166
- candles = data.dig("candles") || []
167
-
168
- summary = if candles.any?
169
- "Retrieved #{candles.length} price candles"
170
- else
171
- "No price data available for the specified parameters"
172
- end
152
+ summary = if price_history.empty?
153
+ "No price data available for the specified parameters"
154
+ else
155
+ "Retrieved #{price_history.count} price candles\n" \
156
+ "First candle: #{price_history.first_candle&.to_h}\n" \
157
+ "Last candle: #{price_history.last_candle&.to_h}"
158
+ end
173
159
 
174
- MCP::Tool::Response.new([{
175
- type: "text",
176
- text: "**Price History for #{symbol.upcase}:**\n\n#{summary}\n\n```json\n#{response.body}\n```"
177
- }])
178
- rescue JSON::ParserError
179
- MCP::Tool::Response.new([{
180
- type: "text",
181
- text: "**Price History for #{symbol.upcase}:**\n\n```json\n#{response.body}\n```"
182
- }])
160
+ # Show a compact JSON representation for advanced users
161
+ json_preview = begin
162
+ require "json"
163
+ JSON.pretty_generate(price_history.to_h)
164
+ rescue
165
+ price_history.to_h.inspect
183
166
  end
167
+
168
+ MCP::Tool::Response.new([{
169
+ type: "text",
170
+ text: "**Price History for #{symbol.upcase}:**\n\n#{summary}\n\n```json\n#{json_preview}\n```"
171
+ }])
184
172
  else
185
173
  log_warn("Empty response from Schwab API for symbol: #{symbol}")
186
174
  MCP::Tool::Response.new([{
@@ -56,8 +56,7 @@ module SchwabMCP
56
56
  - **get_market_hours_tool**: Get market hours for specified markets
57
57
  - **list_movers_tool**: Get top ten movers for a given index
58
58
 
59
- ### Option Strategy Tools:
60
- - **option_strategy_finder_tool**: Find option strategies (iron condor, call spread, put spread)
59
+ ### Option Order Tools:
61
60
  - **preview_order_tool**: Preview an options order before placing (⚠️ SAFE PREVIEW)
62
61
  - **place_order_tool**: Place an options order for execution (⚠️ DESTRUCTIVE)
63
62
  - **replace_order_tool**: Replace an existing order with a new one (⚠️ DESTRUCTIVE)
@@ -84,7 +83,6 @@ module SchwabMCP
84
83
 
85
84
  # Options
86
85
  option_chain_tool(symbol: "SPX", contract_type: "ALL")
87
- option_strategy_finder_tool(strategy_type: "ironcondor", underlying_symbol: "SPX", expiration_date: "2025-01-17")
88
86
  preview_order_tool(account_name: "TRADING_BROKERAGE_ACCOUNT", strategy_type: "ironcondor", price: 1.50, quantity: 1)
89
87
 
90
88
  # Account Management
@@ -197,25 +195,6 @@ module SchwabMCP
197
195
 
198
196
  **Example**: `option_chain_tool(symbol: "SPX", contract_type: "ALL")`
199
197
 
200
- ### option_strategy_finder_tool
201
- Find option strategies using sophisticated algorithms.
202
- **Parameters**:
203
- - `strategy_type` (required) - "ironcondor", "callspread", or "putspread"
204
- - `underlying_symbol` (required) - e.g., "SPX", "$SPX"
205
- - `expiration_date` (required) - Target expiration "YYYY-MM-DD"
206
- - `max_delta` (optional) - Maximum delta for short legs (default: 0.15)
207
- - `max_spread` (optional) - Maximum spread width (default: 20.0)
208
- - `min_credit` (optional) - Minimum credit in dollars (default: 100.0)
209
- - `min_open_interest` (optional) - Minimum open interest (default: 0)
210
- - `dist_from_strike` (optional) - Min distance from current price (default: 0.07)
211
- - `expiration_type`, `settlement_type`, `option_root` (optional) - Filters
212
-
213
- **Examples**:
214
- ```
215
- option_strategy_finder_tool(strategy_type: "ironcondor", underlying_symbol: "SPX", expiration_date: "2025-01-17")
216
- option_strategy_finder_tool(strategy_type: "callspread", underlying_symbol: "SPY", expiration_date: "2025-01-10", max_delta: 0.20, min_credit: 50.0)
217
- ```
218
-
219
198
  ## Order Management Tools
220
199
 
221
200
  ### preview_order_tool ⚠️ SAFE PREVIEW