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