schwab_mcp 0.1.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 +7 -0
- data/.copilotignore +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/Rakefile +12 -0
- data/exe/schwab_mcp +19 -0
- data/exe/schwab_token_refresh +38 -0
- data/exe/schwab_token_reset +49 -0
- data/lib/schwab_mcp/loggable.rb +31 -0
- data/lib/schwab_mcp/logger.rb +62 -0
- data/lib/schwab_mcp/option_chain_filter.rb +213 -0
- data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
- data/lib/schwab_mcp/orders/order_factory.rb +40 -0
- data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
- data/lib/schwab_mcp/redactor.rb +210 -0
- data/lib/schwab_mcp/resources/.keep +0 -0
- data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
- data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
- data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
- data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
- data/lib/schwab_mcp/tools/help_tool.rb +406 -0
- data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
- data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
- data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
- data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
- data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
- data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
- data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
- data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
- data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
- data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
- data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
- data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
- data/lib/schwab_mcp/version.rb +5 -0
- data/lib/schwab_mcp.rb +107 -0
- data/sig/schwab_mcp.rbs +4 -0
- data/start_mcp_server.sh +4 -0
- metadata +115 -0
@@ -0,0 +1,312 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
require_relative "../orders/order_factory"
|
6
|
+
require_relative "../redactor"
|
7
|
+
|
8
|
+
module SchwabMCP
|
9
|
+
module Tools
|
10
|
+
class ReplaceOrderTool < MCP::Tool
|
11
|
+
extend Loggable
|
12
|
+
description "Replace an existing options order with a new order (iron condor, call spread, put spread). The existing order will be canceled and a new order will be created."
|
13
|
+
|
14
|
+
input_schema(
|
15
|
+
properties: {
|
16
|
+
account_name: {
|
17
|
+
type: "string",
|
18
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
19
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
20
|
+
},
|
21
|
+
order_id: {
|
22
|
+
type: "string",
|
23
|
+
description: "The ID of the existing order to replace"
|
24
|
+
},
|
25
|
+
strategy_type: {
|
26
|
+
type: "string",
|
27
|
+
enum: ["ironcondor", "callspread", "putspread"],
|
28
|
+
description: "Type of options strategy for the replacement order"
|
29
|
+
},
|
30
|
+
price: {
|
31
|
+
type: "number",
|
32
|
+
description: "Net price for the replacement order (credit for selling strategies, debit for buying strategies)"
|
33
|
+
},
|
34
|
+
quantity: {
|
35
|
+
type: "integer",
|
36
|
+
description: "Number of contracts (default: 1)",
|
37
|
+
default: 1
|
38
|
+
},
|
39
|
+
order_instruction: {
|
40
|
+
type: "string",
|
41
|
+
enum: ["open", "exit"],
|
42
|
+
description: "Whether to open a new position or exit an existing one (default: open)",
|
43
|
+
default: "open"
|
44
|
+
},
|
45
|
+
put_short_symbol: {
|
46
|
+
type: "string",
|
47
|
+
description: "Option symbol for the short put leg (required for iron condor)"
|
48
|
+
},
|
49
|
+
put_long_symbol: {
|
50
|
+
type: "string",
|
51
|
+
description: "Option symbol for the long put leg (required for iron condor)"
|
52
|
+
},
|
53
|
+
call_short_symbol: {
|
54
|
+
type: "string",
|
55
|
+
description: "Option symbol for the short call leg (required for iron condor)"
|
56
|
+
},
|
57
|
+
call_long_symbol: {
|
58
|
+
type: "string",
|
59
|
+
description: "Option symbol for the long call leg (required for iron condor)"
|
60
|
+
},
|
61
|
+
short_leg_symbol: {
|
62
|
+
type: "string",
|
63
|
+
description: "Option symbol for the short leg (required for call/put spreads)"
|
64
|
+
},
|
65
|
+
long_leg_symbol: {
|
66
|
+
type: "string",
|
67
|
+
description: "Option symbol for the long leg (required for call/put spreads)"
|
68
|
+
}
|
69
|
+
},
|
70
|
+
required: ["account_name", "order_id", "strategy_type", "price"]
|
71
|
+
)
|
72
|
+
|
73
|
+
annotations(
|
74
|
+
title: "Replace Options Order",
|
75
|
+
read_only_hint: false,
|
76
|
+
destructive_hint: true,
|
77
|
+
idempotent_hint: false
|
78
|
+
)
|
79
|
+
|
80
|
+
def self.call(server_context:, **params)
|
81
|
+
log_info("Replacing order #{params[:order_id]} with #{params[:strategy_type]} order for account name: #{params[:account_name]}")
|
82
|
+
|
83
|
+
unless params[:account_name].end_with?('_ACCOUNT')
|
84
|
+
log_error("Invalid account name format: #{params[:account_name]}")
|
85
|
+
return MCP::Tool::Response.new([{
|
86
|
+
type: "text",
|
87
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
88
|
+
}])
|
89
|
+
end
|
90
|
+
|
91
|
+
begin
|
92
|
+
validate_strategy_params(params)
|
93
|
+
client = SchwabRb::Auth.init_client_easy(
|
94
|
+
ENV['SCHWAB_API_KEY'],
|
95
|
+
ENV['SCHWAB_APP_SECRET'],
|
96
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
97
|
+
ENV['TOKEN_PATH']
|
98
|
+
)
|
99
|
+
|
100
|
+
unless client
|
101
|
+
log_error("Failed to initialize Schwab client")
|
102
|
+
return MCP::Tool::Response.new([{
|
103
|
+
type: "text",
|
104
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
105
|
+
}])
|
106
|
+
end
|
107
|
+
|
108
|
+
account_result = resolve_account_details(client, params[:account_name])
|
109
|
+
return account_result if account_result.is_a?(MCP::Tool::Response)
|
110
|
+
|
111
|
+
account_id, account_hash = account_result
|
112
|
+
|
113
|
+
order_builder = SchwabMCP::Orders::OrderFactory.build(
|
114
|
+
strategy_type: params[:strategy_type],
|
115
|
+
account_number: account_id,
|
116
|
+
price: params[:price],
|
117
|
+
quantity: params[:quantity] || 1,
|
118
|
+
order_instruction: (params[:order_instruction] || "open").to_sym,
|
119
|
+
# Iron Condor params
|
120
|
+
put_short_symbol: params[:put_short_symbol],
|
121
|
+
put_long_symbol: params[:put_long_symbol],
|
122
|
+
call_short_symbol: params[:call_short_symbol],
|
123
|
+
call_long_symbol: params[:call_long_symbol],
|
124
|
+
# Vertical spread params
|
125
|
+
short_leg_symbol: params[:short_leg_symbol],
|
126
|
+
long_leg_symbol: params[:long_leg_symbol]
|
127
|
+
)
|
128
|
+
|
129
|
+
log_debug("Making replace order API request for order ID: #{params[:order_id]}")
|
130
|
+
response = client.replace_order(account_hash, params[:order_id], order_builder)
|
131
|
+
|
132
|
+
if response && (200..299).include?(response.status)
|
133
|
+
log_info("Successfully replaced order #{params[:order_id]} with #{params[:strategy_type]} order (HTTP #{response.status})")
|
134
|
+
formatted_response = format_replace_order_response(response, params)
|
135
|
+
MCP::Tool::Response.new([{
|
136
|
+
type: "text",
|
137
|
+
text: formatted_response
|
138
|
+
}])
|
139
|
+
elsif response
|
140
|
+
log_error("Order replacement failed with HTTP status #{response.status}")
|
141
|
+
error_details = extract_error_details(response)
|
142
|
+
MCP::Tool::Response.new([{
|
143
|
+
type: "text",
|
144
|
+
text: "**Error**: Order replacement failed (HTTP #{response.status})\n\n#{error_details}"
|
145
|
+
}])
|
146
|
+
else
|
147
|
+
log_warn("Empty response from Schwab API for order replacement")
|
148
|
+
MCP::Tool::Response.new([{
|
149
|
+
type: "text",
|
150
|
+
text: "**No Data**: Empty response from Schwab API for order replacement"
|
151
|
+
}])
|
152
|
+
end
|
153
|
+
|
154
|
+
rescue => e
|
155
|
+
log_error("Error replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}")
|
156
|
+
log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
|
157
|
+
MCP::Tool::Response.new([{
|
158
|
+
type: "text",
|
159
|
+
text: "**Error** replacing order #{params[:order_id]} with #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
160
|
+
}])
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def self.resolve_account_details(client, account_name)
|
167
|
+
account_id = ENV[account_name]
|
168
|
+
unless account_id
|
169
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
170
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
171
|
+
return MCP::Tool::Response.new([{
|
172
|
+
type: "text",
|
173
|
+
text: "**Error**: Account name '#{account_name}' not found in environment variables.\n\nAvailable accounts: #{available_accounts.join(', ')}\n\nTo configure: Set ENV['#{account_name}'] to your account ID."
|
174
|
+
}])
|
175
|
+
end
|
176
|
+
|
177
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
178
|
+
log_debug("Fetching account numbers mapping")
|
179
|
+
|
180
|
+
account_numbers_response = client.get_account_numbers
|
181
|
+
|
182
|
+
unless account_numbers_response&.body
|
183
|
+
log_error("Failed to retrieve account numbers")
|
184
|
+
return MCP::Tool::Response.new([{
|
185
|
+
type: "text",
|
186
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
187
|
+
}])
|
188
|
+
end
|
189
|
+
|
190
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
191
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
192
|
+
|
193
|
+
account_hash = nil
|
194
|
+
account_mappings.each do |mapping|
|
195
|
+
if mapping[:accountNumber] == account_id
|
196
|
+
account_hash = mapping[:hashValue]
|
197
|
+
break
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
unless account_hash
|
202
|
+
log_error("Account ID not found in available accounts")
|
203
|
+
return MCP::Tool::Response.new([{
|
204
|
+
type: "text",
|
205
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
206
|
+
}])
|
207
|
+
end
|
208
|
+
|
209
|
+
log_debug("Found account hash for account name: #{account_name}")
|
210
|
+
[account_id, account_hash]
|
211
|
+
end
|
212
|
+
|
213
|
+
def self.validate_strategy_params(params)
|
214
|
+
case params[:strategy_type]
|
215
|
+
when 'ironcondor'
|
216
|
+
required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
|
217
|
+
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
218
|
+
unless missing_fields.empty?
|
219
|
+
raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
|
220
|
+
end
|
221
|
+
when 'callspread', 'putspread'
|
222
|
+
required_fields = [:short_leg_symbol, :long_leg_symbol]
|
223
|
+
missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
|
224
|
+
unless missing_fields.empty?
|
225
|
+
raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
|
226
|
+
end
|
227
|
+
else
|
228
|
+
raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def self.format_replace_order_response(response, params)
|
233
|
+
begin
|
234
|
+
strategy_summary = case params[:strategy_type]
|
235
|
+
when 'ironcondor'
|
236
|
+
"**Iron Condor Order Replaced**\n" \
|
237
|
+
"- Put Short: #{params[:put_short_symbol]}\n" \
|
238
|
+
"- Put Long: #{params[:put_long_symbol]}\n" \
|
239
|
+
"- Call Short: #{params[:call_short_symbol]}\n" \
|
240
|
+
"- Call Long: #{params[:call_long_symbol]}\n"
|
241
|
+
when 'callspread', 'putspread'
|
242
|
+
"**#{params[:strategy_type].capitalize} Order Replaced**\n" \
|
243
|
+
"- Short Leg: #{params[:short_leg_symbol]}\n" \
|
244
|
+
"- Long Leg: #{params[:long_leg_symbol]}\n"
|
245
|
+
end
|
246
|
+
|
247
|
+
friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
248
|
+
|
249
|
+
order_details = "**Replacement Order Details:**\n" \
|
250
|
+
"- Original Order ID: #{params[:order_id]}\n" \
|
251
|
+
"- Strategy: #{params[:strategy_type]}\n" \
|
252
|
+
"- Action: #{params[:order_instruction] || 'open'}\n" \
|
253
|
+
"- Quantity: #{params[:quantity] || 1}\n" \
|
254
|
+
"- Price: $#{params[:price]}\n" \
|
255
|
+
"- Account: #{friendly_name} (#{params[:account_name]})\n\n"
|
256
|
+
|
257
|
+
new_order_id = extract_order_id_from_response(response)
|
258
|
+
order_id_info = new_order_id ? "**New Order ID**: #{new_order_id}\n\n" : ""
|
259
|
+
|
260
|
+
response_info = if response.body && !response.body.empty?
|
261
|
+
begin
|
262
|
+
parsed = JSON.parse(response.body)
|
263
|
+
redacted_data = Redactor.redact(parsed)
|
264
|
+
"**Schwab API Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
265
|
+
rescue JSON::ParserError
|
266
|
+
"**Schwab API Response:**\n\n```\n#{response.body}\n```"
|
267
|
+
end
|
268
|
+
else
|
269
|
+
"**Status**: Order replaced successfully (HTTP #{response.status})\n\n" \
|
270
|
+
"The original order has been canceled and a new order has been created."
|
271
|
+
end
|
272
|
+
|
273
|
+
"#{strategy_summary}\n#{order_details}#{order_id_info}#{response_info}"
|
274
|
+
rescue => e
|
275
|
+
log_error("Error formatting response: #{e.message}")
|
276
|
+
"**Order Status**: #{response.status}\n\n**Raw Response**: #{response.body}"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def self.extract_order_id_from_response(response)
|
281
|
+
# Schwab API typically returns the new order ID in the Location header
|
282
|
+
# Format: https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders/{orderId}
|
283
|
+
location = response.headers['Location'] || response.headers['location']
|
284
|
+
return nil unless location
|
285
|
+
|
286
|
+
# Extract order ID from the URL path
|
287
|
+
match = location.match(%r{/orders/(\d+)$})
|
288
|
+
match ? match[1] : nil
|
289
|
+
rescue => e
|
290
|
+
log_debug("Could not extract order ID from response: #{e.message}")
|
291
|
+
nil
|
292
|
+
end
|
293
|
+
|
294
|
+
def self.extract_error_details(response)
|
295
|
+
if response.body && !response.body.empty?
|
296
|
+
begin
|
297
|
+
parsed = JSON.parse(response.body)
|
298
|
+
redacted_data = Redactor.redact(parsed)
|
299
|
+
"**Error Details:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
300
|
+
rescue JSON::ParserError
|
301
|
+
"**Error Details:**\n\n```\n#{response.body}\n```"
|
302
|
+
end
|
303
|
+
else
|
304
|
+
"No additional error details provided."
|
305
|
+
end
|
306
|
+
rescue => e
|
307
|
+
log_debug("Error extracting error details: #{e.message}")
|
308
|
+
"Could not extract error details."
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
@@ -0,0 +1,208 @@
|
|
1
|
+
require "mcp"
|
2
|
+
require "schwab_rb"
|
3
|
+
require "json"
|
4
|
+
require_relative "../loggable"
|
5
|
+
require_relative "../redactor"
|
6
|
+
|
7
|
+
module SchwabMCP
|
8
|
+
module Tools
|
9
|
+
class SchwabAccountDetailsTool < MCP::Tool
|
10
|
+
extend Loggable
|
11
|
+
description "Get account information for a specific account using account name mapping"
|
12
|
+
|
13
|
+
input_schema(
|
14
|
+
properties: {
|
15
|
+
account_name: {
|
16
|
+
type: "string",
|
17
|
+
description: "Account name mapped to environment variable ending with '_ACCOUNT' (e.g., 'TRADING_BROKERAGE_ACCOUNT')",
|
18
|
+
pattern: "^[A-Z_]+_ACCOUNT$"
|
19
|
+
},
|
20
|
+
fields: {
|
21
|
+
type: "array",
|
22
|
+
description: "Optional account fields to retrieve (balances, positions, orders)",
|
23
|
+
items: {
|
24
|
+
type: "string"
|
25
|
+
}
|
26
|
+
}
|
27
|
+
},
|
28
|
+
required: ["account_name"]
|
29
|
+
)
|
30
|
+
|
31
|
+
annotations(
|
32
|
+
title: "Get Schwab Account Information",
|
33
|
+
read_only_hint: true,
|
34
|
+
destructive_hint: false,
|
35
|
+
idempotent_hint: true
|
36
|
+
)
|
37
|
+
|
38
|
+
def self.call(account_name:, fields: nil, server_context:)
|
39
|
+
log_info("Getting account information for account name: #{account_name}")
|
40
|
+
|
41
|
+
unless account_name.end_with?('_ACCOUNT')
|
42
|
+
log_error("Invalid account name format: #{account_name}")
|
43
|
+
return MCP::Tool::Response.new([{
|
44
|
+
type: "text",
|
45
|
+
text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
|
46
|
+
}])
|
47
|
+
end
|
48
|
+
|
49
|
+
begin
|
50
|
+
client = SchwabRb::Auth.init_client_easy(
|
51
|
+
ENV['SCHWAB_API_KEY'],
|
52
|
+
ENV['SCHWAB_APP_SECRET'],
|
53
|
+
ENV['SCHWAB_CALLBACK_URI'],
|
54
|
+
ENV['TOKEN_PATH']
|
55
|
+
)
|
56
|
+
|
57
|
+
unless client
|
58
|
+
log_error("Failed to initialize Schwab client")
|
59
|
+
return MCP::Tool::Response.new([{
|
60
|
+
type: "text",
|
61
|
+
text: "**Error**: Failed to initialize Schwab client. Check your credentials."
|
62
|
+
}])
|
63
|
+
end
|
64
|
+
|
65
|
+
account_id = ENV[account_name]
|
66
|
+
unless account_id
|
67
|
+
available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
|
68
|
+
log_error("Account name '#{account_name}' not found in environment variables")
|
69
|
+
return MCP::Tool::Response.new([{
|
70
|
+
type: "text",
|
71
|
+
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."
|
72
|
+
}])
|
73
|
+
end
|
74
|
+
|
75
|
+
log_debug("Found account ID: [REDACTED] for account name: #{account_name}")
|
76
|
+
log_debug("Fetching account numbers mapping")
|
77
|
+
|
78
|
+
account_numbers_response = client.get_account_numbers
|
79
|
+
|
80
|
+
unless account_numbers_response&.body
|
81
|
+
log_error("Failed to retrieve account numbers")
|
82
|
+
return MCP::Tool::Response.new([{
|
83
|
+
type: "text",
|
84
|
+
text: "**Error**: Failed to retrieve account numbers from Schwab API"
|
85
|
+
}])
|
86
|
+
end
|
87
|
+
|
88
|
+
account_mappings = JSON.parse(account_numbers_response.body, symbolize_names: true)
|
89
|
+
log_debug("Account mappings retrieved (#{account_mappings.length} accounts found)")
|
90
|
+
|
91
|
+
account_hash = nil
|
92
|
+
account_mappings.each do |mapping|
|
93
|
+
if mapping[:accountNumber] == account_id
|
94
|
+
account_hash = mapping[:hashValue]
|
95
|
+
break
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
unless account_hash
|
100
|
+
log_error("Account ID not found in available accounts")
|
101
|
+
available_accounts = account_mappings.map { |m| "[REDACTED]" }.join(", ")
|
102
|
+
return MCP::Tool::Response.new([{
|
103
|
+
type: "text",
|
104
|
+
text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
|
105
|
+
}])
|
106
|
+
end
|
107
|
+
|
108
|
+
log_debug("Found account hash for account ID: #{account_name}")
|
109
|
+
|
110
|
+
log_debug("Fetching account information with fields: #{fields}")
|
111
|
+
account_response = client.get_account(account_hash, fields: fields)
|
112
|
+
|
113
|
+
if account_response&.body
|
114
|
+
log_info("Successfully retrieved account information for #{account_name}")
|
115
|
+
account_data = JSON.parse(account_response.body)
|
116
|
+
|
117
|
+
formatted_response = format_account_data(account_data, account_name, account_id)
|
118
|
+
|
119
|
+
MCP::Tool::Response.new([{
|
120
|
+
type: "text",
|
121
|
+
text: formatted_response
|
122
|
+
}])
|
123
|
+
else
|
124
|
+
log_warn("Empty response from Schwab API for account: #{account_name}")
|
125
|
+
MCP::Tool::Response.new([{
|
126
|
+
type: "text",
|
127
|
+
text: "**No Data**: Empty response from Schwab API for account: #{account_name}"
|
128
|
+
}])
|
129
|
+
end
|
130
|
+
|
131
|
+
rescue JSON::ParserError => e
|
132
|
+
log_error("JSON parsing error: #{e.message}")
|
133
|
+
MCP::Tool::Response.new([{
|
134
|
+
type: "text",
|
135
|
+
text: "**Error**: Failed to parse API response: #{e.message}"
|
136
|
+
}])
|
137
|
+
rescue => e
|
138
|
+
log_error("Error retrieving account information for #{account_name}: #{e.message}")
|
139
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
|
140
|
+
MCP::Tool::Response.new([{
|
141
|
+
type: "text",
|
142
|
+
text: "**Error** retrieving account information for #{account_name}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
|
143
|
+
}])
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
|
149
|
+
def self.format_account_data(account_data, account_name, account_id)
|
150
|
+
account = account_data["securitiesAccount"]
|
151
|
+
friendly_name = account_name.gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
|
152
|
+
|
153
|
+
formatted = "**Account Information for #{friendly_name} (#{account_name}):**\n\n"
|
154
|
+
|
155
|
+
if account
|
156
|
+
formatted += "**Account Number:** #{Redactor::REDACTED_ACCOUNT_PLACEHOLDER}\n"
|
157
|
+
formatted += "**Account Type:** #{account['type']}\n"
|
158
|
+
|
159
|
+
if current_balances = account['currentBalances']
|
160
|
+
formatted += "\n**Current Balances:**\n"
|
161
|
+
formatted += "- Cash Balance: $#{format_currency(current_balances['cashBalance'])}\n"
|
162
|
+
formatted += "- Buying Power: $#{format_currency(current_balances['buyingPower'])}\n"
|
163
|
+
formatted += "- Total Cash: $#{format_currency(current_balances['totalCash'])}\n"
|
164
|
+
formatted += "- Liquidation Value: $#{format_currency(current_balances['liquidationValue'])}\n"
|
165
|
+
formatted += "- Long Market Value: $#{format_currency(current_balances['longMarketValue'])}\n"
|
166
|
+
formatted += "- Short Market Value: $#{format_currency(current_balances['shortMarketValue'])}\n"
|
167
|
+
end
|
168
|
+
|
169
|
+
# Positions summary
|
170
|
+
if positions = account['positions']
|
171
|
+
formatted += "\n**Positions Summary:**\n"
|
172
|
+
formatted += "- Total Positions: #{positions.length}\n"
|
173
|
+
|
174
|
+
if positions.length > 0
|
175
|
+
formatted += "\n**Position Details:**\n"
|
176
|
+
positions.each do |position|
|
177
|
+
symbol = position.dig('instrument', 'symbol')
|
178
|
+
qty = position['longQuantity'].to_f - position['shortQuantity'].to_f
|
179
|
+
market_value = position['marketValue']
|
180
|
+
formatted += "- #{symbol}: #{qty} shares, Market Value: $#{format_currency(market_value)}\n"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
if orders = account['orderStrategies']
|
186
|
+
formatted += "\n**Active Orders:**\n"
|
187
|
+
formatted += "- Total Orders: #{orders.length}\n"
|
188
|
+
|
189
|
+
orders.each do |order|
|
190
|
+
status = order['status']
|
191
|
+
symbol = order.dig('orderLegCollection', 0, 'instrument', 'symbol')
|
192
|
+
formatted += "- #{symbol}: #{status}\n"
|
193
|
+
end
|
194
|
+
end end
|
195
|
+
|
196
|
+
redacted_data = Redactor.redact(account_data)
|
197
|
+
formatted += "\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
|
198
|
+
formatted
|
199
|
+
end
|
200
|
+
|
201
|
+
def self.format_currency(amount)
|
202
|
+
return "0.00" if amount.nil?
|
203
|
+
"%.2f" % amount.to_f
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
data/lib/schwab_mcp.rb
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "mcp"
|
4
|
+
require "mcp/transports/stdio"
|
5
|
+
require "tmpdir"
|
6
|
+
require "schwab_rb"
|
7
|
+
require_relative "schwab_mcp/version"
|
8
|
+
require_relative "schwab_mcp/redactor"
|
9
|
+
require_relative "schwab_mcp/tools/quote_tool"
|
10
|
+
require_relative "schwab_mcp/tools/quotes_tool"
|
11
|
+
require_relative "schwab_mcp/tools/option_chain_tool"
|
12
|
+
require_relative "schwab_mcp/tools/option_strategy_finder_tool"
|
13
|
+
require_relative "schwab_mcp/tools/help_tool"
|
14
|
+
require_relative "schwab_mcp/tools/schwab_account_details_tool"
|
15
|
+
require_relative "schwab_mcp/tools/list_schwab_accounts_tool"
|
16
|
+
require_relative "schwab_mcp/tools/list_account_orders_tool"
|
17
|
+
require_relative "schwab_mcp/tools/list_account_transactions_tool"
|
18
|
+
require_relative "schwab_mcp/tools/get_order_tool"
|
19
|
+
require_relative "schwab_mcp/tools/cancel_order_tool"
|
20
|
+
require_relative "schwab_mcp/tools/preview_order_tool"
|
21
|
+
require_relative "schwab_mcp/tools/place_order_tool"
|
22
|
+
require_relative "schwab_mcp/tools/replace_order_tool"
|
23
|
+
require_relative "schwab_mcp/tools/list_movers_tool"
|
24
|
+
require_relative "schwab_mcp/tools/get_market_hours_tool"
|
25
|
+
require_relative "schwab_mcp/tools/get_price_history_tool"
|
26
|
+
require_relative "schwab_mcp/loggable"
|
27
|
+
|
28
|
+
|
29
|
+
module SchwabMCP
|
30
|
+
class Error < StandardError; end
|
31
|
+
|
32
|
+
TOOLS = [
|
33
|
+
Tools::QuoteTool,
|
34
|
+
Tools::QuotesTool,
|
35
|
+
Tools::OptionChainTool,
|
36
|
+
Tools::OptionStrategyFinderTool,
|
37
|
+
Tools::HelpTool,
|
38
|
+
Tools::SchwabAccountDetailsTool,
|
39
|
+
Tools::ListSchwabAccountsTool,
|
40
|
+
Tools::ListAccountOrdersTool,
|
41
|
+
Tools::ListAccountTransactionsTool,
|
42
|
+
Tools::GetOrderTool,
|
43
|
+
Tools::CancelOrderTool,
|
44
|
+
Tools::PreviewOrderTool,
|
45
|
+
Tools::PlaceOrderTool,
|
46
|
+
Tools::ReplaceOrderTool,
|
47
|
+
Tools::ListMoversTool,
|
48
|
+
Tools::GetMarketHoursTool,
|
49
|
+
Tools::GetPriceHistoryTool
|
50
|
+
].freeze
|
51
|
+
|
52
|
+
class Server
|
53
|
+
include Loggable
|
54
|
+
|
55
|
+
def initialize
|
56
|
+
configure_schwab_rb_logging
|
57
|
+
|
58
|
+
@server = MCP::Server.new(
|
59
|
+
name: "schwab_mcp_server",
|
60
|
+
version: SchwabMCP::VERSION,
|
61
|
+
tools: TOOLS,
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
def start
|
66
|
+
configure_mcp
|
67
|
+
log_info("🚀 Starting Schwab MCP Server #{SchwabMCP::VERSION}")
|
68
|
+
log_info("📊 Available tools: #{TOOLS.map { |tool| tool.name.split('::').last }.join(', ')}")
|
69
|
+
log_info("📝 Logs will be written to: #{log_file_path}")
|
70
|
+
transport = MCP::Transports::StdioTransport.new(@server)
|
71
|
+
transport.open
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def configure_schwab_rb_logging
|
77
|
+
SchwabRb.configure do |config|
|
78
|
+
config.logger = SchwabMCP::Logger.instance
|
79
|
+
config.log_level = ENV.fetch('LOG_LEVEL', 'INFO').upcase
|
80
|
+
end
|
81
|
+
|
82
|
+
log_info("Configured schwab_rb to use shared logger")
|
83
|
+
end
|
84
|
+
|
85
|
+
def log_file_path
|
86
|
+
if ENV['LOGFILE'] && !ENV['LOGFILE'].empty?
|
87
|
+
ENV['LOGFILE']
|
88
|
+
else
|
89
|
+
File.join(Dir.tmpdir, 'schwab_mcp.log')
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def configure_mcp
|
94
|
+
MCP.configure do |config|
|
95
|
+
config.exception_reporter = ->(exception, server_context) do
|
96
|
+
log_error("MCP Error: #{exception.class.name} - #{exception.message}")
|
97
|
+
log_debug(exception.backtrace.first(3).join("\n"))
|
98
|
+
end
|
99
|
+
|
100
|
+
config.instrumentation_callback = ->(data) do
|
101
|
+
duration = data[:duration] ? " - #{data[:duration].round(3)}s" : ""
|
102
|
+
log_debug("MCP: #{data[:method]}#{data[:tool_name] ? " (#{data[:tool_name]})" : ""}#{duration}")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
data/sig/schwab_mcp.rbs
ADDED
data/start_mcp_server.sh
ADDED