Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -287,6 +297,7 @@ PLATFORMS
DEPENDENCIES
aws-sdk-s3 (~> 1)
bootsnap
mcp
pg
propshaft
puma
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down
52 changes: 52 additions & 0 deletions app/controllers/mcp_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/reports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions app/models/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions app/tools/mcp_tools/current_status.rb
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions app/tools/mcp_tools/failing_servers.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading