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,259 @@
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 PreviewOrderTool < MCP::Tool
11
+ extend Loggable
12
+ description "Preview an options order (iron condor, call spread, put spread) to validate the order structure and see estimated costs/proceeds before placing"
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
+ strategy_type: {
22
+ type: "string",
23
+ enum: ["ironcondor", "callspread", "putspread"],
24
+ description: "Type of options strategy to preview"
25
+ },
26
+ price: {
27
+ type: "number",
28
+ description: "Net price for the order (credit for selling strategies, debit for buying strategies)"
29
+ },
30
+ quantity: {
31
+ type: "integer",
32
+ description: "Number of contracts (default: 1)",
33
+ default: 1
34
+ },
35
+ order_instruction: {
36
+ type: "string",
37
+ enum: ["open", "exit"],
38
+ description: "Whether to open a new position or exit an existing one (default: open)",
39
+ default: "open"
40
+ },
41
+ # Iron Condor specific fields
42
+ put_short_symbol: {
43
+ type: "string",
44
+ description: "Option symbol for the short put leg (required for iron condor)"
45
+ },
46
+ put_long_symbol: {
47
+ type: "string",
48
+ description: "Option symbol for the long put leg (required for iron condor)"
49
+ },
50
+ call_short_symbol: {
51
+ type: "string",
52
+ description: "Option symbol for the short call leg (required for iron condor)"
53
+ },
54
+ call_long_symbol: {
55
+ type: "string",
56
+ description: "Option symbol for the long call leg (required for iron condor)"
57
+ },
58
+ # Vertical spread specific fields
59
+ short_leg_symbol: {
60
+ type: "string",
61
+ description: "Option symbol for the short leg (required for call/put spreads)"
62
+ },
63
+ long_leg_symbol: {
64
+ type: "string",
65
+ description: "Option symbol for the long leg (required for call/put spreads)"
66
+ }
67
+ },
68
+ required: ["account_name", "strategy_type", "price"]
69
+ )
70
+
71
+ annotations(
72
+ title: "Preview Options Order",
73
+ read_only_hint: true,
74
+ destructive_hint: false,
75
+ idempotent_hint: true
76
+ )
77
+
78
+ def self.call(server_context:, **params)
79
+ log_info("Previewing #{params[:strategy_type]} order for account name: #{params[:account_name]}")
80
+
81
+ unless params[:account_name].end_with?('_ACCOUNT')
82
+ log_error("Invalid account name format: #{params[:account_name]}")
83
+ return MCP::Tool::Response.new([{
84
+ type: "text",
85
+ text: "**Error**: Account name must end with '_ACCOUNT'. Example: 'TRADING_BROKERAGE_ACCOUNT'"
86
+ }])
87
+ end
88
+
89
+ begin
90
+ validate_strategy_params(params)
91
+ client = SchwabRb::Auth.init_client_easy(
92
+ ENV['SCHWAB_API_KEY'],
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
105
+
106
+ account_result = resolve_account_details(client, params[:account_name])
107
+ return account_result if account_result.is_a?(MCP::Tool::Response)
108
+
109
+ account_id, account_hash = account_result
110
+
111
+ order_builder = SchwabMCP::Orders::OrderFactory.build(
112
+ strategy_type: params[:strategy_type],
113
+ account_number: account_id,
114
+ price: params[:price],
115
+ quantity: params[:quantity] || 1,
116
+ order_instruction: (params[:order_instruction] || "open").to_sym,
117
+ # Iron Condor params
118
+ put_short_symbol: params[:put_short_symbol],
119
+ put_long_symbol: params[:put_long_symbol],
120
+ call_short_symbol: params[:call_short_symbol],
121
+ call_long_symbol: params[:call_long_symbol],
122
+ # Vertical spread params
123
+ short_leg_symbol: params[:short_leg_symbol],
124
+ long_leg_symbol: params[:long_leg_symbol]
125
+ )
126
+
127
+ log_debug("Making preview order API request")
128
+ response = client.preview_order(account_hash, order_builder)
129
+
130
+ if response&.body
131
+ log_info("Successfully previewed #{params[:strategy_type]} order")
132
+ formatted_response = format_preview_response(response.body, params)
133
+ MCP::Tool::Response.new([{
134
+ type: "text",
135
+ text: formatted_response
136
+ }])
137
+ else
138
+ log_warn("Empty response from Schwab API for order preview")
139
+ MCP::Tool::Response.new([{
140
+ type: "text",
141
+ text: "**No Data**: Empty response from Schwab API for order preview"
142
+ }])
143
+ end
144
+
145
+ rescue => e
146
+ log_error("Error previewing #{params[:strategy_type]} order: #{e.message}")
147
+ log_debug("Backtrace: #{e.backtrace.first(5).join('\n')}")
148
+ MCP::Tool::Response.new([{
149
+ type: "text",
150
+ text: "**Error** previewing #{params[:strategy_type]} order: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
151
+ }])
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def self.resolve_account_details(client, account_name)
158
+ account_id = ENV[account_name]
159
+ unless account_id
160
+ available_accounts = ENV.keys.select { |key| key.end_with?('_ACCOUNT') }
161
+ log_error("Account name '#{account_name}' not found in environment variables")
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")
194
+ return MCP::Tool::Response.new([{
195
+ type: "text",
196
+ text: "**Error**: Account ID not found in available accounts. #{account_mappings.length} accounts available."
197
+ }])
198
+ end
199
+
200
+ log_debug("Found account hash for account name: #{account_name}")
201
+ [account_id, account_hash]
202
+ end
203
+
204
+ def self.validate_strategy_params(params)
205
+ case params[:strategy_type]
206
+ when 'ironcondor'
207
+ required_fields = [:put_short_symbol, :put_long_symbol, :call_short_symbol, :call_long_symbol]
208
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
209
+ unless missing_fields.empty?
210
+ raise ArgumentError, "Iron condor strategy requires: #{missing_fields.join(', ')}"
211
+ end
212
+ when 'callspread', 'putspread'
213
+ required_fields = [:short_leg_symbol, :long_leg_symbol]
214
+ missing_fields = required_fields.select { |field| params[field].nil? || params[field].empty? }
215
+ unless missing_fields.empty?
216
+ raise ArgumentError, "#{params[:strategy_type]} strategy requires: #{missing_fields.join(', ')}"
217
+ end
218
+ else
219
+ raise ArgumentError, "Unsupported strategy type: #{params[:strategy_type]}"
220
+ end
221
+ end
222
+
223
+ def self.format_preview_response(response_body, params)
224
+ parsed = JSON.parse(response_body)
225
+ redacted_data = Redactor.redact(parsed)
226
+
227
+ begin
228
+ strategy_summary = case params[:strategy_type]
229
+ when 'ironcondor'
230
+ "**Iron Condor Preview**\n" \
231
+ "- Put Short: #{params[:put_short_symbol]}\n" \
232
+ "- Put Long: #{params[:put_long_symbol]}\n" \
233
+ "- Call Short: #{params[:call_short_symbol]}\n" \
234
+ "- Call Long: #{params[:call_long_symbol]}\n"
235
+ when 'callspread', 'putspread'
236
+ "**#{params[:strategy_type].capitalize} Preview**\n" \
237
+ "- Short Leg: #{params[:short_leg_symbol]}\n" \
238
+ "- Long Leg: #{params[:long_leg_symbol]}\n"
239
+ end
240
+
241
+ friendly_name = params[:account_name].gsub('_ACCOUNT', '').split('_').map(&:capitalize).join(' ')
242
+
243
+ order_details = "**Order Details:**\n" \
244
+ "- Strategy: #{params[:strategy_type]}\n" \
245
+ "- Action: #{params[:order_instruction] || 'open'}\n" \
246
+ "- Quantity: #{params[:quantity] || 1}\n" \
247
+ "- Price: $#{params[:price]}\n" \
248
+ "- Account: #{friendly_name} (#{params[:account_name]})\n\n"
249
+
250
+ full_response = "**Schwab API Preview Response:**\n\n```json\n#{JSON.pretty_generate(redacted_data)}\n```"
251
+
252
+ "#{strategy_summary}\n#{order_details}#{full_response}"
253
+ rescue JSON::ParserError
254
+ "**Order Preview Response:**\n\n```\n#{JSON.pretty_generate(redacted_data)}\n```"
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,77 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require_relative "../loggable"
5
+
6
+ module SchwabMCP
7
+ module Tools
8
+ class QuoteTool < MCP::Tool
9
+ extend Loggable
10
+ description "Get a real-time quote for a single instrument symbol using Schwab API"
11
+
12
+ input_schema(
13
+ properties: {
14
+ symbol: {
15
+ type: "string",
16
+ description: "Instrument symbol (e.g., 'AAPL', 'TSLA')",
17
+ pattern: "^[A-Za-z]{1,5}$"
18
+ }
19
+ },
20
+ required: ["symbol"]
21
+ )
22
+
23
+ annotations(
24
+ title: "Get Financial Instrument Quote",
25
+ read_only_hint: true,
26
+ destructive_hint: false,
27
+ idempotent_hint: true
28
+ )
29
+
30
+ def self.call(symbol:, server_context:)
31
+ log_info("Getting quote for symbol: #{symbol}")
32
+
33
+ begin
34
+ client = SchwabRb::Auth.init_client_easy(
35
+ ENV['SCHWAB_API_KEY'],
36
+ ENV['SCHWAB_APP_SECRET'],
37
+ ENV['SCHWAB_CALLBACK_URI'],
38
+ ENV['TOKEN_PATH']
39
+ )
40
+
41
+ unless client
42
+ log_error("Failed to initialize Schwab client")
43
+ return MCP::Tool::Response.new([{
44
+ type: "text",
45
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
46
+ }])
47
+ end
48
+
49
+ log_debug("Making API request for symbol: #{symbol}")
50
+ response = client.get_quote(symbol.upcase)
51
+
52
+ if response&.body
53
+ log_info("Successfully retrieved quote for #{symbol}")
54
+ MCP::Tool::Response.new([{
55
+ type: "text",
56
+ text: "**Quote for #{symbol.upcase}:**\n\n```json\n#{response.body}\n```"
57
+ }])
58
+ else
59
+ log_warn("Empty response from Schwab API for symbol: #{symbol}")
60
+ MCP::Tool::Response.new([{
61
+ type: "text",
62
+ text: "**No Data**: Empty response from Schwab API for symbol: #{symbol}"
63
+ }])
64
+ end
65
+
66
+ rescue => e
67
+ log_error("Error retrieving quote for #{symbol}: #{e.message}")
68
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
69
+ MCP::Tool::Response.new([{
70
+ type: "text",
71
+ text: "**Error** retrieving quote for #{symbol}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
72
+ }])
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,110 @@
1
+ require "mcp"
2
+ require "schwab_rb"
3
+ require "json"
4
+ require_relative "../loggable"
5
+
6
+ module SchwabMCP
7
+ module Tools
8
+ class QuotesTool < MCP::Tool
9
+ extend Loggable
10
+
11
+ description "Get real-time quotes for multiple instrument symbols using Schwab API formatted as JSON"
12
+
13
+ input_schema(
14
+ properties: {
15
+ symbols: {
16
+ type: "array",
17
+ items: {
18
+ type: "string",
19
+ pattern: "^[A-Za-z0-9/.$-]{1,12}$"
20
+ },
21
+ description: "Array of instrument symbols (e.g., ['AAPL', 'TSLA', '/ES']) - supports futures and other special symbols",
22
+ minItems: 1,
23
+ maxItems: 500
24
+ },
25
+ fields: {
26
+ type: "array",
27
+ items: {
28
+ type: "string"
29
+ },
30
+ description: "Optional array of specific quote fields to return. If not specified, returns all available data."
31
+ },
32
+ indicative: {
33
+ type: "boolean",
34
+ description: "Optional flag to fetch indicative quotes (true/false). If not specified, returns standard quotes."
35
+ }
36
+ },
37
+ required: ["symbols"]
38
+ )
39
+
40
+ annotations(
41
+ title: "Get Financial Instrument Quotes",
42
+ read_only_hint: true,
43
+ destructive_hint: false,
44
+ idempotent_hint: true
45
+ )
46
+
47
+ def self.call(symbols:, fields: ["quote"], indicative: false, server_context:)
48
+ symbols = [symbols] if symbols.is_a?(String)
49
+
50
+ log_info("Getting quotes for #{symbols.length} symbols: #{symbols.join(', ')}")
51
+
52
+ begin
53
+ client = SchwabRb::Auth.init_client_easy(
54
+ ENV['SCHWAB_API_KEY'],
55
+ ENV['SCHWAB_APP_SECRET'],
56
+ ENV['SCHWAB_CALLBACK_URI'],
57
+ ENV['TOKEN_PATH']
58
+ )
59
+
60
+ unless client
61
+ log_error("Failed to initialize Schwab client")
62
+ return MCP::Tool::Response.new([{
63
+ type: "text",
64
+ text: "**Error**: Failed to initialize Schwab client. Check your credentials."
65
+ }])
66
+ end
67
+
68
+ log_debug("Making API request for symbols: #{symbols.join(', ')}")
69
+ log_debug("Fields: #{fields || 'all'}")
70
+ log_debug("Indicative: #{indicative || 'not specified'}")
71
+
72
+ normalized_symbols = symbols.map(&:upcase)
73
+
74
+ response = client.get_quotes(
75
+ normalized_symbols,
76
+ fields: fields,
77
+ indicative: indicative
78
+ )
79
+
80
+ if response&.body
81
+ log_info("Successfully retrieved quotes for #{symbols.length} symbols")
82
+
83
+ symbol_list = normalized_symbols.join(', ')
84
+ field_info = fields ? " (fields: #{fields.join(', ')})" : " (all fields)"
85
+ indicative_info = indicative.nil? ? "" : " (indicative: #{indicative})"
86
+
87
+ MCP::Tool::Response.new([{
88
+ type: "text",
89
+ text: "**Quotes for #{symbol_list}:**#{field_info}#{indicative_info}\n\n```json\n#{response.body}\n```"
90
+ }])
91
+ else
92
+ log_warn("Empty response from Schwab API for symbols: #{symbols.join(', ')}")
93
+ MCP::Tool::Response.new([{
94
+ type: "text",
95
+ text: "**No Data**: Empty response from Schwab API for symbols: #{symbols.join(', ')}"
96
+ }])
97
+ end
98
+
99
+ rescue => e
100
+ log_error("Error retrieving quotes for #{symbols.join(', ')}: #{e.message}")
101
+ log_debug("Backtrace: #{e.backtrace.first(3).join('\n')}")
102
+ MCP::Tool::Response.new([{
103
+ type: "text",
104
+ text: "**Error** retrieving quotes for #{symbols.join(', ')}: #{e.message}\n\n#{e.backtrace.first(3).join('\n')}"
105
+ }])
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end