diff --git a/Gemfile b/Gemfile index a2959add..83fe933f 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gem 'puma' gem 'bootsnap', require: false gem "propshaft" gem 'aws-sdk-s3', '~> 1' +gem 'mcp' group :development, :test do gem 'sqlite3' diff --git a/Gemfile.lock b/Gemfile.lock index a56eec05..b54c93fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM erubi (1.13.1) globalid (1.3.0) activesupport (>= 6.1) + hana (1.3.7) i18n (1.14.8) concurrent-ruby (~> 1.0) io-console (0.8.2) @@ -118,6 +119,11 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.19.9) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) logger (1.7.0) loofah (2.25.2) crass (~> 1.0.2) @@ -129,6 +135,8 @@ GEM net-pop net-smtp marcel (1.1.0) + mcp (1.0.0) + json_schemer (>= 2.4) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (6.0.5) @@ -229,9 +237,11 @@ GEM erb psych (>= 4.0.0) tsort + regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) securerandom (0.4.1) + simpleidn (0.2.3) sqlite3 (2.9.5) mini_portile2 (~> 2.8.0) sqlite3 (2.9.5-aarch64-linux-gnu) @@ -287,6 +297,7 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 (~> 1) bootsnap + mcp pg propshaft puma diff --git a/README.md b/README.md index eb94338d..0270382f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ To run this on local machine, following command can run development server: % env DATABASE_URL=`heroku config:get -a rubyci DATABASE_URL` rails s ``` +# MCP endpoint + +`POST /mcp` serves the Model Context Protocol over Streamable HTTP (stateless, read-only, no authentication). + +``` +claude mcp add --transport http rubyci https://rubyci.org/mcp +``` + +Tools: `list_servers`, `current_status`, `report_history`, `search_failures`, `find_failure_origin`, `failing_servers`, `get_log_excerpt`. + +Typical triage flow: `current_status` shows what is failing now, `failing_servers` shows how widespread a specific failure is, and `find_failure_origin` returns the first bad build with a github.com/ruby/ruby compare URL for the suspect commit range. Combine with a GitHub MCP server to enumerate commits in that range and a bugs.ruby-lang.org MCP server to search or file issues. + # Storage note * To optimize S3 Access, extra directories should have 'o' character like 'log' and 'lcov'. diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 00000000..86c6ec96 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,52 @@ +# MCP (Model Context Protocol) endpoint. Streamable HTTP transport in +# stateless mode: each POST is self-contained, so it works across multiple +# Puma processes without sticky sessions. Read-only, no authentication, +# same public posture as /reports and /search. +class McpController < ApplicationController + skip_forgery_protection + + TOOLS = [ + McpTools::ListServers, + McpTools::CurrentStatus, + McpTools::ReportHistory, + McpTools::SearchFailures, + McpTools::FindFailureOrigin, + McpTools::FailingServers, + McpTools::GetLogExcerpt, + ].freeze + + INSTRUCTIONS = <<~TEXT.freeze + rubyci.org aggregates chkbuild CI results for ruby/ruby. Typical triage flow: + current_status to see what is failing now, failing_servers to see how widespread + a specific failure is, then find_failure_origin to get the first bad build and a + github.com/ruby/ruby compare URL for the suspect commit range. Chain the results + with a GitHub MCP server (enumerate commits in compare_url, inspect diffs) and a + bugs.ruby-lang.org MCP server (search or file issues using the matching_line + snippet and commit SHA). + TEXT + + def handle + server = MCP::Server.new( + name: "rubyci", + version: "1.0.0", + instructions: INSTRUCTIONS, + tools: TOOLS, + ) + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + enable_json_response: true, + # This is a public server; host-header validation is meant for local + # stdio-adjacent deployments and would reject rubyci.org. + dns_rebinding_protection: false, + ) + status, headers, body = transport.handle_request(request) + headers.each { |k, v| response.set_header(k, v) unless k.casecmp?("content-length") } + body = Array(body).join + if body.empty? + head status + else + render body: body, status: status + end + end +end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 25a289cd..0d3a172e 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -43,7 +43,7 @@ def current if stale?(:last_modified => last_modified, :etag => last_modified.to_s, :public => true) @reports = Report.includes(:server).order('reports.branch DESC, servers.ordinal ASC, reports.option ASC'). references(:server). - where('reports.id IN (SELECT MAX(R.id) FROM reports R WHERE R.datetime > ? GROUP BY R.server_id, R.branch, R.option)', 14.days.ago).all + latest_per_config(14.days.ago).all @reports = @reports.to_a.delete_if{|report| report.server.nil? } @reports = filter_deprecated(@reports) diff --git a/app/models/report.rb b/app/models/report.rb index fb374a1d..538747b2 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -7,6 +7,11 @@ class Report < ApplicationRecord belongs_to :server has_one :log_excerpt, dependent: :delete + # Latest report for each (server, branch, option) configuration since the + # given time. Shared by ReportsController#current and the MCP endpoint. + scope :latest_per_config, ->(since) { + where('reports.id IN (SELECT MAX(R.id) FROM reports R WHERE R.datetime > ? GROUP BY R.server_id, R.branch, R.option)', since) + } validates :server_id, :presence => true # SVN revision number or full 40-hex git commit SHA validates :revision, :format => { :with => /\A(?:\d+|\h{40})\z/ }, allow_nil: true @@ -102,6 +107,40 @@ def diffstat summary[/((?:no )?diff[^)>]*)/, 1] end + def success? + if (result = meta&.[]("result")) + result == "success" + else + # scan_recent_ltsv appends " success" to summary for successful builds + summary.include?(" success") + end + end + + def git_sha + sha1 if sha1&.match?(/\A\h{40}\z/) + end + + # Compact serialization for the MCP endpoint. Excludes the raw ltsv/summary + # blobs; hands back external URLs instead of log contents. + def as_mcp_json + { + id: id, + server: server&.name, + server_id: server_id, + branch: branch, + option: option, + datetime: datetime.utc.iso8601, + revision: revision, + commit_sha: git_sha, + result: success? ? "success" : "failure", + summary: shortsummary || summary, + failures: { build: build, test: test, testall: testall, rubyspec: rubyspec }.compact, + log_url: (loguri if server), + fail_html_url: (failuri if server), + commit_url: revisionuri, + }.compact + end + def depsuffixed_name if /vc/ =~ server.uri # mswin diff --git a/app/tools/mcp_tools/current_status.rb b/app/tools/mcp_tools/current_status.rb new file mode 100644 index 00000000..a637e9d5 --- /dev/null +++ b/app/tools/mcp_tools/current_status.rb @@ -0,0 +1,26 @@ +module McpTools + class CurrentStatus < MCP::Tool + extend Helpers + + tool_name "current_status" + description "Latest build result for each (server, branch, option) configuration within " \ + "the last 14 days. Start here to see which configurations are currently failing." + input_schema( + properties: { + branch: { type: "string", description: "Filter by branch, e.g. 'master' or '3.4'" }, + failures_only: { type: "boolean", description: "Return only failing configurations" }, + }, + required: [], + ) + annotations(Helpers::READ_ONLY) + + def self.call(branch: nil, failures_only: false, server_context: nil) + reports = Report.includes(:server).latest_per_config(14.days.ago).to_a + reports.reject! { |r| r.server.nil? || r.branch == 'trunk' } + reports.select! { |r| r.branch == branch } if branch.present? + reports.reject!(&:success?) if failures_only + reports.sort_by! { |r| [r.branch, r.server.ordinal, r.option.to_s] } + json_response(count: reports.size, reports: reports.map(&:as_mcp_json)) + end + end +end diff --git a/app/tools/mcp_tools/failing_servers.rb b/app/tools/mcp_tools/failing_servers.rb new file mode 100644 index 00000000..5a56ff6c --- /dev/null +++ b/app/tools/mcp_tools/failing_servers.rb @@ -0,0 +1,69 @@ +module McpTools + class FailingServers < MCP::Tool + extend Helpers + + MAX_MATCHES = 2000 + + tool_name "failing_servers" + description "Given a failure log substring (e.g. a test name or assertion message), list the " \ + "(server, branch, option) configurations where that failure occurred, with first " \ + "and last occurrence and whether the latest build of each configuration still has " \ + "it. Distinguishes an environment-specific failure from a global regression." + input_schema( + properties: { + query: { type: "string", description: "Substring to match in failure logs" }, + branch: { type: "string", description: "Filter by branch, e.g. 'master'" }, + from: { type: "string", description: "Start date (YYYY-MM-DD, UTC; default 14 days ago)" }, + to: { type: "string", description: "End date (YYYY-MM-DD, UTC, inclusive)" }, + }, + required: ["query"], + ) + annotations(Helpers::READ_ONLY) + + def self.call(query:, branch: nil, from: nil, to: nil, server_context: nil) + return error_response("query must not be blank") if query.strip.empty? + from_t = parse_time(from) || 14.days.ago.utc + to_t = parse_time(to, end_of_day: true) + + scope = Report.joins(:log_excerpt).merge(LogExcerpt.content_match(query)). + includes(:server). + where("reports.datetime >= ?", from_t). + order("reports.datetime ASC").limit(MAX_MATCHES) + scope = scope.where("reports.datetime <= ?", to_t) if to_t + scope = scope.where(branch: branch) if branch.present? + matches = scope.to_a.reject { |r| r.server.nil? } + if matches.empty? + return json_response(query: query, branch: branch, message: "No matching failure in the window") + end + match_ids = matches.map(&:id).to_set + + configs = matches.group_by { |r| [r.server_id, r.branch, r.option] }.map do |(server_id, br, opt), reports| + first = reports.first + last = reports.last + latest = Report.where(server_id: server_id, branch: br, option: opt).order(datetime: :desc).first + { + server: first.server.name, + server_id: server_id, + branch: br, + option: opt, + occurrences: reports.size, + still_failing: match_ids.include?(latest.id), + first_seen: first.as_mcp_json, + last_seen: last.as_mcp_json.merge(matching_line: last.log_excerpt&.matching_line(query)).compact, + } + end + configs.sort_by! { |c| c[:first_seen][:datetime] } + + json_response( + query: query, + branch: branch, + from: from_t.iso8601, + to: to_t&.iso8601, + config_count: configs.size, + total_occurrences: matches.size, + truncated: matches.size >= MAX_MATCHES, + configs: configs, + ) + end + end +end diff --git a/app/tools/mcp_tools/find_failure_origin.rb b/app/tools/mcp_tools/find_failure_origin.rb new file mode 100644 index 00000000..366b072a --- /dev/null +++ b/app/tools/mcp_tools/find_failure_origin.rb @@ -0,0 +1,118 @@ +module McpTools + class FindFailureOrigin < MCP::Tool + extend Helpers + + MAX_REPORTS_PER_CONFIG = 300 + + tool_name "find_failure_origin" + description "Locate when a failure started and the suspect commit range. For each failing " \ + "(server, branch, option) configuration, returns the first bad report, the last " \ + "good report before it, and a github.com/ruby/ruby compare URL between the two " \ + "commits when both are known. Without `query`, analyzes the current failing " \ + "streak of each configuration; with `query`, tracks the earliest failure log " \ + "matching that string (better for a specific test failure, tolerates flakiness). " \ + "Feed compare_url to a GitHub MCP server to enumerate suspect commits." + input_schema( + properties: { + branch: { type: "string", description: "Branch, e.g. 'master'" }, + server: { type: "string", description: "Server id or name; omit to analyze all servers" }, + option: { type: "string", description: "Build option (exact match); omit to analyze all options" }, + query: { type: "string", description: "Substring identifying the failure in logs, e.g. a test name" }, + lookback_days: { type: "integer", description: "How many days back to scan (default 90, max 366)" }, + }, + required: ["branch"], + ) + annotations(Helpers::READ_ONLY) + + def self.call(branch:, server: nil, option: nil, query: nil, lookback_days: 90, server_context: nil) + since = lookback_days.to_i.clamp(1, 366).days.ago.utc + scope = Report.where(branch: branch).where("datetime > ?", since) + if server.present? + srv = resolve_server(server) + return error_response("Unknown server: #{server}") unless srv + scope = scope.where(server_id: srv.id) + end + scope = scope.where(option: option) if option.present? + + configs = scope.distinct.pluck(:server_id, :option) + return error_response("No reports found for branch #{branch} in the lookback window") if configs.empty? + + match_ids = nil + if query.present? + match_ids = scope.joins(:log_excerpt).merge(LogExcerpt.content_match(query)).pluck(:id).to_set + if match_ids.empty? + return json_response(branch: branch, query: query, + message: "No failure log matching the query in the lookback window") + end + end + + results = configs.filter_map do |server_id, opt| + reports = Report.includes(:server). + where(branch: branch, server_id: server_id, option: opt). + where("datetime > ?", since). + order(datetime: :desc).limit(MAX_REPORTS_PER_CONFIG).to_a + next if reports.empty? || reports.first.server.nil? + query.present? ? analyze_query(reports, match_ids, query) : analyze_streak(reports) + end + if results.empty? + return json_response(branch: branch, query: query, + message: "No failing configuration found in the lookback window") + end + + results.sort_by! { |r| r[:first_bad][:datetime] } + earliest = results.first + json_response( + branch: branch, + query: query.presence, + earliest: { + server: earliest[:server], + option: earliest[:option], + datetime: earliest[:first_bad][:datetime], + commit_sha: earliest[:first_bad][:commit_sha], + compare_url: earliest[:compare_url], + }.compact, + configs: results, + ) + end + + # Boundary of the current failing streak: skip configurations whose latest + # report succeeded. + def self.analyze_streak(reports) + return nil if reports.first.success? + streak = reports.take_while { |r| !r.success? } + first_bad = streak.last + last_good = reports[streak.size] + config_entry(first_bad, last_good, reports, occurrences: streak.size) + end + + # Earliest report whose failure log matches the query. last_good is the + # newest report older than it without the matching failure (it may have + # failed for an unrelated reason). + def self.analyze_query(reports, match_ids, query) + matching = reports.select { |r| match_ids.include?(r.id) } + return nil if matching.empty? + first_bad = matching.last + last_good = reports[reports.index(first_bad) + 1] + entry = config_entry(first_bad, last_good, reports, occurrences: matching.size, + still_failing: match_ids.include?(reports.first.id)) + line = first_bad.log_excerpt&.matching_line(query) + entry[:first_bad][:matching_line] = line if line + entry + end + + def self.config_entry(first_bad, last_good, reports, occurrences:, still_failing: true) + { + server: first_bad.server.name, + server_id: first_bad.server_id, + option: first_bad.option, + still_failing: still_failing, + occurrences: occurrences, + first_bad: first_bad.as_mcp_json, + last_good: last_good&.as_mcp_json, + compare_url: compare_url(last_good, first_bad), + note: last_good ? nil : + "No earlier report within the window (window exhausted or scan capped at #{MAX_REPORTS_PER_CONFIG} reports); the failure may have started earlier.", + }.compact + end + end +end diff --git a/app/tools/mcp_tools/get_log_excerpt.rb b/app/tools/mcp_tools/get_log_excerpt.rb new file mode 100644 index 00000000..8f957712 --- /dev/null +++ b/app/tools/mcp_tools/get_log_excerpt.rb @@ -0,0 +1,63 @@ +module McpTools + class GetLogExcerpt < MCP::Tool + extend Helpers + + DEFAULT_MAX_BYTES = 20_000 + + tool_name "get_log_excerpt" + description "Stored failure log excerpt (fail.txt + diff.txt, up to 100KB) for a report. " \ + "Use `grep` to narrow to matching lines with context. Excerpts exist only for " \ + "failed builds on rubyci S3 servers and are pruned after about one year; use " \ + "the report's log_url for the full log." + input_schema( + properties: { + report_id: { type: "integer", description: "Report id (from other tools)" }, + grep: { type: "string", description: "Return only lines containing this substring, with context" }, + context_lines: { type: "integer", description: "Context lines around each grep match (default 5, max 50)" }, + max_bytes: { type: "integer", description: "Byte cap of the returned content (default #{DEFAULT_MAX_BYTES}, max #{LogExcerpt::MAX_CONTENT_BYTES})" }, + }, + required: ["report_id"], + ) + annotations(Helpers::READ_ONLY) + + def self.call(report_id:, grep: nil, context_lines: 5, max_bytes: DEFAULT_MAX_BYTES, server_context: nil) + report = Report.find_by(id: report_id) + return error_response("Report not found: #{report_id}") unless report + excerpt = report.log_excerpt + if excerpt.nil? || excerpt.content.empty? + return error_response("No log excerpt stored for report #{report_id}. " \ + "Excerpts exist only for failed builds on rubyci S3 servers and are pruned after about one year.") + end + + content = excerpt.content + if grep.present? + content = grep_with_context(content, grep, context_lines.to_i.clamp(0, 50)) + return json_response(report: report.as_mcp_json, grep: grep, content: "", + message: "No lines matched") if content.empty? + end + + max = max_bytes.to_i.clamp(1_000, LogExcerpt::MAX_CONTENT_BYTES) + truncated = content.bytesize > max + content = LogExcerpt.truncate_bytes(content, max) + json_response(report: report.as_mcp_json, grep: grep.presence, truncated: truncated, + content: content) + end + + def self.grep_with_context(content, grep, context) + lines = content.lines + needle = grep.downcase + indexes = lines.each_index.select { |i| lines[i].downcase.include?(needle) } + return "" if indexes.empty? + ranges = indexes.map { |i| [[i - context, 0].max, [i + context, lines.size - 1].min] } + merged = [ranges.first] + ranges.drop(1).each do |lo, hi| + if lo <= merged.last[1] + 1 + merged.last[1] = [merged.last[1], hi].max + else + merged << [lo, hi] + end + end + merged.map { |lo, hi| lines[lo..hi].join }.join("...\n") + end + end +end diff --git a/app/tools/mcp_tools/helpers.rb b/app/tools/mcp_tools/helpers.rb new file mode 100644 index 00000000..891a1890 --- /dev/null +++ b/app/tools/mcp_tools/helpers.rb @@ -0,0 +1,42 @@ +module McpTools + # Shared class-level helpers for MCP tool classes. + module Helpers + READ_ONLY = { + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + }.freeze + + def json_response(payload) + MCP::Tool::Response.new([{ type: "text", text: JSON.generate(payload) }]) + end + + def error_response(message) + MCP::Tool::Response.new([{ type: "text", text: message }], error: true) + end + + def parse_time(str, end_of_day: false) + return nil if str.blank? + date = Date.parse(str.to_s) rescue nil + return nil unless date + time = Time.utc(date.year, date.month, date.day) + end_of_day ? time + 86399 : time + end + + # Accepts a numeric id or a server name. + def resolve_server(value) + return nil if value.blank? + if value.to_s.match?(/\A\d+\z/) + Server.find_by(id: value) + else + Server.find_by(name: value.to_s) + end + end + + def compare_url(good, bad) + return nil unless good&.git_sha && bad&.git_sha + "https://github.com/ruby/ruby/compare/#{good.git_sha}...#{bad.git_sha}" + end + end +end diff --git a/app/tools/mcp_tools/list_servers.rb b/app/tools/mcp_tools/list_servers.rb new file mode 100644 index 00000000..43f3d873 --- /dev/null +++ b/app/tools/mcp_tools/list_servers.rb @@ -0,0 +1,18 @@ +module McpTools + class ListServers < MCP::Tool + extend Helpers + + tool_name "list_servers" + description "List the CI servers aggregated by rubyci.org. Use the returned id or name " \ + "as the `server` argument of the other tools." + input_schema(properties: {}, required: []) + annotations(Helpers::READ_ONLY) + + def self.call(server_context: nil) + servers = Server.order(:ordinal).map do |s| + { id: s.id, name: s.name, uri: s.uri, eol_date: s.eol_date&.iso8601 }.compact + end + json_response(servers: servers) + end + end +end diff --git a/app/tools/mcp_tools/report_history.rb b/app/tools/mcp_tools/report_history.rb new file mode 100644 index 00000000..e315d350 --- /dev/null +++ b/app/tools/mcp_tools/report_history.rb @@ -0,0 +1,41 @@ +module McpTools + class ReportHistory < MCP::Tool + extend Helpers + + tool_name "report_history" + description "Build history for a branch, newest first. Narrow by server and option to " \ + "follow a single configuration over time." + input_schema( + properties: { + branch: { type: "string", description: "Branch, e.g. 'master' or '3.4'" }, + server: { type: "string", description: "Server id or name (see list_servers)" }, + option: { type: "string", description: "Build option, e.g. 'yjit' (exact match)" }, + from: { type: "string", description: "Start date (YYYY-MM-DD, UTC)" }, + to: { type: "string", description: "End date (YYYY-MM-DD, UTC, inclusive)" }, + result: { type: "string", enum: ["success", "failure"], description: "Filter by build result" }, + limit: { type: "integer", description: "Max reports to return (default 30, max 100)" }, + }, + required: ["branch"], + ) + annotations(Helpers::READ_ONLY) + + def self.call(branch:, server: nil, option: nil, from: nil, to: nil, result: nil, limit: 30, server_context: nil) + scope = Report.includes(:server).where(branch: branch).order(datetime: :desc) + if server.present? + srv = resolve_server(server) + return error_response("Unknown server: #{server}") unless srv + scope = scope.where(server_id: srv.id) + end + scope = scope.where(option: option) if option.present? + from_t = parse_time(from) + to_t = parse_time(to, end_of_day: true) + scope = scope.where("datetime >= ?", from_t) if from_t + scope = scope.where("datetime <= ?", to_t) if to_t + if result.to_s.match?(/\A(?:success|failure)\z/) + scope = scope.where(Report.arel_table[:ltsv].matches("%\tresult:#{result}\t%")) + end + reports = scope.limit(limit.to_i.clamp(1, 100)).to_a + json_response(count: reports.size, reports: reports.map(&:as_mcp_json)) + end + end +end diff --git a/app/tools/mcp_tools/search_failures.rb b/app/tools/mcp_tools/search_failures.rb new file mode 100644 index 00000000..c16ad445 --- /dev/null +++ b/app/tools/mcp_tools/search_failures.rb @@ -0,0 +1,65 @@ +module McpTools + class SearchFailures < MCP::Tool + extend Helpers + + MAX_LIMIT = 50 + + tool_name "search_failures" + description "Search log excerpts of failed builds (same index as https://rubyci.org/search). " \ + "Use order=asc to find the earliest record of a failure. Excerpts exist only for " \ + "failed builds on rubyci S3 servers and are pruned after about one year." + input_schema( + properties: { + q: { type: "string", description: "Substring to match in failure logs, e.g. a test name or assertion message" }, + server: { type: "string", description: "Server id or name (see list_servers)" }, + branch: { type: "string", description: "Branch, e.g. 'master'" }, + revision: { type: "string", description: "Commit SHA or SVN revision prefix" }, + from: { type: "string", description: "Start date (YYYY-MM-DD, UTC)" }, + to: { type: "string", description: "End date (YYYY-MM-DD, UTC, inclusive)" }, + order: { type: "string", enum: ["asc", "desc"], description: "Sort by datetime (default desc)" }, + limit: { type: "integer", description: "Max hits to return (default 20, max #{MAX_LIMIT})" }, + page: { type: "integer", description: "Page number (default 1)" }, + }, + required: [], + ) + annotations(Helpers::READ_ONLY) + + def self.call(q: nil, server: nil, branch: nil, revision: nil, from: nil, to: nil, + order: "desc", limit: 20, page: 1, server_context: nil) + q = q.to_s.strip + from_t = parse_time(from) + to_t = parse_time(to, end_of_day: true) + unless q.present? || server.present? || branch.present? || revision.present? || from_t || to_t + return error_response("Give at least one filter: q, server, branch, revision, from or to") + end + + reports = Report.joins(:log_excerpt).includes(:server). + order("reports.datetime #{order == 'asc' ? 'ASC' : 'DESC'}") + if server.present? + srv = resolve_server(server) + return error_response("Unknown server: #{server}") unless srv + reports = reports.where(server_id: srv.id) + end + reports = reports.where(branch: branch) if branch.present? + reports = reports.where("reports.datetime >= ?", from_t) if from_t + reports = reports.where("reports.datetime <= ?", to_t) if to_t + if revision.present? + reports = reports.where("reports.revision LIKE ?", "#{Report.sanitize_sql_like(revision)}%") + end + reports = reports.merge(LogExcerpt.content_match(q)) if q.present? + + limit = limit.to_i.clamp(1, MAX_LIMIT) + page = page.to_i.clamp(1, 200) + hits = reports.limit(limit + 1).offset((page - 1) * limit).to_a + has_next = hits.size > limit + hits = hits.first(limit) + + results = hits.map do |report| + entry = report.as_mcp_json + entry[:matching_line] = report.log_excerpt&.matching_line(q) if q.present? + entry.compact + end + json_response(count: results.size, page: page, has_next: has_next, reports: results) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 15bc4a4a..9d7afd5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ get "search" => "search#index" + match "mcp" => "mcp#handle", via: [:post, :get, :delete] + resources :reports, only: [:show, :index] do collection do get "current" diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb new file mode 100644 index 00000000..b20c76f3 --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,167 @@ +require "test_helper" + +class McpControllerTest < ActionDispatch::IntegrationTest + SHA_GOOD = "aaaa000000000000000000000000000000000001" + SHA_BAD = "bbbb000000000000000000000000000000000002" + SHA_HEAD = "cccc000000000000000000000000000000000003" + + setup do + # ordinal base 2000: other test files use 0, 100, 500 and 999 + @server = Server.create!(name: "mcp-srv", uri: "https://rubyci.s3.amazonaws.com/mcp-srv/", ordinal: 2000) + @other = Server.create!(name: "mcp-srv2", uri: "https://rubyci.s3.amazonaws.com/mcp-srv2/", ordinal: 2001) + base = Time.now.utc.change(usec: 0) + + @good = create_report(@server, base - 3.days, SHA_GOOD, success: true) + @first_bad = create_report(@server, base - 2.days, SHA_BAD, success: false) + LogExcerpt.create!(report: @first_bad, content: "TestMcp#test_origin [mcp_test.rb:1]:\nExpected true.") + @latest_bad = create_report(@server, base - 1.day, SHA_HEAD, success: false) + LogExcerpt.create!(report: @latest_bad, content: "TestMcp#test_origin [mcp_test.rb:1]:\nExpected true.\nsecond line here") + + @other_bad = create_report(@other, base - 12.hours, SHA_HEAD, success: false) + LogExcerpt.create!(report: @other_bad, content: "TestMcp#test_origin [mcp_test.rb:1]:\nExpected true.") + end + + test "initialize returns server info and instructions" do + res = mcp_post(method: "initialize", params: { + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "0" } + }) + assert_equal "rubyci", res.dig("result", "serverInfo", "name") + assert_match "find_failure_origin", res.dig("result", "instructions") + end + + test "tools/list returns all tools" do + res = mcp_post(method: "tools/list") + names = res.dig("result", "tools").map { |t| t["name"] } + assert_equal %w[list_servers current_status report_history search_failures + find_failure_origin failing_servers get_log_excerpt].sort, names.sort + end + + test "GET is not allowed in stateless mode" do + get "/mcp" + assert_response :method_not_allowed + end + + test "list_servers" do + result = call_tool("list_servers") + names = result["servers"].map { |s| s["name"] } + assert_includes names, "mcp-srv" + assert_includes names, "mcp-srv2" + end + + test "current_status failures_only" do + result = call_tool("current_status", { branch: "master", failures_only: true }) + entries = result["reports"].select { |r| r["server"].start_with?("mcp-srv") } + assert_equal 2, entries.size + assert entries.all? { |r| r["result"] == "failure" } + latest = entries.find { |r| r["server"] == "mcp-srv" } + assert_equal @latest_bad.id, latest["id"] + end + + test "report_history filters by server and result" do + result = call_tool("report_history", { branch: "master", server: "mcp-srv" }) + assert_equal 3, result["count"] + assert_equal [@latest_bad.id, @first_bad.id, @good.id], result["reports"].map { |r| r["id"] } + + result = call_tool("report_history", { branch: "master", server: "mcp-srv", result: "success" }) + assert_equal [@good.id], result["reports"].map { |r| r["id"] } + end + + test "report_history rejects unknown server" do + res = mcp_post(method: "tools/call", params: { name: "report_history", + arguments: { branch: "master", server: "no-such-server" } }) + assert res.dig("result", "isError") + end + + test "search_failures returns matching_line and orders ascending" do + result = call_tool("search_failures", { q: "test_origin", order: "asc" }) + assert_equal 3, result["count"] + assert_equal @first_bad.id, result["reports"].first["id"] + assert_match "TestMcp#test_origin", result["reports"].first["matching_line"] + end + + test "search_failures requires a filter" do + res = mcp_post(method: "tools/call", params: { name: "search_failures", arguments: {} }) + assert res.dig("result", "isError") + end + + test "find_failure_origin without query finds the streak boundary" do + result = call_tool("find_failure_origin", { branch: "master", server: "mcp-srv" }) + config = result["configs"].first + assert_equal @first_bad.id, config.dig("first_bad", "id") + assert_equal @good.id, config.dig("last_good", "id") + assert_equal "https://github.com/ruby/ruby/compare/#{SHA_GOOD}...#{SHA_BAD}", config["compare_url"] + assert_equal config["compare_url"], result.dig("earliest", "compare_url") + end + + test "find_failure_origin with query reports occurrences across servers" do + result = call_tool("find_failure_origin", { branch: "master", query: "test_origin" }) + assert_equal 2, result["configs"].size + mine = result["configs"].find { |c| c["server"] == "mcp-srv" } + assert_equal 2, mine["occurrences"] + assert mine["still_failing"] + assert_equal @first_bad.id, mine.dig("first_bad", "id") + assert_match "TestMcp#test_origin", mine.dig("first_bad", "matching_line") + assert_equal "mcp-srv", result.dig("earliest", "server") + end + + test "find_failure_origin with unmatched query" do + result = call_tool("find_failure_origin", { branch: "master", query: "no_such_failure" }) + assert_match "No failure log", result["message"] + end + + test "failing_servers groups by configuration" do + result = call_tool("failing_servers", { query: "test_origin" }) + assert_equal 2, result["config_count"] + assert_equal 3, result["total_occurrences"] + mine = result["configs"].find { |c| c["server"] == "mcp-srv" } + assert_equal 2, mine["occurrences"] + assert mine["still_failing"] + assert_equal @first_bad.id, mine.dig("first_seen", "id") + assert_equal @latest_bad.id, mine.dig("last_seen", "id") + assert_match "TestMcp#test_origin", mine.dig("last_seen", "matching_line") + end + + test "get_log_excerpt with grep" do + result = call_tool("get_log_excerpt", { report_id: @latest_bad.id, grep: "second line", context_lines: 0 }) + assert_equal "second line here", result["content"].strip + refute result["truncated"] + end + + test "get_log_excerpt for a report without excerpt" do + res = mcp_post(method: "tools/call", params: { name: "get_log_excerpt", + arguments: { report_id: @good.id } }) + assert res.dig("result", "isError") + end + + private + + def create_report(server, datetime, sha, success:) + if success + summary = "ruby 3.5.0dev success (no diff)" + ltsv = "depsuffixed_name:ruby-master\tresult:success\truby_rev:#{sha}" + else + summary = "ruby 3.5.0dev failed(test-all) (diff:test-all)" + ltsv = "depsuffixed_name:ruby-master\tresult:failure\truby_rev:#{sha}" + end + Report.create!(server: server, branch: "master", option: nil, datetime: datetime, + revision: sha, summary: summary, ltsv: ltsv) + end + + def mcp_post(method:, params: nil) + payload = { jsonrpc: "2.0", id: 1, method: method } + payload[:params] = params if params + post "/mcp", params: payload.to_json, headers: { + "Content-Type" => "application/json", + "Accept" => "application/json", + "MCP-Protocol-Version" => "2025-11-25", + } + assert_response :success + JSON.parse(response.body) + end + + def call_tool(name, arguments = {}) + res = mcp_post(method: "tools/call", params: { name: name, arguments: arguments }) + refute res.dig("result", "isError"), "tool errored: #{res.inspect}" + JSON.parse(res.dig("result", "content", 0, "text")) + end +end