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.
- checksums.yaml +4 -4
- data/.claude/settings.json +14 -0
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +124 -0
- data/README.md +1 -7
- 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 +15 -4
- data/exe/schwab_token_refresh +12 -11
- data/exe/schwab_token_reset +11 -10
- 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 -81
- data/lib/schwab_mcp/tools/get_account_names_tool.rb +58 -0
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +27 -28
- data/lib/schwab_mcp/tools/get_order_tool.rb +50 -137
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +23 -35
- data/lib/schwab_mcp/tools/help_tool.rb +12 -33
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +36 -90
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +43 -98
- data/lib/schwab_mcp/tools/list_movers_tool.rb +21 -34
- data/lib/schwab_mcp/tools/option_chain_tool.rb +132 -84
- data/lib/schwab_mcp/tools/place_order_tool.rb +111 -141
- data/lib/schwab_mcp/tools/preview_order_tool.rb +71 -81
- data/lib/schwab_mcp/tools/quote_tool.rb +33 -28
- data/lib/schwab_mcp/tools/quotes_tool.rb +97 -45
- data/lib/schwab_mcp/tools/replace_order_tool.rb +110 -140
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +56 -98
- data/lib/schwab_mcp/version.rb +1 -1
- data/lib/schwab_mcp.rb +11 -10
- metadata +12 -9
- 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/list_schwab_accounts_tool.rb +0 -162
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +0 -378
- 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:
|
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 =
|
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)
|
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
|
+
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
|
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[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: [
|
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,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?(
|
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
|
-
|
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(
|
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
|
-
|
137
|
-
|
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
|
-
|
144
|
-
|
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
|
-
|
150
|
-
|
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
|
-
|
159
|
-
|
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
|
-
|
168
|
-
unless
|
169
|
-
|
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
|
-
|
173
|
-
|
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("
|
178
|
-
|
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
|
-
|
215
|
-
|
216
|
-
|
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 '
|
222
|
-
required_fields = [
|
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
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
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[
|
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
|