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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +4 -4
  3. data/README.md +123 -0
  4. data/agents.md +217 -0
  5. data/bin/coverband-mcp +42 -0
  6. data/changes.md +23 -0
  7. data/coverband/log.272267 +1 -0
  8. data/coverband.gemspec +3 -1
  9. data/lib/coverband/adapters/hash_redis_store.rb +1 -3
  10. data/lib/coverband/collectors/route_tracker.rb +1 -1
  11. data/lib/coverband/collectors/view_tracker.rb +21 -13
  12. data/lib/coverband/configuration.rb +57 -18
  13. data/lib/coverband/integrations/resque.rb +2 -2
  14. data/lib/coverband/mcp/http_handler.rb +118 -0
  15. data/lib/coverband/mcp/server.rb +116 -0
  16. data/lib/coverband/mcp/tools/get_coverage_summary.rb +41 -0
  17. data/lib/coverband/mcp/tools/get_dead_methods.rb +69 -0
  18. data/lib/coverband/mcp/tools/get_file_coverage.rb +72 -0
  19. data/lib/coverband/mcp/tools/get_route_tracker_data.rb +60 -0
  20. data/lib/coverband/mcp/tools/get_translation_tracker_data.rb +60 -0
  21. data/lib/coverband/mcp/tools/get_uncovered_files.rb +73 -0
  22. data/lib/coverband/mcp/tools/get_view_tracker_data.rb +60 -0
  23. data/lib/coverband/mcp.rb +27 -0
  24. data/lib/coverband/reporters/base.rb +2 -4
  25. data/lib/coverband/reporters/web.rb +17 -14
  26. data/lib/coverband/utils/lines_classifier.rb +1 -1
  27. data/lib/coverband/utils/railtie.rb +1 -1
  28. data/lib/coverband/utils/result.rb +2 -1
  29. data/lib/coverband/utils/source_file.rb +5 -5
  30. data/lib/coverband/utils/tasks.rb +31 -0
  31. data/lib/coverband/version.rb +1 -1
  32. data/lib/coverband.rb +7 -7
  33. data/test/benchmarks/benchmark.rake +7 -15
  34. data/test/coverband/file_store_integration_test.rb +72 -0
  35. data/test/coverband/file_store_redis_error_test.rb +56 -0
  36. data/test/coverband/github_issue_586_test.rb +46 -0
  37. data/test/coverband/initialization_timing_test.rb +71 -0
  38. data/test/coverband/mcp/http_handler_test.rb +159 -0
  39. data/test/coverband/mcp/security_test.rb +145 -0
  40. data/test/coverband/mcp/server_test.rb +125 -0
  41. data/test/coverband/mcp/tools/get_coverage_summary_test.rb +75 -0
  42. data/test/coverband/mcp/tools/get_dead_methods_test.rb +162 -0
  43. data/test/coverband/mcp/tools/get_file_coverage_test.rb +159 -0
  44. data/test/coverband/mcp/tools/get_route_tracker_data_test.rb +122 -0
  45. data/test/coverband/mcp/tools/get_translation_tracker_data_test.rb +122 -0
  46. data/test/coverband/mcp/tools/get_uncovered_files_test.rb +177 -0
  47. data/test/coverband/mcp/tools/get_view_tracker_data_test.rb +122 -0
  48. data/test/coverband/reporters/web_test.rb +5 -0
  49. data/test/coverband/track_key_test.rb +9 -9
  50. data/test/coverband/tracker_initialization_test.rb +75 -0
  51. data/test/coverband/user_environment_simulation_test.rb +75 -0
  52. data/test/coverband/utils/lines_classifier_test.rb +1 -1
  53. data/test/integration/mcp_integration_test.rb +175 -0
  54. data/test/test_helper.rb +4 -5
  55. 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
- "coverband:coverage",
35
- "coverband:coverage_server",
36
- "assets:precompile",
37
- "webpacker:compile",
38
- "db:version",
39
- "db:create",
40
- "db:drop",
41
- "db:seed",
42
- "db:setup",
43
- "db:test:prepare",
44
- "db:structure:dump",
45
- "db:structure:load",
46
- "db:version"]
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(STDOUT)
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
- Coverband::Adapters::RedisStore.new(Redis.new(url: redis_url), redis_store_options)
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
- alias perform_without_coverband perform
33
- alias perform perform_with_coverband
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