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.
- checksums.yaml +4 -4
- data/.claude/settings.json +14 -0
- data/CLAUDE.md +124 -0
- data/debug_env.rb +46 -0
- data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
- data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
- data/exe/schwab_mcp +14 -3
- data/exe/schwab_token_refresh +10 -9
- data/lib/schwab_mcp/redactor.rb +4 -0
- data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -50
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
- data/lib/schwab_mcp/tools/get_order_tool.rb +51 -108
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
- data/lib/schwab_mcp/tools/help_tool.rb +1 -22
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +35 -63
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -72
- data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +18 -31
- data/lib/schwab_mcp/tools/option_chain_tool.rb +130 -82
- data/lib/schwab_mcp/tools/place_order_tool.rb +105 -117
- data/lib/schwab_mcp/tools/preview_order_tool.rb +100 -48
- data/lib/schwab_mcp/tools/quote_tool.rb +33 -26
- data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
- data/lib/schwab_mcp/tools/replace_order_tool.rb +104 -116
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -72
- data/lib/schwab_mcp/version.rb +1 -1
- data/lib/schwab_mcp.rb +1 -2
- data/orders_example.json +7084 -0
- data/spx_option_chain.json +25073 -0
- data/test_mcp.rb +16 -0
- data/test_server.rb +23 -0
- data/trading_brokerage_account_details.json +89 -0
- data/transactions_example.json +488 -0
- metadata +17 -7
- data/lib/schwab_mcp/option_chain_filter.rb +0 -213
- data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
- data/lib/schwab_mcp/orders/order_factory.rb +0 -40
- data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
- 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 =
|
87
|
-
|
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
|
-
|
103
|
+
account_numbers = client.get_account_numbers
|
115
104
|
|
116
|
-
unless
|
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
|
-
|
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 =
|
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. #{
|
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
|
-
|
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
|
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(
|
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(
|
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
|
-
|
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: #{
|
215
|
+
formatted += "- Total Orders: #{orders_array.length}\n\n"
|
245
216
|
|
246
|
-
if
|
217
|
+
if orders_array.length > 0
|
247
218
|
formatted += "**Order Details:**\n"
|
248
|
-
|
219
|
+
orders_array.each_with_index do |order, index|
|
249
220
|
formatted += format_single_order(order, index + 1)
|
250
|
-
formatted += "\n" unless index ==
|
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
|
-
|
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
|
265
|
-
formatted += "- Status: #{order
|
266
|
-
formatted += "- Order Type: #{order
|
267
|
-
formatted += "-
|
268
|
-
formatted += "-
|
269
|
-
formatted += "-
|
270
|
-
formatted += "-
|
271
|
-
formatted += "- Quantity: #{order
|
272
|
-
formatted += "-
|
273
|
-
|
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
|
278
|
-
if leg
|
279
|
-
symbol = leg
|
280
|
-
instruction = leg
|
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 =
|
83
|
-
|
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
|
-
|
99
|
+
account_numbers = client.get_account_numbers
|
111
100
|
|
112
|
-
unless
|
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
|
-
|
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 =
|
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. #{
|
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
|
-
|
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
|
160
|
+
if transactions
|
179
161
|
log_info("Successfully retrieved transactions for #{account_name}")
|
180
|
-
|
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
|
-
|
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: #{
|
187
|
+
text: "**Error**: Failed to parse API response: #{err_msg}"
|
206
188
|
}])
|
207
189
|
rescue => e
|
208
|
-
|
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}: #{
|
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(
|
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
|
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(
|
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
|
264
|
-
formatted += "- Type: #{transaction
|
265
|
-
formatted += "- Status: #{transaction
|
266
|
-
formatted += "- Trade Date: #{transaction
|
267
|
-
formatted += "-
|
268
|
-
formatted += "-
|
269
|
-
formatted += "-
|
270
|
-
formatted += "-
|
271
|
-
|
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
|
257
|
+
transaction.transfer_items.each_with_index do |item, i|
|
276
258
|
formatted += " * Item #{i + 1}:\n"
|
277
|
-
formatted += " - Amount: $#{format_currency(item
|
278
|
-
formatted += " - Cost: $#{format_currency(item
|
279
|
-
formatted += " -
|
280
|
-
formatted += " -
|
281
|
-
|
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
|
287
|
-
formatted += " * Asset Type: #{instrument
|
288
|
-
formatted += " * Description: #{instrument
|
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 =
|
45
|
-
|
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
|
-
|
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
|
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(
|
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("
|
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**:
|
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(
|
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
|
104
|
-
movers_list =
|
105
|
-
symbol = mover
|
106
|
-
description = mover
|
107
|
-
change = mover
|
108
|
-
percent_change = mover
|
109
|
-
volume = mover
|
110
|
-
last_price = mover
|
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
|
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 =
|
30
|
-
|
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
|
-
|
34
|
+
account_numbers = client.get_account_numbers
|
46
35
|
|
47
|
-
unless
|
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
|
-
|
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(
|
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,
|
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(
|
93
|
-
# Get all account IDs from Schwab API
|
94
|
-
schwab_account_ids =
|
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
|
-
|
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,
|
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
|
-
|
130
|
-
configured_accounts.any? { |config| config[:account_id] ==
|
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
|
121
|
+
if unconfigured_accounts.any?
|
134
122
|
response += "**Unconfigured Accounts Available:**\n\n"
|
135
|
-
|
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
|