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,7 +1,10 @@
1
+
2
+ # frozen_string_literal: true
3
+
1
4
  require "mcp"
2
5
  require "schwab_rb"
3
- require "json"
4
6
  require_relative "../loggable"
7
+ require_relative "../schwab_client_factory"
5
8
 
6
9
  module SchwabMCP
7
10
  module Tools
@@ -31,38 +34,28 @@ module SchwabMCP
31
34
  log_info("Getting quote for symbol: #{symbol}")
32
35
 
33
36
  begin
34
- client = SchwabRb::Auth.init_client_easy(
35
- ENV['SCHWAB_API_KEY'],
36
- ENV['SCHWAB_APP_SECRET'],
37
- ENV['SCHWAB_CALLBACK_URI'],
38
- ENV['TOKEN_PATH']
39
- )
40
-
41
- unless client
42
- log_error("Failed to initialize Schwab client")
43
- return MCP::Tool::Response.new([{
44
- type: "text",
45
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
46
- }])
47
- end
37
+ client = SchwabClientFactory.create_client
38
+ return SchwabClientFactory.client_error_response unless client
48
39
 
49
40
  log_debug("Making API request for symbol: #{symbol}")
50
- response = client.get_quote(symbol.upcase)
41
+ quote_obj = client.get_quote(symbol.upcase, return_data_objects: true)
51
42
 
52
- if response&.body
53
- log_info("Successfully retrieved quote for #{symbol}")
54
- MCP::Tool::Response.new([{
55
- type: "text",
56
- text: "**Quote for #{symbol.upcase}:**\n\n```json\n#{response.body}\n```"
57
- }])
58
- else
59
- log_warn("Empty response from Schwab API for symbol: #{symbol}")
60
- MCP::Tool::Response.new([{
43
+ unless quote_obj
44
+ log_warn("No quote data object returned for symbol: #{symbol}")
45
+ return MCP::Tool::Response.new([{
61
46
  type: "text",
62
- text: "**No Data**: Empty response from Schwab API for symbol: #{symbol}"
47
+ text: "**No Data**: No quote data returned for symbol: #{symbol}"
63
48
  }])
64
49
  end
65
50
 
51
+ # Format output based on quote type
52
+ formatted = format_quote_object(quote_obj)
53
+ log_info("Successfully retrieved quote for #{symbol}")
54
+ MCP::Tool::Response.new([{
55
+ type: "text",
56
+ text: "**Quote for #{symbol.upcase}:**\n\n#{formatted}"
57
+ }])
58
+
66
59
  rescue => e
67
60
  log_error("Error retrieving quote for #{symbol}: #{e.message}")
68
61
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -72,6 +65,20 @@ module SchwabMCP
72
65
  }])
73
66
  end
74
67
  end
68
+
69
+ # Format the quote object for display
70
+ def self.format_quote_object(obj)
71
+ case obj
72
+ when SchwabRb::DataObjects::OptionQuote
73
+ "Option: #{obj.symbol}\nLast: #{obj.last_price} Bid: #{obj.bid_price} Ask: #{obj.ask_price} Mark: #{obj.mark} Delta: #{obj.delta} Gamma: #{obj.gamma} Vol: #{obj.volatility} OI: #{obj.open_interest} Exp: #{obj.expiration_month}/#{obj.expiration_day}/#{obj.expiration_year} Strike: #{obj.strike_price}"
74
+ when SchwabRb::DataObjects::EquityQuote
75
+ "Equity: #{obj.symbol}\nLast: #{obj.last_price} Bid: #{obj.bid_price} Ask: #{obj.ask_price} Mark: #{obj.mark} Net Chg: #{obj.net_change} %Chg: #{obj.net_percent_change} Vol: #{obj.total_volume}"
76
+ when SchwabRb::DataObjects::IndexQuote
77
+ "Index: #{obj.symbol}\nLast: #{obj.last_price} Bid: N/A Ask: N/A Mark: #{obj.mark} Net Chg: #{obj.net_change} %Chg: #{obj.net_percent_change} Vol: #{obj.total_volume}"
78
+ else
79
+ obj.respond_to?(:to_h) ? obj.to_h.inspect : obj.inspect
80
+ end
81
+ end
75
82
  end
76
83
  end
77
84
  end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mcp"
2
4
  require "schwab_rb"
3
- require "json"
4
5
  require_relative "../loggable"
6
+ require_relative "../schwab_client_factory"
5
7
 
6
8
  module SchwabMCP
7
9
  module Tools
@@ -44,67 +46,117 @@ module SchwabMCP
44
46
  idempotent_hint: true
45
47
  )
46
48
 
47
- def self.call(symbols:, fields: ["quote"], indicative: false, server_context:)
49
+ def self.call(symbols:, server_context:, fields: ["quote"], indicative: false)
48
50
  symbols = [symbols] if symbols.is_a?(String)
49
51
 
50
- log_info("Getting quotes for #{symbols.length} symbols: #{symbols.join(', ')}")
52
+ log_info("Getting quotes for #{symbols.length} symbols: #{symbols.join(", ")}")
51
53
 
52
54
  begin
53
- client = SchwabRb::Auth.init_client_easy(
54
- ENV['SCHWAB_API_KEY'],
55
- ENV['SCHWAB_APP_SECRET'],
56
- ENV['SCHWAB_CALLBACK_URI'],
57
- ENV['TOKEN_PATH']
58
- )
55
+ client = SchwabClientFactory.create_client
56
+ return SchwabClientFactory.client_error_response unless client
59
57
 
60
- unless client
61
- log_error("Failed to initialize Schwab client")
62
- return MCP::Tool::Response.new([{
63
- type: "text",
64
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
65
- }])
66
- end
67
-
68
- log_debug("Making API request for symbols: #{symbols.join(', ')}")
69
- log_debug("Fields: #{fields || 'all'}")
70
- log_debug("Indicative: #{indicative || 'not specified'}")
58
+ log_debug("Making API request for symbols: #{symbols.join(", ")}")
59
+ log_debug("Fields: #{fields || "all"}")
60
+ log_debug("Indicative: #{indicative || "not specified"}")
71
61
 
72
62
  normalized_symbols = symbols.map(&:upcase)
73
63
 
74
- response = client.get_quotes(
64
+ quotes_data = client.get_quotes(
75
65
  normalized_symbols,
76
66
  fields: fields,
77
- indicative: indicative
67
+ indicative: indicative,
68
+ return_data_objects: true
78
69
  )
79
70
 
80
- if response&.body
81
- log_info("Successfully retrieved quotes for #{symbols.length} symbols")
82
-
83
- symbol_list = normalized_symbols.join(', ')
84
- field_info = fields ? " (fields: #{fields.join(', ')})" : " (all fields)"
85
- indicative_info = indicative.nil? ? "" : " (indicative: #{indicative})"
86
-
87
- MCP::Tool::Response.new([{
88
- type: "text",
89
- text: "**Quotes for #{symbol_list}:**#{field_info}#{indicative_info}\n\n```json\n#{response.body}\n```"
90
- }])
91
- else
92
- log_warn("Empty response from Schwab API for symbols: #{symbols.join(', ')}")
93
- MCP::Tool::Response.new([{
94
- type: "text",
95
- text: "**No Data**: Empty response from Schwab API for symbols: #{symbols.join(', ')}"
96
- }])
71
+ unless quotes_data
72
+ log_warn("No quote data objects returned for symbols: #{symbols.join(", ")}")
73
+ return MCP::Tool::Response.new([{
74
+ type: "text",
75
+ text: "**No Data**: No quote data returned for symbols: " \
76
+ "#{symbols.join(", ")}"
77
+ }])
97
78
  end
98
79
 
99
- rescue => e
100
- log_error("Error retrieving quotes for #{symbols.join(', ')}: #{e.message}")
101
- log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
80
+ # Format quotes output
81
+ formatted_quotes = format_quotes_data(quotes_data, normalized_symbols)
82
+ log_info("Successfully retrieved quotes for #{symbols.length} symbols")
83
+
84
+ symbol_list = normalized_symbols.join(", ")
85
+ field_info = fields ? " (fields: #{fields.join(", ")})" : " (all fields)"
86
+ indicative_info = indicative.nil? ? "" : " (indicative: #{indicative})"
87
+
88
+ MCP::Tool::Response.new([{
89
+ type: "text",
90
+ text: "**Quotes for #{symbol_list}:**#{field_info}#{indicative_info}\n\n" \
91
+ "#{formatted_quotes}"
92
+ }])
93
+ rescue StandardError => e
94
+ log_error("Error retrieving quotes for #{symbols.join(", ")}: #{e.message}")
95
+ log_debug("Backtrace: #{e.backtrace.first(3).join("\n")}")
102
96
  MCP::Tool::Response.new([{
103
- type: "text",
104
- text: "**Error** retrieving quotes for #{symbols.join(', ')}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
105
- }])
97
+ type: "text",
98
+ text: "**Error** retrieving quotes for #{symbols.join(", ")}: #{e.message}\n\n" \
99
+ "#{e.backtrace.first(3).join("\n")}"
100
+ }])
101
+ end
102
+ end
103
+
104
+ # Format the quotes data for display
105
+ def self.format_quotes_data(quotes_data, symbols)
106
+ return "No quotes available" unless quotes_data
107
+
108
+ case quotes_data
109
+ when Hash
110
+ format_hash_quotes(quotes_data, symbols)
111
+ when Array
112
+ quotes_data.map { |quote_obj| format_single_quote(quote_obj) }.join("\n\n")
113
+ else
114
+ format_single_quote(quotes_data)
106
115
  end
107
116
  end
117
+
118
+ # Format hash of quotes (symbol => quote_object)
119
+ def self.format_hash_quotes(quotes_data, symbols)
120
+ formatted_lines = symbols.map do |symbol|
121
+ quote_obj = quotes_data[symbol] || quotes_data[symbol.to_sym]
122
+ quote_obj ? format_single_quote(quote_obj) : "#{symbol}: No data available"
123
+ end
124
+ formatted_lines.join("\n\n")
125
+ end
126
+
127
+ # Format a single quote object for display (reused from quote_tool.rb)
128
+ def self.format_single_quote(obj)
129
+ case obj
130
+ when SchwabRb::DataObjects::OptionQuote
131
+ format_option_quote(obj)
132
+ when SchwabRb::DataObjects::EquityQuote
133
+ format_equity_quote(obj)
134
+ when SchwabRb::DataObjects::IndexQuote
135
+ format_index_quote(obj)
136
+ else
137
+ obj.respond_to?(:to_h) ? obj.to_h.inspect : obj.inspect
138
+ end
139
+ end
140
+
141
+ # Format option quote
142
+ def self.format_option_quote(obj)
143
+ "Option: #{obj.symbol}\nLast: #{obj.last_price} Bid: #{obj.bid_price} Ask: #{obj.ask_price} " \
144
+ "Mark: #{obj.mark} Delta: #{obj.delta} Gamma: #{obj.gamma} Vol: #{obj.volatility} " \
145
+ "OI: #{obj.open_interest} Exp: #{obj.expiration_month}/#{obj.expiration_day}/#{obj.expiration_year} " \
146
+ "Strike: #{obj.strike_price}"
147
+ end
148
+
149
+ # Format equity quote
150
+ def self.format_equity_quote(obj)
151
+ "Equity: #{obj.symbol}\nLast: #{obj.last_price} Bid: #{obj.bid_price} Ask: #{obj.ask_price} " \
152
+ "Mark: #{obj.mark} Net Chg: #{obj.net_change} %Chg: #{obj.net_percent_change} Vol: #{obj.total_volume}"
153
+ end
154
+
155
+ # Format index quote
156
+ def self.format_index_quote(obj)
157
+ "Index: #{obj.symbol}\nLast: #{obj.last_price} Bid: N/A Ask: N/A Mark: #{obj.mark} " \
158
+ "Net Chg: #{obj.net_change} %Chg: #{obj.net_percent_change} Vol: #{obj.total_volume}"
159
+ end
108
160
  end
109
161
  end
110
162
  end
@@ -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
@@ -24,7 +23,7 @@ module SchwabMCP
24
23
  },
25
24
  strategy_type: {
26
25
  type: "string",
27
- enum: ["ironcondor", "callspread", "putspread"],
26
+ enum: %w[ironcondor callspread putspread],
28
27
  description: "Type of options strategy for the replacement order"
29
28
  },
30
29
  price: {
@@ -38,10 +37,16 @@ module SchwabMCP
38
37
  },
39
38
  order_instruction: {
40
39
  type: "string",
41
- enum: ["open", "exit"],
40
+ enum: %w[open exit],
42
41
  description: "Whether to open a new position or exit an existing one (default: open)",
43
42
  default: "open"
44
43
  },
44
+ credit_debit: {
45
+ type: "string",
46
+ enum: %w[credit debit],
47
+ description: "Whether the order is a credit or debit (default: credit)",
48
+ default: "credit"
49
+ },
45
50
  put_short_symbol: {
46
51
  type: "string",
47
52
  description: "Option symbol for the short put leg (required for iron condor)"
@@ -65,9 +70,13 @@ module SchwabMCP
65
70
  long_leg_symbol: {
66
71
  type: "string",
67
72
  description: "Option symbol for the long leg (required for call/put spreads)"
73
+ },
74
+ symbol: {
75
+ type: "string",
76
+ description: "Single option symbol to place an order for (required for single options)"
68
77
  }
69
78
  },
70
- required: ["account_name", "order_id", "strategy_type", "price"]
79
+ required: %w[account_name order_id strategy_type price]
71
80
  )
72
81
 
73
82
  annotations(
@@ -80,42 +89,31 @@ module SchwabMCP
80
89
  def self.call(server_context:, **params)
81
90
  log_info("Replacing order #{params[:order_id]} with #{params[:strategy_type]} order for account name: #{params[:account_name]}")
82
91
 
83
- unless params[:account_name].end_with?('_ACCOUNT')
92
+ unless params[:account_name].end_with?("_ACCOUNT")
84
93
  log_error("Invalid account name format: #{params[:account_name]}")
85
94
  return MCP::Tool::Response.new([{
86
- type: "text",
87
- text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
88
- }])
95
+ type: "text",
96
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
97
+ }])
89
98
  end
90
99
 
91
100
  begin
92
101
  validate_strategy_params(params)
93
- client = SchwabRb::Auth.init_client_easy(
94
- ENV['SCHWAB_API_KEY'],
95
- ENV['SCHWAB_APP_SECRET'],
96
- ENV['SCHWAB_CALLBACK_URI'],
97
- ENV['TOKEN_PATH']
98
- )
99
-
100
- unless client
101
- log_error("Failed to initialize Schwab client")
102
- return MCP::Tool::Response.new([{
103
- type: "text",
104
- text: "**Error**: Failed to initialize Schwab client. Check your credentials."
105
- }])
106
- end
102
+ client = SchwabClientFactory.create_client
103
+ return SchwabClientFactory.client_error_response unless client
107
104
 
108
105
  account_result = resolve_account_details(client, params[:account_name])
109
106
  return account_result if account_result.is_a?(MCP::Tool::Response)
110
107
 
111
108
  account_id, account_hash = account_result
112
109
 
113
- order_builder = SchwabMCP::Orders::OrderFactory.build(
110
+ order_builder = SchwabRb::Orders::OrderFactory.build(
114
111
  strategy_type: params[:strategy_type],
115
112
  account_number: account_id,
116
113
  price: params[:price],
117
114
  quantity: params[:quantity] || 1,
118
115
  order_instruction: (params[:order_instruction] || "open").to_sym,
116
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
119
117
  # Iron Condor params
120
118
  put_short_symbol: params[:put_short_symbol],
121
119
  put_long_symbol: params[:put_long_symbol],
@@ -123,7 +121,9 @@ module SchwabMCP
123
121
  call_long_symbol: params[:call_long_symbol],
124
122
  # Vertical spread params
125
123
  short_leg_symbol: params[:short_leg_symbol],
126
- long_leg_symbol: params[:long_leg_symbol]
124
+ long_leg_symbol: params[:long_leg_symbol],
125
+ # Single option params
126
+ symbol: params[:symbol]
127
127
  )
128
128
 
129
129
  log_debug("Making replace order API request for order ID: #{params[:order_id]}")
@@ -133,77 +133,67 @@ module SchwabMCP
133
133
  log_info("Successfully replaced order #{params[:order_id]} with #{params[:strategy_type]} order (HTTP #{response.status})")
134
134
  formatted_response = format_replace_order_response(response, params)
135
135
  MCP::Tool::Response.new([{
136
- type: "text",
137
- text: formatted_response
138
- }])
136
+ type: "text",
137
+ text: formatted_response
138
+ }])
139
139
  elsif response
140
140
  log_error("Order replacement failed with HTTP status #{response.status}")
141
141
  error_details = extract_error_details(response)
142
142
  MCP::Tool::Response.new([{
143
- type: "text",
144
- text: "**Error**: Order replacement failed (HTTP #{response.status})\n\n#{error_details}"
145
- }])
143
+ type: "text",
144
+ text: "**Error**: Order replacement failed (HTTP #{response.status})\n\n#{error_details}"
145
+ }])
146
146
  else
147
147
  log_warn("Empty response from Schwab API for order replacement")
148
148
  MCP::Tool::Response.new([{
149
- type: "text",
150
- text: "**No Data**: Empty response from Schwab API for order replacement"
151
- }])
149
+ type: "text",
150
+ text: "**No Data**: Empty response from Schwab API for order replacement"
151
+ }])
152
152
  end
153
-
154
- rescue => e
153
+ rescue StandardError => e
155
154
  log_error("Error replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}")
156
155
  log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
157
156
  MCP::Tool::Response.new([{
158
- type: "text",
159
- text: "**Error** replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
160
- }])
157
+ type: "text",
158
+ text: "**Error** replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
159
+ }])
161
160
  end
162
161
  end
163
162
 
164
- private
165
-
166
163
  def self.resolve_account_details(client, account_name)
167
164
  account_id = ENV[account_name]
168
165
  unless account_id
169
- available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
166
+ available_accounts = ENV.keys.select { |key| key.end_with?("_ACCOUNT") }
170
167
  log_error("Account name '#{account_name}' not found in environment variables")
171
168
  return MCP::Tool::Response.new([{
172
- type: "text",
173
- 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."
174
- }])
169
+ type: "text",
170
+ 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."
171
+ }])
175
172
  end
176
173
 
177
174
  log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
178
175
  log_debug("Fetching account numbers mapping")
179
176
 
180
- account_numbers_response = client.get_account_numbers
177
+ account_numbers = client.get_account_numbers
181
178
 
182
- unless account_numbers_response&.body
179
+ unless account_numbers
183
180
  log_error("Failed to retrieve account numbers")
184
181
  return MCP::Tool::Response.new([{
185
- type: "text",
186
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
187
- }])
182
+ type: "text",
183
+ text: "**Error**: Failed to retrieve account numbers from Schwab API"
184
+ }])
188
185
  end
189
186
 
190
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
191
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
187
+ log_debug("Account mappings retrieved (#{account_numbers.size} accounts found)")
192
188
 
193
- account_hash = nil
194
- account_mappings.each do |mapping|
195
- if mapping[:accountNumber] == account_id
196
- account_hash = mapping[:hashValue]
197
- break
198
- end
199
- end
189
+ account_hash = account_numbers.find_hash_value(account_id)
200
190
 
201
191
  unless account_hash
202
192
  log_error("Account ID not found in available accounts")
203
193
  return MCP::Tool::Response.new([{
204
- type: "text",
205
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
206
- }])
194
+ type: "text",
195
+ text: "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
196
+ }])
207
197
  end
208
198
 
209
199
  log_debug("Found account hash for account name: #{account_name}")
@@ -212,17 +202,17 @@ module SchwabMCP
212
202
 
213
203
  def self.validate_strategy_params(params)
214
204
  case params[:strategy_type]
215
- when 'ironcondor'
216
- required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
205
+ when "ironcondor"
206
+ required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
217
207
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
218
208
  unless missing_fields.empty?
219
- raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
209
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
220
210
  end
221
- when 'callspread', 'putspread'
222
- required_fields = [:short_leg_symbol, :long_leg_symbol]
211
+ when "callspread", "putspread"
212
+ required_fields = %i[short_leg_symbol long_leg_symbol]
223
213
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
224
214
  unless missing_fields.empty?
225
- raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
215
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
226
216
  end
227
217
  else
228
218
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
@@ -230,63 +220,61 @@ module SchwabMCP
230
220
  end
231
221
 
232
222
  def self.format_replace_order_response(response, params)
233
- begin
234
- strategy_summary = case params[:strategy_type]
235
- when 'ironcondor'
236
- "**Iron Condor Order Replaced**\n" \
237
- "- Put Short: #{params[:put_short_symbol]}\n" \
238
- "- Put Long: #{params[:put_long_symbol]}\n" \
239
- "- Call Short: #{params[:call_short_symbol]}\n" \
240
- "- Call Long: #{params[:call_long_symbol]}\n"
241
- when 'callspread', 'putspread'
242
- "**#{params[:strategy_type].capitalize} Order Replaced**\n" \
243
- "- Short Leg: #{params[:short_leg_symbol]}\n" \
244
- "- Long Leg: #{params[:long_leg_symbol]}\n"
245
- end
246
-
247
- friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
248
-
249
- order_details = "**Replacement Order Details:**\n" \
250
- "- Original Order ID: #{params[:order_id]}\n" \
251
- "- Strategy: #{params[:strategy_type]}\n" \
252
- "- Action: #{params[:order_instruction] || 'open'}\n" \
253
- "- Quantity: #{params[:quantity] || 1}\n" \
254
- "- Price: $#{params[:price]}\n" \
255
- "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
256
-
257
- new_order_id = extract_order_id_from_response(response)
258
- order_id_info = new_order_id ? "**New Order ID**: #{new_order_id}\n\n" : ""
259
-
260
- response_info = if response.body && !response.body.empty?
261
- begin
262
- parsed = JSON.parse(response.body)
263
- redacted_data = Redactor.redact(parsed)
264
- "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
265
- rescue JSON::ParserError
266
- "**Schwab API Response:**\n\n```\n#{response.body}\n```"
267
- end
268
- else
269
- "**Status**: Order replaced successfully (HTTP #{response.status})\n\n" \
270
- "The original order has been canceled and a new order has been created."
271
- end
272
-
273
- "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
274
- rescue => e
275
- log_error("Error formatting response: #{e.message}")
276
- "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
277
- end
223
+ strategy_summary = case params[:strategy_type]
224
+ when "ironcondor"
225
+ "**Iron Condor Order Replaced**\n" \
226
+ "- Put Short: #{params[:put_short_symbol]}\n" \
227
+ "- Put Long: #{params[:put_long_symbol]}\n" \
228
+ "- Call Short: #{params[:call_short_symbol]}\n" \
229
+ "- Call Long: #{params[:call_long_symbol]}\n"
230
+ when "callspread", "putspread"
231
+ "**#{params[:strategy_type].capitalize} Order Replaced**\n" \
232
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
233
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
234
+ end
235
+
236
+ friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
237
+
238
+ order_details = "**Replacement Order Details:**\n" \
239
+ "- Original Order ID: #{params[:order_id]}\n" \
240
+ "- Strategy: #{params[:strategy_type]}\n" \
241
+ "- Action: #{params[:order_instruction] || "open"}\n" \
242
+ "- Quantity: #{params[:quantity] || 1}\n" \
243
+ "- Price: $#{params[:price]}\n" \
244
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
245
+
246
+ new_order_id = extract_order_id_from_response(response)
247
+ order_id_info = new_order_id ? "**New Order ID**: #{new_order_id}\n\n" : ""
248
+
249
+ response_info = if response.body && !response.body.empty?
250
+ begin
251
+ parsed = JSON.parse(response.body)
252
+ redacted_data = Redactor.redact(parsed)
253
+ "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
254
+ rescue JSON::ParserError
255
+ "**Schwab API Response:**\n\n```\n#{response.body}\n```"
256
+ end
257
+ else
258
+ "**Status**: Order replaced successfully (HTTP #{response.status})\n\n" \
259
+ "The original order has been canceled and a new order has been created."
260
+ end
261
+
262
+ "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
263
+ rescue StandardError => e
264
+ log_error("Error formatting response: #{e.message}")
265
+ "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
278
266
  end
279
267
 
280
268
  def self.extract_order_id_from_response(response)
281
269
  # Schwab API typically returns the new order ID in the Location header
282
270
  # Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
283
- location = response.headers['Location'] || response.headers['location']
271
+ location = response.headers["Location"] || response.headers["location"]
284
272
  return nil unless location
285
273
 
286
274
  # Extract order ID from the URL path
287
275
  match = location.match(%r{/orders/(\d+)$})
288
276
  match ? match[1] : nil
289
- rescue => e
277
+ rescue StandardError => e
290
278
  log_debug("Could not extract order ID from response: #{e.message}")
291
279
  nil
292
280
  end
@@ -303,7 +291,7 @@ module SchwabMCP
303
291
  else
304
292
  "No additional error details provided."
305
293
  end
306
- rescue => e
294
+ rescue StandardError => e
307
295
  log_debug("Error extracting error details: #{e.message}")
308
296
  "Could not extract error details."
309
297
  end