Skip to content

Commit d64eaca

Browse files
authored
Merge pull request #658 from ruby/claude/rubyci-mcp-endpoint-2550ef
Add MCP server endpoint
2 parents 023aeb4 + a320fd0 commit d64eaca

16 files changed

Lines changed: 727 additions & 1 deletion

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ gem 'puma'
77
gem 'bootsnap', require: false
88
gem "propshaft"
99
gem 'aws-sdk-s3', '~> 1'
10+
gem 'mcp'
1011

1112
group :development, :test do
1213
gem 'sqlite3'

Gemfile.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ GEM
108108
erubi (1.13.1)
109109
globalid (1.3.0)
110110
activesupport (>= 6.1)
111+
hana (1.3.7)
111112
i18n (1.14.8)
112113
concurrent-ruby (~> 1.0)
113114
io-console (0.8.2)
@@ -118,6 +119,11 @@ GEM
118119
reline (>= 0.4.2)
119120
jmespath (1.6.2)
120121
json (2.19.9)
122+
json_schemer (2.5.0)
123+
bigdecimal
124+
hana (~> 1.3)
125+
regexp_parser (~> 2.0)
126+
simpleidn (~> 0.2)
121127
logger (1.7.0)
122128
loofah (2.25.2)
123129
crass (~> 1.0.2)
@@ -129,6 +135,8 @@ GEM
129135
net-pop
130136
net-smtp
131137
marcel (1.1.0)
138+
mcp (1.0.0)
139+
json_schemer (>= 2.4)
132140
mini_mime (1.1.5)
133141
mini_portile2 (2.8.9)
134142
minitest (6.0.5)
@@ -229,9 +237,11 @@ GEM
229237
erb
230238
psych (>= 4.0.0)
231239
tsort
240+
regexp_parser (2.12.0)
232241
reline (0.6.3)
233242
io-console (~> 0.5)
234243
securerandom (0.4.1)
244+
simpleidn (0.2.3)
235245
sqlite3 (2.9.5)
236246
mini_portile2 (~> 2.8.0)
237247
sqlite3 (2.9.5-aarch64-linux-gnu)
@@ -287,6 +297,7 @@ PLATFORMS
287297
DEPENDENCIES
288298
aws-sdk-s3 (~> 1)
289299
bootsnap
300+
mcp
290301
pg
291302
propshaft
292303
puma

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ To run this on local machine, following command can run development server:
1010
% env DATABASE_URL=`heroku config:get -a rubyci DATABASE_URL` rails s
1111
```
1212

13+
# MCP endpoint
14+
15+
`POST /mcp` serves the Model Context Protocol over Streamable HTTP (stateless, read-only, no authentication).
16+
17+
```
18+
claude mcp add --transport http rubyci https://rubyci.org/mcp
19+
```
20+
21+
Tools: `list_servers`, `current_status`, `report_history`, `search_failures`, `find_failure_origin`, `failing_servers`, `get_log_excerpt`.
22+
23+
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.
24+
1325
# Storage note
1426

1527
* To optimize S3 Access, extra directories should have 'o' character like 'log' and 'lcov'.

app/controllers/mcp_controller.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# MCP (Model Context Protocol) endpoint. Streamable HTTP transport in
2+
# stateless mode: each POST is self-contained, so it works across multiple
3+
# Puma processes without sticky sessions. Read-only, no authentication,
4+
# same public posture as /reports and /search.
5+
class McpController < ApplicationController
6+
skip_forgery_protection
7+
8+
TOOLS = [
9+
McpTools::ListServers,
10+
McpTools::CurrentStatus,
11+
McpTools::ReportHistory,
12+
McpTools::SearchFailures,
13+
McpTools::FindFailureOrigin,
14+
McpTools::FailingServers,
15+
McpTools::GetLogExcerpt,
16+
].freeze
17+
18+
INSTRUCTIONS = <<~TEXT.freeze
19+
rubyci.org aggregates chkbuild CI results for ruby/ruby. Typical triage flow:
20+
current_status to see what is failing now, failing_servers to see how widespread
21+
a specific failure is, then find_failure_origin to get the first bad build and a
22+
github.com/ruby/ruby compare URL for the suspect commit range. Chain the results
23+
with a GitHub MCP server (enumerate commits in compare_url, inspect diffs) and a
24+
bugs.ruby-lang.org MCP server (search or file issues using the matching_line
25+
snippet and commit SHA).
26+
TEXT
27+
28+
def handle
29+
server = MCP::Server.new(
30+
name: "rubyci",
31+
version: "1.0.0",
32+
instructions: INSTRUCTIONS,
33+
tools: TOOLS,
34+
)
35+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
36+
server,
37+
stateless: true,
38+
enable_json_response: true,
39+
# This is a public server; host-header validation is meant for local
40+
# stdio-adjacent deployments and would reject rubyci.org.
41+
dns_rebinding_protection: false,
42+
)
43+
status, headers, body = transport.handle_request(request)
44+
headers.each { |k, v| response.set_header(k, v) unless k.casecmp?("content-length") }
45+
body = Array(body).join
46+
if body.empty?
47+
head status
48+
else
49+
render body: body, status: status
50+
end
51+
end
52+
end

app/controllers/reports_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def current
4343
if stale?(:last_modified => last_modified, :etag => last_modified.to_s, :public => true)
4444
@reports = Report.includes(:server).order('reports.branch DESC, servers.ordinal ASC, reports.option ASC').
4545
references(:server).
46-
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
46+
latest_per_config(14.days.ago).all
4747
@reports = @reports.to_a.delete_if{|report| report.server.nil? }
4848

4949
@reports = filter_deprecated(@reports)

app/models/report.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
class Report < ApplicationRecord
88
belongs_to :server
99
has_one :log_excerpt, dependent: :delete
10+
# Latest report for each (server, branch, option) configuration since the
11+
# given time. Shared by ReportsController#current and the MCP endpoint.
12+
scope :latest_per_config, ->(since) {
13+
where('reports.id IN (SELECT MAX(R.id) FROM reports R WHERE R.datetime > ? GROUP BY R.server_id, R.branch, R.option)', since)
14+
}
1015
validates :server_id, :presence => true
1116
# SVN revision number or full 40-hex git commit SHA
1217
validates :revision, :format => { :with => /\A(?:\d+|\h{40})\z/ }, allow_nil: true
@@ -102,6 +107,40 @@ def diffstat
102107
summary[/((?:no )?diff[^)>]*)/, 1]
103108
end
104109

110+
def success?
111+
if (result = meta&.[]("result"))
112+
result == "success"
113+
else
114+
# scan_recent_ltsv appends " success" to summary for successful builds
115+
summary.include?(" success")
116+
end
117+
end
118+
119+
def git_sha
120+
sha1 if sha1&.match?(/\A\h{40}\z/)
121+
end
122+
123+
# Compact serialization for the MCP endpoint. Excludes the raw ltsv/summary
124+
# blobs; hands back external URLs instead of log contents.
125+
def as_mcp_json
126+
{
127+
id: id,
128+
server: server&.name,
129+
server_id: server_id,
130+
branch: branch,
131+
option: option,
132+
datetime: datetime.utc.iso8601,
133+
revision: revision,
134+
commit_sha: git_sha,
135+
result: success? ? "success" : "failure",
136+
summary: shortsummary || summary,
137+
failures: { build: build, test: test, testall: testall, rubyspec: rubyspec }.compact,
138+
log_url: (loguri if server),
139+
fail_html_url: (failuri if server),
140+
commit_url: revisionuri,
141+
}.compact
142+
end
143+
105144
def depsuffixed_name
106145
if /vc/ =~ server.uri
107146
# mswin
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
module McpTools
2+
class CurrentStatus < MCP::Tool
3+
extend Helpers
4+
5+
tool_name "current_status"
6+
description "Latest build result for each (server, branch, option) configuration within " \
7+
"the last 14 days. Start here to see which configurations are currently failing."
8+
input_schema(
9+
properties: {
10+
branch: { type: "string", description: "Filter by branch, e.g. 'master' or '3.4'" },
11+
failures_only: { type: "boolean", description: "Return only failing configurations" },
12+
},
13+
required: [],
14+
)
15+
annotations(Helpers::READ_ONLY)
16+
17+
def self.call(branch: nil, failures_only: false, server_context: nil)
18+
reports = Report.includes(:server).latest_per_config(14.days.ago).to_a
19+
reports.reject! { |r| r.server.nil? || r.branch == 'trunk' }
20+
reports.select! { |r| r.branch == branch } if branch.present?
21+
reports.reject!(&:success?) if failures_only
22+
reports.sort_by! { |r| [r.branch, r.server.ordinal, r.option.to_s] }
23+
json_response(count: reports.size, reports: reports.map(&:as_mcp_json))
24+
end
25+
end
26+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
module McpTools
2+
class FailingServers < MCP::Tool
3+
extend Helpers
4+
5+
MAX_MATCHES = 2000
6+
7+
tool_name "failing_servers"
8+
description "Given a failure log substring (e.g. a test name or assertion message), list the " \
9+
"(server, branch, option) configurations where that failure occurred, with first " \
10+
"and last occurrence and whether the latest build of each configuration still has " \
11+
"it. Distinguishes an environment-specific failure from a global regression."
12+
input_schema(
13+
properties: {
14+
query: { type: "string", description: "Substring to match in failure logs" },
15+
branch: { type: "string", description: "Filter by branch, e.g. 'master'" },
16+
from: { type: "string", description: "Start date (YYYY-MM-DD, UTC; default 14 days ago)" },
17+
to: { type: "string", description: "End date (YYYY-MM-DD, UTC, inclusive)" },
18+
},
19+
required: ["query"],
20+
)
21+
annotations(Helpers::READ_ONLY)
22+
23+
def self.call(query:, branch: nil, from: nil, to: nil, server_context: nil)
24+
return error_response("query must not be blank") if query.strip.empty?
25+
from_t = parse_time(from) || 14.days.ago.utc
26+
to_t = parse_time(to, end_of_day: true)
27+
28+
scope = Report.joins(:log_excerpt).merge(LogExcerpt.content_match(query)).
29+
includes(:server).
30+
where("reports.datetime >= ?", from_t).
31+
order("reports.datetime ASC").limit(MAX_MATCHES)
32+
scope = scope.where("reports.datetime <= ?", to_t) if to_t
33+
scope = scope.where(branch: branch) if branch.present?
34+
matches = scope.to_a.reject { |r| r.server.nil? }
35+
if matches.empty?
36+
return json_response(query: query, branch: branch, message: "No matching failure in the window")
37+
end
38+
match_ids = matches.map(&:id).to_set
39+
40+
configs = matches.group_by { |r| [r.server_id, r.branch, r.option] }.map do |(server_id, br, opt), reports|
41+
first = reports.first
42+
last = reports.last
43+
latest = Report.where(server_id: server_id, branch: br, option: opt).order(datetime: :desc).first
44+
{
45+
server: first.server.name,
46+
server_id: server_id,
47+
branch: br,
48+
option: opt,
49+
occurrences: reports.size,
50+
still_failing: match_ids.include?(latest.id),
51+
first_seen: first.as_mcp_json,
52+
last_seen: last.as_mcp_json.merge(matching_line: last.log_excerpt&.matching_line(query)).compact,
53+
}
54+
end
55+
configs.sort_by! { |c| c[:first_seen][:datetime] }
56+
57+
json_response(
58+
query: query,
59+
branch: branch,
60+
from: from_t.iso8601,
61+
to: to_t&.iso8601,
62+
config_count: configs.size,
63+
total_occurrences: matches.size,
64+
truncated: matches.size >= MAX_MATCHES,
65+
configs: configs,
66+
)
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)