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,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
@@ -13,8 +16,8 @@ module SchwabMCP
13
16
  properties: {
14
17
  symbol: {
15
18
  type: "string",
16
- description: "Instrument symbol (e.g., 'AAPL', 'TSLA')",
17
- pattern: "^[A-Za-z]{1,5}$"
19
+ description: "Instrument symbol (e.g., 'AAPL', 'TSLA', '$SPX')",
20
+ pattern: '^[\$\^]?[A-Za-z0-9]{1,5}$'
18
21
  }
19
22
  },
20
23
  required: ["symbol"]
@@ -31,38 +34,27 @@ 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)
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
+ formatted = format_quote_object(quote_obj)
52
+ log_info("Successfully retrieved quote for #{symbol}")
53
+ MCP::Tool::Response.new([{
54
+ type: "text",
55
+ text: "**Quote for #{symbol.upcase}:**\n\n#{formatted}"
56
+ }])
57
+
66
58
  rescue => e
67
59
  log_error("Error retrieving quote for #{symbol}: #{e.message}")
68
60
  log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
@@ -72,6 +64,19 @@ module SchwabMCP
72
64
  }])
73
65
  end
74
66
  end
67
+
68
+ def self.format_quote_object(obj)
69
+ case obj
70
+ when SchwabRb::DataObjects::OptionQuote
71
+ "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}"
72
+ when SchwabRb::DataObjects::EquityQuote
73
+ "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}"
74
+ when SchwabRb::DataObjects::IndexQuote
75
+ "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}"
76
+ else
77
+ obj.respond_to?(:to_h) ? obj.to_h.inspect : obj.inspect
78
+ end
79
+ end
75
80
  end
76
81
  end
77
82
  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[SINGLE VERTICAL IRON_CONDOR],
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,28 @@ 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
- account_id, account_hash = account_result
112
-
113
- order_builder = SchwabMCP::Orders::OrderFactory.build(
108
+ order_builder = SchwabRb::Orders::OrderFactory.build(
114
109
  strategy_type: params[:strategy_type],
115
- account_number: account_id,
116
110
  price: params[:price],
117
111
  quantity: params[:quantity] || 1,
118
112
  order_instruction: (params[:order_instruction] || "open").to_sym,
113
+ credit_debit: (params[:credit_debit] || "credit").to_sym,
119
114
  # Iron Condor params
120
115
  put_short_symbol: params[:put_short_symbol],
121
116
  put_long_symbol: params[:put_long_symbol],
@@ -123,106 +118,79 @@ module SchwabMCP
123
118
  call_long_symbol: params[:call_long_symbol],
124
119
  # Vertical spread params
125
120
  short_leg_symbol: params[:short_leg_symbol],
126
- long_leg_symbol: params[:long_leg_symbol]
121
+ long_leg_symbol: params[:long_leg_symbol],
122
+ # Single option params
123
+ symbol: params[:symbol]
127
124
  )
128
125
 
129
126
  log_debug("Making replace order API request for order ID: #{params[:order_id]}")
130
- response = client.replace_order(account_hash, params[:order_id], order_builder)
127
+ response = client.replace_order(account_name: params[:account_name], order_id: params[:order_id], order: order_builder)
131
128
 
132
129
  if response && (200..299).include?(response.status)
133
130
  log_info("Successfully replaced order #{params[:order_id]} with #{params[:strategy_type]} order (HTTP #{response.status})")
134
131
  formatted_response = format_replace_order_response(response, params)
135
132
  MCP::Tool::Response.new([{
136
- type: "text",
137
- text: formatted_response
138
- }])
133
+ type: "text",
134
+ text: formatted_response
135
+ }])
139
136
  elsif response
140
137
  log_error("Order replacement failed with HTTP status #{response.status}")
141
138
  error_details = extract_error_details(response)
142
139
  MCP::Tool::Response.new([{
143
- type: "text",
144
- text: "**Error**: Order replacement failed (HTTP #{response.status})\n\n#{error_details}"
145
- }])
140
+ type: "text",
141
+ text: "**Error**: Order replacement failed (HTTP #{response.status})\n\n#{error_details}"
142
+ }])
146
143
  else
147
144
  log_warn("Empty response from Schwab API for order replacement")
148
145
  MCP::Tool::Response.new([{
149
- type: "text",
150
- text: "**No Data**: Empty response from Schwab API for order replacement"
151
- }])
146
+ type: "text",
147
+ text: "**No Data**: Empty response from Schwab API for order replacement"
148
+ }])
152
149
  end
153
-
154
- rescue => e
150
+ rescue StandardError => e
155
151
  log_error("Error replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}")
156
152
  log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
157
153
  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
- }])
154
+ type: "text",
155
+ text: "**Error** replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
156
+ }])
161
157
  end
162
158
  end
163
159
 
164
- private
165
-
166
160
  def self.resolve_account_details(client, account_name)
167
- account_id = ENV[account_name]
168
- unless account_id
169
- available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
170
- log_error("Account name '#{account_name}' not found in environment variables")
161
+ available_accounts = client.available_account_names
162
+ unless available_accounts.include?(account_name)
163
+ log_error("Account name '#{account_name}' not found in configured accounts")
171
164
  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
- }])
165
+ type: "text",
166
+ 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."
167
+ }])
175
168
  end
176
169
 
177
- log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
178
- log_debug("Fetching account numbers mapping")
179
-
180
- account_numbers_response = client.get_account_numbers
181
-
182
- unless account_numbers_response&.body
183
- log_error("Failed to retrieve account numbers")
184
- return MCP::Tool::Response.new([{
185
- type: "text",
186
- text: "**Error**: Failed to retrieve account numbers from Schwab API"
187
- }])
188
- end
189
-
190
- account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
191
- log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
192
-
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
200
-
201
- unless account_hash
202
- log_error("Account ID not found in available accounts")
203
- return MCP::Tool::Response.new([{
204
- type: "text",
205
- text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
206
- }])
207
- end
208
-
209
- log_debug("Found account hash for account name: #{account_name}")
210
- [account_id, account_hash]
170
+ log_debug("Using account name: #{account_name}")
171
+ account_name
211
172
  end
212
173
 
213
174
  def self.validate_strategy_params(params)
214
- case params[:strategy_type]
215
- when 'ironcondor'
216
- required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
175
+ strategy = params[:strategy_type].to_s.upcase
176
+ case strategy
177
+ when 'IRON_CONDOR'
178
+ required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
217
179
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
218
180
  unless missing_fields.empty?
219
- raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
181
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
220
182
  end
221
- when 'callspread', 'putspread'
222
- required_fields = [:short_leg_symbol, :long_leg_symbol]
183
+ when 'VERTICAL'
184
+ required_fields = %i[short_leg_symbol long_leg_symbol]
223
185
  missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
224
186
  unless missing_fields.empty?
225
- raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
187
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
188
+ end
189
+ when 'SINGLE'
190
+ required_fields = %i[symbol]
191
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
192
+ unless missing_fields.empty?
193
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
226
194
  end
227
195
  else
228
196
  raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
@@ -230,63 +198,65 @@ module SchwabMCP
230
198
  end
231
199
 
232
200
  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}"
201
+ strategy = params[:strategy_type].to_s.upcase
202
+ strategy_summary = case strategy
203
+ when 'IRON_CONDOR'
204
+ "**Iron Condor Order Replaced**\n" \
205
+ "- Put Short: #{params[:put_short_symbol]}\n" \
206
+ "- Put Long: #{params[:put_long_symbol]}\n" \
207
+ "- Call Short: #{params[:call_short_symbol]}\n" \
208
+ "- Call Long: #{params[:call_long_symbol]}\n"
209
+ when 'VERTICAL'
210
+ "**Vertical Spread Order Replaced**\n" \
211
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
212
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
213
+ when 'SINGLE'
214
+ "**Single Option Order Replaced**\n" \
215
+ "- Symbol: #{params[:symbol]}\n"
277
216
  end
217
+
218
+ friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
219
+
220
+ order_details = "**Replacement Order Details:**\n" \
221
+ "- Original Order ID: #{params[:order_id]}\n" \
222
+ "- Strategy: #{params[:strategy_type]}\n" \
223
+ "- Action: #{params[:order_instruction] || "open"}\n" \
224
+ "- Quantity: #{params[:quantity] || 1}\n" \
225
+ "- Price: $#{params[:price]}\n" \
226
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
227
+
228
+ new_order_id = extract_order_id_from_response(response)
229
+ order_id_info = new_order_id ? "**New Order ID**: #{new_order_id}\n\n" : ""
230
+
231
+ response_info = if response.body && !response.body.empty?
232
+ begin
233
+ parsed = JSON.parse(response.body)
234
+ redacted_data = Redactor.redact(parsed)
235
+ "**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
236
+ rescue JSON::ParserError
237
+ "**Schwab API Response:**\n\n```\n#{response.body}\n```"
238
+ end
239
+ else
240
+ "**Status**: Order replaced successfully (HTTP #{response.status})\n\n" \
241
+ "The original order has been canceled and a new order has been created."
242
+ end
243
+
244
+ "#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
245
+ rescue StandardError => e
246
+ log_error("Error formatting response: #{e.message}")
247
+ "**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
278
248
  end
279
249
 
280
250
  def self.extract_order_id_from_response(response)
281
251
  # Schwab API typically returns the new order ID in the Location header
282
252
  # Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
283
- location = response.headers['Location'] || response.headers['location']
253
+ location = response.headers["Location"] || response.headers["location"]
284
254
  return nil unless location
285
255
 
286
256
  # Extract order ID from the URL path
287
257
  match = location.match(%r{/orders/(\d+)$})
288
258
  match ? match[1] : nil
289
- rescue => e
259
+ rescue StandardError => e
290
260
  log_debug("Could not extract order ID from response: #{e.message}")
291
261
  nil
292
262
  end
@@ -303,7 +273,7 @@ module SchwabMCP
303
273
  else
304
274
  "No additional error details provided."
305
275
  end
306
- rescue => e
276
+ rescue StandardError => e
307
277
  log_debug("Error extracting error details: #{e.message}")
308
278
  "Could not extract error details."
309
279
  end