coverband 6.1.5 → 6.1.7
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/.github/workflows/main.yml +4 -4
- data/README.md +123 -0
- data/agents.md +217 -0
- data/bin/coverband-mcp +42 -0
- data/changes.md +23 -0
- data/coverband/log.272267 +1 -0
- data/coverband.gemspec +3 -1
- data/lib/coverband/adapters/hash_redis_store.rb +1 -3
- data/lib/coverband/collectors/route_tracker.rb +1 -1
- data/lib/coverband/collectors/view_tracker.rb +21 -13
- data/lib/coverband/configuration.rb +57 -18
- data/lib/coverband/integrations/resque.rb +2 -2
- data/lib/coverband/mcp/http_handler.rb +118 -0
- data/lib/coverband/mcp/server.rb +116 -0
- data/lib/coverband/mcp/tools/get_coverage_summary.rb +41 -0
- data/lib/coverband/mcp/tools/get_dead_methods.rb +69 -0
- data/lib/coverband/mcp/tools/get_file_coverage.rb +72 -0
- data/lib/coverband/mcp/tools/get_route_tracker_data.rb +60 -0
- data/lib/coverband/mcp/tools/get_translation_tracker_data.rb +60 -0
- data/lib/coverband/mcp/tools/get_uncovered_files.rb +73 -0
- data/lib/coverband/mcp/tools/get_view_tracker_data.rb +60 -0
- data/lib/coverband/mcp.rb +27 -0
- data/lib/coverband/reporters/base.rb +2 -4
- data/lib/coverband/reporters/web.rb +17 -14
- data/lib/coverband/utils/lines_classifier.rb +1 -1
- data/lib/coverband/utils/railtie.rb +1 -1
- data/lib/coverband/utils/result.rb +2 -1
- data/lib/coverband/utils/source_file.rb +5 -5
- data/lib/coverband/utils/tasks.rb +31 -0
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +7 -7
- data/test/benchmarks/benchmark.rake +7 -15
- data/test/coverband/file_store_integration_test.rb +72 -0
- data/test/coverband/file_store_redis_error_test.rb +56 -0
- data/test/coverband/github_issue_586_test.rb +46 -0
- data/test/coverband/initialization_timing_test.rb +71 -0
- data/test/coverband/mcp/http_handler_test.rb +159 -0
- data/test/coverband/mcp/security_test.rb +145 -0
- data/test/coverband/mcp/server_test.rb +125 -0
- data/test/coverband/mcp/tools/get_coverage_summary_test.rb +75 -0
- data/test/coverband/mcp/tools/get_dead_methods_test.rb +162 -0
- data/test/coverband/mcp/tools/get_file_coverage_test.rb +159 -0
- data/test/coverband/mcp/tools/get_route_tracker_data_test.rb +122 -0
- data/test/coverband/mcp/tools/get_translation_tracker_data_test.rb +122 -0
- data/test/coverband/mcp/tools/get_uncovered_files_test.rb +177 -0
- data/test/coverband/mcp/tools/get_view_tracker_data_test.rb +122 -0
- data/test/coverband/reporters/web_test.rb +5 -0
- data/test/coverband/track_key_test.rb +9 -9
- data/test/coverband/tracker_initialization_test.rb +75 -0
- data/test/coverband/user_environment_simulation_test.rb +75 -0
- data/test/coverband/utils/lines_classifier_test.rb +1 -1
- data/test/integration/mcp_integration_test.rb +175 -0
- data/test/test_helper.rb +4 -5
- metadata +67 -11
|
@@ -14,13 +14,14 @@ module Coverband
|
|
|
14
14
|
:view_tracker, :defer_eager_loading_data,
|
|
15
15
|
:track_routes, :track_redirect_routes, :route_tracker,
|
|
16
16
|
:track_translations, :translations_tracker,
|
|
17
|
-
:trackers, :csp_policy, :hide_settings
|
|
17
|
+
:trackers, :csp_policy, :hide_settings,
|
|
18
|
+
:mcp_enabled
|
|
18
19
|
|
|
19
20
|
attr_writer :logger, :s3_region, :s3_bucket, :s3_access_key_id,
|
|
20
21
|
:s3_secret_access_key, :password, :api_key, :service_url, :coverband_timeout, :service_dev_mode,
|
|
21
22
|
:service_test_mode, :process_type, :track_views, :redis_url,
|
|
22
23
|
:background_reporting_sleep_seconds, :reporting_wiggle,
|
|
23
|
-
:send_deferred_eager_loading_data, :paged_reporting
|
|
24
|
+
:send_deferred_eager_loading_data, :paged_reporting, :mcp_allowed_environments, :mcp_password
|
|
24
25
|
|
|
25
26
|
attr_reader :track_gems, :ignore
|
|
26
27
|
|
|
@@ -31,19 +32,19 @@ module Coverband
|
|
|
31
32
|
# * Perhaps detect heroku deployment ENV var opposed to tasks?
|
|
32
33
|
#####
|
|
33
34
|
IGNORE_TASKS = ["coverband:clear",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
35
|
+
"coverband:coverage",
|
|
36
|
+
"coverband:coverage_server",
|
|
37
|
+
"assets:precompile",
|
|
38
|
+
"webpacker:compile",
|
|
39
|
+
"db:version",
|
|
40
|
+
"db:create",
|
|
41
|
+
"db:drop",
|
|
42
|
+
"db:seed",
|
|
43
|
+
"db:setup",
|
|
44
|
+
"db:test:prepare",
|
|
45
|
+
"db:structure:dump",
|
|
46
|
+
"db:structure:load",
|
|
47
|
+
"db:version"]
|
|
47
48
|
|
|
48
49
|
# Heroku when building assets runs code from a dynamic directory
|
|
49
50
|
# /tmp was added to avoid coverage from /tmp/build directories during
|
|
@@ -91,6 +92,11 @@ module Coverband
|
|
|
91
92
|
@csp_policy = false
|
|
92
93
|
@hide_settings = false
|
|
93
94
|
|
|
95
|
+
# MCP (Model Context Protocol) security settings
|
|
96
|
+
@mcp_enabled = false
|
|
97
|
+
@mcp_password = nil
|
|
98
|
+
@mcp_allowed_environments = %w[development test]
|
|
99
|
+
|
|
94
100
|
# coverband service settings
|
|
95
101
|
@api_key = nil
|
|
96
102
|
@service_url = nil
|
|
@@ -144,14 +150,40 @@ module Coverband
|
|
|
144
150
|
@logger ||= if defined?(Rails.logger) && Rails.logger
|
|
145
151
|
Rails.logger
|
|
146
152
|
else
|
|
147
|
-
Logger.new(
|
|
153
|
+
Logger.new($stdout)
|
|
148
154
|
end
|
|
149
155
|
end
|
|
150
156
|
|
|
157
|
+
# Alias for backward compatibility - track_key uses :routes_tracker symbol
|
|
158
|
+
def routes_tracker
|
|
159
|
+
route_tracker
|
|
160
|
+
end
|
|
161
|
+
|
|
151
162
|
def password
|
|
152
163
|
@password || ENV["COVERBAND_PASSWORD"]
|
|
153
164
|
end
|
|
154
165
|
|
|
166
|
+
def mcp_enabled?
|
|
167
|
+
# MCP is disabled by default and explicitly controlled
|
|
168
|
+
return false unless @mcp_enabled
|
|
169
|
+
|
|
170
|
+
# Check if current environment is allowed
|
|
171
|
+
current_env = (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) ||
|
|
172
|
+
ENV["RAILS_ENV"] ||
|
|
173
|
+
ENV["RACK_ENV"] ||
|
|
174
|
+
"development"
|
|
175
|
+
|
|
176
|
+
mcp_allowed_environments.include?(current_env.to_s)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def mcp_password
|
|
180
|
+
@mcp_password || ENV["COVERBAND_MCP_PASSWORD"]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def mcp_allowed_environments
|
|
184
|
+
@mcp_allowed_environments || %w[development test]
|
|
185
|
+
end
|
|
186
|
+
|
|
155
187
|
# The adjustments here either protect the redis or service from being overloaded
|
|
156
188
|
# the tradeoff being the delay in when reporting data is available
|
|
157
189
|
# if running your own redis increasing this number reduces load on the redis CPU
|
|
@@ -180,7 +212,14 @@ module Coverband
|
|
|
180
212
|
require "coverband/adapters/web_service_store"
|
|
181
213
|
Coverband::Adapters::WebServiceStore.new(service_url)
|
|
182
214
|
else
|
|
183
|
-
|
|
215
|
+
begin
|
|
216
|
+
Coverband::Adapters::RedisStore.new(Redis.new(url: redis_url), redis_store_options)
|
|
217
|
+
rescue Redis::CannotConnectError => error
|
|
218
|
+
logger.info "Redis is not available (#{error}), defaulting to NullStore"
|
|
219
|
+
logger.info "If this is intended, please explicitly configure your store: config.store = Coverband::Adapters::FileStore.new('log/coverage')"
|
|
220
|
+
require "coverband/adapters/null_store"
|
|
221
|
+
Coverband::Adapters::NullStore.new
|
|
222
|
+
end
|
|
184
223
|
end
|
|
185
224
|
end
|
|
186
225
|
|
|
@@ -242,7 +281,7 @@ module Coverband
|
|
|
242
281
|
@all_root_patterns ||= all_root_paths.map { |path| /^#{path}/ }.freeze
|
|
243
282
|
end
|
|
244
283
|
|
|
245
|
-
SKIPPED_SETTINGS = %w[@s3_secret_access_key @store @api_key @password]
|
|
284
|
+
SKIPPED_SETTINGS = %w[@s3_secret_access_key @store @api_key @password @mcp_password]
|
|
246
285
|
def to_h
|
|
247
286
|
instance_variables
|
|
248
287
|
.each_with_object({}) do |var, hash|
|
|
@@ -29,8 +29,8 @@ if defined?(Coverband::COVERBAND_ALTERNATE_PATCH)
|
|
|
29
29
|
ensure
|
|
30
30
|
Coverband.report_coverage
|
|
31
31
|
end
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
alias_method :perform_without_coverband, :perform
|
|
33
|
+
alias_method :perform, :perform_with_coverband
|
|
34
34
|
end
|
|
35
35
|
else
|
|
36
36
|
Resque::Job.prepend(Coverband::ResqueWorker)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
# Rack middleware that adds MCP HTTP endpoint support.
|
|
6
|
+
# Can be used to wrap the existing Coverband::Reporters::Web app
|
|
7
|
+
# or mounted standalone.
|
|
8
|
+
#
|
|
9
|
+
# Usage with existing web UI:
|
|
10
|
+
# map "/coverage" do
|
|
11
|
+
# run Coverband::MCP::HttpHandler.new(Coverband::Reporters::Web.new)
|
|
12
|
+
# end
|
|
13
|
+
# # MCP endpoint available at POST /coverage/mcp
|
|
14
|
+
#
|
|
15
|
+
# Usage standalone:
|
|
16
|
+
# map "/mcp" do
|
|
17
|
+
# run Coverband::MCP::HttpHandler.new
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class HttpHandler
|
|
21
|
+
MCP_PATH = "/mcp"
|
|
22
|
+
|
|
23
|
+
def initialize(app = nil)
|
|
24
|
+
@app = app
|
|
25
|
+
@server = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
request = Rack::Request.new(env)
|
|
30
|
+
|
|
31
|
+
if mcp_request?(request)
|
|
32
|
+
handle_mcp_request(request)
|
|
33
|
+
elsif @app
|
|
34
|
+
@app.call(env)
|
|
35
|
+
else
|
|
36
|
+
not_found_response
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def mcp_request?(request)
|
|
43
|
+
request.post? && request.path_info.end_with?(MCP_PATH)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_mcp_request(request)
|
|
47
|
+
# Check authentication if MCP password is configured
|
|
48
|
+
unless authenticate_mcp_request(request)
|
|
49
|
+
return [401, {
|
|
50
|
+
"Content-Type" => "application/json",
|
|
51
|
+
"Access-Control-Allow-Origin" => "*",
|
|
52
|
+
"Access-Control-Allow-Methods" => "POST, OPTIONS",
|
|
53
|
+
"Access-Control-Allow-Headers" => "Content-Type, Authorization",
|
|
54
|
+
"WWW-Authenticate" => 'Bearer realm="Coverband MCP"'
|
|
55
|
+
}, [JSON.generate({
|
|
56
|
+
"error" => "Authentication required",
|
|
57
|
+
"message" => "MCP access requires authentication. Provide Bearer token via Authorization header."
|
|
58
|
+
})]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
body = request.body.read
|
|
62
|
+
json_request = JSON.parse(body)
|
|
63
|
+
response = mcp_server.handle_json(json_request)
|
|
64
|
+
|
|
65
|
+
# response might already be a JSON string, so check before converting
|
|
66
|
+
response_body = response.is_a?(String) ? response : response.to_json
|
|
67
|
+
|
|
68
|
+
[
|
|
69
|
+
200,
|
|
70
|
+
{
|
|
71
|
+
"content-type" => "application/json",
|
|
72
|
+
"access-control-allow-origin" => "*",
|
|
73
|
+
"access-control-allow-methods" => "POST, OPTIONS",
|
|
74
|
+
"access-control-allow-headers" => "Content-Type"
|
|
75
|
+
},
|
|
76
|
+
[response_body]
|
|
77
|
+
]
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
error_response(400, "Invalid JSON: #{e.message}")
|
|
80
|
+
rescue => e
|
|
81
|
+
error_response(500, "Server error: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def authenticate_mcp_request(request)
|
|
85
|
+
# If no MCP password is configured, allow access
|
|
86
|
+
mcp_password = Coverband.configuration.mcp_password
|
|
87
|
+
return true unless mcp_password
|
|
88
|
+
|
|
89
|
+
# Extract Bearer token from Authorization header
|
|
90
|
+
auth_header = request.get_header("HTTP_AUTHORIZATION")
|
|
91
|
+
return false unless auth_header
|
|
92
|
+
|
|
93
|
+
# Parse Bearer token
|
|
94
|
+
token = auth_header[/Bearer (.+)/, 1]
|
|
95
|
+
return false unless token
|
|
96
|
+
|
|
97
|
+
# Compare with configured MCP password
|
|
98
|
+
token == mcp_password
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def mcp_server
|
|
102
|
+
@server ||= Server.new
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def error_response(status, message)
|
|
106
|
+
[
|
|
107
|
+
status,
|
|
108
|
+
{"content-type" => "application/json"},
|
|
109
|
+
[{error: message}.to_json]
|
|
110
|
+
]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def not_found_response
|
|
114
|
+
[404, {"content-type" => "text/plain"}, ["Not Found"]]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/get_coverage_summary"
|
|
4
|
+
require_relative "tools/get_file_coverage"
|
|
5
|
+
require_relative "tools/get_uncovered_files"
|
|
6
|
+
require_relative "tools/get_dead_methods"
|
|
7
|
+
require_relative "tools/get_view_tracker_data"
|
|
8
|
+
require_relative "tools/get_route_tracker_data"
|
|
9
|
+
require_relative "tools/get_translation_tracker_data"
|
|
10
|
+
|
|
11
|
+
module Coverband
|
|
12
|
+
module MCP
|
|
13
|
+
class Server
|
|
14
|
+
attr_reader :mcp_server
|
|
15
|
+
|
|
16
|
+
DEFAULT_HTTP_PORT = 9023
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
# Ensure Coverband is configured
|
|
20
|
+
Coverband.configure unless Coverband.configured?
|
|
21
|
+
|
|
22
|
+
# Security check: Ensure MCP is enabled and environment is allowed
|
|
23
|
+
unless Coverband.configuration.mcp_enabled?
|
|
24
|
+
raise SecurityError, "MCP is not enabled. Set config.mcp_enabled = true and ensure the current environment is in mcp_allowed_environments."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@mcp_server = ::MCP::Server.new(
|
|
28
|
+
name: "coverband",
|
|
29
|
+
version: Coverband::VERSION,
|
|
30
|
+
instructions: "Coverband production code coverage MCP server. " \
|
|
31
|
+
"Query coverage data, find dead code, and analyze view/route/translation usage.",
|
|
32
|
+
tools: tools
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_stdio
|
|
37
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server)
|
|
38
|
+
transport.open
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_http(port: DEFAULT_HTTP_PORT, host: "localhost")
|
|
42
|
+
require "rack"
|
|
43
|
+
require "rackup"
|
|
44
|
+
|
|
45
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server)
|
|
46
|
+
@mcp_server.transport = transport
|
|
47
|
+
|
|
48
|
+
app = create_rack_app(transport)
|
|
49
|
+
|
|
50
|
+
puts <<~MESSAGE
|
|
51
|
+
=== Coverband MCP Server (HTTP) ===
|
|
52
|
+
|
|
53
|
+
🔒 SECURITY NOTICE:
|
|
54
|
+
This server exposes production coverage data.
|
|
55
|
+
Ensure proper network security (firewall, VPN, etc.)
|
|
56
|
+
Environment: #{(defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}
|
|
57
|
+
Authentication: #{Coverband.configuration.mcp_password ? "✓ Enabled" : "⚠️ DISABLED"}
|
|
58
|
+
|
|
59
|
+
Server running at http://#{host}:#{port}
|
|
60
|
+
|
|
61
|
+
Available tools:
|
|
62
|
+
- get_coverage_summary
|
|
63
|
+
- get_file_coverage
|
|
64
|
+
- get_uncovered_files
|
|
65
|
+
- get_dead_methods
|
|
66
|
+
- get_view_tracker_data
|
|
67
|
+
- get_route_tracker_data
|
|
68
|
+
- get_translation_tracker_data
|
|
69
|
+
|
|
70
|
+
For Claude Desktop, configure with:
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"coverband": {
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["mcp-remote", "http://#{host}:#{port}"]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Press Ctrl+C to stop the server
|
|
81
|
+
MESSAGE
|
|
82
|
+
|
|
83
|
+
Rackup::Handler.get("puma").run(app, Port: port, Host: host, Silent: true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_json(json_request)
|
|
87
|
+
@mcp_server.handle_json(json_request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def create_rack_app(transport)
|
|
93
|
+
Rack::Builder.new do
|
|
94
|
+
use Rack::CommonLogger
|
|
95
|
+
|
|
96
|
+
run lambda { |env|
|
|
97
|
+
request = Rack::Request.new(env)
|
|
98
|
+
transport.handle_request(request)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def tools
|
|
104
|
+
[
|
|
105
|
+
Tools::GetCoverageSummary,
|
|
106
|
+
Tools::GetFileCoverage,
|
|
107
|
+
Tools::GetUncoveredFiles,
|
|
108
|
+
Tools::GetDeadMethods,
|
|
109
|
+
Tools::GetViewTrackerData,
|
|
110
|
+
Tools::GetRouteTrackerData,
|
|
111
|
+
Tools::GetTranslationTrackerData
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetCoverageSummary < ::MCP::Tool
|
|
7
|
+
description "Get overall production code coverage statistics including total files, " \
|
|
8
|
+
"lines of code, lines covered, and coverage percentage."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def self.call(server_context:, **)
|
|
15
|
+
store = Coverband.configuration.store
|
|
16
|
+
report = Coverband::Reporters::JSONReport.new(store)
|
|
17
|
+
data = JSON.parse(report.report)
|
|
18
|
+
|
|
19
|
+
summary = {
|
|
20
|
+
total_files: data["total_files"],
|
|
21
|
+
lines_of_code: data["lines_of_code"],
|
|
22
|
+
lines_covered: data["lines_covered"],
|
|
23
|
+
lines_missed: data["lines_missed"],
|
|
24
|
+
covered_percent: data["covered_percent"],
|
|
25
|
+
covered_strength: data["covered_strength"]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
::MCP::Tool::Response.new([{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: JSON.pretty_generate(summary)
|
|
31
|
+
}])
|
|
32
|
+
rescue => e
|
|
33
|
+
::MCP::Tool::Response.new([{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: "Error getting coverage summary: #{e.message}"
|
|
36
|
+
}])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetDeadMethods < ::MCP::Tool
|
|
7
|
+
description "Analyze code coverage to find methods that have never been executed in production. " \
|
|
8
|
+
"Requires Ruby 2.6+ with RubyVM::AbstractSyntaxTree support."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
file_pattern: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Optional glob pattern to filter files (e.g., 'app/models/**/*.rb')"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, file_pattern: nil, **)
|
|
20
|
+
unless defined?(RubyVM::AbstractSyntaxTree)
|
|
21
|
+
return ::MCP::Tool::Response.new([{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: "Dead method detection requires Ruby 2.6+ with RubyVM::AbstractSyntaxTree support."
|
|
24
|
+
}])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dead_methods = Coverband::Utils::DeadMethods.scan_all
|
|
28
|
+
|
|
29
|
+
if file_pattern
|
|
30
|
+
dead_methods = dead_methods.select do |method|
|
|
31
|
+
File.fnmatch(file_pattern, method[:file_path], File::FNM_PATHNAME)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Group by file for easier reading
|
|
36
|
+
grouped = dead_methods.group_by { |m| m[:file_path] }
|
|
37
|
+
|
|
38
|
+
result = grouped.map do |file_path, methods|
|
|
39
|
+
{
|
|
40
|
+
file: file_path,
|
|
41
|
+
dead_methods: methods.map do |m|
|
|
42
|
+
{
|
|
43
|
+
class_name: m[:class_name],
|
|
44
|
+
method_name: m[:method_name],
|
|
45
|
+
line_number: m[:line_number]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
::MCP::Tool::Response.new([{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: JSON.pretty_generate({
|
|
54
|
+
total_dead_methods: dead_methods.length,
|
|
55
|
+
files_with_dead_methods: grouped.keys.length,
|
|
56
|
+
file_pattern: file_pattern,
|
|
57
|
+
results: result
|
|
58
|
+
})
|
|
59
|
+
}])
|
|
60
|
+
rescue => e
|
|
61
|
+
::MCP::Tool::Response.new([{
|
|
62
|
+
type: "text",
|
|
63
|
+
text: "Error analyzing dead methods: #{e.message}"
|
|
64
|
+
}])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetFileCoverage < ::MCP::Tool
|
|
7
|
+
description "Get detailed line-by-line coverage data for a specific file. " \
|
|
8
|
+
"Returns coverage percentage, lines covered/missed, and per-line hit counts."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
filename: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Full or partial path to the file (e.g., 'app/models/user.rb')"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
required: ["filename"]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def self.call(filename:, server_context:, **)
|
|
21
|
+
store = Coverband.configuration.store
|
|
22
|
+
report = Coverband::Reporters::JSONReport.new(store, {
|
|
23
|
+
filename: filename,
|
|
24
|
+
line_coverage: true
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
data = JSON.parse(report.report)
|
|
28
|
+
|
|
29
|
+
if data["files"].nil? || data["files"].empty?
|
|
30
|
+
return ::MCP::Tool::Response.new([{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: "No coverage data found for file: #{filename}"
|
|
33
|
+
}])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Find matching file(s)
|
|
37
|
+
matching_files = data["files"].select { |path, _| path.include?(filename) }
|
|
38
|
+
|
|
39
|
+
if matching_files.empty?
|
|
40
|
+
return ::MCP::Tool::Response.new([{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: "No coverage data found for file matching: #{filename}"
|
|
43
|
+
}])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result = matching_files.transform_values do |file_data|
|
|
47
|
+
{
|
|
48
|
+
filename: file_data["filename"],
|
|
49
|
+
covered_percent: file_data["covered_percent"],
|
|
50
|
+
lines_of_code: file_data["lines_of_code"],
|
|
51
|
+
lines_covered: file_data["lines_covered"],
|
|
52
|
+
lines_missed: file_data["lines_missed"],
|
|
53
|
+
runtime_percentage: file_data["runtime_percentage"],
|
|
54
|
+
never_loaded: file_data["never_loaded"],
|
|
55
|
+
coverage: file_data["coverage"]
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
::MCP::Tool::Response.new([{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: JSON.pretty_generate(result)
|
|
62
|
+
}])
|
|
63
|
+
rescue => e
|
|
64
|
+
::MCP::Tool::Response.new([{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: "Error getting file coverage: #{e.message}"
|
|
67
|
+
}])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetRouteTrackerData < ::MCP::Tool
|
|
7
|
+
description "Get Rails route usage tracking data. Shows which routes have been hit " \
|
|
8
|
+
"in production and which have never been accessed."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
show_unused_only: {
|
|
13
|
+
type: "boolean",
|
|
14
|
+
description: "Only return unused routes (default: false)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, show_unused_only: false, **)
|
|
20
|
+
tracker = Coverband.configuration.route_tracker
|
|
21
|
+
|
|
22
|
+
unless tracker
|
|
23
|
+
return ::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "Route tracking is not enabled. Enable it with `config.track_routes = true` in your coverband configuration."
|
|
26
|
+
}])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(tracker.as_json)
|
|
30
|
+
|
|
31
|
+
result = if show_unused_only
|
|
32
|
+
{
|
|
33
|
+
tracking_since: tracker.tracking_since,
|
|
34
|
+
unused_routes: data["unused_keys"] || [],
|
|
35
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
tracking_since: tracker.tracking_since,
|
|
40
|
+
used_routes: data["used_keys"] || [],
|
|
41
|
+
unused_routes: data["unused_keys"] || [],
|
|
42
|
+
total_used: data["used_keys"]&.length || 0,
|
|
43
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
::MCP::Tool::Response.new([{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.pretty_generate(result)
|
|
50
|
+
}])
|
|
51
|
+
rescue => e
|
|
52
|
+
::MCP::Tool::Response.new([{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "Error getting route tracker data: #{e.message}"
|
|
55
|
+
}])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class GetTranslationTrackerData < ::MCP::Tool
|
|
7
|
+
description "Get I18n translation key usage tracking data. Shows which translation " \
|
|
8
|
+
"keys have been used in production and which have never been accessed."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
show_unused_only: {
|
|
13
|
+
type: "boolean",
|
|
14
|
+
description: "Only return unused translation keys (default: false)"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.call(server_context:, show_unused_only: false, **)
|
|
20
|
+
tracker = Coverband.configuration.translations_tracker
|
|
21
|
+
|
|
22
|
+
unless tracker
|
|
23
|
+
return ::MCP::Tool::Response.new([{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: "Translation tracking is not enabled. Enable it with `config.track_translations = true` in your coverband configuration."
|
|
26
|
+
}])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(tracker.as_json)
|
|
30
|
+
|
|
31
|
+
result = if show_unused_only
|
|
32
|
+
{
|
|
33
|
+
tracking_since: tracker.tracking_since,
|
|
34
|
+
unused_translations: data["unused_keys"] || [],
|
|
35
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
{
|
|
39
|
+
tracking_since: tracker.tracking_since,
|
|
40
|
+
used_translations: data["used_keys"] || [],
|
|
41
|
+
unused_translations: data["unused_keys"] || [],
|
|
42
|
+
total_used: data["used_keys"]&.length || 0,
|
|
43
|
+
total_unused: data["unused_keys"]&.length || 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
::MCP::Tool::Response.new([{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.pretty_generate(result)
|
|
50
|
+
}])
|
|
51
|
+
rescue => e
|
|
52
|
+
::MCP::Tool::Response.new([{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: "Error getting translation tracker data: #{e.message}"
|
|
55
|
+
}])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|