schwab_mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/settings.json +14 -0
- data/CLAUDE.md +124 -0
- data/debug_env.rb +46 -0
- data/doc/DATA_OBJECTS_MIGRATION_TODO.md +80 -0
- data/doc/SCHWAB_CLIENT_FACTORY_REFACTOR_PLAN.md +187 -0
- data/exe/schwab_mcp +14 -3
- data/exe/schwab_token_refresh +10 -9
- data/lib/schwab_mcp/redactor.rb +4 -0
- data/lib/schwab_mcp/schwab_client_factory.rb +44 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +29 -50
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
- data/lib/schwab_mcp/tools/get_order_tool.rb +51 -108
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
- data/lib/schwab_mcp/tools/help_tool.rb +1 -22
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +35 -63
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -72
- data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +18 -31
- data/lib/schwab_mcp/tools/option_chain_tool.rb +130 -82
- data/lib/schwab_mcp/tools/place_order_tool.rb +105 -117
- data/lib/schwab_mcp/tools/preview_order_tool.rb +100 -48
- data/lib/schwab_mcp/tools/quote_tool.rb +33 -26
- data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
- data/lib/schwab_mcp/tools/replace_order_tool.rb +104 -116
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -72
- data/lib/schwab_mcp/version.rb +1 -1
- data/lib/schwab_mcp.rb +1 -2
- data/orders_example.json +7084 -0
- data/spx_option_chain.json +25073 -0
- data/test_mcp.rb +16 -0
- data/test_server.rb +23 -0
- data/trading_brokerage_account_details.json +89 -0
- data/transactions_example.json +488 -0
- metadata +17 -7
- data/lib/schwab_mcp/option_chain_filter.rb +0 -213
- data/lib/schwab_mcp/orders/iron_condor_order.rb +0 -87
- data/lib/schwab_mcp/orders/order_factory.rb +0 -40
- data/lib/schwab_mcp/orders/vertical_order.rb +0 -62
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
@@ -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 =
|
35
|
-
|
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
|
-
|
41
|
+
quote_obj = client.get_quote(symbol.upcase, return_data_objects: true)
|
51
42
|
|
52
|
-
|
53
|
-
|
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**:
|
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
|
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 =
|
54
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
104
|
-
|
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: [
|
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: [
|
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: [
|
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?(
|
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
|
-
|
87
|
-
|
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 =
|
94
|
-
|
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 =
|
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
|
-
|
137
|
-
|
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
|
-
|
144
|
-
|
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
|
-
|
150
|
-
|
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
|
-
|
159
|
-
|
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?(
|
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
|
-
|
173
|
-
|
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
|
-
|
177
|
+
account_numbers = client.get_account_numbers
|
181
178
|
|
182
|
-
unless
|
179
|
+
unless account_numbers
|
183
180
|
log_error("Failed to retrieve account numbers")
|
184
181
|
return MCP::Tool::Response.new([{
|
185
|
-
|
186
|
-
|
187
|
-
|
182
|
+
type: "text",
|
183
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
184
|
+
}])
|
188
185
|
end
|
189
186
|
|
190
|
-
|
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 =
|
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
|
-
|
205
|
-
|
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
|
216
|
-
required_fields = [
|
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
|
222
|
-
required_fields = [
|
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
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
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[
|
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
|