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
@@ -4,6 +4,7 @@ require "json"
4
4
  require "date"
5
5
  require_relative "../loggable"
6
6
  require_relative "../redactor"
7
+ require_relative "../schwab_client_factory"
7
8
 
8
9
  module SchwabMCP
9
10
  module Tools
@@ -83,20 +84,8 @@ module SchwabMCP
83
84
  end
84
85
 
85
86
  begin
86
- client = SchwabRb::Auth.init_client_easy(
87
- ENV['SCHWAB_API_KEY'],
88
- ENV['SCHWAB_APP_SECRET'],
89
- ENV['SCHWAB_CALLBACK_URI'],
90
- ENV['TOKEN_PATH']
91
- )
92
-
93
- unless client
94
- log_error("Failed to initialize Schwab client")
95
- return MCP::Tool::Response.new([{
96
- type: "text",
97
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
98
- }])
99
- end
87
+ client = SchwabClientFactory.create_client
88
+ return SchwabClientFactory.client_error_response unless client
100
89
 
101
90
  account_id = ENV[account_name]
102
91
  unless account_id
@@ -111,9 +100,9 @@ module SchwabMCP
111
100
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
112
101
  log_debug("Fetching account numbers mapping")
113
102
 
114
- account_numbers_response = client.get_account_numbers
103
+ account_numbers = client.get_account_numbers
115
104
 
116
- unless account_numbers_response&.body
105
+ unless account_numbers
117
106
  log_error("Failed to retrieve account numbers")
118
107
  return MCP::Tool::Response.new([{
119
108
  type: "text",
@@ -121,22 +110,15 @@ module SchwabMCP
121
110
  }])
122
111
  end
123
112
 
124
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
125
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
113
+ log_debug("Account numbers retrieved (#{account_numbers.size} accounts found)")
126
114
 
127
- account_hash = nil
128
- account_mappings.each do |mapping|
129
- if mapping[:accountNumber] == account_id
130
- account_hash = mapping[:hashValue]
131
- break
132
- end
133
- end
115
+ account_hash = account_numbers.find_hash_value(account_id)
134
116
 
135
117
  unless account_hash
136
118
  log_error("Account ID not found in available accounts")
137
119
  return MCP::Tool::Response.new([{
138
120
  type: "text",
139
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
121
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
140
122
  }])
141
123
  end
142
124
 
@@ -171,7 +153,7 @@ module SchwabMCP
171
153
 
172
154
  log_debug("Fetching orders with params - max_results: #{max_results}, from_datetime: #{from_datetime}, to_datetime: #{to_datetime}, status: #{status}")
173
155
 
174
- orders_response = client.get_account_orders(
156
+ orders = client.get_account_orders(
175
157
  account_hash,
176
158
  max_results: max_results,
177
159
  from_entered_datetime: from_datetime,
@@ -179,11 +161,10 @@ module SchwabMCP
179
161
  status: status
180
162
  )
181
163
 
182
- if orders_response&.body
164
+ if orders
183
165
  log_info("Successfully retrieved orders for #{account_name}")
184
- orders_data = JSON.parse(orders_response.body)
185
166
 
186
- formatted_response = format_orders_data(orders_data, account_name, {
167
+ formatted_response = format_orders_data(orders, account_name, {
187
168
  max_results: max_results,
188
169
  from_date: from_date,
189
170
  to_date: to_date,
@@ -202,12 +183,6 @@ module SchwabMCP
202
183
  }])
203
184
  end
204
185
 
205
- rescue JSON::ParserError => e
206
- log_error("JSON parsing error: #{e.message}")
207
- MCP::Tool::Response.new([{
208
- type: "text",
209
- text: "**Error**: Failed to parse API response: #{e.message}"
210
- }])
211
186
  rescue => e
212
187
  log_error("Error retrieving orders for #{account_name}: #{e.message}")
213
188
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -220,7 +195,7 @@ module SchwabMCP
220
195
 
221
196
  private
222
197
 
223
- def self.format_orders_data(orders_data, account_name, filters)
198
+ def self.format_orders_data(orders, account_name, filters)
224
199
  friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
225
200
 
226
201
  formatted = "**Orders for #{friendly_name} (#{account_name}):**\n\n"
@@ -234,26 +209,24 @@ module SchwabMCP
234
209
  formatted += "\n"
235
210
  end
236
211
 
237
- if orders_data.is_a?(Array)
238
- orders = orders_data
239
- else
240
- orders = [orders_data]
241
- end
212
+ orders_array = orders.is_a?(Array) ? orders : [orders]
242
213
 
243
214
  formatted += "**Orders Summary:**\n"
244
- formatted += "- Total Orders: #{orders.length}\n\n"
215
+ formatted += "- Total Orders: #{orders_array.length}\n\n"
245
216
 
246
- if orders.length > 0
217
+ if orders_array.length > 0
247
218
  formatted += "**Order Details:**\n"
248
- orders.each_with_index do |order, index|
219
+ orders_array.each_with_index do |order, index|
249
220
  formatted += format_single_order(order, index + 1)
250
- formatted += "\n" unless index == orders.length - 1
221
+ formatted += "\n" unless index == orders_array.length - 1
251
222
  end
252
223
  else
253
224
  formatted += "No orders found matching the specified criteria.\n"
254
225
  end
255
226
 
256
- redacted_data = Redactor.redact(orders_data)
227
+ # Convert data objects to hash for redaction and display
228
+ orders_hash = orders_array.map(&:to_h)
229
+ redacted_data = Redactor.redact(orders_hash)
257
230
  formatted += "\n**Full Response (Redacted):**\n"
258
231
  formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
259
232
  formatted
@@ -261,23 +234,22 @@ module SchwabMCP
261
234
 
262
235
  def self.format_single_order(order, order_num)
263
236
  formatted = "**Order #{order_num}:**\n"
264
- formatted += "- Order ID: #{order['orderId']}\n" if order['orderId']
265
- formatted += "- Status: #{order['status']}\n" if order['status']
266
- formatted += "- Order Type: #{order['orderType']}\n" if order['orderType']
267
- formatted += "- Session: #{order['session']}\n" if order['session']
268
- formatted += "- Duration: #{order['duration']}\n" if order['duration']
269
- formatted += "- Entered Time: #{order['enteredTime']}\n" if order['enteredTime']
270
- formatted += "- Close Time: #{order['closeTime']}\n" if order['closeTime']
271
- formatted += "- Quantity: #{order['quantity']}\n" if order['quantity']
272
- formatted += "- Filled Quantity: #{order['filledQuantity']}\n" if order['filledQuantity']
273
- formatted += "- Price: $#{format_currency(order['price'])}\n" if order['price']
274
-
275
- if order['orderLegCollection'] && order['orderLegCollection'].any?
237
+ formatted += "- Order ID: #{order.order_id}\n" if order.order_id
238
+ formatted += "- Status: #{order.status}\n" if order.status
239
+ formatted += "- Order Type: #{order.order_type}\n" if order.order_type
240
+ formatted += "- Duration: #{order.duration}\n" if order.duration
241
+ formatted += "- Entered Time: #{order.entered_time}\n" if order.entered_time
242
+ formatted += "- Close Time: #{order.close_time}\n" if order.close_time
243
+ formatted += "- Quantity: #{order.quantity}\n" if order.quantity
244
+ formatted += "- Filled Quantity: #{order.filled_quantity}\n" if order.filled_quantity
245
+ formatted += "- Price: $#{format_currency(order.price)}\n" if order.price
246
+
247
+ if order.order_leg_collection && order.order_leg_collection.any?
276
248
  formatted += "- Instruments:\n"
277
- order['orderLegCollection'].each do |leg|
278
- if leg['instrument']
279
- symbol = leg['instrument']['symbol']
280
- instruction = leg['instruction']
249
+ order.order_leg_collection.each do |leg|
250
+ if leg.instrument
251
+ symbol = leg.instrument.symbol
252
+ instruction = leg.instruction
281
253
  formatted += " * #{symbol} - #{instruction}\n"
282
254
  end
283
255
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "date"
5
5
  require_relative "../loggable"
6
6
  require_relative "../redactor"
7
+ require_relative "../schwab_client_factory"
7
8
 
8
9
  module SchwabMCP
9
10
  module Tools
@@ -79,20 +80,8 @@ module SchwabMCP
79
80
  end
80
81
 
81
82
  begin
82
- client = SchwabRb::Auth.init_client_easy(
83
- ENV['SCHWAB_API_KEY'],
84
- ENV['SCHWAB_APP_SECRET'],
85
- ENV['SCHWAB_CALLBACK_URI'],
86
- ENV['TOKEN_PATH']
87
- )
88
-
89
- unless client
90
- log_error("Failed to initialize Schwab client")
91
- return MCP::Tool::Response.new([{
92
- type: "text",
93
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
94
- }])
95
- end
83
+ client = SchwabClientFactory.create_client
84
+ return SchwabClientFactory.client_error_response unless client
96
85
 
97
86
  account_id = ENV[account_name]
98
87
  unless account_id
@@ -107,9 +96,9 @@ module SchwabMCP
107
96
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
108
97
  log_debug("Fetching account numbers mapping")
109
98
 
110
- account_numbers_response = client.get_account_numbers
99
+ account_numbers = client.get_account_numbers
111
100
 
112
- unless account_numbers_response&.body
101
+ unless account_numbers
113
102
  log_error("Failed to retrieve account numbers")
114
103
  return MCP::Tool::Response.new([{
115
104
  type: "text",
@@ -117,22 +106,15 @@ module SchwabMCP
117
106
  }])
118
107
  end
119
108
 
120
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
121
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
109
+ log_debug("Account mappings retrieved (#{account_numbers.size} accounts found)")
122
110
 
123
- account_hash = nil
124
- account_mappings.each do |mapping|
125
- if mapping[:accountNumber] == account_id
126
- account_hash = mapping[:hashValue]
127
- break
128
- end
129
- end
111
+ account_hash = account_numbers.find_hash_value(account_id)
130
112
 
131
113
  unless account_hash
132
114
  log_error("Account ID not found in available accounts")
133
115
  return MCP::Tool::Response.new([{
134
116
  type: "text",
135
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
117
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
136
118
  }])
137
119
  end
138
120
 
@@ -167,7 +149,7 @@ module SchwabMCP
167
149
 
168
150
  log_debug("Fetching transactions with params - start_date: #{start_date_obj}, end_date: #{end_date_obj}, transaction_types: #{transaction_types}, symbol: #{symbol}")
169
151
 
170
- transactions_response = client.get_transactions(
152
+ transactions = client.get_transactions(
171
153
  account_hash,
172
154
  start_date: start_date_obj,
173
155
  end_date: end_date_obj,
@@ -175,11 +157,9 @@ module SchwabMCP
175
157
  symbol: symbol
176
158
  )
177
159
 
178
- if transactions_response&.body
160
+ if transactions
179
161
  log_info("Successfully retrieved transactions for #{account_name}")
180
- transactions_data = JSON.parse(transactions_response.body)
181
-
182
- formatted_response = format_transactions_data(transactions_data, account_name, {
162
+ formatted_response = format_transactions_data(transactions, account_name, {
183
163
  start_date: start_date,
184
164
  end_date: end_date,
185
165
  transaction_types: transaction_types,
@@ -199,24 +179,29 @@ module SchwabMCP
199
179
  end
200
180
 
201
181
  rescue JSON::ParserError => e
202
- log_error("JSON parsing error: #{e.message}")
182
+ redactor = Redactor.new
183
+ err_msg = redactor.redact(e.message)
184
+ log_error("JSON parsing error: #{err_msg}")
203
185
  MCP::Tool::Response.new([{
204
186
  type: "text",
205
- text: "**Error**: Failed to parse API response: #{e.message}"
187
+ text: "**Error**: Failed to parse API response: #{err_msg}"
206
188
  }])
207
189
  rescue => e
208
- log_error("Error retrieving transactions for #{account_name}: #{e.message}")
190
+ redactor = Redactor.new
191
+ err_msg = redactor.redact(e.message)
192
+ log_error("Error retrieving transactions for #{account_name}: #{err_msg}")
209
193
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
194
+
210
195
  MCP::Tool::Response.new([{
211
196
  type: "text",
212
- text: "**Error** retrieving transactions for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
197
+ text: "**Error** retrieving transactions for #{account_name}: #{err_msg}\n\n#{e.backtrace.first(3).join('\n')}"
213
198
  }])
214
199
  end
215
200
  end
216
201
 
217
202
  private
218
203
 
219
- def self.format_transactions_data(transactions_data, account_name, filters)
204
+ def self.format_transactions_data(transactions, account_name, filters)
220
205
  friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
221
206
 
222
207
  formatted = "**Transactions for #{friendly_name} (#{account_name}):**\n\n"
@@ -230,13 +215,11 @@ module SchwabMCP
230
215
  formatted += "\n"
231
216
  end
232
217
 
233
- transactions = transactions_data.is_a?(Array) ? transactions_data : [transactions_data]
234
-
235
218
  formatted += "**Transactions Summary:**\n"
236
219
  formatted += "- Total Transactions: #{transactions.length}\n\n"
237
220
 
238
221
  if transactions.length > 0
239
- transactions_by_type = transactions.group_by { |t| t['type'] }
222
+ transactions_by_type = transactions.group_by { |t| t.type }
240
223
  formatted += "**Transactions by Type:**\n"
241
224
  transactions_by_type.each do |type, type_transactions|
242
225
  formatted += "- #{type}: #{type_transactions.length} transactions\n"
@@ -252,7 +235,7 @@ module SchwabMCP
252
235
  formatted += "No transactions found matching the specified criteria.\n"
253
236
  end
254
237
 
255
- redacted_data = Redactor.redact(transactions_data)
238
+ redacted_data = Redactor.redact(transactions.map(&:to_h))
256
239
  formatted += "\n**Full Response (Redacted):**\n"
257
240
  formatted += "```json\n#{JSON.pretty_generate(redacted_data)}\n```"
258
241
  formatted
@@ -260,41 +243,29 @@ module SchwabMCP
260
243
 
261
244
  def self.format_single_transaction(transaction, transaction_num)
262
245
  formatted = "**Transaction #{transaction_num}:**\n"
263
- formatted += "- Activity ID: #{transaction['activityId']}\n" if transaction['activityId']
264
- formatted += "- Type: #{transaction['type']}\n" if transaction['type']
265
- formatted += "- Status: #{transaction['status']}\n" if transaction['status']
266
- formatted += "- Trade Date: #{transaction['tradeDate']}\n" if transaction['tradeDate']
267
- formatted += "- Settlement Date: #{transaction['settlementDate']}\n" if transaction['settlementDate']
268
- formatted += "- Net Amount: $#{format_currency(transaction['netAmount'])}\n" if transaction['netAmount']
269
- formatted += "- Sub Account: #{transaction['subAccount']}\n" if transaction['subAccount']
270
- formatted += "- Order ID: #{transaction['orderId']}\n" if transaction['orderId']
271
- formatted += "- Position ID: #{transaction['positionId']}\n" if transaction['positionId']
272
-
273
- if transaction['transferItems'] && transaction['transferItems'].any?
246
+ formatted += "- Activity ID: #{transaction.activity_id}\n" if transaction.activity_id
247
+ formatted += "- Type: #{transaction.type}\n" if transaction.type
248
+ formatted += "- Status: #{transaction.status}\n" if transaction.status
249
+ formatted += "- Trade Date: #{transaction.trade_date}\n" if transaction.trade_date
250
+ formatted += "- Net Amount: $#{format_currency(transaction.net_amount)}\n" if transaction.net_amount
251
+ formatted += "- Sub Account: #{transaction.sub_account}\n" if transaction.sub_account
252
+ formatted += "- Order ID: #{transaction.order_id}\n" if transaction.order_id
253
+ formatted += "- Position ID: #{transaction.position_id}\n" if transaction.position_id
254
+
255
+ if transaction.transfer_items && transaction.transfer_items.any?
274
256
  formatted += "- Transfer Items:\n"
275
- transaction['transferItems'].each_with_index do |item, i|
257
+ transaction.transfer_items.each_with_index do |item, i|
276
258
  formatted += " * Item #{i + 1}:\n"
277
- formatted += " - Amount: $#{format_currency(item['amount'])}\n" if item['amount']
278
- formatted += " - Cost: $#{format_currency(item['cost'])}\n" if item['cost']
279
- formatted += " - Price: $#{format_currency(item['price'])}\n" if item['price']
280
- formatted += " - Fee Type: #{item['feeType']}\n" if item['feeType']
281
- formatted += " - Position Effect: #{item['positionEffect']}\n" if item['positionEffect']
282
-
283
- if item['instrument']
284
- instrument = item['instrument']
259
+ formatted += " - Amount: $#{format_currency(item.amount)}\n" if item.amount
260
+ formatted += " - Cost: $#{format_currency(item.cost)}\n" if item.cost
261
+ formatted += " - Fee Type: #{item.fee_type}\n" if item.fee_type
262
+ formatted += " - Position Effect: #{item.position_effect}\n" if item.position_effect
263
+
264
+ if item.instrument
285
265
  formatted += " - Instrument:\n"
286
- formatted += " * Symbol: #{instrument['symbol']}\n" if instrument['symbol']
287
- formatted += " * Asset Type: #{instrument['assetType']}\n" if instrument['assetType']
288
- formatted += " * Description: #{instrument['description']}\n" if instrument['description']
289
- formatted += " * Closing Price: $#{format_currency(instrument['closingPrice'])}\n" if instrument['closingPrice']
290
-
291
- # Options-specific fields
292
- if instrument['assetType'] == 'OPTION'
293
- formatted += " * Strike Price: $#{format_currency(instrument['strikePrice'])}\n" if instrument['strikePrice']
294
- formatted += " * Put/Call: #{instrument['putCall']}\n" if instrument['putCall']
295
- formatted += " * Expiration Date: #{instrument['expirationDate']}\n" if instrument['expirationDate']
296
- formatted += " * Underlying Symbol: #{instrument['underlyingSymbol']}\n" if instrument['underlyingSymbol']
297
- end
266
+ formatted += " * Symbol: #{item.instrument.symbol}\n" if item.instrument.symbol
267
+ formatted += " * Asset Type: #{item.instrument.respond_to?(:asset_type) ? item.instrument.asset_type : nil}\n" if item.instrument.respond_to?(:asset_type) && item.instrument.asset_type
268
+ formatted += " * Description: #{item.instrument.description}\n" if item.instrument.description
298
269
  end
299
270
  end
300
271
  end
@@ -1,7 +1,7 @@
1
1
  require "mcp"
2
2
  require "schwab_rb"
3
- require "json"
4
3
  require_relative "../loggable"
4
+ require_relative "../schwab_client_factory"
5
5
 
6
6
  module SchwabMCP
7
7
  module Tools
@@ -41,44 +41,31 @@ module SchwabMCP
41
41
  log_info("Getting movers for index: #{index}, sort_order: #{sort_order}, frequency: #{frequency}")
42
42
 
43
43
  begin
44
- client = SchwabRb::Auth.init_client_easy(
45
- ENV['SCHWAB_API_KEY'],
46
- ENV['SCHWAB_APP_SECRET'],
47
- ENV['SCHWAB_CALLBACK_URI'],
48
- ENV['TOKEN_PATH']
49
- )
50
-
51
- unless client
52
- log_error("Failed to initialize Schwab client")
53
- return MCP::Tool::Response.new([{
54
- type: "text",
55
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
56
- }])
57
- end
44
+ client = SchwabClientFactory.create_client
45
+ return SchwabClientFactory.client_error_response unless client
58
46
 
59
47
  log_debug("Making API request for movers - index: #{index}, sort_order: #{sort_order}, frequency: #{frequency}")
60
48
 
61
- response = client.get_movers(
49
+ market_movers = client.get_movers(
62
50
  index,
63
51
  sort_order: sort_order,
64
52
  frequency: frequency
65
53
  )
66
54
 
67
- if response&.body
68
- log_info("Successfully retrieved movers for index #{index}")
69
- parsed_body = JSON.parse(response.body)
55
+ if market_movers && market_movers.count > 0
56
+ log_info("Successfully retrieved #{market_movers.count} movers for index #{index}")
70
57
 
71
- formatted_output = format_movers_response(parsed_body, index, sort_order, frequency)
58
+ formatted_output = format_movers_response(market_movers, index, sort_order, frequency)
72
59
 
73
60
  MCP::Tool::Response.new([{
74
61
  type: "text",
75
62
  text: formatted_output
76
63
  }])
77
64
  else
78
- log_warn("Empty response from Schwab API for movers")
65
+ log_warn("No movers data returned from Schwab API for index #{index}")
79
66
  MCP::Tool::Response.new([{
80
67
  type: "text",
81
- text: "**No Data**: Empty response from Schwab API for movers"
68
+ text: "**No Data**: No movers found for index #{index}"
82
69
  }])
83
70
  end
84
71
 
@@ -94,30 +81,30 @@ module SchwabMCP
94
81
 
95
82
  private
96
83
 
97
- def self.format_movers_response(data, index, sort_order, frequency)
84
+ def self.format_movers_response(market_movers, index, sort_order, frequency)
98
85
  header = "**Market Movers for #{index}**"
99
86
  header += " (sorted by #{sort_order})" if sort_order
100
87
  header += " (frequency filter: #{frequency})" if frequency
101
88
  header += "\n\n"
102
89
 
103
- if data.is_a?(Array) && data.any?
104
- movers_list = data.map.with_index(1) do |mover, i|
105
- symbol = mover['symbol'] || 'N/A'
106
- description = mover['description'] || 'N/A'
107
- change = mover['change'] || 0
108
- percent_change = mover['percentChange'] || 0
109
- volume = mover['totalVolume'] || 0
110
- last_price = mover['last'] || 0
90
+ if market_movers.count > 0
91
+ movers_list = market_movers.movers.map.with_index(1) do |mover, i|
92
+ symbol = mover.symbol || 'N/A'
93
+ description = mover.description || 'N/A'
94
+ change = mover.net_change || 0
95
+ percent_change = mover.net_change_percentage || 0
96
+ volume = mover.volume || 0
97
+ last_price = mover.last_price || 0
111
98
 
112
99
  "#{i}. **#{symbol}** - #{description}\n" \
113
- " Last: $#{last_price}\n" \
114
- " Change: #{change >= 0 ? '+' : ''}#{change} (#{percent_change >= 0 ? '+' : ''}#{percent_change}%)\n" \
100
+ " Last: $#{sprintf('%.2f', last_price)}\n" \
101
+ " Change: #{change >= 0 ? '+' : ''}#{sprintf('%.2f', change)} (#{percent_change >= 0 ? '+' : ''}#{sprintf('%.2f', percent_change)}%)\n" \
115
102
  " Volume: #{volume.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
116
103
  end.join("\n\n")
117
104
 
118
105
  "#{header}#{movers_list}"
119
106
  else
120
- "#{header}No movers data available.\n\n**Raw Response:**\n```json\n#{JSON.pretty_generate(data)}\n```"
107
+ "#{header}No movers data available."
121
108
  end
122
109
  end
123
110
  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
@@ -26,25 +27,13 @@ module SchwabMCP
26
27
  log_info("Listing all configured Schwab accounts")
27
28
 
28
29
  begin
29
- client = SchwabRb::Auth.init_client_easy(
30
- ENV['SCHWAB_API_KEY'],
31
- ENV['SCHWAB_APP_SECRET'],
32
- ENV['SCHWAB_CALLBACK_URI'],
33
- ENV['TOKEN_PATH']
34
- )
35
-
36
- unless client
37
- log_error("Failed to initialize Schwab client")
38
- return MCP::Tool::Response.new([{
39
- type: "text",
40
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
41
- }])
42
- end
30
+ client = SchwabClientFactory.create_client
31
+ return SchwabClientFactory.client_error_response unless client
43
32
 
44
33
  log_debug("Fetching account numbers from Schwab API")
45
- account_numbers_response = client.get_account_numbers
34
+ account_numbers = client.get_account_numbers
46
35
 
47
- unless account_numbers_response&.body
36
+ unless account_numbers
48
37
  log_error("Failed to retrieve account numbers")
49
38
  return MCP::Tool::Response.new([{
50
39
  type: "text",
@@ -52,10 +41,9 @@ module SchwabMCP
52
41
  }])
53
42
  end
54
43
 
55
- account_mappings = JSON.parse(account_numbers_response.body)
56
- log_debug("Retrieved #{account_mappings.length} accounts from Schwab API")
44
+ log_debug("Retrieved #{account_numbers.size} accounts from Schwab API")
57
45
 
58
- configured_accounts = find_configured_accounts(account_mappings)
46
+ configured_accounts = find_configured_accounts(account_numbers)
59
47
 
60
48
  if configured_accounts.empty?
61
49
  return MCP::Tool::Response.new([{
@@ -64,7 +52,7 @@ module SchwabMCP
64
52
  }])
65
53
  end
66
54
 
67
- formatted_response = format_accounts_list(configured_accounts, account_mappings)
55
+ formatted_response = format_accounts_list(configured_accounts, account_numbers)
68
56
 
69
57
  MCP::Tool::Response.new([{
70
58
  type: "text",
@@ -89,9 +77,9 @@ module SchwabMCP
89
77
 
90
78
  private
91
79
 
92
- def self.find_configured_accounts(account_mappings)
93
- # Get all account IDs from Schwab API
94
- schwab_account_ids = account_mappings.map { |mapping| mapping["accountNumber"] }
80
+ def self.find_configured_accounts(account_numbers)
81
+ # Get all account IDs from Schwab API data object
82
+ schwab_account_ids = account_numbers.account_numbers
95
83
 
96
84
  # Find environment variables ending with "_ACCOUNT"
97
85
  configured = []
@@ -99,11 +87,12 @@ module SchwabMCP
99
87
  next unless key.end_with?('_ACCOUNT')
100
88
 
101
89
  if schwab_account_ids.include?(value)
90
+ account = account_numbers.find_by_account_number(value)
102
91
  configured << {
103
92
  name: key,
104
93
  friendly_name: friendly_name_from_env_key(key),
105
94
  account_id: value,
106
- mapping: account_mappings.find { |m| m["accountNumber"] == value }
95
+ account: account
107
96
  }
108
97
  end
109
98
  end
@@ -116,24 +105,22 @@ module SchwabMCP
116
105
  env_key.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
117
106
  end
118
107
 
119
- def self.format_accounts_list(configured_accounts, all_mappings)
108
+ def self.format_accounts_list(configured_accounts, account_numbers)
120
109
  response = "**Configured Schwab Accounts:**\n\n"
121
110
 
122
111
  configured_accounts.each_with_index do |account, index|
123
112
  response += "#{index + 1}. **#{account[:friendly_name]}** (`#{account[:name]}`)\n"
124
- response += " - Account ID: #{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}\n"
125
113
  response += " - Status: ✅ Configured\n\n"
126
114
  end
127
115
 
128
116
  # Show unconfigured accounts (if any)
129
- unconfigured = all_mappings.reject do |mapping|
130
- configured_accounts.any? { |config| config[:account_id] == mapping["accountNumber"] }
117
+ unconfigured_accounts = account_numbers.accounts.reject do |account_obj|
118
+ configured_accounts.any? { |config| config[:account_id] == account_obj.account_number }
131
119
  end
132
120
 
133
- if unconfigured.any?
121
+ if unconfigured_accounts.any?
134
122
  response += "**Unconfigured Accounts Available:**\n\n"
135
- unconfigured.each_with_index do |mapping, index|
136
- response += "#{index + 1}. Account ID: #{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}\n"
123
+ unconfigured_accounts.each_with_index do |account_obj, index|
137
124
  response += " - To configure: Set `YOUR_NAME_ACCOUNT=#{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}` in your .env file\n\n"
138
125
  end
139
126
  end