woods 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +54 -0
- data/README.md +6 -0
- data/exe/woods-mcp-http +5 -3
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +16 -15
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +34 -11
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +4 -1
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/console/sql_noise_stripper.rb +35 -7
- data/lib/woods/console/sql_table_scanner.rb +49 -9
- data/lib/woods/console/sql_validator.rb +13 -8
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +1 -2
- data/lib/woods/console/tools/tier1.rb +1 -2
- data/lib/woods/console/tools/tier4.rb +1 -2
- data/lib/woods/mcp/http_transport_options.rb +24 -0
- data/lib/woods/mcp/server.rb +2 -9
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +9 -3
- data/lib/woods/retrieval/query_classifier.rb +1 -2
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/session_flow_assembler.rb +3 -6
- data/lib/woods/session_tracer/session_flow_document.rb +4 -8
- data/lib/woods/version.rb +1 -1
- metadata +43 -7
|
@@ -24,7 +24,7 @@ module Woods
|
|
|
24
24
|
# SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
|
|
25
25
|
# # => "SELECT '' FROM t"
|
|
26
26
|
#
|
|
27
|
-
module SqlNoiseStripper
|
|
27
|
+
module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
|
|
28
28
|
# Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
|
|
29
29
|
# Line comments are stripped to (but not including) the newline so that
|
|
30
30
|
# newline-separated statement structure is preserved for callers that
|
|
@@ -40,7 +40,7 @@ module Woods
|
|
|
40
40
|
|
|
41
41
|
def self.strip_comments(sql)
|
|
42
42
|
out = sql.gsub(LINE_COMMENT, '')
|
|
43
|
-
out.gsub(BLOCK_COMMENT, '')
|
|
43
|
+
out.gsub(BLOCK_COMMENT, ' ')
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Strips single-quoted string literals and (for the `:postgres` dialect)
|
|
@@ -53,7 +53,7 @@ module Woods
|
|
|
53
53
|
# single-quote scanner.
|
|
54
54
|
#
|
|
55
55
|
# @param sql [String] the SQL string to process
|
|
56
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
56
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
|
|
57
57
|
# - `:postgres` — single-quoted strings support `''` as an apostrophe
|
|
58
58
|
# escape. Backslash is treated literally and does not escape quotes.
|
|
59
59
|
# Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
|
|
@@ -61,13 +61,14 @@ module Woods
|
|
|
61
61
|
# and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
|
|
62
62
|
# are also stripped (MySQL does not use them, but stripping them is
|
|
63
63
|
# harmless and keeps the two dialects consistent).
|
|
64
|
+
# - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
|
|
64
65
|
# @return [String] a new string with all string literals replaced by `''`
|
|
65
66
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
66
67
|
DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
|
|
67
68
|
SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
|
|
68
69
|
SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
|
|
69
70
|
|
|
70
|
-
SUPPORTED_DIALECTS = %i[postgres mysql].freeze
|
|
71
|
+
SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
|
|
71
72
|
private_constant :SUPPORTED_DIALECTS
|
|
72
73
|
|
|
73
74
|
def self.strip_literals(sql, dialect: :postgres)
|
|
@@ -77,7 +78,7 @@ module Woods
|
|
|
77
78
|
|
|
78
79
|
# Strip dollar-quoted strings first so stray apostrophes inside them
|
|
79
80
|
# do not interfere with the single-quote scanner.
|
|
80
|
-
out = sql.gsub(DOLLAR_QUOTED, "''")
|
|
81
|
+
out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
|
|
81
82
|
|
|
82
83
|
pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
|
|
83
84
|
out.gsub(pattern, "''")
|
|
@@ -98,7 +99,7 @@ module Woods
|
|
|
98
99
|
# character rather than swallowing the rest of the statement.
|
|
99
100
|
#
|
|
100
101
|
# @param sql [String] the SQL string to process
|
|
101
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
102
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
|
|
102
103
|
# single-quote escape rules (see {.strip_literals}).
|
|
103
104
|
# @return [String] a new string with comments removed and every string
|
|
104
105
|
# literal replaced by `''`
|
|
@@ -126,7 +127,16 @@ module Woods
|
|
|
126
127
|
out << ch
|
|
127
128
|
i += 1
|
|
128
129
|
end
|
|
129
|
-
elsif
|
|
130
|
+
elsif ['"', '`'].include?(ch)
|
|
131
|
+
close = quoted_identifier_end(sql, i, ch)
|
|
132
|
+
if close
|
|
133
|
+
out << sql[i...close]
|
|
134
|
+
i = close
|
|
135
|
+
else
|
|
136
|
+
out << ch
|
|
137
|
+
i += 1
|
|
138
|
+
end
|
|
139
|
+
elsif dialect != :sqlite && ch == '$' && (tag = dollar_tag_at(sql, i))
|
|
130
140
|
close = sql.index(tag, i + tag.length)
|
|
131
141
|
if close
|
|
132
142
|
out << "''"
|
|
@@ -141,6 +151,7 @@ module Woods
|
|
|
141
151
|
elsif ch == '/' && sql[i + 1] == '*'
|
|
142
152
|
close = sql.index('*/', i + 2)
|
|
143
153
|
if close
|
|
154
|
+
out << ' '
|
|
144
155
|
i = close + 2
|
|
145
156
|
else
|
|
146
157
|
# Unterminated block comment: never under-detect. Leave it in
|
|
@@ -158,6 +169,23 @@ module Woods
|
|
|
158
169
|
out
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
# Preserve quoted identifiers while shielding their comment markers.
|
|
173
|
+
# @api private
|
|
174
|
+
def self.quoted_identifier_end(sql, start, quote)
|
|
175
|
+
i = start + 1
|
|
176
|
+
while i < sql.length
|
|
177
|
+
if sql[i] == quote
|
|
178
|
+
return i + 1 unless sql[i + 1] == quote
|
|
179
|
+
|
|
180
|
+
i += 2
|
|
181
|
+
else
|
|
182
|
+
i += 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
private_class_method :quoted_identifier_end
|
|
188
|
+
|
|
161
189
|
# Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
|
|
162
190
|
# `$tag$`) at the start of the given slice.
|
|
163
191
|
DOLLAR_TAG = /\A\$\w*\$/
|
|
@@ -120,11 +120,11 @@ module Woods
|
|
|
120
120
|
#
|
|
121
121
|
# @param sql [String, nil] the SQL string to scan
|
|
122
122
|
# @return [Array<String>] identifiers in first-encounter order, deduplicated
|
|
123
|
-
def self.identifiers_in(sql)
|
|
123
|
+
def self.identifiers_in(sql, dialect: nil)
|
|
124
124
|
return [] if sql.nil? || sql.empty?
|
|
125
125
|
|
|
126
126
|
results = []
|
|
127
|
-
%i[postgres mysql].each do |dialect|
|
|
127
|
+
(dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
|
|
128
128
|
stripped = strip_noise(sql, dialect: dialect)
|
|
129
129
|
collect_join_identifiers(stripped, results)
|
|
130
130
|
collect_from_identifiers(stripped, results)
|
|
@@ -132,6 +132,49 @@ module Woods
|
|
|
132
132
|
results.uniq
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Table-factor prefixes, retaining commas after balanced subqueries and
|
|
136
|
+
# JOIN predicates. Each nested FROM/JOIN is also scanned independently.
|
|
137
|
+
# @param sql [String] noise-stripped SQL
|
|
138
|
+
# @return [Array<String>]
|
|
139
|
+
def self.relation_factors(sql)
|
|
140
|
+
sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
|
|
141
|
+
suffix = sql[Regexp.last_match.end(0)..]
|
|
142
|
+
split_top_level_commas(relation_clause(suffix))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Quoted tokens shield punctuation and clause words; a closing parenthesis
|
|
147
|
+
# at depth zero ends this query's clause, not a nested table expression.
|
|
148
|
+
def self.relation_clause(suffix)
|
|
149
|
+
depth = 0
|
|
150
|
+
tokens = /
|
|
151
|
+
"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
|
|
152
|
+
\b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
|
|
153
|
+
/ix
|
|
154
|
+
suffix.to_enum(:scan, tokens).each do
|
|
155
|
+
token = Regexp.last_match[0]
|
|
156
|
+
start = Regexp.last_match.begin(0)
|
|
157
|
+
finish = Regexp.last_match.end(0)
|
|
158
|
+
boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
|
|
159
|
+
return suffix[0...start] if depth.zero? && boundary
|
|
160
|
+
|
|
161
|
+
depth += 1 if token == '('
|
|
162
|
+
depth -= 1 if token == ')'
|
|
163
|
+
end
|
|
164
|
+
suffix
|
|
165
|
+
end
|
|
166
|
+
private_class_method :relation_clause
|
|
167
|
+
|
|
168
|
+
def self.relation_keyword_boundary?(prefix, rest, token)
|
|
169
|
+
return false unless token.match?(/\A[A-Za-z]/)
|
|
170
|
+
return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
|
|
171
|
+
return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
|
|
172
|
+
return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
|
|
173
|
+
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
private_class_method :relation_keyword_boundary?
|
|
177
|
+
|
|
135
178
|
# @api private
|
|
136
179
|
# Comments and literals must be stripped in a single combined pass —
|
|
137
180
|
# stripping them separately lets a comment marker inside a literal
|
|
@@ -153,12 +196,9 @@ module Woods
|
|
|
153
196
|
|
|
154
197
|
# @api private
|
|
155
198
|
def self.collect_from_identifiers(sql, results)
|
|
156
|
-
sql.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ident = lead_identifier(chunk)
|
|
160
|
-
results << ident if ident
|
|
161
|
-
end
|
|
199
|
+
relation_factors(sql).each do |chunk|
|
|
200
|
+
ident = lead_identifier(chunk)
|
|
201
|
+
results << ident if ident
|
|
162
202
|
end
|
|
163
203
|
end
|
|
164
204
|
private_class_method :collect_from_identifiers
|
|
@@ -169,7 +209,7 @@ module Woods
|
|
|
169
209
|
depth = 0
|
|
170
210
|
buf = +''
|
|
171
211
|
parts = []
|
|
172
|
-
clause.
|
|
212
|
+
clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
|
|
173
213
|
case ch
|
|
174
214
|
when '('
|
|
175
215
|
depth += 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'woods/console/sql_noise_stripper'
|
|
4
|
+
require 'woods/console/sqlite_read_guard'
|
|
4
5
|
|
|
5
6
|
# @see Woods
|
|
6
7
|
module Woods
|
|
@@ -95,17 +96,17 @@ module Woods
|
|
|
95
96
|
[kw, /\b#{kw}\b/i]
|
|
96
97
|
end.freeze
|
|
97
98
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end.freeze
|
|
99
|
+
# @param dialect [Symbol, nil] Known connection dialect, when available
|
|
100
|
+
def initialize(dialect: nil)
|
|
101
|
+
@dialect = dialect
|
|
102
|
+
end
|
|
103
103
|
|
|
104
104
|
# @raise [SqlValidationError] if the SQL is not a safe read-only statement
|
|
105
105
|
def validate!(sql)
|
|
106
106
|
raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
|
|
107
107
|
|
|
108
108
|
normalized = sql.strip
|
|
109
|
+
SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
|
|
109
110
|
|
|
110
111
|
# Reject multiple statements (semicolons not inside string literals)
|
|
111
112
|
if contains_multiple_statements?(normalized)
|
|
@@ -152,7 +153,7 @@ module Woods
|
|
|
152
153
|
# @param sql [String]
|
|
153
154
|
# @return [Boolean]
|
|
154
155
|
def contains_multiple_statements?(sql)
|
|
155
|
-
stripped = SqlNoiseStripper.strip_noise(sql)
|
|
156
|
+
stripped = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
|
|
156
157
|
stripped.include?(';')
|
|
157
158
|
end
|
|
158
159
|
|
|
@@ -191,8 +192,12 @@ module Woods
|
|
|
191
192
|
# @param sql [String]
|
|
192
193
|
# @raise [SqlValidationError] if a dangerous function is found
|
|
193
194
|
def check_dangerous_functions!(sql)
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
view = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
|
|
196
|
+
view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
|
|
197
|
+
func = (quoted || backtick || bare).downcase
|
|
198
|
+
next unless DANGEROUS_FUNCTIONS.include?(func)
|
|
199
|
+
|
|
200
|
+
raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
|
|
196
201
|
end
|
|
197
202
|
end
|
|
198
203
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/console/sql_noise_stripper'
|
|
4
|
+
require 'woods/console/sql_table_scanner'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Console
|
|
8
|
+
# Refuse SQLite-specific syntax outside the scanner's supported grammar.
|
|
9
|
+
# This is a conservative read boundary, not a general-purpose SQL parser.
|
|
10
|
+
module SqliteReadGuard
|
|
11
|
+
SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
12
|
+
UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
|
|
13
|
+
QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
|
|
14
|
+
|
|
15
|
+
# @param sql [String] SQL to execute on SQLite
|
|
16
|
+
# @raise [SqlValidationError] when an identifier or table factor cannot be checked
|
|
17
|
+
# @return [void]
|
|
18
|
+
def self.validate!(sql)
|
|
19
|
+
view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
|
|
20
|
+
refuse! if view.match?(UNSUPPORTED_SYNTAX)
|
|
21
|
+
SqlTableScanner.relation_factors(view).each do |factor|
|
|
22
|
+
factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
|
|
23
|
+
refuse! unless supported_factor?(factor.strip)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A SELECT/WITH subquery has its own independently scanned table factors.
|
|
28
|
+
# Parenthesized table groups and string-quoted names are refused rather
|
|
29
|
+
# than silently omitted from the blocked-table scan.
|
|
30
|
+
def self.supported_factor?(factor)
|
|
31
|
+
return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
|
|
32
|
+
|
|
33
|
+
match = SqlTableScanner::LEAD_IDENT.match(factor)
|
|
34
|
+
match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
|
|
35
|
+
end
|
|
36
|
+
private_class_method :supported_factor?
|
|
37
|
+
|
|
38
|
+
def self.refuse!
|
|
39
|
+
raise SqlValidationError,
|
|
40
|
+
'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
|
|
41
|
+
'Use simple bare or double-quoted identifiers and SELECT subqueries.'
|
|
42
|
+
end
|
|
43
|
+
private_class_method :refuse!
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -32,10 +32,10 @@ module Woods
|
|
|
32
32
|
|
|
33
33
|
def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
|
|
34
34
|
|
|
35
|
-
def check_sql!(sql)
|
|
35
|
+
def check_sql!(sql, dialect: nil)
|
|
36
36
|
return unless active? && sql&.length&.positive?
|
|
37
37
|
|
|
38
|
-
SqlTableScanner.identifiers_in(sql).each do |raw|
|
|
38
|
+
SqlTableScanner.identifiers_in(sql, dialect: dialect).each do |raw|
|
|
39
39
|
raise TableGateError, reject_message(raw) if blocked?(raw)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Metrics/ModuleLength is disabled here because the module body is almost
|
|
6
6
|
# entirely declarative data, not imperative logic. Decomposition would just
|
|
7
7
|
# scatter the tool catalogue across many files with no readability gain.
|
|
8
|
-
# rubocop:disable Metrics/ModuleLength
|
|
8
|
+
# rubocop:disable-next Metrics/ModuleLength
|
|
9
9
|
module Woods
|
|
10
10
|
module Console
|
|
11
11
|
module Server
|
|
@@ -549,4 +549,3 @@ module Woods
|
|
|
549
549
|
end
|
|
550
550
|
end
|
|
551
551
|
end
|
|
552
|
-
# rubocop:enable Metrics/ModuleLength
|
|
@@ -98,13 +98,12 @@ module Woods
|
|
|
98
98
|
# @param scope [Hash, nil] Filter conditions
|
|
99
99
|
# @param columns [Array<String>, nil] Columns to include
|
|
100
100
|
# @return [Hash] Bridge request
|
|
101
|
-
# rubocop:disable Metrics/ParameterLists
|
|
101
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
102
102
|
def console_recent(model:, order_by: 'created_at', direction: 'desc', limit: 10, scope: nil, columns: nil)
|
|
103
103
|
limit = [limit, 50].min
|
|
104
104
|
{ tool: 'recent', params: { model: model, order_by: order_by, direction: direction,
|
|
105
105
|
limit: limit, scope: scope, columns: columns }.compact }
|
|
106
106
|
end
|
|
107
|
-
# rubocop:enable Metrics/ParameterLists
|
|
108
107
|
|
|
109
108
|
# System health check.
|
|
110
109
|
#
|
|
@@ -61,7 +61,7 @@ module Woods
|
|
|
61
61
|
# @param scope [Hash, nil] Filter conditions
|
|
62
62
|
# @param limit [Integer, nil] Row limit (max 10000)
|
|
63
63
|
# @return [Hash] Bridge request
|
|
64
|
-
# rubocop:disable Metrics/ParameterLists
|
|
64
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
65
65
|
def console_query(model:, select:, joins: nil, group_by: nil, having: nil, order: nil, scope: nil, limit: nil)
|
|
66
66
|
limit = [limit, MAX_QUERY_LIMIT].min if limit
|
|
67
67
|
{
|
|
@@ -78,7 +78,6 @@ module Woods
|
|
|
78
78
|
}.compact
|
|
79
79
|
}
|
|
80
80
|
end
|
|
81
|
-
# rubocop:enable Metrics/ParameterLists
|
|
82
81
|
end
|
|
83
82
|
end
|
|
84
83
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module MCP
|
|
7
|
+
# Keep the SDK DNS-rebinding guard aligned with Woods' explicit allowlist.
|
|
8
|
+
# Empty configuration retains the SDK's strict loopback defaults.
|
|
9
|
+
module HttpTransportOptions
|
|
10
|
+
def self.for(origins)
|
|
11
|
+
normalized = Array(origins).map { |origin| origin.strip.downcase.delete_suffix('/') }.reject(&:empty?)
|
|
12
|
+
return {} if normalized.empty?
|
|
13
|
+
|
|
14
|
+
hosts = normalized.filter_map do |origin|
|
|
15
|
+
uri = URI.parse(origin)
|
|
16
|
+
uri.host if %w[http https].include?(uri.scheme)
|
|
17
|
+
rescue URI::InvalidURIError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
{ allowed_origins: normalized, allowed_hosts: hosts.uniq }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -93,15 +93,12 @@ module Woods
|
|
|
93
93
|
)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
server =
|
|
96
|
+
server = VersionAwareServer.new(
|
|
97
97
|
name: 'woods',
|
|
98
98
|
version: Woods::VERSION,
|
|
99
99
|
resources: resources,
|
|
100
100
|
resource_templates: resource_templates
|
|
101
101
|
)
|
|
102
|
-
# Rewrite "Tool not found" into version-aware update guidance for agents
|
|
103
|
-
# running against an older gem than the skill they're following assumes.
|
|
104
|
-
server.singleton_class.prepend(VersionAwareToolDispatch)
|
|
105
102
|
|
|
106
103
|
define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
107
104
|
define_search_tool(server, reader, respond, respond_err, renderer)
|
|
@@ -200,11 +197,7 @@ module Woods
|
|
|
200
197
|
meta[:config_key] = config_key if config_key
|
|
201
198
|
meta[:doc_link] = doc_link if doc_link
|
|
202
199
|
meta.merge!(extra) unless extra.empty?
|
|
203
|
-
::MCP::Tool::Response.new(
|
|
204
|
-
[{ type: 'text', text: message }],
|
|
205
|
-
error: true,
|
|
206
|
-
meta: meta
|
|
207
|
-
)
|
|
200
|
+
::MCP::Tool::Response.new([{ type: 'text', text: message }], error: true, meta: meta)
|
|
208
201
|
end
|
|
209
202
|
|
|
210
203
|
def truncate_section(array, limit)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'mcp'
|
|
3
4
|
require_relative '../update_check'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
6
7
|
module MCP
|
|
7
|
-
# Prepended
|
|
8
|
+
# Prepended before +MCP::Server+ initialization so that a call to a tool the
|
|
8
9
|
# installed gem does not define produces version-aware, self-healing
|
|
9
10
|
# guidance instead of a bare "Tool not found".
|
|
10
11
|
#
|
|
@@ -12,8 +13,7 @@ module Woods
|
|
|
12
13
|
# to call a tool that only exists in a later Woods release. The upstream
|
|
13
14
|
# +mcp+ gem raises +RequestHandlerError("Tool not found: X")+ with no seam to
|
|
14
15
|
# customize the message, so we intercept the built server's +call_tool+
|
|
15
|
-
#
|
|
16
|
-
# honored on both the stdio and HTTP transports) and re-raise with the
|
|
16
|
+
# before the SDK captures its bound handler method and re-raise with the
|
|
17
17
|
# installed version plus an update hint the agent can relay to the user.
|
|
18
18
|
#
|
|
19
19
|
# Only the genuine tool-not-found case is rewritten; every other
|
|
@@ -43,5 +43,11 @@ module Woods
|
|
|
43
43
|
error.error_type == :invalid_params && error.message.to_s.start_with?('Tool not found:')
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
# MCP 0.23 captures method(:call_tool) during initialization. The override
|
|
48
|
+
# must already exist then; a later singleton prepend misses that handler.
|
|
49
|
+
class VersionAwareServer < ::MCP::Server
|
|
50
|
+
prepend VersionAwareToolDispatch
|
|
51
|
+
end
|
|
46
52
|
end
|
|
47
53
|
end
|
|
@@ -30,9 +30,8 @@ module Woods
|
|
|
30
30
|
debug: /\b(bug|error|fix|broken|failing|wrong|issue|problem|crash|exception)\b/i,
|
|
31
31
|
implement: /\b(implement|add|create|build|write|make|generate)\b/i,
|
|
32
32
|
compare: /\b(compare|difference|vs|versus|between|contrast)\b/i,
|
|
33
|
-
# rubocop:disable Layout/LineLength
|
|
33
|
+
# rubocop:disable-next Layout/LineLength
|
|
34
34
|
framework: /\b(how does rails|what does rails|rails .+ work|work.+\brails\b|in rails\b|activerecord|actioncontroller|activejob|actionmailer|actioncable|actiontext|activestorage|solid_queue|solid_cache|solid_cable|kamal|propshaft|importmap|hotwire|turbo|stimulus|zeitwerk)\b/i,
|
|
35
|
-
# rubocop:enable Layout/LineLength
|
|
36
35
|
reference: /\b(show me|what is|what are|list|options for|api|interface|signature)\b/i,
|
|
37
36
|
understand: /\b(how|why|explain|understand|what happens|describe|overview)\b/i
|
|
38
37
|
}.freeze
|
|
@@ -85,7 +85,7 @@ module Woods
|
|
|
85
85
|
# @param env [Hash] Rack environment
|
|
86
86
|
# @param status [Integer] HTTP response status
|
|
87
87
|
# @param duration_ms [Integer] Request duration in milliseconds
|
|
88
|
-
# rubocop:disable Metrics/MethodLength
|
|
88
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
89
89
|
def record_request(env, status, duration_ms)
|
|
90
90
|
path = env['PATH_INFO'] || ''
|
|
91
91
|
return if excluded?(path)
|
|
@@ -116,7 +116,6 @@ module Woods
|
|
|
116
116
|
|
|
117
117
|
@store.record(session_id, request_data)
|
|
118
118
|
end
|
|
119
|
-
# rubocop:enable Metrics/MethodLength
|
|
120
119
|
|
|
121
120
|
# Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
|
|
122
121
|
#
|
|
@@ -24,7 +24,7 @@ module Woods
|
|
|
24
24
|
# doc = assembler.assemble("abc123", budget: 8000, depth: 1)
|
|
25
25
|
# puts doc.to_context
|
|
26
26
|
#
|
|
27
|
-
# rubocop:disable Metrics/ClassLength
|
|
27
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
28
28
|
class SessionFlowAssembler
|
|
29
29
|
ASYNC_TYPES = %w[job mailer].to_set.freeze
|
|
30
30
|
|
|
@@ -41,7 +41,7 @@ module Woods
|
|
|
41
41
|
# @param budget [Integer] Maximum token budget (default: 8000)
|
|
42
42
|
# @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
|
|
43
43
|
# @return [SessionFlowDocument] The assembled document
|
|
44
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
44
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
45
45
|
def assemble(session_id, budget: 8000, depth: 1)
|
|
46
46
|
requests = @store.read(session_id)
|
|
47
47
|
return empty_document(session_id) if requests.empty?
|
|
@@ -89,7 +89,6 @@ module Woods
|
|
|
89
89
|
token_count: token_count
|
|
90
90
|
)
|
|
91
91
|
end
|
|
92
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
93
92
|
|
|
94
93
|
private
|
|
95
94
|
|
|
@@ -115,7 +114,7 @@ module Woods
|
|
|
115
114
|
# Resolve dependencies for a unit, separating sync deps from async side effects.
|
|
116
115
|
#
|
|
117
116
|
# @return [Array<String>] Non-async dependency identifiers added
|
|
118
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
117
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
119
118
|
def resolve_dependencies(unit_id, seen_units, context_pool,
|
|
120
119
|
side_effects, step, dependency_map, depth)
|
|
121
120
|
graph = @reader.dependency_graph
|
|
@@ -154,7 +153,6 @@ module Woods
|
|
|
154
153
|
|
|
155
154
|
added
|
|
156
155
|
end
|
|
157
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
158
156
|
|
|
159
157
|
# Expand transitive dependencies (depth 2+).
|
|
160
158
|
#
|
|
@@ -249,6 +247,5 @@ module Woods
|
|
|
249
247
|
SessionFlowDocument.new(session_id: session_id)
|
|
250
248
|
end
|
|
251
249
|
end
|
|
252
|
-
# rubocop:enable Metrics/ClassLength
|
|
253
250
|
end
|
|
254
251
|
end
|
|
@@ -23,7 +23,7 @@ module Woods
|
|
|
23
23
|
# doc.to_markdown # => human-readable document
|
|
24
24
|
# doc.to_context # => LLM XML format
|
|
25
25
|
#
|
|
26
|
-
# rubocop:disable Metrics/ClassLength
|
|
26
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
27
27
|
class SessionFlowDocument
|
|
28
28
|
attr_reader :session_id, :steps, :context_pool, :side_effects,
|
|
29
29
|
:dependency_map, :token_count, :generated_at
|
|
@@ -35,7 +35,7 @@ module Woods
|
|
|
35
35
|
# @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
|
|
36
36
|
# @param token_count [Integer] Estimated total tokens
|
|
37
37
|
# @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
|
|
38
|
-
# rubocop:disable Metrics/ParameterLists
|
|
38
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
39
39
|
def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
|
|
40
40
|
dependency_map: {}, token_count: 0, generated_at: nil)
|
|
41
41
|
@session_id = session_id
|
|
@@ -46,7 +46,6 @@ module Woods
|
|
|
46
46
|
@token_count = token_count
|
|
47
47
|
@generated_at = generated_at || Time.now.utc.iso8601
|
|
48
48
|
end
|
|
49
|
-
# rubocop:enable Metrics/ParameterLists
|
|
50
49
|
|
|
51
50
|
# Serialize to a JSON-compatible Hash.
|
|
52
51
|
#
|
|
@@ -86,7 +85,7 @@ module Woods
|
|
|
86
85
|
# Render as human-readable Markdown.
|
|
87
86
|
#
|
|
88
87
|
# @return [String]
|
|
89
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
88
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
90
89
|
def to_markdown
|
|
91
90
|
lines = []
|
|
92
91
|
lines << "## Session: #{@session_id}"
|
|
@@ -146,14 +145,13 @@ module Woods
|
|
|
146
145
|
|
|
147
146
|
lines.join("\n")
|
|
148
147
|
end
|
|
149
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
150
148
|
|
|
151
149
|
# Render as LLM-consumable XML context.
|
|
152
150
|
#
|
|
153
151
|
# Follows the format from docs/CONTEXT_AND_CHUNKING.md.
|
|
154
152
|
#
|
|
155
153
|
# @return [String]
|
|
156
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
154
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
157
155
|
def to_context
|
|
158
156
|
lines = []
|
|
159
157
|
header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
|
|
@@ -201,7 +199,6 @@ module Woods
|
|
|
201
199
|
lines << '</session_context>'
|
|
202
200
|
lines.join("\n")
|
|
203
201
|
end
|
|
204
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
205
202
|
|
|
206
203
|
# @api private
|
|
207
204
|
def self.deep_symbolize_keys(obj)
|
|
@@ -218,6 +215,5 @@ module Woods
|
|
|
218
215
|
end
|
|
219
216
|
private_class_method :deep_symbolize_keys
|
|
220
217
|
end
|
|
221
|
-
# rubocop:enable Metrics/ClassLength
|
|
222
218
|
end
|
|
223
219
|
end
|
data/lib/woods/version.rb
CHANGED