Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ docs/**/*
*.zip
assets/stylesheets/components/_environment.scss
assets/stylesheets/global/_icons.scss
.mcp.json
20 changes: 20 additions & 0 deletions lib/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class App < Sinatra::Application

configure :test do
set :docs_manifest_path, File.join(root, 'test', 'files', 'docs.json')
set :docs_path, File.join(root, 'test', 'files', 'docs')
end

def self.parse_docs
Expand Down Expand Up @@ -275,6 +276,25 @@ def service_worker_cache_name
200
end

require 'mcp/server'

post '/mcp' do
content_type :json
begin
body = request.body.read
payload = JSON.parse(body)
Comment on lines +281 to +285
Mcp::Server.handle(payload, settings).to_json
rescue JSON::ParserError => err
error_response(nil, -32700, "Parse error: #{err.message}").to_json
rescue => err
error_response(nil, -32603, "Internal error: #{err.message}").to_json
end
end

def error_response(id, code, message)
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => code, 'message' => message } }
end

%w(docs.json application.js application.css).each do |asset|
class_eval <<-CODE, __FILE__, __LINE__ + 1
get '/#{asset}' do
Expand Down
276 changes: 276 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
module Mcp
# Dispatches a single JSON-RPC 2.0 request (already parsed into a Hash with
# string keys) to the appropriate MCP handler and returns a response Hash
# ready to be serialized back to the client.
module Server
DB_CACHE = {}
TOOLS = [
{
'name' => 'devdocs_list_docsets',
'description' => 'List documentation sets available on this DevDocs instance. Returns paginated results with optional filtering.',
'inputSchema' => {
'type' => 'object',
'properties' => {
'offset' => { 'type' => 'integer', 'description' => 'Number of results to skip (default: 0)', 'minimum' => 0 },
'limit' => { 'type' => 'integer', 'description' => 'Maximum results to return (default: 50, max: 500)', 'minimum' => 1, 'maximum' => 500 },
'query' => { 'type' => 'string', 'description' => 'Filter by slug or name (case-insensitive substring match)' },
},
'additionalProperties' => false,
},
},
{
'name' => 'devdocs_search',
'description' => 'Search entry names/paths within one downloaded DevDocs doc set. Returns paginated results.',
'inputSchema' => {
'type' => 'object',
'properties' => {
'slug' => { 'type' => 'string' },
'query' => { 'type' => 'string', 'description' => 'Non-empty search query' },
'offset' => { 'type' => 'integer', 'description' => 'Number of results to skip (default: 0)', 'minimum' => 0 },
'limit' => { 'type' => 'integer', 'description' => 'Maximum results to return (default: 50, max: 500)', 'minimum' => 1, 'maximum' => 500 },
},
'required' => %w(slug query),
'additionalProperties' => false,
},
},
{
'name' => 'devdocs_get_page',
'description' => 'Fetch one entry from a DevDocs doc set as plain text.',
'inputSchema' => {
'type' => 'object',
'properties' => {
'slug' => { 'type' => 'string' },
'path' => { 'type' => 'string' },
},
'required' => %w(slug path),
'additionalProperties' => false,
},
},
].freeze

def self.handle(request, app_settings)
case request['method']
when 'initialize'
respond(request, {
'protocolVersion' => '2024-11-05',
'capabilities' => { 'tools' => {} },
'serverInfo' => { 'name' => 'devdocs-mcp', 'version' => '1.0.0' },
})
when 'tools/list'
respond(request, { 'tools' => TOOLS })
when 'tools/call'
call_tool(request, app_settings)
else
error(request, -32601, "Unsupported method: #{request['method']}")
end
rescue => err
error(request, -32603, "Internal error: #{err.message}")
end

def self.error(request, code, message)
{ 'jsonrpc' => '2.0', 'id' => request['id'], 'error' => { 'code' => code, 'message' => message } }
end

def self.call_tool(request, app_settings)
params = request['params']
tool_name = params['name']
arguments = params['arguments'] || {}
Comment on lines +75 to +77

tool_def = TOOLS.find { |t| t['name'] == tool_name }
unless tool_def
return error(request, -32602, "Unknown tool: #{tool_name}")
end

validation_error = validate_arguments(arguments, tool_def['inputSchema'])
if validation_error
return error(request, -32602, validation_error)
end

case tool_name
when 'devdocs_list_docsets'
result = list_docsets(app_settings, arguments)
as_text_result(request, result)
when 'devdocs_search'
slug = arguments['slug']
query = arguments['query']
begin
result = search_docset(app_settings, slug, query, arguments)
as_text_result(request, result)
rescue => err
error(request, -32603, "Search failed: #{err.message}")
end
when 'devdocs_get_page'
slug = arguments['slug']
path = arguments['path']
begin
text = get_page(app_settings, slug, path)
respond(request, { 'content' => [{ 'type' => 'text', 'text' => text }] })
rescue => err
error(request, -32603, "Page retrieval failed: #{err.message}")
end
end
end

def self.validate_arguments(arguments, schema)
required = schema['required'] || []
properties = schema['properties'] || {}

required.each do |field|
return "Missing required field: #{field}" unless arguments.key?(field)
end

arguments.each do |field, value|
return "Unknown field: #{field}" unless properties.key?(field)
prop_schema = properties[field]
error_msg = validate_value(value, prop_schema)
return error_msg if error_msg
end

return "Additional properties not allowed" if schema['additionalProperties'] == false && arguments.keys.any? { |k| !properties.key?(k) }

nil
end

def self.validate_value(value, schema)
type = schema['type']

case type
when 'string'
return "Expected string, got #{value.class}" unless value.is_a?(String)
when 'integer'
return "Expected integer, got #{value.class}" unless value.is_a?(Integer)
when 'number'
return "Expected number, got #{value.class}" unless value.is_a?(Numeric)
end

if schema['minimum'] && value < schema['minimum']
return "Value #{value} is below minimum #{schema['minimum']}"
end
if schema['maximum'] && value > schema['maximum']
return "Value #{value} exceeds maximum #{schema['maximum']}"
end

nil
end

def self.list_docsets(app_settings, args)
offset = (args['offset'] || 0).to_i
limit = [(args['limit'] || 50).to_i, 500].min
query = args['query']&.downcase

all_docsets = app_settings.docs.values.map do |docset|
{
'slug' => docset['slug'],
'name' => docset['name'],
'version' => docset['version'],
}
end

filtered = if query
all_docsets.select do |docset|
docset['slug'].downcase.include?(query) || docset['name'].downcase.include?(query)
end
else
all_docsets
end

total_count = filtered.length
paginated = filtered.drop(offset).take(limit)

{
'docsets' => paginated,
'offset' => offset,
'limit' => limit,
'total' => total_count,
'returned' => paginated.length,
}
end

def self.validate_slug(app_settings, slug)
unless app_settings.docs.key?(slug)
raise ArgumentError, "Invalid docset slug: #{slug}"
end
slug
end

def self.get_page(app_settings, slug, path)
validate_slug(app_settings, slug)
db = load_db(app_settings, slug)
html = db[path]
raise "Page not found: #{path}" unless html
html_to_text(html)
end

def self.load_db(app_settings, slug)
cache_key = "#{app_settings.docs_path}:#{slug}"
return DB_CACHE[cache_key] if DB_CACHE.key?(cache_key)
Comment on lines +205 to +206

db_path = File.join(app_settings.docs_path, slug, 'db.json')
unless File.exist?(db_path)
raise "Page database not available for #{slug}. Full content is served from the CDN."
Comment on lines +208 to +210
end

DB_CACHE[cache_key] = JSON.parse(File.read(db_path))
DB_CACHE[cache_key]
end

def self.html_to_text(html)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
text_parts = []

doc.traverse do |node|
if node.text?
text_parts << node.text
elsif block_element?(node.name)
text_parts << "\n" if text_parts.last != "\n"
end
end

text_parts.join.squeeze(' ').gsub(/\n\s*\n/, "\n").strip
end

def self.block_element?(tag_name)
return false unless tag_name
%w(p div h1 h2 h3 h4 h5 h6 ul ol li blockquote pre br).include?(tag_name.downcase)
end

def self.search_docset(app_settings, slug, query, args = {})
raise "Query cannot be empty" if query.to_s.strip.empty?

validate_slug(app_settings, slug)
index_path = File.join(app_settings.docs_path, slug, 'index.json')
unless File.exist?(index_path)
raise "Search index not available for #{slug}. The search index is served from the CDN."
Comment on lines +241 to +243
end

offset = (args['offset'] || 0).to_i
limit = [(args['limit'] || 50).to_i, 500].min

index = JSON.parse(File.read(index_path))
query_lower = query.downcase

all_matches = index['entries'].select do |entry|
entry['name'].downcase.include?(query_lower) || entry['path'].downcase.include?(query_lower)
end
Comment thread
Copilot marked this conversation as resolved.

total_count = all_matches.length
paginated = all_matches.drop(offset).take(limit)

{
'entries' => paginated,
'offset' => offset,
'limit' => limit,
'total' => total_count,
'returned' => paginated.length,
}
end

def self.as_text_result(request, data)
respond(request, { 'content' => [{ 'type' => 'text', 'text' => data.to_json }] })
end

def self.respond(request, result)
{ 'jsonrpc' => '2.0', 'id' => request['id'], 'result' => result }
end
end
end
2 changes: 1 addition & 1 deletion test/files/docs.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"name":"CSS","slug":"css","type":"mdn","release":null,"mtime":1420139788,"db_size":3460507,"alias":null},{"name":"DOM","slug":"dom","type":"mdn","release":null,"mtime":1420139789,"db_size":11399128,"alias":null},{"name":"DOM Events","slug":"dom_events","type":"mdn","release":null,"mtime":1420139790,"db_size":889020,"alias":null},{"name":"HTML","slug":"html~5","type":"mdn","version":"5","mtime":1420139791,"db_size":1835647,"alias":null},{"name":"HTML","slug":"html~4","type":"mdn","version":"4","mtime":1420139790,"db_size":1835646,"alias":null},{"name":"HTTP","slug":"http","type":"rfc","release":null,"mtime":1420139790,"db_size":183083,"alias":null},{"name":"JavaScript","slug":"javascript","type":"mdn","release":null,"mtime":1420139791,"db_size":4125477,"alias":"js"}]
[{"name":"CSS","slug":"css","type":"mdn","release":null,"mtime":1420139788,"db_size":3460507,"alias":null},{"name":"DOM","slug":"dom","type":"mdn","release":null,"mtime":1420139789,"db_size":11399128,"alias":null},{"name":"DOM Events","slug":"dom_events","type":"mdn","release":null,"mtime":1420139790,"db_size":889020,"alias":null},{"name":"HTML","slug":"html~5","type":"mdn","version":"5","mtime":1420139791,"db_size":1835647,"alias":null},{"name":"HTML","slug":"html~4","type":"mdn","version":"4","mtime":1420139790,"db_size":1835646,"alias":null},{"name":"HTTP","slug":"http","type":"rfc","release":null,"mtime":1420139790,"db_size":183083,"alias":null},{"name":"JavaScript","slug":"javascript","type":"mdn","release":null,"mtime":1420139791,"db_size":4125477,"alias":"js"},{"name":"MCP Fixture","slug":"mcp_fixture","type":"test","release":null,"mtime":1420139791,"db_size":1024,"alias":null}]
1 change: 1 addition & 0 deletions test/files/docs/mcp_fixture/db.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"array/push":"<h1>Array#push</h1> <p>Appends &amp; returns the array.</p>","array/pop":"<h1>Array#pop</h1> <p>Removes the last element.</p>"}
1 change: 1 addition & 0 deletions test/files/docs/mcp_fixture/index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"entries":[{"name":"Array#push","path":"array/push","type":"Array"},{"name":"Array#pop","path":"array/pop","type":"Array"},{"name":"String#upcase","path":"string/upcase","type":"String"}],"types":[]}
Loading