simplecov-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +379 -32
  3. data/exe/simplecov-mcp +19 -2
  4. data/lib/simple_cov/mcp.rb +9 -0
  5. data/lib/simple_cov_mcp/base_tool.rb +54 -0
  6. data/lib/simple_cov_mcp/cli.rb +390 -0
  7. data/lib/simple_cov_mcp/error_handler.rb +131 -0
  8. data/lib/simple_cov_mcp/error_handler_factory.rb +38 -0
  9. data/lib/simple_cov_mcp/errors.rb +176 -0
  10. data/lib/simple_cov_mcp/mcp_server.rb +30 -0
  11. data/lib/simple_cov_mcp/model.rb +104 -0
  12. data/lib/simple_cov_mcp/staleness_checker.rb +125 -0
  13. data/lib/simple_cov_mcp/tools/all_files_coverage_tool.rb +63 -0
  14. data/lib/simple_cov_mcp/tools/coverage_detailed_tool.rb +29 -0
  15. data/lib/simple_cov_mcp/tools/coverage_raw_tool.rb +29 -0
  16. data/lib/simple_cov_mcp/tools/coverage_summary_tool.rb +29 -0
  17. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +61 -0
  18. data/lib/simple_cov_mcp/tools/help_tool.rb +133 -0
  19. data/lib/simple_cov_mcp/tools/uncovered_lines_tool.rb +29 -0
  20. data/lib/simple_cov_mcp/tools/version_tool.rb +31 -0
  21. data/lib/simple_cov_mcp/util.rb +122 -0
  22. data/lib/simple_cov_mcp/version.rb +5 -0
  23. data/lib/simple_cov_mcp.rb +102 -0
  24. data/lib/simplecov_mcp.rb +2 -3
  25. data/spec/all_files_coverage_tool_spec.rb +46 -0
  26. data/spec/base_tool_spec.rb +58 -0
  27. data/spec/cli_error_spec.rb +103 -0
  28. data/spec/cli_json_source_spec.rb +92 -0
  29. data/spec/cli_source_spec.rb +37 -0
  30. data/spec/cli_spec.rb +72 -0
  31. data/spec/cli_table_spec.rb +28 -0
  32. data/spec/cli_usage_spec.rb +58 -0
  33. data/spec/coverage_table_tool_spec.rb +64 -0
  34. data/spec/error_handler_spec.rb +72 -0
  35. data/spec/errors_stale_spec.rb +49 -0
  36. data/spec/fixtures/project1/lib/bar.rb +4 -0
  37. data/spec/fixtures/project1/lib/foo.rb +5 -0
  38. data/spec/help_tool_spec.rb +47 -0
  39. data/spec/legacy_shim_spec.rb +13 -0
  40. data/spec/mcp_server_spec.rb +78 -0
  41. data/spec/model_staleness_spec.rb +49 -0
  42. data/spec/simplecov_mcp_model_spec.rb +51 -32
  43. data/spec/spec_helper.rb +37 -7
  44. data/spec/staleness_more_spec.rb +39 -0
  45. data/spec/util_spec.rb +78 -0
  46. data/spec/version_spec.rb +10 -0
  47. metadata +56 -13
  48. data/lib/simplecov/mcp/base_tool.rb +0 -18
  49. data/lib/simplecov/mcp/cli.rb +0 -98
  50. data/lib/simplecov/mcp/model.rb +0 -59
  51. data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
  52. data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
  53. data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
  54. data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
  55. data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
  56. data/lib/simplecov/mcp/util.rb +0 -94
  57. data/lib/simplecov/mcp/version.rb +0 -8
  58. data/lib/simplecov/mcp.rb +0 -28
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ # Base error class for all SimpleCov MCP errors
5
+ class Error < StandardError
6
+ attr_reader :original_error
7
+
8
+ def initialize(message = nil, original_error = nil)
9
+ @original_error = original_error
10
+ super(message)
11
+ end
12
+
13
+ def user_friendly_message
14
+ message
15
+ end
16
+ end
17
+
18
+ # Configuration or setup related errors
19
+ class ConfigurationError < Error
20
+ def user_friendly_message
21
+ "Configuration error: #{message}"
22
+ end
23
+ end
24
+
25
+ # File or path related errors
26
+ class FileError < Error
27
+ def user_friendly_message
28
+ "File error: #{message}"
29
+ end
30
+ end
31
+
32
+ # More specific file errors
33
+ class FileNotFoundError < FileError; end
34
+ class FilePermissionError < FileError; end
35
+ class NotAFileError < FileError; end
36
+ class ResultsetNotFoundError < FileError; end
37
+
38
+ # Coverage data related errors
39
+ class CoverageDataError < Error
40
+ def user_friendly_message
41
+ "Coverage data error: #{message}"
42
+ end
43
+ end
44
+
45
+ # Coverage data is present but appears stale compared to source files
46
+ class CoverageDataStaleError < CoverageDataError
47
+ attr_reader :file_path, :file_mtime, :cov_timestamp, :src_len, :cov_len, :resultset_path
48
+
49
+ def initialize(message = nil, original_error = nil, file_path: nil, file_mtime: nil, cov_timestamp: nil, src_len: nil, cov_len: nil, resultset_path: nil)
50
+ super(message, original_error)
51
+ @file_path = file_path
52
+ @file_mtime = file_mtime
53
+ @cov_timestamp = cov_timestamp
54
+ @src_len = src_len
55
+ @cov_len = cov_len
56
+ @resultset_path = resultset_path
57
+ end
58
+
59
+ def user_friendly_message
60
+ base = "Coverage data stale: #{message || default_message}"
61
+ base + build_details
62
+ end
63
+
64
+ private
65
+
66
+ def default_message
67
+ fp = file_path || 'file'
68
+ "Coverage data appears stale for #{fp}"
69
+ end
70
+
71
+ def build_details
72
+ file_utc, file_local = format_time_both(@file_mtime)
73
+ cov_utc, cov_local = format_epoch_both(@cov_timestamp)
74
+ delta_str = format_delta_seconds(@file_mtime, @cov_timestamp)
75
+ details = []
76
+ details << "\nFile - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{@src_len}"
77
+ details << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{@cov_len}"
78
+ details << "\nDelta - file is #{delta_str} newer than coverage" if delta_str
79
+ details << "\nResultset - #{@resultset_path}" if @resultset_path
80
+ details.join
81
+ end
82
+
83
+ def format_epoch_both(epoch_seconds)
84
+ return [nil, nil] unless epoch_seconds
85
+ t = Time.at(epoch_seconds.to_i)
86
+ [t.utc.iso8601, t.getlocal.iso8601]
87
+ rescue StandardError
88
+ [epoch_seconds.to_s, epoch_seconds.to_s]
89
+ end
90
+
91
+ def format_time_both(time)
92
+ return [nil, nil] unless time
93
+ t = time.is_a?(Time) ? time : Time.parse(time.to_s)
94
+ [t.utc.iso8601, t.getlocal.iso8601]
95
+ rescue StandardError
96
+ [time.to_s, time.to_s]
97
+ end
98
+
99
+ def format_delta_seconds(file_mtime, cov_timestamp)
100
+ return nil unless file_mtime && cov_timestamp
101
+ seconds = file_mtime.to_i - cov_timestamp.to_i
102
+ sign = seconds >= 0 ? '+' : '-'
103
+ "#{sign}#{seconds.abs}s"
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+
109
+ # Project-level stale coverage (global) — coverage timestamp older than
110
+ # one or more source files, or new tracked files missing from coverage.
111
+ class CoverageDataProjectStaleError < CoverageDataError
112
+ attr_reader :cov_timestamp, :newer_files, :missing_files, :deleted_files, :resultset_path
113
+
114
+ def initialize(message = nil, original_error = nil, cov_timestamp: nil, newer_files: [], missing_files: [], deleted_files: [], resultset_path: nil)
115
+ super(message, original_error)
116
+ @cov_timestamp = cov_timestamp
117
+ @newer_files = Array(newer_files)
118
+ @missing_files = Array(missing_files)
119
+ @deleted_files = Array(deleted_files)
120
+ @resultset_path = resultset_path
121
+ end
122
+
123
+ def user_friendly_message
124
+ base = "Coverage data stale (project): #{message || default_message}"
125
+ base + build_details
126
+ end
127
+
128
+ private
129
+
130
+ def default_message
131
+ 'Coverage data appears stale for project'
132
+ end
133
+
134
+ def build_details
135
+ cov_utc, cov_local = format_epoch_both(@cov_timestamp)
136
+ parts = []
137
+ parts << "\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
138
+ unless @newer_files.empty?
139
+ parts << "\nNewer files (#{@newer_files.size}):"
140
+ parts.concat(@newer_files.first(10).map { |f| " - #{f}" })
141
+ parts << " ..." if @newer_files.size > 10
142
+ end
143
+ unless @missing_files.empty?
144
+ parts << "\nMissing files (new in project, not in coverage, #{@missing_files.size}):"
145
+ parts.concat(@missing_files.first(10).map { |f| " - #{f}" })
146
+ parts << " ..." if @missing_files.size > 10
147
+ end
148
+ unless @deleted_files.empty?
149
+ parts << "\nCoverage-only files (deleted or moved in project, #{@deleted_files.size}):"
150
+ parts.concat(@deleted_files.first(10).map { |f| " - #{f}" })
151
+ parts << " ..." if @deleted_files.size > 10
152
+ end
153
+ parts << "\nResultset - #{@resultset_path}" if @resultset_path
154
+ parts.join
155
+ end
156
+
157
+ def format_epoch_both(epoch_seconds)
158
+ return [nil, nil] unless epoch_seconds
159
+ t = Time.at(epoch_seconds.to_i)
160
+ [t.utc.iso8601, t.getlocal.iso8601]
161
+ rescue StandardError
162
+ [epoch_seconds.to_s, epoch_seconds.to_s]
163
+ end
164
+ end
165
+
166
+ # Command line usage errors
167
+ class UsageError < Error
168
+ def self.for_subcommand(usage_fragment)
169
+ new("Usage: simplecov-mcp #{usage_fragment}")
170
+ end
171
+
172
+ def user_friendly_message
173
+ message
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCovMcp
4
+ class MCPServer
5
+ def initialize
6
+ # Configure error handling for MCP server mode using the factory
7
+ SimpleCovMcp.error_handler = ErrorHandlerFactory.for_mcp_server
8
+ end
9
+
10
+ def run
11
+ tools = [
12
+ Tools::AllFilesCoverageTool,
13
+ Tools::CoverageDetailedTool,
14
+ Tools::CoverageRawTool,
15
+ Tools::CoverageSummaryTool,
16
+ Tools::UncoveredLinesTool,
17
+ Tools::CoverageTableTool,
18
+ Tools::HelpTool,
19
+ Tools::VersionTool
20
+ ]
21
+
22
+ server = ::MCP::Server.new(
23
+ name: 'simplecov-mcp',
24
+ version: SimpleCovMcp::VERSION,
25
+ tools: tools
26
+ )
27
+ ::MCP::Server::Transports::StdioTransport.new(server).open
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'util'
4
+ require_relative 'errors'
5
+ require_relative 'staleness_checker'
6
+
7
+ module SimpleCovMcp
8
+ class CoverageModel
9
+ # Create a CoverageModel
10
+ #
11
+ # Params:
12
+ # - root: project root directory (default '.')
13
+ # - resultset: path or directory to .resultset.json
14
+ # - staleness: 'off' or 'error' (default 'off'). When 'error', raises
15
+ # stale errors if sources are newer than coverage or line counts mismatch.
16
+ # - tracked_globs: only used for all_files project-level staleness.
17
+ def initialize(root: '.', resultset: nil, staleness: 'off', tracked_globs: nil)
18
+ @root = File.absolute_path(root || '.')
19
+ @resultset = resultset
20
+ @checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: staleness, tracked_globs: tracked_globs)
21
+ begin
22
+ @cov = CovUtil.load_latest_coverage(@root, resultset: resultset)
23
+ rescue Errno::ENOENT => e
24
+ raise FileError.new("Coverage data not found at #{resultset || @root}")
25
+ rescue JSON::ParserError => e
26
+ raise CoverageDataError.new("Invalid coverage data format")
27
+ rescue => e
28
+ raise CoverageDataError.new("Failed to load coverage data: #{e.message}")
29
+ end
30
+ end
31
+
32
+ # Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
33
+ def raw_for(path)
34
+ file_abs, coverage_lines = resolve(path)
35
+ { 'file' => file_abs, 'lines' => coverage_lines }
36
+ end
37
+
38
+ # Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'pct'=>} }
39
+ def summary_for(path)
40
+ file_abs, coverage_lines = resolve(path)
41
+ { 'file' => file_abs, 'summary' => CovUtil.summary(coverage_lines) }
42
+ end
43
+
44
+ # Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
45
+ def uncovered_for(path)
46
+ file_abs, coverage_lines = resolve(path)
47
+ { 'file' => file_abs, 'uncovered' => CovUtil.uncovered(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
48
+ end
49
+
50
+ # Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
51
+ def detailed_for(path)
52
+ file_abs, coverage_lines = resolve(path)
53
+ { 'file' => file_abs, 'lines' => CovUtil.detailed(coverage_lines), 'summary' => CovUtil.summary(coverage_lines) }
54
+ end
55
+
56
+ # Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
57
+ def all_files(sort_order: :ascending, check_stale: !@checker.off?, tracked_globs: nil)
58
+ stale_checker = StalenessChecker.new(root: @root, resultset: @resultset, mode: 'off', tracked_globs: tracked_globs)
59
+ rows = @cov.map do |abs_path, data|
60
+ next unless data['lines'].is_a?(Array)
61
+ s = CovUtil.summary(data['lines'])
62
+ stale = stale_checker.stale_for_file?(abs_path, data['lines'])
63
+ { 'file' => abs_path, 'covered' => s['covered'], 'total' => s['total'], 'percentage' => s['pct'], 'stale' => stale }
64
+ end.compact
65
+
66
+ if check_stale
67
+ StalenessChecker.new(root: @root, resultset: @resultset, mode: 'error', tracked_globs: tracked_globs)
68
+ .check_project!(@cov)
69
+ end
70
+
71
+ rows.sort! do |a, b|
72
+ pct_cmp = (sort_order.to_s == 'descending') ? (b['percentage'] <=> a['percentage']) : (a['percentage'] <=> b['percentage'])
73
+ pct_cmp == 0 ? (a['file'] <=> b['file']) : pct_cmp
74
+ end
75
+ rows
76
+ end
77
+
78
+ private
79
+
80
+ def resolve(path)
81
+ file_abs = File.absolute_path(path, @root)
82
+ coverage_lines = CovUtil.lookup_lines(@cov, file_abs)
83
+ @checker.check_file!(file_abs, coverage_lines) unless @checker.off?
84
+ if coverage_lines.nil?
85
+ raise FileError.new("No coverage data found for file: #{path}")
86
+ end
87
+ [file_abs, coverage_lines]
88
+ rescue Errno::ENOENT => e
89
+ raise FileNotFoundError.new("File not found: #{path}")
90
+ end
91
+
92
+ # staleness handled by StalenessChecker
93
+
94
+ def check_all_files_staleness!(cov_timestamp, tracked_globs: nil)
95
+ # handled by StalenessChecker
96
+ end
97
+
98
+ def rel_to_root(path)
99
+ Pathname.new(path).relative_path_from(Pathname.new(File.absolute_path(@root))).to_s
100
+ end
101
+
102
+ # Detailed stale message construction moved to CoverageDataStaleError
103
+ end
104
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module SimpleCovMcp
6
+ # Lightweight service object to check staleness of coverage vs. sources
7
+ class StalenessChecker
8
+ MODES = %w[off error].freeze
9
+
10
+ def initialize(root:, resultset:, mode: 'off', tracked_globs: nil)
11
+ @root = File.absolute_path(root || '.')
12
+ @resultset = resultset
13
+ @mode = (mode || 'off').to_s
14
+ @tracked_globs = tracked_globs
15
+ @cov_timestamp = nil
16
+ @resultset_path = nil
17
+ end
18
+
19
+ def off?
20
+ @mode == 'off'
21
+ end
22
+
23
+ # Raise CoverageDataStaleError if stale (only in error mode)
24
+ def check_file!(file_abs, coverage_lines)
25
+ return if off?
26
+ ts = coverage_timestamp
27
+ fm = File.mtime(file_abs) if File.file?(file_abs)
28
+ cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
29
+ src_len = safe_count_lines(file_abs)
30
+ if (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
31
+ raise CoverageDataStaleError.new(
32
+ nil,
33
+ nil,
34
+ file_path: rel(file_abs),
35
+ file_mtime: fm,
36
+ cov_timestamp: ts,
37
+ src_len: src_len,
38
+ cov_len: cov_len,
39
+ resultset_path: resultset_path
40
+ )
41
+ end
42
+ end
43
+
44
+ # Compute whether a specific file appears stale relative to coverage.
45
+ # Ignores mode and never raises; returns true when:
46
+ # - the file is missing/deleted, or
47
+ # - the file mtime is newer than the coverage timestamp, or
48
+ # - the source line count differs from the coverage lines array length (when present).
49
+ def stale_for_file?(file_abs, coverage_lines)
50
+ ts = coverage_timestamp
51
+ return true unless File.file?(file_abs)
52
+
53
+ fm = File.mtime(file_abs)
54
+ cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
55
+ src_len = safe_count_lines(file_abs)
56
+ (fm && fm.to_i > ts.to_i) || (cov_len.positive? && src_len != cov_len)
57
+ rescue StandardError
58
+ # Be conservative: if we cannot determine, mark as stale
59
+ true
60
+ end
61
+
62
+ # Raise CoverageDataProjectStaleError if any covered file is newer or if
63
+ # tracked files are missing from coverage, or coverage includes deleted files.
64
+ def check_project!(coverage_map)
65
+ return if off?
66
+ ts = coverage_timestamp
67
+ newer = []
68
+ deleted = []
69
+ coverage_files = coverage_map.keys
70
+ coverage_files.each do |abs|
71
+ if File.file?(abs)
72
+ newer << rel(abs) if File.mtime(abs).to_i > ts.to_i
73
+ else
74
+ deleted << rel(abs)
75
+ end
76
+ end
77
+
78
+ missing = []
79
+ if @tracked_globs && !Array(@tracked_globs).empty?
80
+ patterns = Array(@tracked_globs).map { |g| File.absolute_path(g, @root) }
81
+ tracked = patterns.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
82
+ .select { |p| File.file?(p) }
83
+ covered_set = coverage_files.to_set rescue coverage_files
84
+ tracked.each do |abs|
85
+ missing << rel(abs) unless covered_set.include?(abs)
86
+ end
87
+ end
88
+
89
+ if !newer.empty? || !missing.empty? || !deleted.empty?
90
+ raise CoverageDataProjectStaleError.new(
91
+ nil,
92
+ nil,
93
+ cov_timestamp: ts,
94
+ newer_files: newer,
95
+ missing_files: missing,
96
+ deleted_files: deleted,
97
+ resultset_path: resultset_path
98
+ )
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def coverage_timestamp
105
+ @cov_timestamp ||= CovUtil.latest_timestamp(@root, resultset: @resultset)
106
+ end
107
+
108
+ def resultset_path
109
+ @resultset_path ||= CovUtil.find_resultset(@root, resultset: @resultset)
110
+ rescue StandardError
111
+ nil
112
+ end
113
+
114
+ def safe_count_lines(path)
115
+ return 0 unless File.file?(path)
116
+ File.foreach(path).count
117
+ rescue StandardError
118
+ 0
119
+ end
120
+
121
+ def rel(path)
122
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../model'
4
+ require_relative '../base_tool'
5
+
6
+ module SimpleCovMcp
7
+ module Tools
8
+ class AllFilesCoverageTool < BaseTool
9
+ description <<~DESC
10
+ Use this when the user wants coverage percentages for every tracked file in the project.
11
+ Do not use this for single-file stats; prefer coverage.summary or coverage.uncovered_lines for that.
12
+ Inputs: optional project root, alternate .resultset path, sort order, staleness mode, and tracked_globs to alert on new files.
13
+ Output: JSON {"files": [{"file","covered","total","percentage","stale"}, ...], "counts": {"total", "ok", "stale"}} sorted as requested. "stale" is a boolean.
14
+ Examples: "List files with the lowest coverage"; "Show repo coverage sorted descending".
15
+ DESC
16
+ input_schema(
17
+ type: 'object',
18
+ additionalProperties: false,
19
+ properties: {
20
+ root: {
21
+ type: 'string',
22
+ description: 'Project root used to resolve relative inputs.',
23
+ default: '.'
24
+ },
25
+ resultset: {
26
+ type: 'string',
27
+ description: 'Path to the SimpleCov .resultset.json file.'
28
+ },
29
+ sort_order: {
30
+ type: 'string',
31
+ description: "Sort order for coverage percentages. 'ascending' highlights the riskiest files first.",
32
+ default: 'ascending',
33
+ enum: ['ascending', 'descending']
34
+ },
35
+ stale: {
36
+ type: 'string',
37
+ description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
38
+ enum: ['off', 'error'],
39
+ default: 'off'
40
+ },
41
+ tracked_globs: {
42
+ type: 'array',
43
+ description: 'Glob patterns for files that should exist in the coverage report (helps flag new files).',
44
+ items: { type: 'string' }
45
+ }
46
+ }
47
+ )
48
+ class << self
49
+ def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', tracked_globs: nil, server_context:)
50
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: stale, tracked_globs: tracked_globs)
51
+ files = model.all_files(sort_order: sort_order, check_stale: (stale.to_s == 'error'), tracked_globs: tracked_globs)
52
+ total = files.length
53
+ stale_count = files.count { |f| f['stale'] }
54
+ ok_count = total - stale_count
55
+ payload = { files: files, counts: { total: total, ok: ok_count, stale: stale_count } }
56
+ ::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(payload) }])
57
+ rescue => e
58
+ handle_mcp_error(e, 'AllFilesCoverageTool')
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+ require_relative '../model'
5
+
6
+ module SimpleCovMcp
7
+ module Tools
8
+ class CoverageDetailedTool < BaseTool
9
+ description <<~DESC
10
+ Use this when the user needs per-line coverage data for a single file.
11
+ Do not use this for high-level counts; coverage.summary is cheaper for aggregate numbers.
12
+ Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
+ Output: JSON object with "file", "lines" => [{"line": 12, "hits": 0, "covered": false}], plus "summary" with totals.
14
+ Example: "Show detailed coverage for lib/simple_cov_mcp/model.rb".
15
+ DESC
16
+ input_schema(**input_schema_def)
17
+ class << self
18
+ def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
+ mode = stale
20
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
+ data = model.detailed_for(path)
22
+ ::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
23
+ rescue => e
24
+ handle_mcp_error(e, 'CoverageDetailedTool')
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+ require_relative '../model'
5
+
6
+ module SimpleCovMcp
7
+ module Tools
8
+ class CoverageRawTool < BaseTool
9
+ description <<~DESC
10
+ Use this when you need the raw SimpleCov `lines` array for a file exactly as stored on disk.
11
+ Do not use this for human-friendly explanations; choose coverage.detailed or coverage.summary instead.
12
+ Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
+ Output: JSON object with "file" and "lines" (array of integers/nulls) mirroring SimpleCov's native structure.
14
+ Example: "Fetch the raw coverage array for spec/support/foo_helper.rb".
15
+ DESC
16
+ input_schema(**input_schema_def)
17
+ class << self
18
+ def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
+ mode = stale
20
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
+ data = model.raw_for(path)
22
+ ::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
23
+ rescue => e
24
+ handle_mcp_error(e, 'CoverageRawTool')
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+ require_relative '../model'
5
+
6
+ module SimpleCovMcp
7
+ module Tools
8
+ class CoverageSummaryTool < BaseTool
9
+ description <<~DESC
10
+ Use this when the user asks for the covered/total line counts and percentage for a specific file.
11
+ Do not use this for multi-file reports; coverage.all_files or coverage.table handle those.
12
+ Inputs: file path (required) plus optional root/resultset/stale mode inherited from BaseTool.
13
+ Output: JSON object {"file": String, "summary": {"covered": Integer, "total": Integer, "pct": Float}}.
14
+ Examples: "What is the coverage for lib/simple_cov_mcp/tools/all_files_coverage_tool.rb?".
15
+ DESC
16
+ input_schema(**input_schema_def)
17
+ class << self
18
+ def call(path:, root: '.', resultset: nil, stale: 'off', server_context:)
19
+ mode = stale
20
+ model = CoverageModel.new(root: root, resultset: resultset, staleness: mode)
21
+ data = model.summary_for(path)
22
+ ::MCP::Tool::Response.new([{ type: 'text', text: JSON.pretty_generate(data) }])
23
+ rescue => e
24
+ handle_mcp_error(e, 'CoverageSummaryTool')
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative '../cli'
5
+ require_relative '../base_tool'
6
+
7
+ module SimpleCovMcp
8
+ module Tools
9
+ class CoverageTableTool < BaseTool
10
+ description <<~DESC
11
+ Use this when a user wants the plain text coverage table exactly like `simplecov-mcp --table` would print (no ANSI colors).
12
+ Do not use this for machine-readable data; coverage.all_files returns structured JSON.
13
+ Inputs: optional project root/resultset path/sort order/staleness mode matching the CLI flags.
14
+ Output: text block containing the formatted coverage table with headers and percentages.
15
+ Example: "Show me the CLI coverage table sorted descending".
16
+ DESC
17
+ input_schema(
18
+ type: 'object',
19
+ additionalProperties: false,
20
+ properties: {
21
+ root: {
22
+ type: 'string',
23
+ description: 'Project root used to resolve relative inputs.',
24
+ default: '.'
25
+ },
26
+ resultset: {
27
+ type: 'string',
28
+ description: 'Path to the SimpleCov .resultset.json file.'
29
+ },
30
+ sort_order: {
31
+ type: 'string',
32
+ description: "Sort order for the printed coverage table (ascending or descending).",
33
+ default: 'ascending',
34
+ enum: ['ascending', 'descending']
35
+ },
36
+ stale: {
37
+ type: 'string',
38
+ description: "How to handle missing/outdated coverage data. 'off' skips checks; 'error' raises.",
39
+ enum: ['off', 'error'],
40
+ default: 'off'
41
+ }
42
+ }
43
+ )
44
+
45
+ class << self
46
+ def call(root: '.', resultset: nil, sort_order: 'ascending', stale: 'off', server_context:)
47
+ # Capture the output of the CLI's table report while honoring CLI options
48
+ output = StringIO.new
49
+ cli = CoverageCLI.new
50
+ cli.instance_variable_set(:@root, root || '.')
51
+ cli.instance_variable_set(:@resultset, resultset)
52
+ cli.instance_variable_set(:@stale_mode, (stale || 'off').to_s)
53
+ cli.show_default_report(sort_order: sort_order.to_sym, output: output)
54
+ ::MCP::Tool::Response.new([{ type: 'text', text: output.string }])
55
+ rescue => e
56
+ handle_mcp_error(e, 'CoverageTableTool')
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end