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
@@ -1,9 +1,8 @@
1
1
  require "mcp"
2
2
  require "schwab_rb"
3
- require "json"
4
3
  require_relative "../loggable"
5
- require_relative "../orders/order_factory"
6
4
  require_relative "../redactor"
5
+ require_relative "../schwab_client_factory"
7
6
 
8
7
  module SchwabMCP
9
8
  module Tools
@@ -20,7 +19,7 @@ module SchwabMCP
20
19
  },
21
20
  strategy_type: {
22
21
  type: "string",
23
- enum: ["ironcondor", "callspread", "putspread"],
22
+ enum: %w[single ironcondor vertical],
24
23
  description: "Type of options strategy to place"
25
24
  },
26
25
  price: {
@@ -34,10 +33,16 @@ module SchwabMCP
34
33
  },
35
34
  order_instruction: {
36
35
  type: "string",
37
- enum: ["open", "exit"],
36
+ enum: %w[open exit],
38
37
  description: "Whether to open a new position or exit an existing one (default: open)",
39
38
  default: "open"
40
39
  },
40
+ credit_debit: {
41
+ type: "string",
42
+ enum: %w[credit debit],
43
+ description: "Whether the order is a credit or debit (default: credit)",
44
+ default: "credit"
45
+ },
41
46
  put_short_symbol: {
42
47
  type: "string",
43
48
  description: "Option symbol for the short put leg (required for iron condor)"
@@ -56,14 +61,18 @@ module SchwabMCP
56
61
  },
57
62
  short_leg_symbol: {
58
63
  type: "string",
59
- description: "Option symbol for the short leg (required for call/put spreads)"
64
+ description: "Option symbol for the short leg (required for vertical spreads)"
60
65
  },
61
66
  long_leg_symbol: {
62
67
  type: "string",
63
- description: "Option symbol for the long leg (required for call/put spreads)"
64
- }
68
+ description: "Option symbol for the long leg (required for vertical spreads)"
69
+ },
70
+ symbol: {
71
+ type: "string",
72
+ description: "Single option symbol to place an order for (required for single options)"
73
+ },
65
74
  },
66
- required: ["account_name", "strategy_type", "price"]
75
+ required: %w[account_name strategy_type price]
67
76
  )
68
77
 
69
78
  annotations(
@@ -76,42 +85,31 @@ module SchwabMCP
76
85
  def self.call(server_context:, **params)
77
86
  log_info("Placing #{params[:strategy_type]} order for account name: #{params[:account_name]}")
78
87
 
79
- unless params[:account_name].end_with?('_ACCOUNT')
88
+ unless params[:account_name].end_with?("_ACCOUNT")
80
89
  log_error("Invalid account name format: #{params[:account_name]}")
81
90
  return MCP::Tool::Response.new([{
82
- type: "text",
83
- text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
84
- }])
91
+ type: "text",
92
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
93
+ }])
85
94
  end
86
95
 
87
96
  begin
88
97
  validate_strategy_params(params)
89
- client = SchwabRb::Auth.init_client_easy(
90
- ENV['SCHWAB_API_KEY'],
91
- ENV['SCHWAB_APP_SECRET'],
92
- ENV['SCHWAB_CALLBACK_URI'],
93
- ENV['TOKEN_PATH']
94
- )
95
-
96
- unless client
97
- log_error("Failed to initialize Schwab client")
98
- return MCP::Tool::Response.new([{
99
- type: "text",
100
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
101
- }])
102
- end
98
+ client = SchwabClientFactory.create_client
99
+ return SchwabClientFactory.client_error_response unless client
103
100
 
104
101
  account_result = resolve_account_details(client, params[:account_name])
105
102
  return account_result if account_result.is_a?(MCP::Tool::Response)
106
103
 
107
104
  account_id, account_hash = account_result
108
105
 
109
- order_builder = SchwabMCP::Orders::OrderFactory.build(
106
+ order_builder = SchwabRb::Orders::OrderFactory.build(
110
107
  strategy_type: params[:strategy_type],
111
108
  account_number: account_id,
112
109
  price: params[:price],
113
110
  quantity: params[:quantity] || 1,
114
111
  order_instruction: (params[:order_instruction] || "open").to_sym,
112
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
115
113
  # Iron Condor params
116
114
  put_short_symbol: params[:put_short_symbol],
117
115
  put_long_symbol: params[:put_long_symbol],
@@ -119,7 +117,9 @@ module SchwabMCP
119
117
  call_long_symbol: params[:call_long_symbol],
120
118
  # Vertical spread params
121
119
  short_leg_symbol: params[:short_leg_symbol],
122
- long_leg_symbol: params[:long_leg_symbol]
120
+ long_leg_symbol: params[:long_leg_symbol],
121
+ # Single
122
+ symbol: params[:symbol]
123
123
  )
124
124
 
125
125
  log_debug("Making place order API request")
@@ -129,77 +129,67 @@ module SchwabMCP
129
129
  log_info("Successfully placed #{params[:strategy_type]} order (HTTP #{response.status})")
130
130
  formatted_response = format_place_order_response(response, params)
131
131
  MCP::Tool::Response.new([{
132
- type: "text",
133
- text: formatted_response
134
- }])
132
+ type: "text",
133
+ text: formatted_response
134
+ }])
135
135
  elsif response
136
136
  log_error("Order placement failed with HTTP status #{response.status}")
137
137
  error_details = extract_error_details(response)
138
138
  MCP::Tool::Response.new([{
139
- type: "text",
140
- text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
141
- }])
139
+ type: "text",
140
+ text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
141
+ }])
142
142
  else
143
143
  log_warn("Empty response from Schwab API for order placement")
144
144
  MCP::Tool::Response.new([{
145
- type: "text",
146
- text: "**No Data**: Empty response from Schwab API for order placement"
147
- }])
145
+ type: "text",
146
+ text: "**No Data**: Empty response from Schwab API for order placement"
147
+ }])
148
148
  end
149
-
150
- rescue => e
149
+ rescue StandardError => e
151
150
  log_error("Error placing #{params[:strategy_type]} order: #{e.message}")
152
151
  log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
153
152
  MCP::Tool::Response.new([{
154
- type: "text",
155
- text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
156
- }])
153
+ type: "text",
154
+ text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
155
+ }])
157
156
  end
158
157
  end
159
158
 
160
- private
161
-
162
159
  def self.resolve_account_details(client, account_name)
163
160
  account_id = ENV[account_name]
164
161
  unless account_id
165
- available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
162
+ available_accounts = ENV.keys.select { |key| key.end_with?("_ACCOUNT") }
166
163
  log_error("Account name '#{account_name}' not found in environment variables")
167
164
  return MCP::Tool::Response.new([{
168
- type: "text",
169
- text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
170
- }])
165
+ type: "text",
166
+ text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(", ")}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
167
+ }])
171
168
  end
172
169
 
173
170
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
174
171
  log_debug("Fetching account numbers mapping")
175
172
 
176
- account_numbers_response = client.get_account_numbers
173
+ account_numbers = client.get_account_numbers
177
174
 
178
- unless account_numbers_response&.body
175
+ unless account_numbers
179
176
  log_error("Failed to retrieve account numbers")
180
177
  return MCP::Tool::Response.new([{
181
- type: "text",
182
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
183
- }])
178
+ type: "text",
179
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
180
+ }])
184
181
  end
185
182
 
186
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
187
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
183
+ log_debug("Account mappings retrieved (#{account_numbers.size} accounts found)")
188
184
 
189
- account_hash = nil
190
- account_mappings.each do |mapping|
191
- if mapping[:accountNumber] == account_id
192
- account_hash = mapping[:hashValue]
193
- break
194
- end
195
- end
185
+ account_hash = account_numbers.find_hash_value(account_id)
196
186
 
197
187
  unless account_hash
198
188
  log_error("Account ID not found in available accounts")
199
189
  return MCP::Tool::Response.new([{
200
- type: "text",
201
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
202
- }])
190
+ type: "text",
191
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
192
+ }])
203
193
  end
204
194
 
205
195
  log_debug("Found account hash for account name: #{account_name}")
@@ -208,17 +198,17 @@ module SchwabMCP
208
198
 
209
199
  def self.validate_strategy_params(params)
210
200
  case params[:strategy_type]
211
- when 'ironcondor'
212
- required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
201
+ when "ironcondor"
202
+ required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
213
203
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
214
204
  unless missing_fields.empty?
215
- raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
205
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
216
206
  end
217
- when 'callspread', 'putspread'
218
- required_fields = [:short_leg_symbol, :long_leg_symbol]
207
+ when "callspread", "putspread"
208
+ required_fields = %i[short_leg_symbol long_leg_symbol]
219
209
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
220
210
  unless missing_fields.empty?
221
- raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
211
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
222
212
  end
223
213
  else
224
214
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
@@ -226,60 +216,58 @@ module SchwabMCP
226
216
  end
227
217
 
228
218
  def self.format_place_order_response(response, params)
229
- begin
230
- strategy_summary = case params[:strategy_type]
231
- when 'ironcondor'
232
- "**Iron Condor Order Placed**\n" \
233
- "- Put Short: #{params[:put_short_symbol]}\n" \
234
- "- Put Long: #{params[:put_long_symbol]}\n" \
235
- "- Call Short: #{params[:call_short_symbol]}\n" \
236
- "- Call Long: #{params[:call_long_symbol]}\n"
237
- when 'callspread', 'putspread'
238
- "**#{params[:strategy_type].capitalize} Order Placed**\n" \
239
- "- Short Leg: #{params[:short_leg_symbol]}\n" \
240
- "- Long Leg: #{params[:long_leg_symbol]}\n"
241
- end
242
-
243
- friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
244
-
245
- order_details = "**Order Details:**\n" \
246
- "- Strategy: #{params[:strategy_type]}\n" \
247
- "- Action: #{params[:order_instruction] || 'open'}\n" \
248
- "- Quantity: #{params[:quantity] || 1}\n" \
249
- "- Price: $#{params[:price]}\n" \
250
- "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
251
-
252
- order_id = extract_order_id_from_response(response)
253
- order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
254
-
255
- response_info = if response.body && !response.body.empty?
256
- begin
257
- parsed = JSON.parse(response.body)
258
- redacted_data = Redactor.redact(parsed)
259
- "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
260
- rescue JSON::ParserError
261
- "**Schwab API Response:**\n\n```\n#{response.body}\n```"
262
- end
263
- else
264
- "**Status**: Order submitted successfully (HTTP #{response.status})"
265
- end
266
-
267
- "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
268
- rescue => e
269
- log_error("Error formatting response: #{e.message}")
270
- "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
271
- end
219
+ strategy_summary = case params[:strategy_type]
220
+ when "ironcondor"
221
+ "**Iron Condor Order Placed**\n" \
222
+ "- Put Short: #{params[:put_short_symbol]}\n" \
223
+ "- Put Long: #{params[:put_long_symbol]}\n" \
224
+ "- Call Short: #{params[:call_short_symbol]}\n" \
225
+ "- Call Long: #{params[:call_long_symbol]}\n"
226
+ when "callspread", "putspread"
227
+ "**#{params[:strategy_type].capitalize} Order Placed**\n" \
228
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
229
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
230
+ end
231
+
232
+ friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
233
+
234
+ order_details = "**Order Details:**\n" \
235
+ "- Strategy: #{params[:strategy_type]}\n" \
236
+ "- Action: #{params[:order_instruction] || "open"}\n" \
237
+ "- Quantity: #{params[:quantity] || 1}\n" \
238
+ "- Price: $#{params[:price]}\n" \
239
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
240
+
241
+ order_id = extract_order_id_from_response(response)
242
+ order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
243
+
244
+ response_info = if response.body && !response.body.empty?
245
+ begin
246
+ parsed = JSON.parse(response.body)
247
+ redacted_data = Redactor.redact(parsed)
248
+ "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
249
+ rescue JSON::ParserError
250
+ "**Schwab API Response:**\n\n```\n#{response.body}\n```"
251
+ end
252
+ else
253
+ "**Status**: Order submitted successfully (HTTP #{response.status})"
254
+ end
255
+
256
+ "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
257
+ rescue StandardError => e
258
+ log_error("Error formatting response: #{e.message}")
259
+ "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
272
260
  end
273
261
 
274
262
  def self.extract_order_id_from_response(response)
275
263
  # Schwab API typically returns the order ID in the Location header
276
264
  # Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
277
- location = response.headers['Location'] || response.headers['location']
265
+ location = response.headers["Location"] || response.headers["location"]
278
266
  return nil unless location
279
267
 
280
268
  match = location.match(%r{/orders/(\d+)$})
281
269
  match ? match[1] : nil
282
- rescue => e
270
+ rescue StandardError => e
283
271
  log_debug("Could not extract order ID from response: #{e.message}")
284
272
  nil
285
273
  end
@@ -296,7 +284,7 @@ module SchwabMCP
296
284
  else
297
285
  "No additional error details provided."
298
286
  end
299
- rescue => e
287
+ rescue StandardError => e
300
288
  log_debug("Error extracting error details: #{e.message}")
301
289
  "Could not extract error details."
302
290
  end
@@ -2,8 +2,8 @@ require "mcp"
2
2
  require "schwab_rb"
3
3
  require "json"
4
4
  require_relative "../loggable"
5
- require_relative "../orders/order_factory"
6
5
  require_relative "../redactor"
6
+ require_relative "../schwab_client_factory"
7
7
 
8
8
  module SchwabMCP
9
9
  module Tools
@@ -20,7 +20,7 @@ module SchwabMCP
20
20
  },
21
21
  strategy_type: {
22
22
  type: "string",
23
- enum: ["ironcondor", "callspread", "putspread"],
23
+ enum: ["ironcondor", "vertical", "single"],
24
24
  description: "Type of options strategy to preview"
25
25
  },
26
26
  price: {
@@ -38,6 +38,12 @@ module SchwabMCP
38
38
  description: "Whether to open a new position or exit an existing one (default: open)",
39
39
  default: "open"
40
40
  },
41
+ credit_debit: {
42
+ type: "string",
43
+ enum: %w[credit debit],
44
+ description: "Whether the order is a credit or debit (default: credit)",
45
+ default: "credit"
46
+ },
41
47
  # Iron Condor specific fields
42
48
  put_short_symbol: {
43
49
  type: "string",
@@ -63,6 +69,11 @@ module SchwabMCP
63
69
  long_leg_symbol: {
64
70
  type: "string",
65
71
  description: "Option symbol for the long leg (required for call/put spreads)"
72
+ },
73
+ # Single option specific field
74
+ symbol: {
75
+ type: "string",
76
+ description: "Single option symbol to place an order for (required for single options)"
66
77
  }
67
78
  },
68
79
  required: ["account_name", "strategy_type", "price"]
@@ -80,40 +91,30 @@ module SchwabMCP
80
91
 
81
92
  unless params[:account_name].end_with?('_ACCOUNT')
82
93
  log_error("Invalid account name format: #{params[:account_name]}")
94
+ error_msg = "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
83
95
  return MCP::Tool::Response.new([{
84
96
  type: "text",
85
- text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
97
+ text: Redactor.redact_formatted_text(error_msg)
86
98
  }])
87
99
  end
88
100
 
89
101
  begin
90
102
  validate_strategy_params(params)
91
- client = SchwabRb::Auth.init_client_easy(
92
- ENV['SCHWAB_API_KEY'],
93
- ENV['SCHWAB_APP_SECRET'],
94
- ENV['SCHWAB_CALLBACK_URI'],
95
- ENV['TOKEN_PATH']
96
- )
97
-
98
- unless client
99
- log_error("Failed to initialize Schwab client")
100
- return MCP::Tool::Response.new([{
101
- type: "text",
102
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
103
- }])
104
- end
103
+ client = SchwabClientFactory.create_client
104
+ return SchwabClientFactory.client_error_response unless client
105
105
 
106
106
  account_result = resolve_account_details(client, params[:account_name])
107
107
  return account_result if account_result.is_a?(MCP::Tool::Response)
108
108
 
109
109
  account_id, account_hash = account_result
110
110
 
111
- order_builder = SchwabMCP::Orders::OrderFactory.build(
111
+ order_builder = SchwabRb::Orders::OrderFactory.build(
112
112
  strategy_type: params[:strategy_type],
113
113
  account_number: account_id,
114
114
  price: params[:price],
115
115
  quantity: params[:quantity] || 1,
116
116
  order_instruction: (params[:order_instruction] || "open").to_sym,
117
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
117
118
  # Iron Condor params
118
119
  put_short_symbol: params[:put_short_symbol],
119
120
  put_long_symbol: params[:put_long_symbol],
@@ -121,33 +122,36 @@ module SchwabMCP
121
122
  call_long_symbol: params[:call_long_symbol],
122
123
  # Vertical spread params
123
124
  short_leg_symbol: params[:short_leg_symbol],
124
- long_leg_symbol: params[:long_leg_symbol]
125
+ long_leg_symbol: params[:long_leg_symbol],
126
+ # Single option params
127
+ symbol: params[:symbol],
125
128
  )
126
129
 
127
130
  log_debug("Making preview order API request")
128
- response = client.preview_order(account_hash, order_builder)
131
+ response = client.preview_order(account_hash, order_builder, return_data_objects: true)
129
132
 
130
- if response&.body
133
+ if response
131
134
  log_info("Successfully previewed #{params[:strategy_type]} order")
132
- formatted_response = format_preview_response(response.body, params)
135
+ formatted_response = format_preview_response(response, params)
133
136
  MCP::Tool::Response.new([{
134
137
  type: "text",
135
138
  text: formatted_response
136
139
  }])
137
140
  else
138
141
  log_warn("Empty response from Schwab API for order preview")
142
+ error_msg = "**No Data**: Empty response from Schwab API for order preview"
139
143
  MCP::Tool::Response.new([{
140
144
  type: "text",
141
- text: "**No Data**: Empty response from Schwab API for order preview"
145
+ text: Redactor.redact_formatted_text(error_msg)
142
146
  }])
143
147
  end
144
148
 
145
149
  rescue => e
146
150
  log_error("Error previewing #{params[:strategy_type]} order: #{e.message}")
147
- log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
151
+ error_msg = "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\\n')}"
148
152
  MCP::Tool::Response.new([{
149
153
  type: "text",
150
- text: "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
154
+ text: Redactor.redact_formatted_text(error_msg)
151
155
  }])
152
156
  end
153
157
  end
@@ -159,46 +163,38 @@ module SchwabMCP
159
163
  unless account_id
160
164
  available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
161
165
  log_error("Account name '#{account_name}' not found in environment variables")
166
+ error_msg = "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
162
167
  return MCP::Tool::Response.new([{
163
168
  type: "text",
164
- text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
169
+ text: Redactor.redact_formatted_text(error_msg)
165
170
  }])
166
171
  end
167
172
 
168
173
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
169
174
  log_debug("Fetching account numbers mapping")
170
175
 
171
- account_numbers_response = client.get_account_numbers
172
-
173
- unless account_numbers_response&.body
174
- log_error("Failed to retrieve account numbers")
176
+ account_numbers = client.get_account_numbers(return_data_objects: true)
177
+ unless account_numbers && !account_numbers.empty?
178
+ log_error("Failed to retrieve account numbers or no accounts returned")
179
+ error_msg = "**Error**: Failed to retrieve account numbers from Schwab API or no accounts returned"
175
180
  return MCP::Tool::Response.new([{
176
181
  type: "text",
177
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
182
+ text: Redactor.redact_formatted_text(error_msg)
178
183
  }])
179
184
  end
180
185
 
181
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
182
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
183
-
184
- account_hash = nil
185
- account_mappings.each do |mapping|
186
- if mapping[:accountNumber] == account_id
187
- account_hash = mapping[:hashValue]
188
- break
189
- end
190
- end
191
-
192
- unless account_hash
186
+ mapping = account_numbers.accounts.find { |acct| acct.account_number == account_id }
187
+ unless mapping
193
188
  log_error("Account ID not found in available accounts")
189
+ error_msg = "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
194
190
  return MCP::Tool::Response.new([{
195
191
  type: "text",
196
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
192
+ text: Redactor.redact_formatted_text(error_msg)
197
193
  }])
198
194
  end
199
195
 
200
196
  log_debug("Found account hash for account name: #{account_name}")
201
- [account_id, account_hash]
197
+ [account_id, mapping.hash_value]
202
198
  end
203
199
 
204
200
  def self.validate_strategy_params(params)
@@ -209,12 +205,18 @@ module SchwabMCP
209
205
  unless missing_fields.empty?
210
206
  raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
211
207
  end
212
- when 'callspread', 'putspread'
208
+ when 'vertical'
213
209
  required_fields = [:short_leg_symbol, :long_leg_symbol]
214
210
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
215
211
  unless missing_fields.empty?
216
212
  raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
217
213
  end
214
+ when 'single'
215
+ required_fields = [:symbol]
216
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
217
+ unless missing_fields.empty?
218
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
219
+ end
218
220
  else
219
221
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
220
222
  end
@@ -232,10 +234,13 @@ module SchwabMCP
232
234
  "- Put Long: #{params[:put_long_symbol]}\n" \
233
235
  "- Call Short: #{params[:call_short_symbol]}\n" \
234
236
  "- Call Long: #{params[:call_long_symbol]}\n"
235
- when 'callspread', 'putspread'
236
- "**#{params[:strategy_type].capitalize} Preview**\n" \
237
+ when 'vertical'
238
+ "**Vertical Preview**\n" \
237
239
  "- Short Leg: #{params[:short_leg_symbol]}\n" \
238
240
  "- Long Leg: #{params[:long_leg_symbol]}\n"
241
+ when 'single'
242
+ "**Single Option Preview**\n" \
243
+ "- Symbol: #{params[:symbol]}\n"
239
244
  end
240
245
 
241
246
  friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
@@ -254,6 +259,53 @@ module SchwabMCP
254
259
  "**Order Preview Response:**\n\n```\n#{JSON.pretty_generate(redacted_data)}\n```"
255
260
  end
256
261
  end
262
+ def self.format_preview_response(order_preview, params)
263
+ # order_preview is a SchwabRb::DataObjects::OrderPreview
264
+ begin
265
+ strategy_summary = case params[:strategy_type]
266
+ when 'ironcondor'
267
+ "**Iron Condor Preview**\n" \
268
+ "- Put Short: #{params[:put_short_symbol]}\n" \
269
+ "- Put Long: #{params[:put_long_symbol]}\n" \
270
+ "- Call Short: #{params[:call_short_symbol]}\n" \
271
+ "- Call Long: #{params[:call_long_symbol]}\n"
272
+ when 'vertical'
273
+ "**Vertical Preview**\n" \
274
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
275
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
276
+ when 'single'
277
+ "**Single Option Preview**\n" \
278
+ "- Symbol: #{params[:symbol]}\n"
279
+ end
280
+
281
+ friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
282
+
283
+ order_details = "**Order Details:**\n" \
284
+ "- Strategy: #{params[:strategy_type]}\n" \
285
+ "- Action: #{params[:order_instruction] || 'open'}\n" \
286
+ "- Quantity: #{params[:quantity] || 1}\n" \
287
+ "- Price: $#{params[:price]}\n" \
288
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
289
+
290
+ # Use OrderPreview data object for summary
291
+ op = order_preview
292
+ summary = "**Preview Result:**\n" \
293
+ "- Status: #{op.status || 'N/A'}\n" \
294
+ "- Price: $#{op.price || 'N/A'}\n" \
295
+ "- Quantity: #{op.quantity || 'N/A'}\n" \
296
+ "- Commission: $#{op.commission}\n" \
297
+ "- Fees: $#{op.fees}\n" \
298
+ "- Accepted?: #{op.accepted? ? 'Yes' : 'No'}\n"
299
+
300
+ # Redact and pretty print the full data object as JSON
301
+ redacted_data = Redactor.redact(op.to_h)
302
+ full_response = "**Schwab API Preview Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
303
+
304
+ "#{strategy_summary}\n#{order_details}#{summary}\n#{full_response}"
305
+ rescue => e
306
+ "**Order Preview Response:**\n\nError formatting preview: #{e.message}"
307
+ end
308
+ end
257
309
  end
258
310
  end
259
311
  end