Skip to content

Commit 48d7960

Browse files
authored
[Render Preview] [Feature] Ruby UI MCP server (#391)
* Add ruby_ui MCP server design spec * Add ruby_ui MCP server implementation plan * [Feature] Scaffold ruby_ui-mcp Rails engine gem * [Feature] MCP Registry data model + tests * [Feature] MCP RegistryBuilder reads gem source * [Feature] MCP build CLI + initial registry.json * [Feature] MCP tools: list, search, view * [Feature] MCP tools: examples, add_command, audit * [Feature] MCP Server wires 7 tools to ruby-sdk * [Feature] MCP Rails engine + Rack endpoint * [Feature] Mount ruby_ui-mcp engine in docs app at /mcp * [Documentation] Add MCP docs page with multi-client install * [CI] Add MCP test + registry drift jobs * [Documentation] Add MCP README * [Bug Fix] Standardrb whitespace + deterministic registry build * [Bug Fix] COPY mcp/ in docs Dockerfile for ruby_ui-mcp path dep * [Feature] Extract VisualCodeExample blocks + richer docs_markdown * [Feature] MCP tool get_install_command_for_project * [Refactor] Drop dead code + centralize tool response helpers
1 parent e10b980 commit 48d7960

54 files changed

Lines changed: 3900 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,49 @@ jobs:
6262
- name: Run tests
6363
run: bin/rails db:test:prepare test
6464

65+
mcp:
66+
name: MCP (Ruby ${{ matrix.ruby }})
67+
runs-on: ubuntu-latest
68+
defaults:
69+
run:
70+
working-directory: mcp
71+
strategy:
72+
fail-fast: false
73+
matrix:
74+
ruby: ["3.3", "3.4"]
75+
steps:
76+
- uses: actions/checkout@v6
77+
- name: Set up Ruby
78+
uses: ruby/setup-ruby@v1
79+
with:
80+
ruby-version: ${{ matrix.ruby }}
81+
bundler-cache: true
82+
rubygems: latest
83+
working-directory: mcp
84+
- name: Run tests
85+
run: bundle exec rake test
86+
87+
mcp-registry-check:
88+
name: MCP registry up to date
89+
runs-on: ubuntu-latest
90+
steps:
91+
- uses: actions/checkout@v6
92+
- name: Set up Ruby
93+
uses: ruby/setup-ruby@v1
94+
with:
95+
ruby-version: "3.3"
96+
bundler-cache: true
97+
working-directory: mcp
98+
- name: Rebuild registry
99+
working-directory: mcp
100+
run: bundle exec exe/ruby-ui-mcp-build
101+
- name: Fail on diff
102+
run: |
103+
if ! git diff --exit-code mcp/data/registry.json; then
104+
echo "registry.json out of date — run 'cd mcp && bundle exec exe/ruby-ui-mcp-build' and commit"
105+
exit 1
106+
fi
107+
65108
docker-build:
66109
name: Docker build (Devcontainer)
67110
if: github.ref == 'refs/heads/main'

docs/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz
4848
npm install -g pnpm@$PNPM_VERSION && \
4949
rm -rf /tmp/node-build-master
5050

51-
# Copy the gem first so docs/Gemfile's `path: "../gem"` resolves during bundle install.
51+
# Copy the gem and mcp sources first so docs/Gemfile's path: "../gem" / "../mcp" resolve during bundle install.
5252
COPY gem /gem
53+
COPY mcp /mcp
5354

5455
# Install application gems (cwd = /rails)
5556
COPY docs/Gemfile docs/Gemfile.lock ./
@@ -78,6 +79,7 @@ FROM base
7879
# Copy built artifacts: gems, application, and the gem subtree
7980
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
8081
COPY --from=build /gem /gem
82+
COPY --from=build /mcp /mcp
8183
COPY --from=build /rails /rails
8284

8385
# Run and own only the runtime files as a non-root user for security

docs/Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,5 @@ gem "tailwind_merge", "~> 1.5.1"
8181
gem "rss", "0.3.2"
8282

8383
gem "rouge", "~> 4.7"
84+
85+
gem "ruby_ui-mcp", path: "../mcp"

docs/Gemfile.lock

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ PATH
1717
specs:
1818
ruby_ui (1.2.0)
1919

20+
PATH
21+
remote: ../mcp
22+
specs:
23+
ruby_ui-mcp (0.1.0)
24+
mcp (>= 0.1)
25+
rack-attack (>= 6.7)
26+
rails (>= 8.0)
27+
reverse_markdown (>= 2.1)
28+
2029
GEM
2130
remote: https://rubygems.org/
2231
specs:
@@ -138,6 +147,9 @@ GEM
138147
jsbundling-rails (1.3.1)
139148
railties (>= 6.0.0)
140149
json (2.19.4)
150+
json-schema (6.2.0)
151+
addressable (~> 2.8)
152+
bigdecimal (>= 3.1, < 5)
141153
language_server-protocol (3.17.0.5)
142154
lint_roller (1.1.0)
143155
logger (1.7.0)
@@ -154,6 +166,8 @@ GEM
154166
net-smtp
155167
marcel (1.1.0)
156168
matrix (0.4.2)
169+
mcp (0.15.0)
170+
json-schema (>= 4.1)
157171
method_source (1.1.0)
158172
mini_mime (1.1.5)
159173
mini_portile2 (2.8.9)
@@ -200,6 +214,8 @@ GEM
200214
nio4r (~> 2.0)
201215
racc (1.8.1)
202216
rack (3.2.6)
217+
rack-attack (6.8.0)
218+
rack (>= 1.0, < 4)
203219
rack-session (2.1.2)
204220
base64 (>= 0.1.0)
205221
rack (>= 3.0.0)
@@ -246,6 +262,8 @@ GEM
246262
regexp_parser (2.11.3)
247263
reline (0.6.3)
248264
io-console (~> 0.5)
265+
reverse_markdown (3.0.2)
266+
nokogiri
249267
rexml (3.4.4)
250268
rouge (4.7.0)
251269
rss (0.3.2)
@@ -344,6 +362,7 @@ DEPENDENCIES
344362
rouge (~> 4.7)
345363
rss (= 0.3.2)
346364
ruby_ui!
365+
ruby_ui-mcp!
347366
selenium-webdriver
348367
sqlite3 (= 2.9.4)
349368
standard

docs/app/components/shared/menu.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def getting_started_links
5252
{name: "Dark mode", path: docs_dark_mode_path},
5353
{name: "Theming", path: docs_theming_path},
5454
{name: "Customizing components", path: docs_customizing_components_path},
55-
{name: "Changelog", path: docs_changelog_path}
55+
{name: "Changelog", path: docs_changelog_path},
56+
{name: "MCP Server", path: docs_mcp_path}
5657
]
5758
end
5859

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ class DocsController < ApplicationController
44
layout -> { Views::Layouts::DocsLayout }
55

66
# GETTING STARTED
7+
def mcp
8+
render Views::Docs::Mcp.new
9+
end
10+
711
def introduction
812
render Views::Docs::GettingStarted::Introduction.new
913
end

docs/app/lib/site_files.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class SiteFiles
6868
description: "Recent RubyUI component and documentation changes.",
6969
priority: 0.6,
7070
changefreq: "weekly"
71+
},
72+
{
73+
title: "MCP Server",
74+
path: "/docs/mcp",
75+
description: "Connect AI agents to Ruby UI components, source, examples, and install commands via the Model Context Protocol.",
76+
priority: 0.8,
77+
changefreq: "monthly"
7178
}
7279
].freeze
7380

docs/app/views/docs/mcp.rb

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# frozen_string_literal: true
2+
3+
class Views::Docs::Mcp < Views::Base
4+
def view_template
5+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
6+
render Docs::Header.new(
7+
title: "MCP Server",
8+
description: "Connect AI agents to Ruby UI components, source, examples, and install commands."
9+
)
10+
11+
# About MCP
12+
div(class: "space-y-4") do
13+
Heading(level: 2) { "About MCP" }
14+
p(class: "text-foreground/80 leading-relaxed") do
15+
plain "MCP (Model Context Protocol) is an open standard for connecting AI assistants to external data sources and tools. "
16+
plain "Ruby UI exposes an MCP server so your AI agent can list available components, view their source files, search the docs, and generate the exact install command for your app."
17+
end
18+
end
19+
20+
# Setup
21+
div(class: "space-y-6") do
22+
Heading(level: 2) { "Setup" }
23+
p(class: "text-foreground/80") { "Add the Ruby UI MCP server to your editor or AI client using the snippets below." }
24+
25+
# Claude Code
26+
div(class: "space-y-2") do
27+
Heading(level: 3) { "Claude Code" }
28+
Codeblock("claude mcp add --transport http ruby-ui https://rubyui.com/mcp", syntax: :shell, clipboard: true)
29+
end
30+
31+
# Cursor
32+
div(class: "space-y-2") do
33+
Heading(level: 3) { "Cursor" }
34+
p(class: "text-sm text-foreground/70") { "Add to .cursor/mcp.json:" }
35+
Codeblock(cursor_config_json, syntax: :json, clipboard: true)
36+
end
37+
38+
# Claude Desktop
39+
div(class: "space-y-2") do
40+
Heading(level: 3) { "Claude Desktop" }
41+
p(class: "text-sm text-foreground/70") { "Add to claude_desktop_config.json:" }
42+
Codeblock(generic_config_json, syntax: :json, clipboard: true)
43+
end
44+
45+
# Windsurf
46+
div(class: "space-y-2") do
47+
Heading(level: 3) { "Windsurf" }
48+
p(class: "text-sm text-foreground/70") { "Add to mcp_config.json:" }
49+
Codeblock(generic_config_json, syntax: :json, clipboard: true)
50+
end
51+
52+
# VS Code
53+
div(class: "space-y-2") do
54+
Heading(level: 3) { "VS Code" }
55+
p(class: "text-sm text-foreground/70") { "Add to .vscode/mcp.json:" }
56+
Codeblock(generic_config_json, syntax: :json, clipboard: true)
57+
end
58+
59+
# Zed
60+
div(class: "space-y-2") do
61+
Heading(level: 3) { "Zed" }
62+
p(class: "text-sm text-foreground/70") { "Add to settings.json:" }
63+
Codeblock(zed_config_json, syntax: :json, clipboard: true)
64+
end
65+
end
66+
67+
# Usage
68+
div(class: "space-y-4") do
69+
Heading(level: 2) { "Usage" }
70+
p(class: "text-foreground/80") { "Once connected, ask your agent questions like:" }
71+
ul(class: "list-disc list-inside space-y-1 text-foreground/80") do
72+
li { "Install Button and Dialog from Ruby UI." }
73+
li { "Show me the source of the Card component." }
74+
li { "Search Ruby UI for a date input." }
75+
li { "Audit my Ruby UI install." }
76+
end
77+
end
78+
79+
# Tools
80+
div(class: "space-y-4") do
81+
Heading(level: 2) { "Tools" }
82+
p(class: "text-foreground/80") { "The MCP server exposes the following tools:" }
83+
div(class: "overflow-x-auto rounded-md border") do
84+
table(class: "w-full text-sm") do
85+
thead(class: "border-b bg-muted/50") do
86+
tr do
87+
th(class: "px-4 py-3 text-left font-medium") { "Tool" }
88+
th(class: "px-4 py-3 text-left font-medium") { "Description" }
89+
end
90+
end
91+
tbody do
92+
tools_list.each_with_index do |(tool, description), i|
93+
tr(class: i.even? ? "" : "bg-muted/30") do
94+
td(class: "px-4 py-3 font-mono text-xs") { tool }
95+
td(class: "px-4 py-3 text-foreground/80") { description }
96+
end
97+
end
98+
end
99+
end
100+
end
101+
end
102+
103+
# Troubleshooting
104+
div(class: "space-y-4") do
105+
Heading(level: 2) { "Troubleshooting" }
106+
ul(class: "list-disc list-inside space-y-2 text-foreground/80") do
107+
li { "Endpoint must be reachable; corporate proxies may block streamable HTTP." }
108+
li { "If the agent can't find components, ask it to call get_project_registries first." }
109+
li { "Run bundle exec rails g ruby_ui:component <Name> only inside a Rails app with ruby_ui in its Gemfile." }
110+
end
111+
end
112+
end
113+
end
114+
115+
private
116+
117+
def cursor_config_json
118+
<<~JSON
119+
{
120+
"mcpServers": {
121+
"ruby-ui": { "url": "https://rubyui.com/mcp" }
122+
}
123+
}
124+
JSON
125+
end
126+
127+
def generic_config_json
128+
cursor_config_json
129+
end
130+
131+
def zed_config_json
132+
<<~JSON
133+
{
134+
"context_servers": {
135+
"ruby-ui": { "source": "http", "url": "https://rubyui.com/mcp" }
136+
}
137+
}
138+
JSON
139+
end
140+
141+
def tools_list
142+
[
143+
["get_project_registries", "Lists available registries."],
144+
["list_items_in_registries", "Returns all components with descriptions."],
145+
["search_items_in_registries", "Fuzzy search by name, description, or docs."],
146+
["view_items_in_registries", "Returns full source files and dependencies."],
147+
["get_item_examples_from_registries", "Returns code examples per component."],
148+
["get_add_command_for_items", "Returns a validated rails g ruby_ui:component … command."],
149+
["get_audit_checklist", "Returns a post-install verification checklist."],
150+
["get_install_command_for_project", "Returns commands to bootstrap ruby_ui in a fresh Rails project."]
151+
]
152+
end
153+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
require "rack/attack"
4+
5+
class Rack::Attack
6+
throttle("mcp/ip", limit: 60, period: 60.seconds) do |req|
7+
req.ip if req.path.start_with?("/mcp")
8+
end
9+
end
10+
11+
Rails.application.config.middleware.use Rack::Attack

docs/config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
Rails.application.routes.draw do
2+
mount RubyUI::MCP::Engine => "/mcp"
3+
24
get "llms.txt", to: "site_files#llms", as: :llms_txt, format: false
35
get "llms-full.txt", to: "site_files#llms_full", as: :llms_full_txt, format: false
46
get "sitemap.xml", to: "site_files#sitemap", as: :sitemap_xml, format: false
@@ -7,6 +9,7 @@
79

810
scope "docs" do
911
# GETTING STARTED
12+
get "mcp", to: "docs#mcp", as: :docs_mcp
1013
get "introduction", to: "docs#introduction", as: :docs_introduction
1114
get "installation", to: "docs#installation", as: :docs_installation
1215
get "theming", to: "docs#theming", as: :docs_theming

0 commit comments

Comments
 (0)