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,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 ironcondor vertical],
|
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,31 @@ 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
104
|
account_id, account_hash = account_result
|
108
105
|
|
109
|
-
order_builder =
|
106
|
+
order_builder = SchwabRb::Orders::OrderFactory.build(
|
110
107
|
strategy_type: params[:strategy_type],
|
111
108
|
account_number: account_id,
|
112
109
|
price: params[:price],
|
113
110
|
quantity: params[:quantity] || 1,
|
114
111
|
order_instruction: (params[:order_instruction] || "open").to_sym,
|
112
|
+
credit_debit: (params[:credit_debit] || "credit").to_sym,
|
115
113
|
# Iron Condor params
|
116
114
|
put_short_symbol: params[:put_short_symbol],
|
117
115
|
put_long_symbol: params[:put_long_symbol],
|
@@ -119,7 +117,9 @@ module SchwabMCP
|
|
119
117
|
call_long_symbol: params[:call_long_symbol],
|
120
118
|
# Vertical spread params
|
121
119
|
short_leg_symbol: params[:short_leg_symbol],
|
122
|
-
long_leg_symbol: params[:long_leg_symbol]
|
120
|
+
long_leg_symbol: params[:long_leg_symbol],
|
121
|
+
# Single
|
122
|
+
symbol: params[:symbol]
|
123
123
|
)
|
124
124
|
|
125
125
|
log_debug("Making place order API request")
|
@@ -129,77 +129,67 @@ module SchwabMCP
|
|
129
129
|
log_info("Successfully placed #{params[:strategy_type]} order (HTTP #{response.status})")
|
130
130
|
formatted_response = format_place_order_response(response, params)
|
131
131
|
MCP::Tool::Response.new([{
|
132
|
-
|
133
|
-
|
134
|
-
|
132
|
+
type: "text",
|
133
|
+
text: formatted_response
|
134
|
+
}])
|
135
135
|
elsif response
|
136
136
|
log_error("Order placement failed with HTTP status #{response.status}")
|
137
137
|
error_details = extract_error_details(response)
|
138
138
|
MCP::Tool::Response.new([{
|
139
|
-
|
140
|
-
|
141
|
-
|
139
|
+
type: "text",
|
140
|
+
text: "**Error**: Order placement failed (HTTP #{response.status})\n\n#{error_details}"
|
141
|
+
}])
|
142
142
|
else
|
143
143
|
log_warn("Empty response from Schwab API for order placement")
|
144
144
|
MCP::Tool::Response.new([{
|
145
|
-
|
146
|
-
|
147
|
-
|
145
|
+
type: "text",
|
146
|
+
text: "**No Data**: Empty response from Schwab API for order placement"
|
147
|
+
}])
|
148
148
|
end
|
149
|
-
|
150
|
-
rescue => e
|
149
|
+
rescue StandardError => e
|
151
150
|
log_error("Error placing #{params[:strategy_type]} order: #{e.message}")
|
152
151
|
log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
|
153
152
|
MCP::Tool::Response.new([{
|
154
|
-
|
155
|
-
|
156
|
-
|
153
|
+
type: "text",
|
154
|
+
text: "**Error** placing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
155
|
+
}])
|
157
156
|
end
|
158
157
|
end
|
159
158
|
|
160
|
-
private
|
161
|
-
|
162
159
|
def self.resolve_account_details(client, account_name)
|
163
160
|
account_id = ENV[account_name]
|
164
161
|
unless account_id
|
165
|
-
available_accounts = ENV.keys.select { |key| key.end_with?(
|
162
|
+
available_accounts = ENV.keys.select { |key| key.end_with?("_ACCOUNT") }
|
166
163
|
log_error("Account name '#{account_name}' not found in environment variables")
|
167
164
|
return MCP::Tool::Response.new([{
|
168
|
-
|
169
|
-
|
170
|
-
|
165
|
+
type: "text",
|
166
|
+
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."
|
167
|
+
}])
|
171
168
|
end
|
172
169
|
|
173
170
|
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
174
171
|
log_debug("Fetching account numbers mapping")
|
175
172
|
|
176
|
-
|
173
|
+
account_numbers = client.get_account_numbers
|
177
174
|
|
178
|
-
unless
|
175
|
+
unless account_numbers
|
179
176
|
log_error("Failed to retrieve account numbers")
|
180
177
|
return MCP::Tool::Response.new([{
|
181
|
-
|
182
|
-
|
183
|
-
|
178
|
+
type: "text",
|
179
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
180
|
+
}])
|
184
181
|
end
|
185
182
|
|
186
|
-
|
187
|
-
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
183
|
+
log_debug("Account mappings retrieved (#{account_numbers.size} accounts found)")
|
188
184
|
|
189
|
-
account_hash =
|
190
|
-
account_mappings.each do |mapping|
|
191
|
-
if mapping[:accountNumber] == account_id
|
192
|
-
account_hash = mapping[:hashValue]
|
193
|
-
break
|
194
|
-
end
|
195
|
-
end
|
185
|
+
account_hash = account_numbers.find_hash_value(account_id)
|
196
186
|
|
197
187
|
unless account_hash
|
198
188
|
log_error("Account ID not found in available accounts")
|
199
189
|
return MCP::Tool::Response.new([{
|
200
|
-
|
201
|
-
|
202
|
-
|
190
|
+
type: "text",
|
191
|
+
text: "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
|
192
|
+
}])
|
203
193
|
end
|
204
194
|
|
205
195
|
log_debug("Found account hash for account name: #{account_name}")
|
@@ -208,17 +198,17 @@ module SchwabMCP
|
|
208
198
|
|
209
199
|
def self.validate_strategy_params(params)
|
210
200
|
case params[:strategy_type]
|
211
|
-
when
|
212
|
-
required_fields = [
|
201
|
+
when "ironcondor"
|
202
|
+
required_fields = %i[put_short_symbol put_long_symbol call_short_symbol call_long_symbol]
|
213
203
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
214
204
|
unless missing_fields.empty?
|
215
|
-
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(
|
205
|
+
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(", ")}"
|
216
206
|
end
|
217
|
-
when
|
218
|
-
required_fields = [
|
207
|
+
when "callspread", "putspread"
|
208
|
+
required_fields = %i[short_leg_symbol long_leg_symbol]
|
219
209
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
220
210
|
unless missing_fields.empty?
|
221
|
-
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(
|
211
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(", ")}"
|
222
212
|
end
|
223
213
|
else
|
224
214
|
raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
|
@@ -226,60 +216,58 @@ module SchwabMCP
|
|
226
216
|
end
|
227
217
|
|
228
218
|
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
|
-
|
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
|
-
"**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
|
271
|
-
end
|
219
|
+
strategy_summary = case params[:strategy_type]
|
220
|
+
when "ironcondor"
|
221
|
+
"**Iron Condor Order Placed**\n" \
|
222
|
+
"- Put Short: #{params[:put_short_symbol]}\n" \
|
223
|
+
"- Put Long: #{params[:put_long_symbol]}\n" \
|
224
|
+
"- Call Short: #{params[:call_short_symbol]}\n" \
|
225
|
+
"- Call Long: #{params[:call_long_symbol]}\n"
|
226
|
+
when "callspread", "putspread"
|
227
|
+
"**#{params[:strategy_type].capitalize} Order Placed**\n" \
|
228
|
+
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
229
|
+
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
230
|
+
end
|
231
|
+
|
232
|
+
friendly_name = params[:account_name].gsub("_ACCOUNT", "").split("_").map(&:capitalize).join(" ")
|
233
|
+
|
234
|
+
order_details = "**Order Details:**\n" \
|
235
|
+
"- Strategy: #{params[:strategy_type]}\n" \
|
236
|
+
"- Action: #{params[:order_instruction] || "open"}\n" \
|
237
|
+
"- Quantity: #{params[:quantity] || 1}\n" \
|
238
|
+
"- Price: $#{params[:price]}\n" \
|
239
|
+
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
240
|
+
|
241
|
+
order_id = extract_order_id_from_response(response)
|
242
|
+
order_id_info = order_id ? "**Order ID**: #{order_id}\n\n" : ""
|
243
|
+
|
244
|
+
response_info = if response.body && !response.body.empty?
|
245
|
+
begin
|
246
|
+
parsed = JSON.parse(response.body)
|
247
|
+
redacted_data = Redactor.redact(parsed)
|
248
|
+
"**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
249
|
+
rescue JSON::ParserError
|
250
|
+
"**Schwab API Response:**\n\n```\n#{response.body}\n```"
|
251
|
+
end
|
252
|
+
else
|
253
|
+
"**Status**: Order submitted successfully (HTTP #{response.status})"
|
254
|
+
end
|
255
|
+
|
256
|
+
"#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
|
257
|
+
rescue StandardError => e
|
258
|
+
log_error("Error formatting response: #{e.message}")
|
259
|
+
"**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
|
272
260
|
end
|
273
261
|
|
274
262
|
def self.extract_order_id_from_response(response)
|
275
263
|
# Schwab API typically returns the order ID in the Location header
|
276
264
|
# Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
|
277
|
-
location = response.headers[
|
265
|
+
location = response.headers["Location"] || response.headers["location"]
|
278
266
|
return nil unless location
|
279
267
|
|
280
268
|
match = location.match(%r{/orders/(\d+)$})
|
281
269
|
match ? match[1] : nil
|
282
|
-
rescue => e
|
270
|
+
rescue StandardError => e
|
283
271
|
log_debug("Could not extract order ID from response: #{e.message}")
|
284
272
|
nil
|
285
273
|
end
|
@@ -296,7 +284,7 @@ module SchwabMCP
|
|
296
284
|
else
|
297
285
|
"No additional error details provided."
|
298
286
|
end
|
299
|
-
rescue => e
|
287
|
+
rescue StandardError => e
|
300
288
|
log_debug("Error extracting error details: #{e.message}")
|
301
289
|
"Could not extract error details."
|
302
290
|
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: ["ironcondor", "
|
23
|
+
enum: ["ironcondor", "vertical", "single"],
|
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,30 @@ 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
109
|
account_id, account_hash = account_result
|
110
110
|
|
111
|
-
order_builder =
|
111
|
+
order_builder = SchwabRb::Orders::OrderFactory.build(
|
112
112
|
strategy_type: params[:strategy_type],
|
113
113
|
account_number: account_id,
|
114
114
|
price: params[:price],
|
115
115
|
quantity: params[:quantity] || 1,
|
116
116
|
order_instruction: (params[:order_instruction] || "open").to_sym,
|
117
|
+
credit_debit: (params[:credit_debit] || "credit").to_sym,
|
117
118
|
# Iron Condor params
|
118
119
|
put_short_symbol: params[:put_short_symbol],
|
119
120
|
put_long_symbol: params[:put_long_symbol],
|
@@ -121,33 +122,36 @@ module SchwabMCP
|
|
121
122
|
call_long_symbol: params[:call_long_symbol],
|
122
123
|
# Vertical spread params
|
123
124
|
short_leg_symbol: params[:short_leg_symbol],
|
124
|
-
long_leg_symbol: params[:long_leg_symbol]
|
125
|
+
long_leg_symbol: params[:long_leg_symbol],
|
126
|
+
# Single option params
|
127
|
+
symbol: params[:symbol],
|
125
128
|
)
|
126
129
|
|
127
130
|
log_debug("Making preview order API request")
|
128
|
-
response = client.preview_order(account_hash, order_builder)
|
131
|
+
response = client.preview_order(account_hash, order_builder, return_data_objects: true)
|
129
132
|
|
130
|
-
if response
|
133
|
+
if response
|
131
134
|
log_info("Successfully previewed #{params[:strategy_type]} order")
|
132
|
-
formatted_response = format_preview_response(response
|
135
|
+
formatted_response = format_preview_response(response, params)
|
133
136
|
MCP::Tool::Response.new([{
|
134
137
|
type: "text",
|
135
138
|
text: formatted_response
|
136
139
|
}])
|
137
140
|
else
|
138
141
|
log_warn("Empty response from Schwab API for order preview")
|
142
|
+
error_msg = "**No Data**: Empty response from Schwab API for order preview"
|
139
143
|
MCP::Tool::Response.new([{
|
140
144
|
type: "text",
|
141
|
-
text:
|
145
|
+
text: Redactor.redact_formatted_text(error_msg)
|
142
146
|
}])
|
143
147
|
end
|
144
148
|
|
145
149
|
rescue => e
|
146
150
|
log_error("Error previewing #{params[:strategy_type]} order: #{e.message}")
|
147
|
-
|
151
|
+
error_msg = "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\\n')}"
|
148
152
|
MCP::Tool::Response.new([{
|
149
153
|
type: "text",
|
150
|
-
text:
|
154
|
+
text: Redactor.redact_formatted_text(error_msg)
|
151
155
|
}])
|
152
156
|
end
|
153
157
|
end
|
@@ -159,46 +163,38 @@ module SchwabMCP
|
|
159
163
|
unless account_id
|
160
164
|
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
161
165
|
log_error("Account name '#{account_name}' not found in environment variables")
|
166
|
+
error_msg = "**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."
|
162
167
|
return MCP::Tool::Response.new([{
|
163
168
|
type: "text",
|
164
|
-
text:
|
169
|
+
text: Redactor.redact_formatted_text(error_msg)
|
165
170
|
}])
|
166
171
|
end
|
167
172
|
|
168
173
|
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
169
174
|
log_debug("Fetching account numbers mapping")
|
170
175
|
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
176
|
+
account_numbers = client.get_account_numbers(return_data_objects: true)
|
177
|
+
unless account_numbers && !account_numbers.empty?
|
178
|
+
log_error("Failed to retrieve account numbers or no accounts returned")
|
179
|
+
error_msg = "**Error**: Failed to retrieve account numbers from Schwab API or no accounts returned"
|
175
180
|
return MCP::Tool::Response.new([{
|
176
181
|
type: "text",
|
177
|
-
text:
|
182
|
+
text: Redactor.redact_formatted_text(error_msg)
|
178
183
|
}])
|
179
184
|
end
|
180
185
|
|
181
|
-
|
182
|
-
|
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
|
186
|
+
mapping = account_numbers.accounts.find { |acct| acct.account_number == account_id }
|
187
|
+
unless mapping
|
193
188
|
log_error("Account ID not found in available accounts")
|
189
|
+
error_msg = "**Error**: Account ID not found in available accounts. #{account_numbers.size} accounts available."
|
194
190
|
return MCP::Tool::Response.new([{
|
195
191
|
type: "text",
|
196
|
-
text:
|
192
|
+
text: Redactor.redact_formatted_text(error_msg)
|
197
193
|
}])
|
198
194
|
end
|
199
195
|
|
200
196
|
log_debug("Found account hash for account name: #{account_name}")
|
201
|
-
[account_id,
|
197
|
+
[account_id, mapping.hash_value]
|
202
198
|
end
|
203
199
|
|
204
200
|
def self.validate_strategy_params(params)
|
@@ -209,12 +205,18 @@ module SchwabMCP
|
|
209
205
|
unless missing_fields.empty?
|
210
206
|
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
|
211
207
|
end
|
212
|
-
when '
|
208
|
+
when 'vertical'
|
213
209
|
required_fields = [:short_leg_symbol, :long_leg_symbol]
|
214
210
|
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
215
211
|
unless missing_fields.empty?
|
216
212
|
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
|
217
213
|
end
|
214
|
+
when 'single'
|
215
|
+
required_fields = [:symbol]
|
216
|
+
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
217
|
+
unless missing_fields.empty?
|
218
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
|
219
|
+
end
|
218
220
|
else
|
219
221
|
raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
|
220
222
|
end
|
@@ -232,10 +234,13 @@ module SchwabMCP
|
|
232
234
|
"- Put Long: #{params[:put_long_symbol]}\n" \
|
233
235
|
"- Call Short: #{params[:call_short_symbol]}\n" \
|
234
236
|
"- Call Long: #{params[:call_long_symbol]}\n"
|
235
|
-
when '
|
236
|
-
"
|
237
|
+
when 'vertical'
|
238
|
+
"**Vertical Preview**\n" \
|
237
239
|
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
238
240
|
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
241
|
+
when 'single'
|
242
|
+
"**Single Option Preview**\n" \
|
243
|
+
"- Symbol: #{params[:symbol]}\n"
|
239
244
|
end
|
240
245
|
|
241
246
|
friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
@@ -254,6 +259,53 @@ module SchwabMCP
|
|
254
259
|
"**Order Preview Response:**\n\n```\n#{JSON.pretty_generate(redacted_data)}\n```"
|
255
260
|
end
|
256
261
|
end
|
262
|
+
def self.format_preview_response(order_preview, params)
|
263
|
+
# order_preview is a SchwabRb::DataObjects::OrderPreview
|
264
|
+
begin
|
265
|
+
strategy_summary = case params[:strategy_type]
|
266
|
+
when 'ironcondor'
|
267
|
+
"**Iron Condor Preview**\n" \
|
268
|
+
"- Put Short: #{params[:put_short_symbol]}\n" \
|
269
|
+
"- Put Long: #{params[:put_long_symbol]}\n" \
|
270
|
+
"- Call Short: #{params[:call_short_symbol]}\n" \
|
271
|
+
"- Call Long: #{params[:call_long_symbol]}\n"
|
272
|
+
when 'vertical'
|
273
|
+
"**Vertical Preview**\n" \
|
274
|
+
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
275
|
+
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
276
|
+
when 'single'
|
277
|
+
"**Single Option Preview**\n" \
|
278
|
+
"- Symbol: #{params[:symbol]}\n"
|
279
|
+
end
|
280
|
+
|
281
|
+
friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
282
|
+
|
283
|
+
order_details = "**Order Details:**\n" \
|
284
|
+
"- Strategy: #{params[:strategy_type]}\n" \
|
285
|
+
"- Action: #{params[:order_instruction] || 'open'}\n" \
|
286
|
+
"- Quantity: #{params[:quantity] || 1}\n" \
|
287
|
+
"- Price: $#{params[:price]}\n" \
|
288
|
+
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
289
|
+
|
290
|
+
# Use OrderPreview data object for summary
|
291
|
+
op = order_preview
|
292
|
+
summary = "**Preview Result:**\n" \
|
293
|
+
"- Status: #{op.status || 'N/A'}\n" \
|
294
|
+
"- Price: $#{op.price || 'N/A'}\n" \
|
295
|
+
"- Quantity: #{op.quantity || 'N/A'}\n" \
|
296
|
+
"- Commission: $#{op.commission}\n" \
|
297
|
+
"- Fees: $#{op.fees}\n" \
|
298
|
+
"- Accepted?: #{op.accepted? ? 'Yes' : 'No'}\n"
|
299
|
+
|
300
|
+
# Redact and pretty print the full data object as JSON
|
301
|
+
redacted_data = Redactor.redact(op.to_h)
|
302
|
+
full_response = "**Schwab API Preview Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
303
|
+
|
304
|
+
"#{strategy_summary}\n#{order_details}#{summary}\n#{full_response}"
|
305
|
+
rescue => e
|
306
|
+
"**Order Preview Response:**\n\nError formatting preview: #{e.message}"
|
307
|
+
end
|
308
|
+
end
|
257
309
|
end
|
258
310
|
end
|
259
311
|
end
|