schwab_mcp 0.1.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.json +14 -0
  3. data/CHANGELOG.md +12 -0
  4. data/CLAUDE.md +124 -0
  5. data/README.md +1 -7
  6. data/debug_env.rb +46 -0
  7. data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
  8. data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
  9. data/exe/schwab_mcp +15 -4
  10. data/exe/schwab_token_refresh +12 -11
  11. data/exe/schwab_token_reset +11 -10
  12. data/lib/schwab_mcp/redactor.rb +4 -0
  13. data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
  14. data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -81
  15. data/lib/schwab_mcp/tools/get_account_names_tool.rb +58 -0
  16. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
  17. data/lib/schwab_mcp/tools/get_order_tool.rb +50 -137
  18. data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
  19. data/lib/schwab_mcp/tools/help_tool.rb +12 -33
  20. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +36 -90
  21. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -98
  22. data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
  23. data/lib/schwab_mcp/tools/option_chain_tool.rb +132 -84
  24. data/lib/schwab_mcp/tools/place_order_tool.rb +111 -141
  25. data/lib/schwab_mcp/tools/preview_order_tool.rb +71 -81
  26. data/lib/schwab_mcp/tools/quote_tool.rb +33 -28
  27. data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
  28. data/lib/schwab_mcp/tools/replace_order_tool.rb +110 -140
  29. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -98
  30. data/lib/schwab_mcp/version.rb +1 -1
  31. data/lib/schwab_mcp.rb +11 -10
  32. metadata +12 -9
  33. data/lib/schwab_mcp/option_chain_filter.rb +0 -213
  34. data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
  35. data/lib/schwab_mcp/orders/order_factory.rb +0 -40
  36. data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
  37. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +0 -162
  38. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
  39. data/start_mcp_server.sh +0 -4
@@ -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 VERTICAL IRON_CONDOR],
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,28 @@ 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
- account_id, account_hash = account_result
108
-
109
- order_builder = SchwabMCP::Orders::OrderFactory.build(
104
+ order_builder = SchwabRb::Orders::OrderFactory.build(
110
105
  strategy_type: params[:strategy_type],
111
- account_number: account_id,
112
106
  price: params[:price],
113
107
  quantity: params[:quantity] || 1,
114
108
  order_instruction: (params[:order_instruction] || "open").to_sym,
109
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
115
110
  # Iron Condor params
116
111
  put_short_symbol: params[:put_short_symbol],
117
112
  put_long_symbol: params[:put_long_symbol],
@@ -119,106 +114,79 @@ module SchwabMCP
119
114
  call_long_symbol: params[:call_long_symbol],
120
115
  # Vertical spread params
121
116
  short_leg_symbol: params[:short_leg_symbol],
122
- long_leg_symbol: params[:long_leg_symbol]
117
+ long_leg_symbol: params[:long_leg_symbol],
118
+ # Single
119
+ symbol: params[:symbol]
123
120
  )
124
121
 
125
122
  log_debug("Making place order API request")
126
- response = client.place_order(account_hash, order_builder)
123
+ response = client.place_order(account_name: params[:account_name], order: order_builder)
127
124
 
128
125
  if response && (200..299).include?(response.status)
129
126
  log_info("Successfully placed #{params[:strategy_type]} order (HTTP #{response.status})")
130
127
  formatted_response = format_place_order_response(response, params)
131
128
  MCP::Tool::Response.new([{
132
- type: "text",
133
- text: formatted_response
134
- }])
129
+ type: "text",
130
+ text: formatted_response
131
+ }])
135
132
  elsif response
136
133
  log_error("Order placement failed with HTTP status #{response.status}")
137
134
  error_details = extract_error_details(response)
138
135
  MCP::Tool::Response.new([{
139
- type: "text",
140
- text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
141
- }])
136
+ type: "text",
137
+ text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
138
+ }])
142
139
  else
143
140
  log_warn("Empty response from Schwab API for order placement")
144
141
  MCP::Tool::Response.new([{
145
- type: "text",
146
- text: "**No Data**: Empty response from Schwab API for order placement"
147
- }])
142
+ type: "text",
143
+ text: "**No Data**: Empty response from Schwab API for order placement"
144
+ }])
148
145
  end
149
-
150
- rescue => e
146
+ rescue StandardError => e
151
147
  log_error("Error placing #{params[:strategy_type]} order: #{e.message}")
152
148
  log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
153
149
  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
- }])
150
+ type: "text",
151
+ text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
152
+ }])
157
153
  end
158
154
  end
159
155
 
160
- private
161
-
162
156
  def self.resolve_account_details(client, account_name)
163
- account_id = ENV[account_name]
164
- unless account_id
165
- available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
166
- log_error("Account name '#{account_name}' not found in environment variables")
167
- 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
- }])
171
- end
172
-
173
- log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
174
- log_debug("Fetching account numbers mapping")
175
-
176
- account_numbers_response = client.get_account_numbers
177
-
178
- unless account_numbers_response&.body
179
- log_error("Failed to retrieve account numbers")
180
- return MCP::Tool::Response.new([{
181
- type: "text",
182
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
183
- }])
184
- end
185
-
186
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
187
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
188
-
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
196
-
197
- unless account_hash
198
- log_error("Account ID not found in available accounts")
157
+ available_accounts = client.available_account_names
158
+ unless available_accounts.include?(account_name)
159
+ log_error("Account name '#{account_name}' not found in configured accounts")
199
160
  return MCP::Tool::Response.new([{
200
- type: "text",
201
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
202
- }])
161
+ type: "text",
162
+ text: "**Error**: Account name '#{account_name}' not found in configured accounts.\n\nAvailable accounts: #{available_accounts.join(", ")}\n\nTo configure: Add the account to your schwab_rb configuration file."
163
+ }])
203
164
  end
204
165
 
205
- log_debug("Found account hash for account name: #{account_name}")
206
- [account_id, account_hash]
166
+ log_debug("Using account name: #{account_name}")
167
+ account_name
207
168
  end
208
169
 
209
170
  def self.validate_strategy_params(params)
210
- case params[:strategy_type]
211
- when 'ironcondor'
212
- required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
171
+ strategy = params[:strategy_type].to_s.upcase
172
+ case strategy
173
+ when 'IRON_CONDOR'
174
+ required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
213
175
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
214
176
  unless missing_fields.empty?
215
- raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
177
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
216
178
  end
217
- when 'callspread', 'putspread'
218
- required_fields = [:short_leg_symbol, :long_leg_symbol]
179
+ when 'VERTICAL'
180
+ required_fields = %i[short_leg_symbol long_leg_symbol]
219
181
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
220
182
  unless missing_fields.empty?
221
- raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
183
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
184
+ end
185
+ when 'SINGLE'
186
+ required_fields = %i[symbol]
187
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
188
+ unless missing_fields.empty?
189
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
222
190
  end
223
191
  else
224
192
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
@@ -226,60 +194,62 @@ module SchwabMCP
226
194
  end
227
195
 
228
196
  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}"
197
+ strategy = params[:strategy_type].to_s.upcase
198
+ strategy_summary = case strategy
199
+ when 'IRON_CONDOR'
200
+ "**Iron Condor Order Placed**\n" \
201
+ "- Put Short: #{params[:put_short_symbol]}\n" \
202
+ "- Put Long: #{params[:put_long_symbol]}\n" \
203
+ "- Call Short: #{params[:call_short_symbol]}\n" \
204
+ "- Call Long: #{params[:call_long_symbol]}\n"
205
+ when 'VERTICAL'
206
+ "**Vertical Spread Order Placed**\n" \
207
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
208
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
209
+ when 'SINGLE'
210
+ "**Single Option Order Placed**\n" \
211
+ "- Symbol: #{params[:symbol]}\n"
271
212
  end
213
+
214
+ friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
215
+
216
+ order_details = "**Order Details:**\n" \
217
+ "- Strategy: #{params[:strategy_type]}\n" \
218
+ "- Action: #{params[:order_instruction] || "open"}\n" \
219
+ "- Quantity: #{params[:quantity] || 1}\n" \
220
+ "- Price: $#{params[:price]}\n" \
221
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
222
+
223
+ order_id = extract_order_id_from_response(response)
224
+ order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
225
+
226
+ response_info = if response.body && !response.body.empty?
227
+ begin
228
+ parsed = JSON.parse(response.body)
229
+ redacted_data = Redactor.redact(parsed)
230
+ "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
231
+ rescue JSON::ParserError
232
+ "**Schwab API Response:**\n\n```\n#{response.body}\n```"
233
+ end
234
+ else
235
+ "**Status**: Order submitted successfully (HTTP #{response.status})"
236
+ end
237
+
238
+ "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
239
+ rescue StandardError => e
240
+ log_error("Error formatting response: #{e.message}")
241
+ "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
272
242
  end
273
243
 
274
244
  def self.extract_order_id_from_response(response)
275
245
  # Schwab API typically returns the order ID in the Location header
276
246
  # Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
277
- location = response.headers['Location'] || response.headers['location']
247
+ location = response.headers["Location"] || response.headers["location"]
278
248
  return nil unless location
279
249
 
280
250
  match = location.match(%r{/orders/(\d+)$})
281
251
  match ? match[1] : nil
282
- rescue => e
252
+ rescue StandardError => e
283
253
  log_debug("Could not extract order ID from response: #{e.message}")
284
254
  nil
285
255
  end
@@ -296,7 +266,7 @@ module SchwabMCP
296
266
  else
297
267
  "No additional error details provided."
298
268
  end
299
- rescue => e
269
+ rescue StandardError => e
300
270
  log_debug("Error extracting error details: #{e.message}")
301
271
  "Could not extract error details."
302
272
  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: %w[SINGLE VERTICAL IRON_CONDOR],
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,27 @@ 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
- account_id, account_hash = account_result
110
-
111
- order_builder = SchwabMCP::Orders::OrderFactory.build(
109
+ order_builder = SchwabRb::Orders::OrderFactory.build(
112
110
  strategy_type: params[:strategy_type],
113
- account_number: account_id,
114
111
  price: params[:price],
115
112
  quantity: params[:quantity] || 1,
116
113
  order_instruction: (params[:order_instruction] || "open").to_sym,
114
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
117
115
  # Iron Condor params
118
116
  put_short_symbol: params[:put_short_symbol],
119
117
  put_long_symbol: params[:put_long_symbol],
@@ -121,33 +119,36 @@ module SchwabMCP
121
119
  call_long_symbol: params[:call_long_symbol],
122
120
  # Vertical spread params
123
121
  short_leg_symbol: params[:short_leg_symbol],
124
- long_leg_symbol: params[:long_leg_symbol]
122
+ long_leg_symbol: params[:long_leg_symbol],
123
+ # Single option params
124
+ symbol: params[:symbol]
125
125
  )
126
126
 
127
127
  log_debug("Making preview order API request")
128
- response = client.preview_order(account_hash, order_builder)
128
+ response = client.preview_order(account_name: params[:account_name], order: order_builder, return_data_objects: true)
129
129
 
130
- if response&.body
130
+ if response
131
131
  log_info("Successfully previewed #{params[:strategy_type]} order")
132
- formatted_response = format_preview_response(response.body, params)
132
+ formatted_response = format_preview_response(response, params)
133
133
  MCP::Tool::Response.new([{
134
134
  type: "text",
135
135
  text: formatted_response
136
136
  }])
137
137
  else
138
138
  log_warn("Empty response from Schwab API for order preview")
139
+ error_msg = "**No Data**: Empty response from Schwab API for order preview"
139
140
  MCP::Tool::Response.new([{
140
141
  type: "text",
141
- text: "**No Data**: Empty response from Schwab API for order preview"
142
+ text: Redactor.redact_formatted_text(error_msg)
142
143
  }])
143
144
  end
144
145
 
145
146
  rescue => e
146
147
  log_error("Error previewing #{params[:strategy_type]} order: #{e.message}")
147
- log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
148
+ error_msg = "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\\n')}"
148
149
  MCP::Tool::Response.new([{
149
150
  type: "text",
150
- text: "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
151
+ text: Redactor.redact_formatted_text(error_msg)
151
152
  }])
152
153
  end
153
154
  end
@@ -155,87 +156,64 @@ module SchwabMCP
155
156
  private
156
157
 
157
158
  def self.resolve_account_details(client, account_name)
158
- account_id = ENV[account_name]
159
- unless account_id
160
- available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
161
- log_error("Account name '#{account_name}' not found in environment variables")
162
- return MCP::Tool::Response.new([{
163
- 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."
165
- }])
166
- end
167
-
168
- log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
169
- log_debug("Fetching account numbers mapping")
170
-
171
- account_numbers_response = client.get_account_numbers
172
-
173
- unless account_numbers_response&.body
174
- log_error("Failed to retrieve account numbers")
175
- return MCP::Tool::Response.new([{
176
- type: "text",
177
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
178
- }])
179
- end
180
-
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
193
- log_error("Account ID not found in available accounts")
159
+ available_accounts = client.available_account_names
160
+ unless available_accounts.include?(account_name)
161
+ log_error("Account name '#{account_name}' not found in configured accounts")
162
+ error_msg = "**Error**: Account name '#{account_name}' not found in configured accounts.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Add the account to your schwab_rb configuration file."
194
163
  return MCP::Tool::Response.new([{
195
164
  type: "text",
196
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
165
+ text: Redactor.redact_formatted_text(error_msg)
197
166
  }])
198
167
  end
199
168
 
200
- log_debug("Found account hash for account name: #{account_name}")
201
- [account_id, account_hash]
169
+ log_debug("Using account name: #{account_name}")
170
+ account_name
202
171
  end
203
172
 
204
173
  def self.validate_strategy_params(params)
205
- case params[:strategy_type]
206
- when 'ironcondor'
174
+ strategy = params[:strategy_type].to_s.upcase
175
+ case strategy
176
+ when 'IRON_CONDOR'
207
177
  required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
208
178
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
209
179
  unless missing_fields.empty?
210
180
  raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
211
181
  end
212
- when 'callspread', 'putspread'
182
+ when 'VERTICAL'
213
183
  required_fields = [:short_leg_symbol, :long_leg_symbol]
214
184
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
215
185
  unless missing_fields.empty?
216
186
  raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
217
187
  end
188
+ when 'SINGLE'
189
+ required_fields = [:symbol]
190
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
191
+ unless missing_fields.empty?
192
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
193
+ end
218
194
  else
219
195
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
220
196
  end
221
197
  end
222
198
 
223
- def self.format_preview_response(response_body, params)
224
- parsed = JSON.parse(response_body)
225
- redacted_data = Redactor.redact(parsed)
226
-
199
+ def self.format_preview_response(order_preview, params)
200
+ # order_preview is a SchwabRb::DataObjects::OrderPreview
227
201
  begin
228
- strategy_summary = case params[:strategy_type]
229
- when 'ironcondor'
202
+ strategy = params[:strategy_type].to_s.upcase
203
+ strategy_summary = case strategy
204
+ when 'IRON_CONDOR'
230
205
  "**Iron Condor Preview**\n" \
231
206
  "- Put Short: #{params[:put_short_symbol]}\n" \
232
207
  "- Put Long: #{params[:put_long_symbol]}\n" \
233
208
  "- Call Short: #{params[:call_short_symbol]}\n" \
234
209
  "- Call Long: #{params[:call_long_symbol]}\n"
235
- when 'callspread', 'putspread'
236
- "**#{params[:strategy_type].capitalize} Preview**\n" \
210
+ when 'VERTICAL'
211
+ "**Vertical Spread Preview**\n" \
237
212
  "- Short Leg: #{params[:short_leg_symbol]}\n" \
238
213
  "- Long Leg: #{params[:long_leg_symbol]}\n"
214
+ when 'SINGLE'
215
+ "**Single Option Preview**\n" \
216
+ "- Symbol: #{params[:symbol]}\n"
239
217
  end
240
218
 
241
219
  friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
@@ -247,11 +225,23 @@ module SchwabMCP
247
225
  "- Price: $#{params[:price]}\n" \
248
226
  "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
249
227
 
228
+ # Use OrderPreview data object for summary
229
+ op = order_preview
230
+ summary = "**Preview Result:**\n" \
231
+ "- Status: #{op.status || 'N/A'}\n" \
232
+ "- Price: $#{op.price || 'N/A'}\n" \
233
+ "- Quantity: #{op.quantity || 'N/A'}\n" \
234
+ "- Commission: $#{op.commission}\n" \
235
+ "- Fees: $#{op.fees}\n" \
236
+ "- Accepted?: #{op.accepted? ? 'Yes' : 'No'}\n"
237
+
238
+ # Redact and pretty print the full data object as JSON
239
+ redacted_data = Redactor.redact(op.to_h)
250
240
  full_response = "**Schwab API Preview Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
251
241
 
252
- "#{strategy_summary}\n#{order_details}#{full_response}"
253
- rescue JSON::ParserError
254
- "**Order Preview Response:**\n\n```\n#{JSON.pretty_generate(redacted_data)}\n```"
242
+ "#{strategy_summary}\n#{order_details}#{summary}\n#{full_response}"
243
+ rescue => e
244
+ "**Order Preview Response:**\n\nError formatting preview: #{e.message}"
255
245
  end
256
246
  end
257
247
  end