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,213 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative "loggable"
|
3
|
+
|
4
|
+
module SchwabMCP
|
5
|
+
class OptionChainFilter
|
6
|
+
include Loggable
|
7
|
+
|
8
|
+
attr_reader :expiration_date, :underlying_price, :expiration_type,
|
9
|
+
:settlement_type, :option_root, :max_delta, :max_spread,
|
10
|
+
:min_credit, :min_open_interest, :dist_from_strike, :quantity,
|
11
|
+
:min_delta, :min_strike, :max_strike
|
12
|
+
|
13
|
+
def initialize(
|
14
|
+
expiration_date:,
|
15
|
+
underlying_price: nil,
|
16
|
+
expiration_type: nil,
|
17
|
+
settlement_type: nil,
|
18
|
+
option_root: nil,
|
19
|
+
min_delta: 0.0,
|
20
|
+
max_delta: 0.15,
|
21
|
+
max_spread: 20.0,
|
22
|
+
min_credit: 0.0,
|
23
|
+
min_open_interest: 0,
|
24
|
+
dist_from_strike: 0.0,
|
25
|
+
quantity: 1,
|
26
|
+
max_strike: nil,
|
27
|
+
min_strike: nil
|
28
|
+
)
|
29
|
+
@expiration_date = expiration_date
|
30
|
+
@underlying_price = underlying_price
|
31
|
+
@expiration_type = expiration_type
|
32
|
+
@settlement_type = settlement_type
|
33
|
+
@option_root = option_root
|
34
|
+
@max_spread = max_spread
|
35
|
+
@min_credit = min_credit
|
36
|
+
@min_open_interest = min_open_interest
|
37
|
+
@dist_from_strike = dist_from_strike
|
38
|
+
@quantity = quantity
|
39
|
+
@max_delta = max_delta
|
40
|
+
@min_delta = min_delta
|
41
|
+
@max_strike = max_strike
|
42
|
+
@min_strike = min_strike
|
43
|
+
end
|
44
|
+
|
45
|
+
def select(options_map)
|
46
|
+
filtered_options = []
|
47
|
+
exp_date_str = expiration_date.strftime('%Y-%m-%d')
|
48
|
+
|
49
|
+
options_map.each do |date_key, strikes|
|
50
|
+
strikes.each do |strike_key, option_array|
|
51
|
+
option_array.each do |option|
|
52
|
+
next unless passes_delta_filter?(option)
|
53
|
+
next unless passes_strike_range_filter?(option)
|
54
|
+
filtered_options << option
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
log_debug("Found #{filtered_options.size} filtered options")
|
60
|
+
filtered_options
|
61
|
+
end
|
62
|
+
|
63
|
+
def find_spreads(options_map, option_type)
|
64
|
+
spreads = []
|
65
|
+
exp_date_str = expiration_date.strftime('%Y-%m-%d')
|
66
|
+
|
67
|
+
short_cnt = 0
|
68
|
+
|
69
|
+
options_map.each do |date_key, strikes|
|
70
|
+
next unless date_matches?(date_key, exp_date_str)
|
71
|
+
log_debug("Processing options for date: #{date_key}. Searching #{strikes.size} strikes...")
|
72
|
+
|
73
|
+
strikes.each do |strike_key, option_array|
|
74
|
+
option_array.each do |short_option|
|
75
|
+
next unless passes_short_option_filters?(short_option)
|
76
|
+
|
77
|
+
log_debug("Found short option: #{short_option[:symbol]} at strike #{short_option[:strikePrice]}")
|
78
|
+
|
79
|
+
short_cnt += 1
|
80
|
+
|
81
|
+
short_strike = short_option[:strikePrice]
|
82
|
+
short_mark = short_option[:mark]
|
83
|
+
|
84
|
+
long_options = find_long_option_candidates(strikes, short_option, option_type)
|
85
|
+
|
86
|
+
long_options.each do |long_option|
|
87
|
+
spread = build_spread(short_option, long_option)
|
88
|
+
spreads << spread if spread
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
log_debug("Found #{spreads.size} #{option_type} spreads for #{short_cnt} short options")
|
95
|
+
|
96
|
+
spreads
|
97
|
+
end
|
98
|
+
|
99
|
+
def passes_short_option_filters?(option)
|
100
|
+
return false unless passes_delta_filter?(option)
|
101
|
+
return false unless passes_open_interest_filter?(option)
|
102
|
+
return false unless passes_distance_filter?(option)
|
103
|
+
return false unless passes_optional_filters?(option)
|
104
|
+
|
105
|
+
true
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def date_matches?(date_key, exp_date_str)
|
111
|
+
date_key.to_s.include?(exp_date_str)
|
112
|
+
end
|
113
|
+
|
114
|
+
def passes_delta_filter?(option)
|
115
|
+
delta = option[:delta]&.abs || 0.0
|
116
|
+
delta <= max_delta && delta >= min_delta
|
117
|
+
end
|
118
|
+
|
119
|
+
def passes_open_interest_filter?(option)
|
120
|
+
open_interest = option[:openInterest] || 0
|
121
|
+
open_interest >= min_open_interest
|
122
|
+
end
|
123
|
+
|
124
|
+
def passes_distance_filter?(option)
|
125
|
+
raise "Underlying price must be set for distance filter" unless underlying_price
|
126
|
+
|
127
|
+
strike = option[:strikePrice]
|
128
|
+
return false unless strike
|
129
|
+
|
130
|
+
distance = ((underlying_price - strike) / underlying_price).abs
|
131
|
+
distance >= dist_from_strike
|
132
|
+
end
|
133
|
+
|
134
|
+
def passes_optional_filters?(option)
|
135
|
+
return false if expiration_type && option[:expirationType] != expiration_type
|
136
|
+
return false if settlement_type && option[:settlementType] != settlement_type
|
137
|
+
return false if option_root && option[:optionRoot] != option_root
|
138
|
+
|
139
|
+
true
|
140
|
+
end
|
141
|
+
|
142
|
+
def passes_strike_range_filter?(option)
|
143
|
+
strike = option[:strikePrice]
|
144
|
+
return false unless strike
|
145
|
+
|
146
|
+
return false if @min_strike && strike < @min_strike
|
147
|
+
return false if @max_strike && strike > @max_strike
|
148
|
+
|
149
|
+
true
|
150
|
+
end
|
151
|
+
|
152
|
+
def find_long_option_candidates(strikes, short_option, option_type)
|
153
|
+
short_strike = short_option[:strikePrice]
|
154
|
+
candidates = []
|
155
|
+
|
156
|
+
strikes.each do |long_strike_key, long_option_array|
|
157
|
+
long_option_array.each do |long_option|
|
158
|
+
long_strike = long_option[:strikePrice]
|
159
|
+
long_mark = long_option[:mark]
|
160
|
+
|
161
|
+
next unless long_mark > 0
|
162
|
+
next unless valid_spread_structure?(short_strike, long_strike, option_type)
|
163
|
+
next unless passes_min_credit?(short_option, long_option)
|
164
|
+
next unless passes_optional_filters?(long_option)
|
165
|
+
next unless passes_open_interest_filter?(long_option)
|
166
|
+
|
167
|
+
candidates << long_option
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
candidates
|
172
|
+
end
|
173
|
+
|
174
|
+
def valid_spread_structure?(short_strike, long_strike, option_type)
|
175
|
+
case option_type
|
176
|
+
when 'call'
|
177
|
+
long_strike > short_strike && (long_strike - short_strike) <= max_spread
|
178
|
+
|
179
|
+
when 'put'
|
180
|
+
long_strike < short_strike && (short_strike - long_strike) <= max_spread
|
181
|
+
else
|
182
|
+
false
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def passes_min_credit?(short_option, long_option)
|
187
|
+
return true if min_credit <= 0
|
188
|
+
|
189
|
+
short_mark = short_option[:mark]
|
190
|
+
long_mark = long_option[:mark]
|
191
|
+
credit = short_mark - long_mark
|
192
|
+
|
193
|
+
credit * 100 >= min_credit
|
194
|
+
end
|
195
|
+
|
196
|
+
def build_spread(short_option, long_option)
|
197
|
+
short_mark = short_option[:mark]
|
198
|
+
long_mark = long_option[:mark]
|
199
|
+
credit = short_mark - long_mark
|
200
|
+
short_strike = short_option[:strikePrice]
|
201
|
+
long_strike = long_option[:strikePrice]
|
202
|
+
|
203
|
+
{
|
204
|
+
short_option: short_option,
|
205
|
+
long_option: long_option,
|
206
|
+
credit: credit,
|
207
|
+
delta: short_option[:delta] || 0,
|
208
|
+
spread_width: (short_strike - long_strike).abs,
|
209
|
+
quantity: quantity
|
210
|
+
}
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schwab_rb'
|
4
|
+
|
5
|
+
module SchwabMCP
|
6
|
+
module Orders
|
7
|
+
class IronCondorOrder
|
8
|
+
class << self
|
9
|
+
def build(
|
10
|
+
account_number:,
|
11
|
+
put_short_symbol:,
|
12
|
+
put_long_symbol:,
|
13
|
+
call_short_symbol:,
|
14
|
+
call_long_symbol:,
|
15
|
+
price:,
|
16
|
+
order_instruction: :open,
|
17
|
+
quantity: 1
|
18
|
+
)
|
19
|
+
schwab_order_builder.new.tap do |builder|
|
20
|
+
builder.set_account_number(account_number)
|
21
|
+
builder.set_order_strategy_type('SINGLE')
|
22
|
+
builder.set_session(SchwabRb::Orders::Session::NORMAL)
|
23
|
+
builder.set_duration(SchwabRb::Orders::Duration::DAY)
|
24
|
+
builder.set_order_type(order_type(order_instruction))
|
25
|
+
builder.set_complex_order_strategy_type(SchwabRb::Order::ComplexOrderStrategyTypes::IRON_CONDOR)
|
26
|
+
builder.set_quantity(quantity)
|
27
|
+
builder.set_price(price)
|
28
|
+
|
29
|
+
instructions = leg_instructions_for_position(order_instruction)
|
30
|
+
|
31
|
+
builder.add_option_leg(
|
32
|
+
instructions[:put_short],
|
33
|
+
put_short_symbol,
|
34
|
+
quantity
|
35
|
+
)
|
36
|
+
builder.add_option_leg(
|
37
|
+
instructions[:put_long],
|
38
|
+
put_long_symbol,
|
39
|
+
quantity
|
40
|
+
)
|
41
|
+
builder.add_option_leg(
|
42
|
+
instructions[:call_short],
|
43
|
+
call_short_symbol,
|
44
|
+
quantity
|
45
|
+
)
|
46
|
+
builder.add_option_leg(
|
47
|
+
instructions[:call_long],
|
48
|
+
call_long_symbol,
|
49
|
+
quantity
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def order_type(order_instruction)
|
55
|
+
if order_instruction == :open
|
56
|
+
SchwabRb::Order::Types::NET_CREDIT
|
57
|
+
else
|
58
|
+
SchwabRb::Order::Types::NET_DEBIT
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def leg_instructions_for_position(order_instruction)
|
63
|
+
case order_instruction
|
64
|
+
when :open
|
65
|
+
{
|
66
|
+
put_short: SchwabRb::Orders::OptionInstructions::SELL_TO_OPEN,
|
67
|
+
put_long: SchwabRb::Orders::OptionInstructions::BUY_TO_OPEN,
|
68
|
+
call_short: SchwabRb::Orders::OptionInstructions::SELL_TO_OPEN,
|
69
|
+
call_long: SchwabRb::Orders::OptionInstructions::BUY_TO_OPEN
|
70
|
+
}
|
71
|
+
when :exit
|
72
|
+
{
|
73
|
+
put_short: SchwabRb::Orders::OptionInstructions::BUY_TO_CLOSE,
|
74
|
+
put_long: SchwabRb::Orders::OptionInstructions::SELL_TO_CLOSE,
|
75
|
+
call_short: SchwabRb::Orders::OptionInstructions::BUY_TO_CLOSE,
|
76
|
+
call_long: SchwabRb::Orders::OptionInstructions::SELL_TO_CLOSE
|
77
|
+
}
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def schwab_order_builder
|
82
|
+
SchwabRb::Orders::Builder
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schwab_rb'
|
4
|
+
require_relative 'iron_condor_order'
|
5
|
+
require_relative 'vertical_order'
|
6
|
+
|
7
|
+
module SchwabMCP
|
8
|
+
module Orders
|
9
|
+
class OrderFactory
|
10
|
+
class << self
|
11
|
+
def build(**options)
|
12
|
+
case options[:strategy_type] || 'none'
|
13
|
+
when 'ironcondor'
|
14
|
+
IronCondorOrder.build(
|
15
|
+
put_short_symbol: options[:put_short_symbol],
|
16
|
+
put_long_symbol: options[:put_long_symbol],
|
17
|
+
call_short_symbol: options[:call_short_symbol],
|
18
|
+
call_long_symbol: options[:call_long_symbol],
|
19
|
+
price: options[:price],
|
20
|
+
account_number: options[:account_number],
|
21
|
+
order_instruction: options[:order_instruction] || :open,
|
22
|
+
quantity: options[:quantity] || 1
|
23
|
+
)
|
24
|
+
when 'callspread', 'putspread'
|
25
|
+
VerticalOrder.build(
|
26
|
+
short_leg_symbol: options[:short_leg_symbol],
|
27
|
+
long_leg_symbol: options[:long_leg_symbol],
|
28
|
+
price: options[:price],
|
29
|
+
account_number: options[:account_number],
|
30
|
+
order_instruction: options[:order_instruction] || :open,
|
31
|
+
quantity: options[:quantity] || 1
|
32
|
+
)
|
33
|
+
else
|
34
|
+
raise "Unsupported trade strategy: #{options[:strategy_type] || 'none'}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schwab_rb'
|
4
|
+
|
5
|
+
module SchwabMCP
|
6
|
+
module Orders
|
7
|
+
class VerticalOrder
|
8
|
+
class << self
|
9
|
+
def build(short_leg_symbol:, long_leg_symbol:, price:, account_number:, order_instruction: :open, quantity: 1)
|
10
|
+
schwab_order_builder.new.tap do |builder|
|
11
|
+
builder.set_account_number(account_number)
|
12
|
+
builder.set_order_strategy_type('SINGLE')
|
13
|
+
builder.set_session(SchwabRb::Orders::Session::NORMAL)
|
14
|
+
builder.set_duration(SchwabRb::Orders::Duration::DAY)
|
15
|
+
builder.set_order_type(order_type(order_instruction))
|
16
|
+
builder.set_complex_order_strategy_type(SchwabRb::Order::ComplexOrderStrategyTypes::VERTICAL)
|
17
|
+
builder.set_quantity(quantity)
|
18
|
+
builder.set_price(price)
|
19
|
+
builder.add_option_leg(
|
20
|
+
short_leg_instruction(order_instruction),
|
21
|
+
short_leg_symbol,
|
22
|
+
quantity
|
23
|
+
)
|
24
|
+
builder.add_option_leg(
|
25
|
+
long_leg_instruction(order_instruction),
|
26
|
+
long_leg_symbol,
|
27
|
+
quantity
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def order_type(order_instruction)
|
33
|
+
if order_instruction == :open
|
34
|
+
SchwabRb::Order::Types::NET_CREDIT
|
35
|
+
else
|
36
|
+
SchwabRb::Order::Types::NET_DEBIT
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def short_leg_instruction(order_instruction)
|
41
|
+
if order_instruction == :open
|
42
|
+
SchwabRb::Orders::OptionInstructions::SELL_TO_OPEN
|
43
|
+
else
|
44
|
+
SchwabRb::Orders::OptionInstructions::BUY_TO_CLOSE
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def long_leg_instruction(order_instruction)
|
49
|
+
if order_instruction == :open
|
50
|
+
SchwabRb::Orders::OptionInstructions::BUY_TO_OPEN
|
51
|
+
else
|
52
|
+
SchwabRb::Orders::OptionInstructions::SELL_TO_CLOSE
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def schwab_order_builder
|
57
|
+
SchwabRb::Orders::Builder
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module SchwabMCP
|
6
|
+
class Redactor
|
7
|
+
ACCOUNT_NUMBER_PATTERN = /\b\d{8,9}\b/
|
8
|
+
HASH_VALUE_PATTERN = /\b[A-Za-z0-9]{20,}\b/
|
9
|
+
BEARER_TOKEN_PATTERN = /Bearer\s+[A-Za-z0-9\.\-_]+/i
|
10
|
+
ACCOUNT_FIELDS = %w[
|
11
|
+
accountNumber
|
12
|
+
accountId
|
13
|
+
account_number
|
14
|
+
account_id
|
15
|
+
hashValue
|
16
|
+
hash_value
|
17
|
+
].freeze
|
18
|
+
NON_SENSITIVE_FIELDS = %w[
|
19
|
+
cusip
|
20
|
+
orderId
|
21
|
+
order_id
|
22
|
+
legId
|
23
|
+
leg_id
|
24
|
+
strikePrice
|
25
|
+
strike_price
|
26
|
+
quantity
|
27
|
+
daysToExpiration
|
28
|
+
days_to_expiration
|
29
|
+
expirationDate
|
30
|
+
expiration_date
|
31
|
+
price
|
32
|
+
netChange
|
33
|
+
net_change
|
34
|
+
mismarkedQuantity
|
35
|
+
mismarked_quantity
|
36
|
+
].freeze
|
37
|
+
REDACTED_PLACEHOLDER = '[REDACTED]'
|
38
|
+
REDACTED_ACCOUNT_PLACEHOLDER = '[REDACTED_ACCOUNT]'
|
39
|
+
REDACTED_HASH_PLACEHOLDER = '[REDACTED_HASH]'
|
40
|
+
REDACTED_TOKEN_PLACEHOLDER = '[REDACTED_TOKEN]'
|
41
|
+
|
42
|
+
class << self
|
43
|
+
# Main entry point for redacting any data structure
|
44
|
+
# @param data [Object] Data to redact (Hash, Array, String, or other)
|
45
|
+
# @return [Object] Redacted copy of the data
|
46
|
+
def redact(data)
|
47
|
+
case data
|
48
|
+
when Hash
|
49
|
+
redact_hash(data)
|
50
|
+
when Array
|
51
|
+
redact_array(data)
|
52
|
+
when String
|
53
|
+
redact_string(data)
|
54
|
+
else
|
55
|
+
data
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Redact a JSON response from Schwab API
|
60
|
+
# @param response_body [String] Raw response body from API
|
61
|
+
# @return [String] Pretty-formatted redacted JSON
|
62
|
+
def redact_api_response(response_body)
|
63
|
+
return response_body unless response_body.is_a?(String)
|
64
|
+
|
65
|
+
begin
|
66
|
+
parsed = JSON.parse(response_body)
|
67
|
+
redacted = redact(parsed)
|
68
|
+
JSON.pretty_generate(redacted)
|
69
|
+
rescue JSON::ParserError
|
70
|
+
# If parsing fails, redact as string
|
71
|
+
redact_string(response_body)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Redact formatted text that might contain sensitive data
|
76
|
+
# @param text [String] Formatted text to redact
|
77
|
+
# @return [String] Redacted text
|
78
|
+
def redact_formatted_text(text)
|
79
|
+
return text unless text.is_a?(String)
|
80
|
+
redacted = text.dup
|
81
|
+
redacted.gsub!(BEARER_TOKEN_PATTERN, "Bearer #{REDACTED_TOKEN_PLACEHOLDER}")
|
82
|
+
|
83
|
+
# Redact account numbers in specific text patterns
|
84
|
+
redacted.gsub!(/Account\s+ID:\s*\d{8,9}/i, "Account ID: #{REDACTED_ACCOUNT_PLACEHOLDER}")
|
85
|
+
redacted.gsub!(/Account\s+Number:\s*\d{8,9}/i, "Account Number: #{REDACTED_ACCOUNT_PLACEHOLDER}")
|
86
|
+
redacted.gsub!(/Account:\s*\d{8,9}/i, "Account: #{REDACTED_ACCOUNT_PLACEHOLDER}")
|
87
|
+
|
88
|
+
# Redact account numbers in log patterns like "account_number: 123456789"
|
89
|
+
redacted.gsub!(/account[_\s]*number[_\s]*[:\=]\s*\d{8,9}/i, "account_number: #{REDACTED_ACCOUNT_PLACEHOLDER}")
|
90
|
+
redacted.gsub!(/account[_\s]*id[_\s]*[:\=]\s*\d{8,9}/i, "account_id: #{REDACTED_ACCOUNT_PLACEHOLDER}")
|
91
|
+
|
92
|
+
redacted
|
93
|
+
end
|
94
|
+
|
95
|
+
# Redact a log message that might contain sensitive data
|
96
|
+
# @param message [String] Log message to redact
|
97
|
+
# @return [String] Redacted log message
|
98
|
+
def redact_log_message(message)
|
99
|
+
return message unless message.is_a?(String)
|
100
|
+
|
101
|
+
redact_formatted_text(message)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Redact an MCP tool response before sending to client
|
105
|
+
# @param response [Hash] MCP tool response
|
106
|
+
# @return [Hash] Redacted response
|
107
|
+
def redact_mcp_response(response)
|
108
|
+
return response unless response.is_a?(Hash)
|
109
|
+
|
110
|
+
redacted = redact(response)
|
111
|
+
|
112
|
+
# Also redact any content in the response content field
|
113
|
+
if redacted.dig("content")
|
114
|
+
case redacted["content"]
|
115
|
+
when String
|
116
|
+
redacted["content"] = redact_formatted_text(redacted["content"])
|
117
|
+
when Array
|
118
|
+
redacted["content"] = redacted["content"].map do |item|
|
119
|
+
if item.is_a?(Hash) && item["text"]
|
120
|
+
item["text"] = redact_formatted_text(item["text"])
|
121
|
+
end
|
122
|
+
item
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
redacted
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
# Redact a hash/object recursively
|
133
|
+
# @param hash [Hash] Hash to redact
|
134
|
+
# @return [Hash] Redacted copy of hash
|
135
|
+
def redact_hash(hash)
|
136
|
+
return hash unless hash.is_a?(Hash)
|
137
|
+
|
138
|
+
redacted = {}
|
139
|
+
|
140
|
+
hash.each do |key, value|
|
141
|
+
key_str = key.to_s.downcase
|
142
|
+
|
143
|
+
# Check if this is a known non-sensitive field
|
144
|
+
if NON_SENSITIVE_FIELDS.any? { |field| key_str.include?(field.downcase) }
|
145
|
+
redacted[key] = value
|
146
|
+
# Check if this is a known account-related field
|
147
|
+
elsif ACCOUNT_FIELDS.any? { |field| key_str.include?(field.downcase) }
|
148
|
+
redacted[key] = redact_account_field(value)
|
149
|
+
else
|
150
|
+
redacted[key] = redact(value)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
redacted
|
155
|
+
end
|
156
|
+
|
157
|
+
# Redact an array recursively
|
158
|
+
# @param array [Array] Array to redact
|
159
|
+
# @return [Array] Redacted copy of array
|
160
|
+
def redact_array(array)
|
161
|
+
return array unless array.is_a?(Array)
|
162
|
+
|
163
|
+
array.map { |item| redact(item) }
|
164
|
+
end
|
165
|
+
|
166
|
+
# Redact a string value
|
167
|
+
# @param string [String] String to redact
|
168
|
+
# @return [String] Redacted string
|
169
|
+
def redact_string(string)
|
170
|
+
return string unless string.is_a?(String)
|
171
|
+
|
172
|
+
redacted = string.dup
|
173
|
+
|
174
|
+
# Redact bearer tokens
|
175
|
+
redacted.gsub!(BEARER_TOKEN_PATTERN, "Bearer #{REDACTED_TOKEN_PLACEHOLDER}")
|
176
|
+
|
177
|
+
# Redact JSON-embedded account data (specific patterns)
|
178
|
+
redacted.gsub!(/"hashValue":\s*"[^"]+"/i, "\"hashValue\": \"#{REDACTED_HASH_PLACEHOLDER}\"")
|
179
|
+
redacted.gsub!(/"accountNumber":\s*"?\d{8,9}"?/i, "\"accountNumber\": \"#{REDACTED_ACCOUNT_PLACEHOLDER}\"")
|
180
|
+
redacted.gsub!(/"account_id":\s*"?\d{8,9}"?/i, "\"account_id\": \"#{REDACTED_ACCOUNT_PLACEHOLDER}\"")
|
181
|
+
|
182
|
+
redacted
|
183
|
+
end
|
184
|
+
|
185
|
+
# Redact a field that is known to contain account information
|
186
|
+
# @param value [Object] Value to redact
|
187
|
+
# @return [Object] Redacted value
|
188
|
+
def redact_account_field(value)
|
189
|
+
case value
|
190
|
+
when String
|
191
|
+
if value.match?(ACCOUNT_NUMBER_PATTERN) && value.length.between?(8, 9)
|
192
|
+
REDACTED_ACCOUNT_PLACEHOLDER
|
193
|
+
elsif value.length > 20 && value.match?(/\A[A-Za-z0-9]+\z/)
|
194
|
+
REDACTED_HASH_PLACEHOLDER
|
195
|
+
else
|
196
|
+
value
|
197
|
+
end
|
198
|
+
when Integer
|
199
|
+
if value.to_s.length.between?(8, 9)
|
200
|
+
REDACTED_ACCOUNT_PLACEHOLDER
|
201
|
+
else
|
202
|
+
value
|
203
|
+
end
|
204
|
+
else
|
205
|
+
redact(value)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
File without changes
|