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,9 +1,8 @@
|
|
1
1
|
require "mcp"
|
2
2
|
require "schwab_rb"
|
3
|
-
require "json"
|
4
3
|
require_relative "../loggable"
|
5
|
-
require_relative "../orders/order_factory"
|
6
4
|
require_relative "../redactor"
|
5
|
+
require_relative "../schwab_client_factory"
|
7
6
|
|
8
7
|
module SchwabMCP
|
9
8
|
module Tools
|
@@ -20,7 +19,7 @@ module SchwabMCP
|
|
20
19
|
},
|
21
20
|
strategy_type: {
|
22
21
|
type: "string",
|
23
|
-
enum: [
|
22
|
+
enum: %w[SINGLE VERTICAL IRON_CONDOR],
|
24
23
|
description: "Type of options strategy to place"
|
25
24
|
},
|
26
25
|
price: {
|
@@ -34,10 +33,16 @@ module SchwabMCP
|
|
34
33
|
},
|
35
34
|
order_instruction: {
|
36
35
|
type: "string",
|
37
|
-
enum: [
|
36
|
+
enum: %w[open exit],
|
38
37
|
description: "Whether to open a new position or exit an existing one (default: open)",
|
39
38
|
default: "open"
|
40
39
|
},
|
40
|
+
credit_debit: {
|
41
|
+
type: "string",
|
42
|
+
enum: %w[credit debit],
|
43
|
+
description: "Whether the order is a credit or debit (default: credit)",
|
44
|
+
default: "credit"
|
45
|
+
},
|
41
46
|
put_short_symbol: {
|
42
47
|
type: "string",
|
43
48
|
description: "Option symbol for the short put leg (required for iron condor)"
|
@@ -56,14 +61,18 @@ module SchwabMCP
|
|
56
61
|
},
|
57
62
|
short_leg_symbol: {
|
58
63
|
type: "string",
|
59
|
-
description: "Option symbol for the short leg (required for
|
64
|
+
description: "Option symbol for the short leg (required for vertical spreads)"
|
60
65
|
},
|
61
66
|
long_leg_symbol: {
|
62
67
|
type: "string",
|
63
|
-
description: "Option symbol for the long leg (required for
|
64
|
-
}
|
68
|
+
description: "Option symbol for the long leg (required for vertical spreads)"
|
69
|
+
},
|
70
|
+
symbol: {
|
71
|
+
type: "string",
|
72
|
+
description: "Single option symbol to place an order for (required for single options)"
|
73
|
+
},
|
65
74
|
},
|
66
|
-
required: [
|
75
|
+
required: %w[account_name strategy_type price]
|
67
76
|
)
|
68
77
|
|
69
78
|
annotations(
|
@@ -76,42 +85,28 @@ module SchwabMCP
|
|
76
85
|
def self.call(server_context:, **params)
|
77
86
|
log_info("Placing #{params[:strategy_type]} order for account name: #{params[:account_name]}")
|
78
87
|
|
79
|
-
unless params[:account_name].end_with?(
|
88
|
+
unless params[:account_name].end_with?("_ACCOUNT")
|
80
89
|
log_error("Invalid account name format: #{params[:account_name]}")
|
81
90
|
return MCP::Tool::Response.new([{
|
82
|
-
|
83
|
-
|
84
|
-
|
91
|
+
type: "text",
|
92
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
93
|
+
}])
|
85
94
|
end
|
86
95
|
|
87
96
|
begin
|
88
97
|
validate_strategy_params(params)
|
89
|
-
client =
|
90
|
-
|
91
|
-
ENV['SCHWAB_APP_SECRET'],
|
92
|
-
ENV['SCHWAB_CALLBACK_URI'],
|
93
|
-
ENV['TOKEN_PATH']
|
94
|
-
)
|
95
|
-
|
96
|
-
unless client
|
97
|
-
log_error("Failed to initialize Schwab client")
|
98
|
-
return MCP::Tool::Response.new([{
|
99
|
-
type: "text",
|
100
|
-
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
101
|
-
}])
|
102
|
-
end
|
98
|
+
client = SchwabClientFactory.create_client
|
99
|
+
return SchwabClientFactory.client_error_response unless client
|
103
100
|
|
104
101
|
account_result = resolve_account_details(client, params[:account_name])
|
105
102
|
return account_result if account_result.is_a?(MCP::Tool::Response)
|
106
103
|
|
107
|
-
|
108
|
-
|
109
|
-
order_builder = SchwabMCP::Orders::OrderFactory.build(
|
104
|
+
order_builder = SchwabRb::Orders::OrderFactory.build(
|
110
105
|
strategy_type: params[:strategy_type],
|
111
|
-
account_number: account_id,
|
112
106
|
price: params[:price],
|
113
107
|
quantity: params[:quantity] || 1,
|
114
108
|
order_instruction: (params[:order_instruction] || "open").to_sym,
|
109
|
+
credit_debit: (params[:credit_debit] || "credit").to_sym,
|
115
110
|
# Iron Condor params
|
116
111
|
put_short_symbol: params[:put_short_symbol],
|
117
112
|
put_long_symbol: params[:put_long_symbol],
|
@@ -119,106 +114,79 @@ module SchwabMCP
|
|
119
114
|
call_long_symbol: params[:call_long_symbol],
|
120
115
|
# Vertical spread params
|
121
116
|
short_leg_symbol: params[:short_leg_symbol],
|
122
|
-
long_leg_symbol: params[:long_leg_symbol]
|
117
|
+
long_leg_symbol: params[:long_leg_symbol],
|
118
|
+
# Single
|
119
|
+
symbol: params[:symbol]
|
123
120
|
)
|
124
121
|
|
125
122
|
log_debug("Making place order API request")
|
126
|
-
response = client.place_order(
|
123
|
+
response = client.place_order(account_name: params[:account_name], order: order_builder)
|
127
124
|
|
128
125
|
if response && (200..299).include?(response.status)
|
129
126
|
log_info("Successfully placed #{params[:strategy_type]} order (HTTP #{response.status})")
|
130
127
|
formatted_response = format_place_order_response(response, params)
|
131
128
|
MCP::Tool::Response.new([{
|
132
|
-
|
133
|
-
|
134
|
-
|
129
|
+
type: "text",
|
130
|
+
text: formatted_response
|
131
|
+
}])
|
135
132
|
elsif response
|
136
133
|
log_error("Order placement failed with HTTP status #{response.status}")
|
137
134
|
error_details = extract_error_details(response)
|
138
135
|
MCP::Tool::Response.new([{
|
139
|
-
|
140
|
-
|
141
|
-
|
136
|
+
type: "text",
|
137
|
+
text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
|
138
|
+
}])
|
142
139
|
else
|
143
140
|
log_warn("Empty response from Schwab API for order placement")
|
144
141
|
MCP::Tool::Response.new([{
|
145
|
-
|
146
|
-
|
147
|
-
|
142
|
+
type: "text",
|
143
|
+
text: "**No Data**: Empty response from Schwab API for order placement"
|
144
|
+
}])
|
148
145
|
end
|
149
|
-
|
150
|
-
rescue => e
|
146
|
+
rescue StandardError => e
|
151
147
|
log_error("Error placing #{params[:strategy_type]} order: #{e.message}")
|
152
148
|
log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
|
153
149
|
MCP::Tool::Response.new([{
|
154
|
-
|
155
|
-
|
156
|
-
|
150
|
+
type: "text",
|
151
|
+
text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
152
|
+
}])
|
157
153
|
end
|
158
154
|
end
|
159
155
|
|
160
|
-
private
|
161
|
-
|
162
156
|
def self.resolve_account_details(client, account_name)
|
163
|
-
|
164
|
-
unless
|
165
|
-
|
166
|
-
log_error("Account name '#{account_name}' not found in environment variables")
|
167
|
-
return MCP::Tool::Response.new([{
|
168
|
-
type: "text",
|
169
|
-
text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
|
170
|
-
}])
|
171
|
-
end
|
172
|
-
|
173
|
-
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
174
|
-
log_debug("Fetching account numbers mapping")
|
175
|
-
|
176
|
-
account_numbers_response = client.get_account_numbers
|
177
|
-
|
178
|
-
unless account_numbers_response&.body
|
179
|
-
log_error("Failed to retrieve account numbers")
|
180
|
-
return MCP::Tool::Response.new([{
|
181
|
-
type: "text",
|
182
|
-
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
183
|
-
}])
|
184
|
-
end
|
185
|
-
|
186
|
-
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
187
|
-
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
188
|
-
|
189
|
-
account_hash = nil
|
190
|
-
account_mappings.each do |mapping|
|
191
|
-
if mapping[:accountNumber] == account_id
|
192
|
-
account_hash = mapping[:hashValue]
|
193
|
-
break
|
194
|
-
end
|
195
|
-
end
|
196
|
-
|
197
|
-
unless account_hash
|
198
|
-
log_error("Account ID not found in available accounts")
|
157
|
+
available_accounts = client.available_account_names
|
158
|
+
unless available_accounts.include?(account_name)
|
159
|
+
log_error("Account name '#{account_name}' not found in configured accounts")
|
199
160
|
return MCP::Tool::Response.new([{
|
200
|
-
|
201
|
-
|
202
|
-
|
161
|
+
type: "text",
|
162
|
+
text: "**Error**: Account name '#{account_name}' not found in configured accounts.\n\nAvailable accounts: #{available_accounts.join(", ")}\n\nTo configure: Add the account to your schwab_rb configuration file."
|
163
|
+
}])
|
203
164
|
end
|
204
165
|
|
205
|
-
log_debug("
|
206
|
-
|
166
|
+
log_debug("Using account name: #{account_name}")
|
167
|
+
account_name
|
207
168
|
end
|
208
169
|
|
209
170
|
def self.validate_strategy_params(params)
|
210
|
-
|
211
|
-
|
212
|
-
|
171
|
+
strategy = params[:strategy_type].to_s.upcase
|
172
|
+
case strategy
|
173
|
+
when 'IRON_CONDOR'
|
174
|
+
required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
|
213
175
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
214
176
|
unless missing_fields.empty?
|
215
|
-
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(
|
177
|
+
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
|
216
178
|
end
|
217
|
-
when '
|
218
|
-
required_fields = [
|
179
|
+
when 'VERTICAL'
|
180
|
+
required_fields = %i[short_leg_symbol long_leg_symbol]
|
219
181
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
220
182
|
unless missing_fields.empty?
|
221
|
-
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(
|
183
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
|
184
|
+
end
|
185
|
+
when 'SINGLE'
|
186
|
+
required_fields = %i[symbol]
|
187
|
+
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
188
|
+
unless missing_fields.empty?
|
189
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
|
222
190
|
end
|
223
191
|
else
|
224
192
|
raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
|
@@ -226,60 +194,62 @@ module SchwabMCP
|
|
226
194
|
end
|
227
195
|
|
228
196
|
def self.format_place_order_response(response, params)
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
order_details = "**Order Details:**\n" \
|
246
|
-
"- Strategy: #{params[:strategy_type]}\n" \
|
247
|
-
"- Action: #{params[:order_instruction] || 'open'}\n" \
|
248
|
-
"- Quantity: #{params[:quantity] || 1}\n" \
|
249
|
-
"- Price: $#{params[:price]}\n" \
|
250
|
-
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
251
|
-
|
252
|
-
order_id = extract_order_id_from_response(response)
|
253
|
-
order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
|
254
|
-
|
255
|
-
response_info = if response.body && !response.body.empty?
|
256
|
-
begin
|
257
|
-
parsed = JSON.parse(response.body)
|
258
|
-
redacted_data = Redactor.redact(parsed)
|
259
|
-
"**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
260
|
-
rescue JSON::ParserError
|
261
|
-
"**Schwab API Response:**\n\n```\n#{response.body}\n```"
|
262
|
-
end
|
263
|
-
else
|
264
|
-
"**Status**: Order submitted successfully (HTTP #{response.status})"
|
265
|
-
end
|
266
|
-
|
267
|
-
"#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
|
268
|
-
rescue => e
|
269
|
-
log_error("Error formatting response: #{e.message}")
|
270
|
-
"**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
|
197
|
+
strategy = params[:strategy_type].to_s.upcase
|
198
|
+
strategy_summary = case strategy
|
199
|
+
when 'IRON_CONDOR'
|
200
|
+
"**Iron Condor Order Placed**\n" \
|
201
|
+
"- Put Short: #{params[:put_short_symbol]}\n" \
|
202
|
+
"- Put Long: #{params[:put_long_symbol]}\n" \
|
203
|
+
"- Call Short: #{params[:call_short_symbol]}\n" \
|
204
|
+
"- Call Long: #{params[:call_long_symbol]}\n"
|
205
|
+
when 'VERTICAL'
|
206
|
+
"**Vertical Spread Order Placed**\n" \
|
207
|
+
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
208
|
+
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
209
|
+
when 'SINGLE'
|
210
|
+
"**Single Option Order Placed**\n" \
|
211
|
+
"- Symbol: #{params[:symbol]}\n"
|
271
212
|
end
|
213
|
+
|
214
|
+
friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
|
215
|
+
|
216
|
+
order_details = "**Order Details:**\n" \
|
217
|
+
"- Strategy: #{params[:strategy_type]}\n" \
|
218
|
+
"- Action: #{params[:order_instruction] || "open"}\n" \
|
219
|
+
"- Quantity: #{params[:quantity] || 1}\n" \
|
220
|
+
"- Price: $#{params[:price]}\n" \
|
221
|
+
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
222
|
+
|
223
|
+
order_id = extract_order_id_from_response(response)
|
224
|
+
order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
|
225
|
+
|
226
|
+
response_info = if response.body && !response.body.empty?
|
227
|
+
begin
|
228
|
+
parsed = JSON.parse(response.body)
|
229
|
+
redacted_data = Redactor.redact(parsed)
|
230
|
+
"**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
231
|
+
rescue JSON::ParserError
|
232
|
+
"**Schwab API Response:**\n\n```\n#{response.body}\n```"
|
233
|
+
end
|
234
|
+
else
|
235
|
+
"**Status**: Order submitted successfully (HTTP #{response.status})"
|
236
|
+
end
|
237
|
+
|
238
|
+
"#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
|
239
|
+
rescue StandardError => e
|
240
|
+
log_error("Error formatting response: #{e.message}")
|
241
|
+
"**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
|
272
242
|
end
|
273
243
|
|
274
244
|
def self.extract_order_id_from_response(response)
|
275
245
|
# Schwab API typically returns the order ID in the Location header
|
276
246
|
# Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
|
277
|
-
location = response.headers[
|
247
|
+
location = response.headers["Location"] || response.headers["location"]
|
278
248
|
return nil unless location
|
279
249
|
|
280
250
|
match = location.match(%r{/orders/(\d+)$})
|
281
251
|
match ? match[1] : nil
|
282
|
-
rescue => e
|
252
|
+
rescue StandardError => e
|
283
253
|
log_debug("Could not extract order ID from response: #{e.message}")
|
284
254
|
nil
|
285
255
|
end
|
@@ -296,7 +266,7 @@ module SchwabMCP
|
|
296
266
|
else
|
297
267
|
"No additional error details provided."
|
298
268
|
end
|
299
|
-
rescue => e
|
269
|
+
rescue StandardError => e
|
300
270
|
log_debug("Error extracting error details: #{e.message}")
|
301
271
|
"Could not extract error details."
|
302
272
|
end
|
@@ -2,8 +2,8 @@ require "mcp"
|
|
2
2
|
require "schwab_rb"
|
3
3
|
require "json"
|
4
4
|
require_relative "../loggable"
|
5
|
-
require_relative "../orders/order_factory"
|
6
5
|
require_relative "../redactor"
|
6
|
+
require_relative "../schwab_client_factory"
|
7
7
|
|
8
8
|
module SchwabMCP
|
9
9
|
module Tools
|
@@ -20,7 +20,7 @@ module SchwabMCP
|
|
20
20
|
},
|
21
21
|
strategy_type: {
|
22
22
|
type: "string",
|
23
|
-
enum: [
|
23
|
+
enum: %w[SINGLE VERTICAL IRON_CONDOR],
|
24
24
|
description: "Type of options strategy to preview"
|
25
25
|
},
|
26
26
|
price: {
|
@@ -38,6 +38,12 @@ module SchwabMCP
|
|
38
38
|
description: "Whether to open a new position or exit an existing one (default: open)",
|
39
39
|
default: "open"
|
40
40
|
},
|
41
|
+
credit_debit: {
|
42
|
+
type: "string",
|
43
|
+
enum: %w[credit debit],
|
44
|
+
description: "Whether the order is a credit or debit (default: credit)",
|
45
|
+
default: "credit"
|
46
|
+
},
|
41
47
|
# Iron Condor specific fields
|
42
48
|
put_short_symbol: {
|
43
49
|
type: "string",
|
@@ -63,6 +69,11 @@ module SchwabMCP
|
|
63
69
|
long_leg_symbol: {
|
64
70
|
type: "string",
|
65
71
|
description: "Option symbol for the long leg (required for call/put spreads)"
|
72
|
+
},
|
73
|
+
# Single option specific field
|
74
|
+
symbol: {
|
75
|
+
type: "string",
|
76
|
+
description: "Single option symbol to place an order for (required for single options)"
|
66
77
|
}
|
67
78
|
},
|
68
79
|
required: ["account_name", "strategy_type", "price"]
|
@@ -80,40 +91,27 @@ module SchwabMCP
|
|
80
91
|
|
81
92
|
unless params[:account_name].end_with?('_ACCOUNT')
|
82
93
|
log_error("Invalid account name format: #{params[:account_name]}")
|
94
|
+
error_msg = "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
83
95
|
return MCP::Tool::Response.new([{
|
84
96
|
type: "text",
|
85
|
-
text:
|
97
|
+
text: Redactor.redact_formatted_text(error_msg)
|
86
98
|
}])
|
87
99
|
end
|
88
100
|
|
89
101
|
begin
|
90
102
|
validate_strategy_params(params)
|
91
|
-
client =
|
92
|
-
|
93
|
-
ENV['SCHWAB_APP_SECRET'],
|
94
|
-
ENV['SCHWAB_CALLBACK_URI'],
|
95
|
-
ENV['TOKEN_PATH']
|
96
|
-
)
|
97
|
-
|
98
|
-
unless client
|
99
|
-
log_error("Failed to initialize Schwab client")
|
100
|
-
return MCP::Tool::Response.new([{
|
101
|
-
type: "text",
|
102
|
-
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
103
|
-
}])
|
104
|
-
end
|
103
|
+
client = SchwabClientFactory.create_client
|
104
|
+
return SchwabClientFactory.client_error_response unless client
|
105
105
|
|
106
106
|
account_result = resolve_account_details(client, params[:account_name])
|
107
107
|
return account_result if account_result.is_a?(MCP::Tool::Response)
|
108
108
|
|
109
|
-
|
110
|
-
|
111
|
-
order_builder = SchwabMCP::Orders::OrderFactory.build(
|
109
|
+
order_builder = SchwabRb::Orders::OrderFactory.build(
|
112
110
|
strategy_type: params[:strategy_type],
|
113
|
-
account_number: account_id,
|
114
111
|
price: params[:price],
|
115
112
|
quantity: params[:quantity] || 1,
|
116
113
|
order_instruction: (params[:order_instruction] || "open").to_sym,
|
114
|
+
credit_debit: (params[:credit_debit] || "credit").to_sym,
|
117
115
|
# Iron Condor params
|
118
116
|
put_short_symbol: params[:put_short_symbol],
|
119
117
|
put_long_symbol: params[:put_long_symbol],
|
@@ -121,33 +119,36 @@ module SchwabMCP
|
|
121
119
|
call_long_symbol: params[:call_long_symbol],
|
122
120
|
# Vertical spread params
|
123
121
|
short_leg_symbol: params[:short_leg_symbol],
|
124
|
-
long_leg_symbol: params[:long_leg_symbol]
|
122
|
+
long_leg_symbol: params[:long_leg_symbol],
|
123
|
+
# Single option params
|
124
|
+
symbol: params[:symbol]
|
125
125
|
)
|
126
126
|
|
127
127
|
log_debug("Making preview order API request")
|
128
|
-
response = client.preview_order(
|
128
|
+
response = client.preview_order(account_name: params[:account_name], order: order_builder, return_data_objects: true)
|
129
129
|
|
130
|
-
if response
|
130
|
+
if response
|
131
131
|
log_info("Successfully previewed #{params[:strategy_type]} order")
|
132
|
-
formatted_response = format_preview_response(response
|
132
|
+
formatted_response = format_preview_response(response, params)
|
133
133
|
MCP::Tool::Response.new([{
|
134
134
|
type: "text",
|
135
135
|
text: formatted_response
|
136
136
|
}])
|
137
137
|
else
|
138
138
|
log_warn("Empty response from Schwab API for order preview")
|
139
|
+
error_msg = "**No Data**: Empty response from Schwab API for order preview"
|
139
140
|
MCP::Tool::Response.new([{
|
140
141
|
type: "text",
|
141
|
-
text:
|
142
|
+
text: Redactor.redact_formatted_text(error_msg)
|
142
143
|
}])
|
143
144
|
end
|
144
145
|
|
145
146
|
rescue => e
|
146
147
|
log_error("Error previewing #{params[:strategy_type]} order: #{e.message}")
|
147
|
-
|
148
|
+
error_msg = "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\\n')}"
|
148
149
|
MCP::Tool::Response.new([{
|
149
150
|
type: "text",
|
150
|
-
text:
|
151
|
+
text: Redactor.redact_formatted_text(error_msg)
|
151
152
|
}])
|
152
153
|
end
|
153
154
|
end
|
@@ -155,87 +156,64 @@ module SchwabMCP
|
|
155
156
|
private
|
156
157
|
|
157
158
|
def self.resolve_account_details(client, account_name)
|
158
|
-
|
159
|
-
unless
|
160
|
-
|
161
|
-
|
162
|
-
return MCP::Tool::Response.new([{
|
163
|
-
type: "text",
|
164
|
-
text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
|
165
|
-
}])
|
166
|
-
end
|
167
|
-
|
168
|
-
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
169
|
-
log_debug("Fetching account numbers mapping")
|
170
|
-
|
171
|
-
account_numbers_response = client.get_account_numbers
|
172
|
-
|
173
|
-
unless account_numbers_response&.body
|
174
|
-
log_error("Failed to retrieve account numbers")
|
175
|
-
return MCP::Tool::Response.new([{
|
176
|
-
type: "text",
|
177
|
-
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
178
|
-
}])
|
179
|
-
end
|
180
|
-
|
181
|
-
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
182
|
-
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
183
|
-
|
184
|
-
account_hash = nil
|
185
|
-
account_mappings.each do |mapping|
|
186
|
-
if mapping[:accountNumber] == account_id
|
187
|
-
account_hash = mapping[:hashValue]
|
188
|
-
break
|
189
|
-
end
|
190
|
-
end
|
191
|
-
|
192
|
-
unless account_hash
|
193
|
-
log_error("Account ID not found in available accounts")
|
159
|
+
available_accounts = client.available_account_names
|
160
|
+
unless available_accounts.include?(account_name)
|
161
|
+
log_error("Account name '#{account_name}' not found in configured accounts")
|
162
|
+
error_msg = "**Error**: Account name '#{account_name}' not found in configured accounts.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Add the account to your schwab_rb configuration file."
|
194
163
|
return MCP::Tool::Response.new([{
|
195
164
|
type: "text",
|
196
|
-
text:
|
165
|
+
text: Redactor.redact_formatted_text(error_msg)
|
197
166
|
}])
|
198
167
|
end
|
199
168
|
|
200
|
-
log_debug("
|
201
|
-
|
169
|
+
log_debug("Using account name: #{account_name}")
|
170
|
+
account_name
|
202
171
|
end
|
203
172
|
|
204
173
|
def self.validate_strategy_params(params)
|
205
|
-
|
206
|
-
|
174
|
+
strategy = params[:strategy_type].to_s.upcase
|
175
|
+
case strategy
|
176
|
+
when 'IRON_CONDOR'
|
207
177
|
required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
|
208
178
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
209
179
|
unless missing_fields.empty?
|
210
180
|
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
|
211
181
|
end
|
212
|
-
when '
|
182
|
+
when 'VERTICAL'
|
213
183
|
required_fields = [:short_leg_symbol, :long_leg_symbol]
|
214
184
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
215
185
|
unless missing_fields.empty?
|
216
186
|
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
|
217
187
|
end
|
188
|
+
when 'SINGLE'
|
189
|
+
required_fields = [:symbol]
|
190
|
+
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
191
|
+
unless missing_fields.empty?
|
192
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
|
193
|
+
end
|
218
194
|
else
|
219
195
|
raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
|
220
196
|
end
|
221
197
|
end
|
222
198
|
|
223
|
-
def self.format_preview_response(
|
224
|
-
|
225
|
-
redacted_data = Redactor.redact(parsed)
|
226
|
-
|
199
|
+
def self.format_preview_response(order_preview, params)
|
200
|
+
# order_preview is a SchwabRb::DataObjects::OrderPreview
|
227
201
|
begin
|
228
|
-
|
229
|
-
|
202
|
+
strategy = params[:strategy_type].to_s.upcase
|
203
|
+
strategy_summary = case strategy
|
204
|
+
when 'IRON_CONDOR'
|
230
205
|
"**Iron Condor Preview**\n" \
|
231
206
|
"- Put Short: #{params[:put_short_symbol]}\n" \
|
232
207
|
"- Put Long: #{params[:put_long_symbol]}\n" \
|
233
208
|
"- Call Short: #{params[:call_short_symbol]}\n" \
|
234
209
|
"- Call Long: #{params[:call_long_symbol]}\n"
|
235
|
-
when '
|
236
|
-
"
|
210
|
+
when 'VERTICAL'
|
211
|
+
"**Vertical Spread Preview**\n" \
|
237
212
|
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
238
213
|
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
214
|
+
when 'SINGLE'
|
215
|
+
"**Single Option Preview**\n" \
|
216
|
+
"- Symbol: #{params[:symbol]}\n"
|
239
217
|
end
|
240
218
|
|
241
219
|
friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
@@ -247,11 +225,23 @@ module SchwabMCP
|
|
247
225
|
"- Price: $#{params[:price]}\n" \
|
248
226
|
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
249
227
|
|
228
|
+
# Use OrderPreview data object for summary
|
229
|
+
op = order_preview
|
230
|
+
summary = "**Preview Result:**\n" \
|
231
|
+
"- Status: #{op.status || 'N/A'}\n" \
|
232
|
+
"- Price: $#{op.price || 'N/A'}\n" \
|
233
|
+
"- Quantity: #{op.quantity || 'N/A'}\n" \
|
234
|
+
"- Commission: $#{op.commission}\n" \
|
235
|
+
"- Fees: $#{op.fees}\n" \
|
236
|
+
"- Accepted?: #{op.accepted? ? 'Yes' : 'No'}\n"
|
237
|
+
|
238
|
+
# Redact and pretty print the full data object as JSON
|
239
|
+
redacted_data = Redactor.redact(op.to_h)
|
250
240
|
full_response = "**Schwab API Preview Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
251
241
|
|
252
|
-
"#{strategy_summary}\n#{order_details}#{full_response}"
|
253
|
-
rescue
|
254
|
-
"**Order Preview Response:**\n\
|
242
|
+
"#{strategy_summary}\n#{order_details}#{summary}\n#{full_response}"
|
243
|
+
rescue => e
|
244
|
+
"**Order Preview Response:**\n\nError formatting preview: #{e.message}"
|
255
245
|
end
|
256
246
|
end
|
257
247
|
end
|