simplecov-mcp 0.3.0 → 1.0.1

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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +173 -356
  4. data/docs/ADVANCED_USAGE.md +967 -0
  5. data/docs/ARCHITECTURE.md +79 -0
  6. data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
  7. data/docs/CLI_USAGE.md +637 -0
  8. data/docs/DEVELOPMENT.md +82 -0
  9. data/docs/ERROR_HANDLING.md +93 -0
  10. data/docs/EXAMPLES.md +430 -0
  11. data/docs/INSTALLATION.md +352 -0
  12. data/docs/LIBRARY_API.md +635 -0
  13. data/docs/MCP_INTEGRATION.md +488 -0
  14. data/docs/TROUBLESHOOTING.md +276 -0
  15. data/docs/arch-decisions/001-x-arch-decision.md +93 -0
  16. data/docs/arch-decisions/002-x-arch-decision.md +157 -0
  17. data/docs/arch-decisions/003-x-arch-decision.md +163 -0
  18. data/docs/arch-decisions/004-x-arch-decision.md +199 -0
  19. data/docs/arch-decisions/005-x-arch-decision.md +187 -0
  20. data/docs/arch-decisions/README.md +60 -0
  21. data/docs/presentations/simplecov-mcp-presentation.md +249 -0
  22. data/exe/simplecov-mcp +4 -4
  23. data/lib/simplecov_mcp/app_context.rb +26 -0
  24. data/lib/simplecov_mcp/base_tool.rb +74 -0
  25. data/lib/simplecov_mcp/cli.rb +234 -0
  26. data/lib/simplecov_mcp/cli_config.rb +56 -0
  27. data/lib/simplecov_mcp/commands/base_command.rb +78 -0
  28. data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
  29. data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
  30. data/lib/simplecov_mcp/commands/list_command.rb +13 -0
  31. data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
  32. data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
  33. data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
  34. data/lib/simplecov_mcp/commands/version_command.rb +18 -0
  35. data/lib/simplecov_mcp/constants.rb +22 -0
  36. data/lib/simplecov_mcp/error_handler.rb +124 -0
  37. data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
  38. data/lib/simplecov_mcp/errors.rb +198 -0
  39. data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
  40. data/lib/simplecov_mcp/mcp_server.rb +40 -0
  41. data/lib/simplecov_mcp/mode_detector.rb +55 -0
  42. data/lib/simplecov_mcp/model.rb +305 -0
  43. data/lib/simplecov_mcp/option_normalizers.rb +92 -0
  44. data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
  45. data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
  46. data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
  47. data/lib/simplecov_mcp/path_relativizer.rb +61 -0
  48. data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
  49. data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
  50. data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
  51. data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
  52. data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
  53. data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
  54. data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
  55. data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
  56. data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
  57. data/lib/simplecov_mcp/resultset_loader.rb +136 -0
  58. data/lib/simplecov_mcp/staleness_checker.rb +243 -0
  59. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
  60. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
  61. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
  62. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
  63. data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
  64. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
  65. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
  66. data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
  67. data/lib/simplecov_mcp/util.rb +82 -0
  68. data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
  69. data/lib/simplecov_mcp.rb +144 -2
  70. data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
  71. data/spec/TIMESTAMPS.md +48 -0
  72. data/spec/all_files_coverage_tool_spec.rb +29 -25
  73. data/spec/base_tool_spec.rb +11 -10
  74. data/spec/cli/show_default_report_spec.rb +33 -0
  75. data/spec/cli_config_spec.rb +137 -0
  76. data/spec/cli_enumerated_options_spec.rb +68 -0
  77. data/spec/cli_error_spec.rb +105 -47
  78. data/spec/cli_source_spec.rb +82 -23
  79. data/spec/cli_spec.rb +140 -5
  80. data/spec/cli_success_predicate_spec.rb +141 -0
  81. data/spec/cli_table_spec.rb +1 -1
  82. data/spec/cli_usage_spec.rb +10 -26
  83. data/spec/commands/base_command_spec.rb +187 -0
  84. data/spec/commands/command_factory_spec.rb +72 -0
  85. data/spec/commands/detailed_command_spec.rb +48 -0
  86. data/spec/commands/raw_command_spec.rb +46 -0
  87. data/spec/commands/summary_command_spec.rb +47 -0
  88. data/spec/commands/uncovered_command_spec.rb +49 -0
  89. data/spec/constants_spec.rb +61 -0
  90. data/spec/coverage_table_tool_spec.rb +17 -33
  91. data/spec/error_handler_spec.rb +22 -13
  92. data/spec/error_mode_spec.rb +143 -0
  93. data/spec/errors_edge_cases_spec.rb +239 -0
  94. data/spec/errors_stale_spec.rb +2 -2
  95. data/spec/file_based_mcp_tools_spec.rb +99 -0
  96. data/spec/fixtures/project1/lib/bar.rb +0 -1
  97. data/spec/fixtures/project1/lib/foo.rb +0 -1
  98. data/spec/help_tool_spec.rb +11 -17
  99. data/spec/integration_spec.rb +847 -0
  100. data/spec/logging_fallback_spec.rb +128 -0
  101. data/spec/mcp_logging_spec.rb +44 -0
  102. data/spec/mcp_server_integration_spec.rb +23 -0
  103. data/spec/mcp_server_spec.rb +15 -4
  104. data/spec/mode_detector_spec.rb +148 -0
  105. data/spec/model_error_handling_spec.rb +208 -0
  106. data/spec/model_staleness_spec.rb +40 -10
  107. data/spec/option_normalizers_spec.rb +204 -0
  108. data/spec/option_parsers/env_options_parser_spec.rb +233 -0
  109. data/spec/option_parsers/error_helper_spec.rb +222 -0
  110. data/spec/path_relativizer_spec.rb +83 -0
  111. data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
  112. data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
  113. data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
  114. data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
  115. data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
  116. data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
  117. data/spec/resolvers/resolver_factory_spec.rb +61 -0
  118. data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
  119. data/spec/resultset_loader_spec.rb +167 -0
  120. data/spec/shared_examples/README.md +115 -0
  121. data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
  122. data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
  123. data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
  124. data/spec/simple_cov_mcp_module_spec.rb +16 -0
  125. data/spec/simplecov_mcp_model_spec.rb +340 -9
  126. data/spec/simplecov_mcp_opts_spec.rb +182 -0
  127. data/spec/spec_helper.rb +147 -4
  128. data/spec/staleness_checker_spec.rb +373 -0
  129. data/spec/staleness_more_spec.rb +16 -13
  130. data/spec/support/mcp_runner.rb +64 -0
  131. data/spec/tools_error_handling_spec.rb +144 -0
  132. data/spec/util_spec.rb +109 -34
  133. data/spec/version_spec.rb +117 -9
  134. data/spec/version_tool_spec.rb +131 -10
  135. metadata +120 -63
  136. data/lib/simple_cov/mcp.rb +0 -9
  137. data/lib/simple_cov_mcp/base_tool.rb +0 -70
  138. data/lib/simple_cov_mcp/cli.rb +0 -390
  139. data/lib/simple_cov_mcp/error_handler.rb +0 -131
  140. data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
  141. data/lib/simple_cov_mcp/errors.rb +0 -176
  142. data/lib/simple_cov_mcp/mcp_server.rb +0 -30
  143. data/lib/simple_cov_mcp/model.rb +0 -104
  144. data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
  145. data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
  146. data/lib/simple_cov_mcp/util.rb +0 -122
  147. data/lib/simple_cov_mcp.rb +0 -102
  148. data/spec/coverage_detailed_tool_spec.rb +0 -36
  149. data/spec/coverage_raw_tool_spec.rb +0 -32
  150. data/spec/coverage_summary_tool_spec.rb +0 -39
  151. data/spec/legacy_shim_spec.rb +0 -13
  152. data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,847 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'SimpleCov MCP Integration Tests' do
6
+ # Timeout for MCP server operations (increased for JRuby compatibility)
7
+ MCP_TIMEOUT = 5
8
+
9
+ let(:project_root) { (FIXTURES_DIR / 'project1').to_s }
10
+ let(:coverage_dir) { File.join(project_root, 'coverage') }
11
+ let(:resultset_path) { File.join(coverage_dir, '.resultset.json') }
12
+
13
+ describe 'End-to-End Coverage Model Functionality' do
14
+ context 'with real coverage data and files' do
15
+ it 'provides complete coverage analysis workflow' do
16
+ # Initialize model with real fixture data
17
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
18
+
19
+ # Test all_files returns real coverage data
20
+ all_files = model.all_files
21
+ expect(all_files).to be_an(Array)
22
+ expect(all_files.length).to eq(2)
23
+
24
+ # Verify file paths and coverage data structure
25
+ foo_file = all_files.find { |f| f['file'].include?('foo.rb') }
26
+ bar_file = all_files.find { |f| f['file'].include?('bar.rb') }
27
+
28
+ expect(foo_file).to include('covered', 'total', 'percentage', 'stale')
29
+ expect(bar_file).to include('covered', 'total', 'percentage', 'stale')
30
+
31
+ # Verify actual coverage calculations match fixture data
32
+ # foo.rb has coverage: [1, 0, nil, 2] -> 2 covered out of 3 executable = 66.67%
33
+ expect(foo_file['total']).to eq(3)
34
+ expect(foo_file['covered']).to eq(2)
35
+ expect(foo_file['percentage']).to be_within(0.01).of(66.67)
36
+
37
+ # bar.rb has coverage: [0, 0, 1] -> 1 covered out of 3 executable = 33.33%
38
+ expect(bar_file['total']).to eq(3)
39
+ expect(bar_file['covered']).to eq(1)
40
+ expect(bar_file['percentage']).to be_within(0.01).of(33.33)
41
+ end
42
+
43
+ it 'provides detailed per-file analysis' do
44
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
45
+
46
+ # Test raw coverage data
47
+ raw = model.raw_for('lib/foo.rb')
48
+ expect(raw['file']).to end_with('lib/foo.rb')
49
+ expect(raw['lines']).to eq([1, 0, nil, 2])
50
+
51
+ # Test summary calculation
52
+ summary = model.summary_for('lib/foo.rb')
53
+ expect(summary['file']).to end_with('lib/foo.rb')
54
+ expect(summary['summary']).to include('covered' => 2, 'total' => 3)
55
+ expect(summary['summary']['pct']).to be_within(0.01).of(66.67)
56
+
57
+ # Test uncovered lines detection
58
+ uncovered = model.uncovered_for('lib/foo.rb')
59
+ expect(uncovered['file']).to end_with('lib/foo.rb')
60
+ expect(uncovered['uncovered']).to eq([2]) # Line 2 has 0 hits
61
+ expect(uncovered['summary']).to include('covered' => 2, 'total' => 3)
62
+
63
+ # Test detailed line-by-line analysis
64
+ detailed = model.detailed_for('lib/foo.rb')
65
+ expect(detailed['file']).to end_with('lib/foo.rb')
66
+ expect(detailed['lines']).to eq([
67
+ { 'line' => 1, 'hits' => 1, 'covered' => true },
68
+ { 'line' => 2, 'hits' => 0, 'covered' => false },
69
+ { 'line' => 4, 'hits' => 2, 'covered' => true }
70
+ ])
71
+ end
72
+
73
+ it 'generates properly formatted coverage tables' do
74
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
75
+
76
+ # Test default table generation
77
+ table = model.format_table
78
+
79
+ # Verify table structure (Unicode box drawing)
80
+ expect(table).to include('┌', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘')
81
+
82
+ # Verify headers
83
+ expect(table).to include('File', '%', 'Covered', 'Total', 'Stale')
84
+
85
+ # Verify file data appears
86
+ expect(table).to include('lib/foo.rb', 'lib/bar.rb')
87
+
88
+ # Verify percentages are formatted correctly
89
+ expect(table).to include('66.67', '33.33')
90
+
91
+ # Verify counts summary
92
+ expect(table).to include('Files: total 2')
93
+
94
+ # Test sorting (ascending by default - bar.rb should be first with lower coverage)
95
+ lines = table.split("\n")
96
+ data_lines = lines.select { |line| line.include?('lib/') }
97
+ expect(data_lines.first).to include('lib/bar.rb') # Lower coverage first
98
+ expect(data_lines.last).to include('lib/foo.rb') # Higher coverage last
99
+ end
100
+
101
+ it 'supports different sorting options' do
102
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
103
+
104
+ # Test ascending sort
105
+ asc_files = model.all_files(sort_order: :ascending)
106
+ expect(asc_files.first['file']).to end_with('lib/bar.rb') # Lower coverage first
107
+ expect(asc_files.last['file']).to end_with('lib/foo.rb') # Higher coverage last
108
+
109
+ # Test descending sort
110
+ desc_files = model.all_files(sort_order: :descending)
111
+ expect(desc_files.first['file']).to end_with('lib/foo.rb') # Higher coverage first
112
+ expect(desc_files.last['file']).to end_with('lib/bar.rb') # Lower coverage last
113
+ end
114
+ end
115
+ end
116
+
117
+ describe 'CLI Integration with Real Coverage Data' do
118
+ it 'executes all major CLI commands without errors' do
119
+ # Test list command
120
+ list_output = nil
121
+ silence_output do |out, _err|
122
+ cli = SimpleCovMcp::CoverageCLI.new
123
+ cli.run(['list', '--root', project_root, '--resultset', coverage_dir])
124
+ list_output = out.string
125
+ end
126
+
127
+ expect(list_output).to include('lib/foo.rb', 'lib/bar.rb')
128
+ expect(list_output).to include('66.67', '33.33')
129
+
130
+ # Test summary command
131
+ summary_output = nil
132
+ silence_output do |out, _err|
133
+ cli = SimpleCovMcp::CoverageCLI.new
134
+ cli.run(['summary', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
135
+ summary_output = out.string
136
+ end
137
+
138
+ expect(summary_output).to include('66.67%', '2/3')
139
+
140
+ # Test JSON output
141
+ json_output = nil
142
+ silence_output do |out, _err|
143
+ cli = SimpleCovMcp::CoverageCLI.new
144
+ cli.run([
145
+ 'summary', 'lib/foo.rb', '--json', '--root', project_root, '--resultset', coverage_dir
146
+ ])
147
+ json_output = out.string
148
+ end
149
+
150
+ json_data = JSON.parse(json_output)
151
+ expect(json_data).to include('file', 'summary')
152
+ expect(json_data['summary']).to include('covered' => 2, 'total' => 3)
153
+ end
154
+
155
+ it 'handles different output formats correctly' do
156
+ # Test uncovered command with different outputs
157
+ uncovered_output = nil
158
+ silence_output do |out, _err|
159
+ cli = SimpleCovMcp::CoverageCLI.new
160
+ cli.run(['uncovered', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
161
+ uncovered_output = out.string
162
+ end
163
+
164
+ expect(uncovered_output).to match(/Uncovered lines:\s*2\b/)
165
+
166
+ # Test detailed command
167
+ detailed_output = nil
168
+ silence_output do |out, _err|
169
+ cli = SimpleCovMcp::CoverageCLI.new
170
+ cli.run(['detailed', 'lib/foo.rb', '--root', project_root, '--resultset', coverage_dir])
171
+ detailed_output = out.string
172
+ end
173
+
174
+ expect(detailed_output).to include('Line', 'Hits', 'Covered')
175
+ end
176
+ end
177
+
178
+ describe 'MCP Tool Integration with Real Data' do
179
+ let(:server_context) { instance_double('ServerContext').as_null_object }
180
+
181
+ before do
182
+ setup_mcp_response_stub
183
+ end
184
+
185
+ it 'executes all MCP tools with real coverage data' do
186
+ # Test coverage summary tool
187
+ summary_response = SimpleCovMcp::Tools::CoverageSummaryTool.call(
188
+ path: 'lib/foo.rb',
189
+ root: project_root,
190
+ resultset: coverage_dir,
191
+ server_context: server_context
192
+ )
193
+
194
+ data, item = expect_mcp_text_json(summary_response, expected_keys: ['file', 'summary'])
195
+ expect(data['summary']).to include('covered' => 2, 'total' => 3)
196
+
197
+ # Test raw coverage tool
198
+ raw_response = SimpleCovMcp::Tools::CoverageRawTool.call(
199
+ path: 'lib/foo.rb',
200
+ root: project_root,
201
+ resultset: coverage_dir,
202
+ server_context: server_context
203
+ )
204
+
205
+ raw_data, raw_item = expect_mcp_text_json(raw_response, expected_keys: ['file', 'lines'])
206
+ expect(raw_data['lines']).to eq([1, 0, nil, 2])
207
+
208
+ # Test all files tool
209
+ all_files_response = SimpleCovMcp::Tools::AllFilesCoverageTool.call(
210
+ root: project_root,
211
+ resultset: coverage_dir,
212
+ server_context: server_context
213
+ )
214
+
215
+ all_data, _ = expect_mcp_text_json(all_files_response, expected_keys: ['files', 'counts'])
216
+ expect(all_data['files'].length).to eq(2)
217
+ expect(all_data['counts']['total']).to eq(2)
218
+ end
219
+
220
+ it 'provides consistent data across different tools' do
221
+ # Get data from summary tool
222
+ summary_response = SimpleCovMcp::Tools::CoverageSummaryTool.call(
223
+ path: 'lib/foo.rb',
224
+ root: project_root,
225
+ resultset: coverage_dir,
226
+ server_context: server_context
227
+ )
228
+ summary_data, _ = expect_mcp_text_json(summary_response)
229
+
230
+ # Get data from detailed tool
231
+ detailed_response = SimpleCovMcp::Tools::CoverageDetailedTool.call(
232
+ path: 'lib/foo.rb',
233
+ root: project_root,
234
+ resultset: coverage_dir,
235
+ server_context: server_context
236
+ )
237
+ detailed_data, _ = expect_mcp_text_json(detailed_response)
238
+
239
+ # Verify consistency between tools
240
+ expect(summary_data['summary']['covered']).to eq(2)
241
+ expect(summary_data['summary']['total']).to eq(3)
242
+ expect(detailed_data['summary']['covered']).to eq(2)
243
+ expect(detailed_data['summary']['total']).to eq(3)
244
+
245
+ # Count covered lines in detailed data
246
+ covered_lines = detailed_data['lines'].count { |line| line['covered'] }
247
+ expect(covered_lines).to eq(2)
248
+ end
249
+ end
250
+
251
+ describe 'Error Handling Integration' do
252
+ it 'handles missing files gracefully' do
253
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
254
+
255
+ expect do
256
+ model.summary_for('lib/nonexistent.rb')
257
+ end.to raise_error(SimpleCovMcp::FileError, /No coverage entry found/)
258
+ end
259
+
260
+ it 'handles invalid resultset paths gracefully' do
261
+ expect do
262
+ SimpleCovMcp::CoverageModel.new(root: project_root, resultset: '/nonexistent/path')
263
+ end.to raise_error(SimpleCovMcp::ResultsetNotFoundError, /Specified resultset not found/)
264
+ end
265
+
266
+ it 'provides helpful CLI error messages' do
267
+ output, error, status = nil, nil, nil
268
+ silence_output do |out, err|
269
+ begin
270
+ cli = SimpleCovMcp::CoverageCLI.new
271
+ cli.run([
272
+ 'summary', 'lib/nonexistent.rb', '--root', project_root, '--resultset', coverage_dir
273
+ ])
274
+ status = 0
275
+ rescue SystemExit => e
276
+ status = e.status
277
+ end
278
+ output = out.string
279
+ error = err.string
280
+ end
281
+
282
+ expect(status).to eq(1)
283
+ expect(error).to include('File error:', 'No coverage entry found')
284
+ end
285
+ end
286
+
287
+ describe 'Multi-File Scenarios' do
288
+ it 'handles projects with mixed coverage levels' do
289
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
290
+
291
+ # Get all files and verify range of coverage
292
+ files = model.all_files
293
+ coverages = files.map { |f| f['percentage'] }
294
+
295
+ expect(coverages.min).to be < 50 # bar.rb at ~33%
296
+ expect(coverages.max).to be > 50 # foo.rb at ~67%
297
+ expect(coverages).to include(a_value_within(0.1).of(33.33))
298
+ expect(coverages).to include(a_value_within(0.1).of(66.67))
299
+ end
300
+
301
+ it 'generates comprehensive project reports' do
302
+ model = SimpleCovMcp::CoverageModel.new(root: project_root, resultset: coverage_dir)
303
+
304
+ table = model.format_table
305
+
306
+ # Should show both files with different coverage levels
307
+ expect(table).to match(/lib\/bar\.rb.*33\.33/)
308
+ expect(table).to match(/lib\/foo\.rb.*66\.67/)
309
+
310
+ # Should show project totals
311
+ expect(table).to include('Files: total 2')
312
+ end
313
+ end
314
+
315
+ describe 'MCP Server Protocol Integration', :slow do
316
+ # spec/ is one level deep, so ../.. goes up to repo root
317
+ let(:repo_root) { File.expand_path('..', __dir__) }
318
+ let(:exe_path) { File.join(repo_root, 'exe', 'simplecov-mcp') }
319
+ let(:lib_path) { File.join(repo_root, 'lib') }
320
+
321
+ let(:default_env) do
322
+ {
323
+ 'RUBY_LIB' => lib_path,
324
+ 'SIMPLECOV_MCP_OPTS' => "--root #{project_root} --resultset #{coverage_dir}"
325
+ }
326
+ end
327
+
328
+ def runner_args(env: default_env, timeout: 5)
329
+ {
330
+ env: env,
331
+ lib_path: lib_path,
332
+ exe_path: exe_path,
333
+ timeout: timeout
334
+ }
335
+ end
336
+
337
+ # Run the MCP executable with a single JSON-RPC request hash and return the captured streams.
338
+ def run_mcp_json(request_hash, env: default_env, timeout: MCP_TIMEOUT)
339
+ Spec::Support::McpRunner.call_json(
340
+ request_hash,
341
+ **runner_args(env: env, timeout: timeout)
342
+ )
343
+ end
344
+
345
+ # Run the MCP executable with a sequence of JSON-RPC requests (one per line).
346
+ def run_mcp_json_stream(request_hashes, env: default_env, timeout: MCP_TIMEOUT)
347
+ Spec::Support::McpRunner.call_json_stream(
348
+ request_hashes,
349
+ **runner_args(env: env, timeout: timeout)
350
+ )
351
+ end
352
+
353
+ # Run the MCP executable with a raw string payload (already encoded as needed).
354
+ def run_mcp_input(input, env: default_env, timeout: MCP_TIMEOUT)
355
+ Spec::Support::McpRunner.call(
356
+ input: input,
357
+ **runner_args(env: env, timeout: timeout)
358
+ )
359
+ end
360
+
361
+ def parse_jsonrpc_response(output)
362
+ # MCP server should only write JSON-RPC responses to stdout.
363
+ output.lines.each do |line|
364
+ stripped = line.strip
365
+ next if stripped.empty?
366
+
367
+ begin
368
+ parsed = JSON.parse(stripped)
369
+ rescue JSON::ParserError => e
370
+ raise "Unexpected non-JSON output from MCP server stdout: #{stripped.inspect} (#{e.message})"
371
+ end
372
+
373
+ return parsed if parsed['jsonrpc'] == '2.0'
374
+
375
+ raise "Unexpected JSON-RPC payload on stdout: #{stripped.inspect}"
376
+ end
377
+
378
+ raise "No JSON-RPC response found on stdout. Raw output: #{output.inspect}"
379
+ end
380
+
381
+ it 'starts MCP server without errors' do
382
+ request = {
383
+ jsonrpc: '2.0',
384
+ id: 1,
385
+ method: 'tools/list'
386
+ }
387
+
388
+ result = run_mcp_json(request)
389
+ stdout = result[:stdout]
390
+ stderr = result[:stderr]
391
+
392
+ # Should not crash with NameError about OptionParser
393
+ expect(stderr).not_to include('NameError')
394
+ expect(stderr).not_to include('uninitialized constant')
395
+ expect(stderr).not_to include('OptionParser')
396
+
397
+ # Should produce valid JSON-RPC output
398
+ response = parse_jsonrpc_response(stdout)
399
+ expect(response).not_to be_nil
400
+ expect(response['jsonrpc']).to eq('2.0')
401
+ expect(response['id']).to eq(1)
402
+ end
403
+
404
+ it 'handles tools/list request' do
405
+ request = {
406
+ jsonrpc: '2.0',
407
+ id: 2,
408
+ method: 'tools/list'
409
+ }
410
+
411
+ stdout = run_mcp_json(request)[:stdout]
412
+ response = parse_jsonrpc_response(stdout)
413
+
414
+ expect(response).to include('result')
415
+ tools = response['result']['tools']
416
+ expect(tools).to be_an(Array)
417
+
418
+ # Verify expected tools are registered
419
+ tool_names = tools.map { |t| t['name'] }
420
+ expect(tool_names).to include(
421
+ 'all_files_coverage_tool',
422
+ 'coverage_summary_tool',
423
+ 'coverage_raw_tool',
424
+ 'uncovered_lines_tool',
425
+ 'coverage_detailed_tool',
426
+ 'coverage_table_tool',
427
+ 'help_tool',
428
+ 'version_tool'
429
+ )
430
+ end
431
+
432
+ it 'executes coverage_summary_tool via JSON-RPC' do
433
+ request = {
434
+ jsonrpc: '2.0',
435
+ id: 3,
436
+ method: 'tools/call',
437
+ params: {
438
+ name: 'coverage_summary_tool',
439
+ arguments: {
440
+ path: 'lib/foo.rb',
441
+ root: project_root,
442
+ resultset: coverage_dir
443
+ }
444
+ }
445
+ }
446
+
447
+ stdout = run_mcp_json(request)[:stdout]
448
+ response = parse_jsonrpc_response(stdout)
449
+
450
+ expect(response['id']).to eq(3)
451
+ expect(response).to have_key('result')
452
+
453
+ content = response['result']['content']
454
+ expect(content).to be_an(Array)
455
+ expect(content.first['type']).to eq('text')
456
+
457
+ # Parse the JSON coverage data from the text response
458
+ coverage_data = JSON.parse(content.first['text'])
459
+ expect(coverage_data).to include('file', 'summary')
460
+ expect(coverage_data['summary']).to include('covered' => 2, 'total' => 3)
461
+ end
462
+
463
+ it 'executes all_files_coverage_tool via JSON-RPC' do
464
+ request = {
465
+ jsonrpc: '2.0',
466
+ id: 4,
467
+ method: 'tools/call',
468
+ params: {
469
+ name: 'all_files_coverage_tool',
470
+ arguments: {
471
+ root: project_root,
472
+ resultset: coverage_dir
473
+ }
474
+ }
475
+ }
476
+
477
+ stdout = run_mcp_json(request)[:stdout]
478
+ response = parse_jsonrpc_response(stdout)
479
+
480
+ expect(response['id']).to eq(4)
481
+ content = response['result']['content']
482
+ coverage_data = JSON.parse(content.first['text'])
483
+
484
+ expect(coverage_data).to include('files', 'counts')
485
+ expect(coverage_data['files']).to be_an(Array)
486
+ expect(coverage_data['files'].length).to eq(2)
487
+ expect(coverage_data['counts']['total']).to eq(2)
488
+ end
489
+
490
+ it 'executes uncovered_lines_tool via JSON-RPC' do
491
+ request = {
492
+ jsonrpc: '2.0',
493
+ id: 5,
494
+ method: 'tools/call',
495
+ params: {
496
+ name: 'uncovered_lines_tool',
497
+ arguments: {
498
+ path: 'lib/foo.rb',
499
+ root: project_root,
500
+ resultset: coverage_dir
501
+ }
502
+ }
503
+ }
504
+
505
+ stdout = run_mcp_json(request)[:stdout]
506
+ response = parse_jsonrpc_response(stdout)
507
+
508
+ expect(response['id']).to eq(5)
509
+ content = response['result']['content']
510
+ coverage_data = JSON.parse(content.first['text'])
511
+
512
+ expect(coverage_data).to include('file', 'uncovered', 'summary')
513
+ expect(coverage_data['uncovered']).to eq([2]) # Line 2 is uncovered
514
+ end
515
+
516
+ it 'executes help_tool via JSON-RPC' do
517
+ request = {
518
+ jsonrpc: '2.0',
519
+ id: 6,
520
+ method: 'tools/call',
521
+ params: {
522
+ name: 'help_tool',
523
+ arguments: {}
524
+ }
525
+ }
526
+
527
+ stdout = run_mcp_json(request)[:stdout]
528
+ response = parse_jsonrpc_response(stdout)
529
+
530
+ expect(response['id']).to eq(6)
531
+ content = response['result']['content']
532
+ expect(content.first['type']).to eq('text')
533
+
534
+ # Help tool returns JSON with tool list
535
+ help_data = JSON.parse(content.first['text'])
536
+ expect(help_data).to have_key('tools')
537
+ expect(help_data['tools']).to be_an(Array)
538
+ tool_names = help_data['tools'].map { |t| t['tool'] }
539
+ expect(tool_names).to include('coverage_summary_tool')
540
+ end
541
+
542
+ it 'executes version_tool via JSON-RPC' do
543
+ request = {
544
+ jsonrpc: '2.0',
545
+ id: 7,
546
+ method: 'tools/call',
547
+ params: {
548
+ name: 'version_tool',
549
+ arguments: {}
550
+ }
551
+ }
552
+
553
+ stdout = run_mcp_json(request)[:stdout]
554
+ response = parse_jsonrpc_response(stdout)
555
+
556
+ expect(response['id']).to eq(7)
557
+ content = response['result']['content']
558
+ expect(content.first['type']).to eq('text')
559
+
560
+ version_text = content.first['text']
561
+ # Version format is "SimpleCovMcp version: X.Y.Z"
562
+ expect(version_text).to match(/SimpleCovMcp version: \d+\.\d+/)
563
+ end
564
+
565
+ it 'handles error responses for invalid tool calls' do
566
+ request = {
567
+ jsonrpc: '2.0',
568
+ id: 8,
569
+ method: 'tools/call',
570
+ params: {
571
+ name: 'coverage_summary_tool',
572
+ arguments: {
573
+ path: 'nonexistent_file.rb',
574
+ root: project_root,
575
+ resultset: coverage_dir
576
+ }
577
+ }
578
+ }
579
+
580
+ result = run_mcp_json(request)
581
+ response = parse_jsonrpc_response(result[:stdout])
582
+
583
+ # MCP should return a response (not crash)
584
+ expect(response['id']).to eq(8)
585
+
586
+ # Should include error information in content or error field
587
+ if response['error']
588
+ expect(response['error']).to have_key('message')
589
+ elsif response['result']
590
+ content = response['result']['content']
591
+ text = content.first['text']
592
+ expect(text.downcase).to include('error').or include('not found')
593
+ end
594
+ end
595
+
596
+ it 'handles malformed JSON-RPC requests' do
597
+ malformed_request = "{'jsonrpc': '2.0', 'id': 999, 'method': 'invalid'}"
598
+
599
+ env = { 'RUBY_LIB' => lib_path }
600
+ result = run_mcp_input(malformed_request, env: env)
601
+
602
+ # Should handle gracefully without crashing
603
+ # May return error response or empty output
604
+ expect(result[:stderr]).not_to include('NameError')
605
+ expect(result[:stderr]).not_to include('uninitialized constant')
606
+ end
607
+
608
+ it 'respects --log-file configuration in MCP mode' do
609
+ request = {
610
+ jsonrpc: '2.0',
611
+ id: 10,
612
+ method: 'tools/call',
613
+ params: {
614
+ name: 'version_tool',
615
+ arguments: {}
616
+ }
617
+ }
618
+
619
+ result = run_mcp_json(
620
+ request,
621
+ env: default_env.merge('SIMPLECOV_MCP_OPTS' => '--log-file stderr')
622
+ )
623
+
624
+ response = parse_jsonrpc_response(result[:stdout])
625
+ expect(response).not_to be_nil
626
+ expect(response['id']).to eq(10)
627
+ end
628
+
629
+ it 'prohibits stdout logging in MCP mode' do
630
+ # Attempt to start MCP server with --log-file stdout should fail
631
+ env = {
632
+ 'RUBY_LIB' => lib_path,
633
+ 'SIMPLECOV_MCP_OPTS' => '--log-file stdout'
634
+ }
635
+
636
+ result = run_mcp_input(nil, env: env)
637
+
638
+ combined_output = result[:stdout] + result[:stderr]
639
+ expect(combined_output).to include('stdout').and include('not permitted')
640
+ expect(result[:status].exitstatus).not_to eq(0)
641
+ end
642
+
643
+ it 'handles multiple sequential requests' do
644
+ requests = [
645
+ { jsonrpc: '2.0', id: 100, method: 'tools/list' },
646
+ { jsonrpc: '2.0', id: 101, method: 'tools/call',
647
+ params: { name: 'version_tool', arguments: {} } }
648
+ ]
649
+
650
+ result = run_mcp_json_stream(requests)
651
+
652
+ responses = result[:stdout].lines.map do |line|
653
+ next if line.strip.empty?
654
+
655
+ begin
656
+ parsed = JSON.parse(line)
657
+ parsed if parsed['jsonrpc'] == '2.0'
658
+ rescue JSON::ParserError
659
+ nil
660
+ end
661
+ end.compact
662
+
663
+ expect(responses.length).to be >= 1
664
+ response_ids = responses.map { |r| r['id'] }
665
+ expect(response_ids).to include(100).or include(101)
666
+ end
667
+
668
+ context 'MCP protocol error handling' do
669
+ it 'returns error for unknown tool name' do
670
+ request = {
671
+ jsonrpc: '2.0',
672
+ id: 200,
673
+ method: 'tools/call',
674
+ params: {
675
+ name: 'nonexistent_tool_that_does_not_exist',
676
+ arguments: {}
677
+ }
678
+ }
679
+
680
+ result = run_mcp_json(request)
681
+ response = parse_jsonrpc_response(result[:stdout])
682
+
683
+ expect(response['id']).to eq(200)
684
+ expect(response['jsonrpc']).to eq('2.0')
685
+
686
+ # MCP server should return error in result content or error field
687
+ if response['error']
688
+ # Standard JSON-RPC error format
689
+ expect(response['error']).to have_key('message')
690
+ # MCP returns "Internal error" for unknown tools
691
+ expect(response['error']['message']).to be_a(String)
692
+ expect(response['error']['message'].length).to be > 0
693
+ elsif response['result']
694
+ # MCP may wrap errors in content
695
+ content = response['result']['content']
696
+ expect(content).to be_an(Array)
697
+ text = content.first['text']
698
+ expect(text.downcase).to include('error').or include('not found')
699
+ else
700
+ fail 'Expected either error or result field in response'
701
+ end
702
+ end
703
+
704
+ it 'returns error for missing required arguments' do
705
+ request = {
706
+ jsonrpc: '2.0',
707
+ id: 201,
708
+ method: 'tools/call',
709
+ params: {
710
+ name: 'coverage_summary_tool',
711
+ arguments: {} # Missing required 'path' argument
712
+ }
713
+ }
714
+
715
+ result = run_mcp_json(request)
716
+ response = parse_jsonrpc_response(result[:stdout])
717
+
718
+ expect(response['id']).to eq(201)
719
+ expect(response['jsonrpc']).to eq('2.0')
720
+
721
+ # Should return an error about missing path
722
+ if response['error']
723
+ expect(response['error']).to have_key('message')
724
+ elsif response['result']
725
+ content = response['result']['content']
726
+ text = content.first['text']
727
+ expect(text.downcase).to include('error').or include('required').or include('path')
728
+ else
729
+ fail 'Expected either error or result field in response'
730
+ end
731
+ end
732
+
733
+ it 'handles invalid argument types gracefully' do
734
+ request = {
735
+ jsonrpc: '2.0',
736
+ id: 202,
737
+ method: 'tools/call',
738
+ params: {
739
+ name: 'coverage_summary_tool',
740
+ arguments: {
741
+ path: 12345, # Should be string, not number
742
+ root: project_root,
743
+ resultset: coverage_dir
744
+ }
745
+ }
746
+ }
747
+
748
+ result = run_mcp_json(request)
749
+ response = parse_jsonrpc_response(result[:stdout])
750
+
751
+ expect(response['id']).to eq(202)
752
+ expect(response['jsonrpc']).to eq('2.0')
753
+
754
+ # Should handle gracefully (may coerce to string or return error)
755
+ expect(response).to have_key('result').or have_key('error')
756
+ end
757
+
758
+ it 'returns properly formatted JSON-RPC error responses' do
759
+ request = {
760
+ jsonrpc: '2.0',
761
+ id: 203,
762
+ method: 'tools/call',
763
+ params: {
764
+ name: 'coverage_summary_tool',
765
+ arguments: {
766
+ path: 'definitely_does_not_exist.rb',
767
+ root: project_root,
768
+ resultset: coverage_dir
769
+ }
770
+ }
771
+ }
772
+
773
+ result = run_mcp_json(request)
774
+ response = parse_jsonrpc_response(result[:stdout])
775
+
776
+ # Verify JSON-RPC 2.0 compliance
777
+ expect(response).to include('jsonrpc' => '2.0', 'id' => 203)
778
+
779
+ # Must have either 'result' or 'error', but not both
780
+ has_result = response.key?('result')
781
+ has_error = response.key?('error')
782
+ expect(has_result ^ has_error).to be true
783
+
784
+ # If error field exists, verify structure
785
+ if has_error
786
+ expect(response['error']).to have_key('message')
787
+ expect(response['error']['message']).to be_a(String)
788
+ end
789
+ end
790
+
791
+ it 'handles requests with missing params field' do
792
+ request = {
793
+ jsonrpc: '2.0',
794
+ id: 204,
795
+ method: 'tools/call'
796
+ # Missing params field entirely
797
+ }
798
+
799
+ result = run_mcp_json(request)
800
+
801
+ # Should not crash - either returns error or handles gracefully
802
+ expect(result[:stderr]).not_to include('NameError')
803
+ expect(result[:stderr]).not_to include('NoMethodError')
804
+
805
+ # Parse response if available
806
+ if result[:stdout] && !result[:stdout].strip.empty?
807
+ response = parse_jsonrpc_response(result[:stdout])
808
+ expect(response['jsonrpc']).to eq('2.0')
809
+ expect(response['id']).to eq(204)
810
+ end
811
+ end
812
+
813
+ it 'handles completely invalid JSON input' do
814
+ invalid_json = 'this is not JSON at all'
815
+
816
+ result = run_mcp_input(invalid_json, env: default_env)
817
+
818
+ # Should not crash with unhandled exception
819
+ combined = result[:stdout] + result[:stderr]
820
+ expect(combined).not_to include('uninitialized constant')
821
+
822
+ # May log error to stderr, but shouldn't crash
823
+ if result[:status]
824
+ # Exit code may be non-zero but shouldn't be a crash (e.g., signal)
825
+ expect(result[:status].exitstatus).to be_a(Integer)
826
+ end
827
+ end
828
+
829
+ it 'handles empty input gracefully' do
830
+ result = run_mcp_input('', env: default_env)
831
+
832
+ # Empty input should be handled without crash
833
+ expect(result[:stderr]).not_to include('NameError')
834
+ expect(result[:stderr]).not_to include('NoMethodError')
835
+ end
836
+
837
+ it 'handles partial JSON input' do
838
+ partial_json = '{"jsonrpc": "2.0", "id": 300, "method":'
839
+
840
+ result = run_mcp_input(partial_json, env: default_env)
841
+
842
+ # Should handle gracefully without crashing
843
+ expect(result[:stderr]).not_to include('uninitialized constant')
844
+ end
845
+ end
846
+ end
847
+ end