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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.copilotignore +3 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +157 -0
  9. data/Rakefile +12 -0
  10. data/exe/schwab_mcp +19 -0
  11. data/exe/schwab_token_refresh +38 -0
  12. data/exe/schwab_token_reset +49 -0
  13. data/lib/schwab_mcp/loggable.rb +31 -0
  14. data/lib/schwab_mcp/logger.rb +62 -0
  15. data/lib/schwab_mcp/option_chain_filter.rb +213 -0
  16. data/lib/schwab_mcp/orders/iron_condor_order.rb +87 -0
  17. data/lib/schwab_mcp/orders/order_factory.rb +40 -0
  18. data/lib/schwab_mcp/orders/vertical_order.rb +62 -0
  19. data/lib/schwab_mcp/redactor.rb +210 -0
  20. data/lib/schwab_mcp/resources/.keep +0 -0
  21. data/lib/schwab_mcp/tools/cancel_order_tool.rb +226 -0
  22. data/lib/schwab_mcp/tools/get_market_hours_tool.rb +104 -0
  23. data/lib/schwab_mcp/tools/get_order_tool.rb +263 -0
  24. data/lib/schwab_mcp/tools/get_price_history_tool.rb +203 -0
  25. data/lib/schwab_mcp/tools/help_tool.rb +406 -0
  26. data/lib/schwab_mcp/tools/list_account_orders_tool.rb +295 -0
  27. data/lib/schwab_mcp/tools/list_account_transactions_tool.rb +311 -0
  28. data/lib/schwab_mcp/tools/list_movers_tool.rb +125 -0
  29. data/lib/schwab_mcp/tools/list_schwab_accounts_tool.rb +162 -0
  30. data/lib/schwab_mcp/tools/option_chain_tool.rb +274 -0
  31. data/lib/schwab_mcp/tools/option_strategy_finder_tool.rb +378 -0
  32. data/lib/schwab_mcp/tools/place_order_tool.rb +305 -0
  33. data/lib/schwab_mcp/tools/preview_order_tool.rb +259 -0
  34. data/lib/schwab_mcp/tools/quote_tool.rb +77 -0
  35. data/lib/schwab_mcp/tools/quotes_tool.rb +110 -0
  36. data/lib/schwab_mcp/tools/replace_order_tool.rb +312 -0
  37. data/lib/schwab_mcp/tools/schwab_account_details_tool.rb +208 -0
  38. data/lib/schwab_mcp/version.rb +5 -0
  39. data/lib/schwab_mcp.rb +107 -0
  40. data/sig/schwab_mcp.rbs +4 -0
  41. data/start_mcp_server.sh +4 -0
  42. 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
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchwabMCP
4
+ VERSION = "0.1.0"
5
+ end
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
@@ -0,0 +1,4 @@
1
+ module SchwabMCP
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+
3
+ cd /Users/jplatta/repos/schwab_mcp
4
+ exec /Users/jplatta/.asdf/shims/bundle exec exe/schwab_mcp