coverband 6.1.6 → 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 (51) 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 +18 -0
  7. data/coverband/log.272267 +1 -0
  8. data/coverband.gemspec +3 -1
  9. data/lib/coverband/collectors/route_tracker.rb +1 -1
  10. data/lib/coverband/collectors/view_tracker.rb +21 -13
  11. data/lib/coverband/configuration.rb +57 -18
  12. data/lib/coverband/integrations/resque.rb +2 -2
  13. data/lib/coverband/mcp/http_handler.rb +118 -0
  14. data/lib/coverband/mcp/server.rb +116 -0
  15. data/lib/coverband/mcp/tools/get_coverage_summary.rb +41 -0
  16. data/lib/coverband/mcp/tools/get_dead_methods.rb +69 -0
  17. data/lib/coverband/mcp/tools/get_file_coverage.rb +72 -0
  18. data/lib/coverband/mcp/tools/get_route_tracker_data.rb +60 -0
  19. data/lib/coverband/mcp/tools/get_translation_tracker_data.rb +60 -0
  20. data/lib/coverband/mcp/tools/get_uncovered_files.rb +73 -0
  21. data/lib/coverband/mcp/tools/get_view_tracker_data.rb +60 -0
  22. data/lib/coverband/mcp.rb +27 -0
  23. data/lib/coverband/reporters/base.rb +2 -4
  24. data/lib/coverband/reporters/web.rb +17 -14
  25. data/lib/coverband/utils/lines_classifier.rb +1 -1
  26. data/lib/coverband/utils/result.rb +2 -1
  27. data/lib/coverband/utils/source_file.rb +5 -5
  28. data/lib/coverband/utils/tasks.rb +31 -0
  29. data/lib/coverband/version.rb +1 -1
  30. data/lib/coverband.rb +2 -2
  31. data/test/coverband/file_store_integration_test.rb +72 -0
  32. data/test/coverband/file_store_redis_error_test.rb +56 -0
  33. data/test/coverband/github_issue_586_test.rb +46 -0
  34. data/test/coverband/initialization_timing_test.rb +71 -0
  35. data/test/coverband/mcp/http_handler_test.rb +159 -0
  36. data/test/coverband/mcp/security_test.rb +145 -0
  37. data/test/coverband/mcp/server_test.rb +125 -0
  38. data/test/coverband/mcp/tools/get_coverage_summary_test.rb +75 -0
  39. data/test/coverband/mcp/tools/get_dead_methods_test.rb +162 -0
  40. data/test/coverband/mcp/tools/get_file_coverage_test.rb +159 -0
  41. data/test/coverband/mcp/tools/get_route_tracker_data_test.rb +122 -0
  42. data/test/coverband/mcp/tools/get_translation_tracker_data_test.rb +122 -0
  43. data/test/coverband/mcp/tools/get_uncovered_files_test.rb +177 -0
  44. data/test/coverband/mcp/tools/get_view_tracker_data_test.rb +122 -0
  45. data/test/coverband/reporters/web_test.rb +5 -0
  46. data/test/coverband/tracker_initialization_test.rb +75 -0
  47. data/test/coverband/user_environment_simulation_test.rb +75 -0
  48. data/test/coverband/utils/lines_classifier_test.rb +1 -1
  49. data/test/integration/mcp_integration_test.rb +175 -0
  50. data/test/test_helper.rb +4 -5
  51. metadata +65 -6
@@ -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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coverband
4
+ module MCP
5
+ module Tools
6
+ class GetUncoveredFiles < ::MCP::Tool
7
+ description "Get files with coverage below a specified threshold. " \
8
+ "Useful for finding code that may need more production testing or could be dead code."
9
+
10
+ input_schema(
11
+ properties: {
12
+ threshold: {
13
+ type: "number",
14
+ description: "Coverage percentage threshold (default: 50). Files below this are returned."
15
+ },
16
+ include_never_loaded: {
17
+ type: "boolean",
18
+ description: "Include files that were never loaded in production (default: true)"
19
+ }
20
+ }
21
+ )
22
+
23
+ def self.call(server_context:, threshold: 50, include_never_loaded: true, **)
24
+ store = Coverband.configuration.store
25
+ report = Coverband::Reporters::JSONReport.new(store, line_coverage: false)
26
+ data = JSON.parse(report.report)
27
+
28
+ files = data["files"] || {}
29
+
30
+ uncovered = files.select do |_path, file_data|
31
+ percent = file_data["covered_percent"] || 0
32
+ never_loaded = file_data["never_loaded"]
33
+
34
+ if include_never_loaded
35
+ percent < threshold || never_loaded
36
+ else
37
+ percent < threshold && !never_loaded
38
+ end
39
+ end
40
+
41
+ # Sort by coverage percentage ascending (least covered first)
42
+ sorted = uncovered.sort_by { |_path, data| data["covered_percent"] || 0 }
43
+
44
+ result = sorted.map do |path, file_data|
45
+ {
46
+ file: path,
47
+ covered_percent: file_data["covered_percent"],
48
+ lines_of_code: file_data["lines_of_code"],
49
+ lines_covered: file_data["lines_covered"],
50
+ lines_missed: file_data["lines_missed"],
51
+ never_loaded: file_data["never_loaded"]
52
+ }
53
+ end
54
+
55
+ ::MCP::Tool::Response.new([{
56
+ type: "text",
57
+ text: JSON.pretty_generate({
58
+ threshold: threshold,
59
+ include_never_loaded: include_never_loaded,
60
+ total_uncovered_files: result.length,
61
+ files: result
62
+ })
63
+ }])
64
+ rescue => e
65
+ ::MCP::Tool::Response.new([{
66
+ type: "text",
67
+ text: "Error getting uncovered files: #{e.message}"
68
+ }])
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coverband
4
+ module MCP
5
+ module Tools
6
+ class GetViewTrackerData < ::MCP::Tool
7
+ description "Get Rails view template usage tracking data. Shows which view templates " \
8
+ "have been rendered 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 views (default: false)"
15
+ }
16
+ }
17
+ )
18
+
19
+ def self.call(server_context:, show_unused_only: false, **)
20
+ tracker = Coverband.configuration.view_tracker
21
+
22
+ unless tracker
23
+ return ::MCP::Tool::Response.new([{
24
+ type: "text",
25
+ text: "View tracking is not enabled. Enable it with `config.track_views = 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_views: data["unused_keys"] || [],
35
+ total_unused: data["unused_keys"]&.length || 0
36
+ }
37
+ else
38
+ {
39
+ tracking_since: tracker.tracking_since,
40
+ used_views: data["used_keys"] || [],
41
+ unused_views: 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 view tracker data: #{e.message}"
55
+ }])
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end