Skip to content
Open
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 .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
9 changes: 9 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,14 @@ def service_worker_cache_name
200
end

require 'mcp/server'

post '/mcp' do
content_type :json
payload = JSON.parse(request.body.read)
Mcp::Server.handle(payload, settings).to_json
Comment on lines +283 to +284
end

%w(docs.json application.js application.css).each do |asset|
class_eval <<-CODE, __FILE__, __LINE__ + 1
get '/#{asset}' do
Expand Down
169 changes: 169 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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
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.',
'inputSchema' => {
'type' => 'object',
'properties' => {
'slug' => { 'type' => 'string' },
'query' => { 'type' => 'string' },
},
'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
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']
case params['name']
Comment on lines +69 to +71
when 'devdocs_list_docsets'
result = list_docsets(app_settings, params['arguments'] || {})
as_text_result(request, result)
when 'devdocs_search'
slug = params['arguments']['slug']
query = params['arguments']['query']
begin
entries = search_docset(app_settings, slug, query)
as_text_result(request, entries)
rescue => err
error(request, -32603, "Search failed: #{err.message}")
end
when 'devdocs_get_page'
slug = params['arguments']['slug']
path = params['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.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_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."
end
db = JSON.parse(File.read(db_path))
Comment on lines +138 to +142
Comment on lines +136 to +142
html = db[path]
Comment on lines +142 to +143
raise "Page not found: #{path}" unless html
Nokogiri::HTML::DocumentFragment.parse(html).text.squeeze(' ').strip
Comment on lines +143 to +145
end

def self.search_docset(app_settings, slug, query)
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."
end
index = JSON.parse(File.read(index_path))
query_lower = query.downcase
index['entries'].select do |entry|
entry['name'].downcase.include?(query_lower) || entry['path'].downcase.include?(query_lower)
end
Comment on lines +155 to +158
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":[]}
184 changes: 184 additions & 0 deletions test/mcp_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
require 'test_helper'
require 'rack/test'
require 'app'

class McpTest < Minitest::Spec
include Rack::Test::Methods

def app
App
end

before do
current_session.env('HTTPS', 'on')
end

def rpc(method, params = nil, id: 1)
body = { jsonrpc: '2.0', id: id, method: method }
body[:params] = params if params
post '/mcp', body.to_json, 'CONTENT_TYPE' => 'application/json'
JSON.parse(last_response.body)
end

describe 'POST /mcp' do
it 'responds to initialize with protocol info' do
result = rpc('initialize')['result']
assert_equal '2024-11-05', result['protocolVersion']
assert result['capabilities'].key?('tools')
end

it 'lists the devdocs tools' do
tools = rpc('tools/list')['result']['tools']
names = tools.map { |t| t['name'] }
assert_includes names, 'devdocs_list_docsets'
assert_includes names, 'devdocs_search'
assert_includes names, 'devdocs_get_page'
end

it 'calls devdocs_list_docsets and returns paginated docsets in condensed format' do
result = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => {} })['result']
response = JSON.parse(result['content'].first['text'])

assert response.key?('docsets')
assert response.key?('offset')
assert response.key?('limit')
assert response.key?('total')
assert response.key?('returned')

docsets = response['docsets']
assert docsets.length > 0
first = docsets.first
assert first.key?('slug')
assert first.key?('name')
assert first.key?('version')
refute first.key?('release_date'), 'should not include release_date'
refute first.key?('mtime'), 'should not include mtime'

slugs = docsets.map { |d| d['slug'] }
assert_includes slugs, 'css'
assert_includes slugs, 'html~5'
end

it 'paginates results with offset and limit' do
result = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'offset' => 0, 'limit' => 2 }
})['result']
response = JSON.parse(result['content'].first['text'])

assert_equal 0, response['offset']
assert_equal 2, response['limit']
assert_equal 2, response['returned']
assert response['total'] > 2
assert_equal 2, response['docsets'].length
end

it 'respects offset to skip results' do
first_page = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'offset' => 0, 'limit' => 2 }
})['result']
first_docsets = JSON.parse(first_page['content'].first['text'])['docsets'].map { |d| d['slug'] }

second_page = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'offset' => 2, 'limit' => 2 }
})['result']
second_docsets = JSON.parse(second_page['content'].first['text'])['docsets'].map { |d| d['slug'] }

assert first_docsets != second_docsets
end

it 'filters docsets by query string' do
result = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'query' => 'css' }
})['result']
response = JSON.parse(result['content'].first['text'])

docsets = response['docsets']
assert docsets.length > 0
assert docsets.all? { |d| d['slug'].downcase.include?('css') || d['name'].downcase.include?('css') }
end

it 'filters case-insensitively' do
result = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'query' => 'CSS' }
})['result']
response = JSON.parse(result['content'].first['text'])

docsets = response['docsets']
assert docsets.length > 0
assert docsets.any? { |d| d['slug'] == 'css' }
end

it 'returns empty docsets for non-matching query' do
result = rpc('tools/call', {
'name' => 'devdocs_list_docsets',
'arguments' => { 'query' => 'nonexistentdocthing' }
})['result']
response = JSON.parse(result['content'].first['text'])

assert_equal 0, response['returned']
assert_equal [], response['docsets']
assert response['total'] == 0
end

it 'calls devdocs_search and returns matching entries for a doc set' do
args = { 'slug' => 'mcp_fixture', 'query' => 'push' }
result = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result']
entries = JSON.parse(result['content'].first['text'])
assert_equal 1, entries.length
assert_equal 'array/push', entries.first['path']
end

it 'calls devdocs_get_page and returns the entry as plain text' do
args = { 'slug' => 'mcp_fixture', 'path' => 'array/push' }
result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result']
text = result['content'].first['text']
assert_includes text, 'Array#push'
assert_includes text, 'Appends & returns the array.'
refute_includes text, '<h1>'
end

it 'returns error for invalid slug in search (path traversal protection)' do
args = { 'slug' => '../../../etc/passwd', 'query' => 'test' }
response = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })
assert response.key?('error'), 'should return an error for invalid slug'
assert_equal(-32603, response['error']['code'])
assert_includes response['error']['message'], 'Invalid docset slug'
end

it 'returns error for invalid slug in get_page (path traversal protection)' do
args = { 'slug' => '..\\windows\\system32', 'path' => '/test' }
response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })
assert response.key?('error'), 'should return an error for invalid slug'
assert_equal(-32603, response['error']['code'])
assert_includes response['error']['message'], 'Invalid docset slug'
end

it 'returns error for missing search index in devdocs_search' do
args = { 'slug' => 'css', 'query' => 'test' }
response = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })
if response.key?('error')
assert_equal(-32603, response['error']['code'])
assert_includes response['error']['message'].downcase, 'search index'
end
end

it 'returns error for missing page database in devdocs_get_page' do
args = { 'slug' => 'css', 'path' => '/test' }
response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })
if response.key?('error')
assert_equal(-32603, response['error']['code'])
assert_includes response['error']['message'].downcase, 'database'
end
end

it 'returns a JSON-RPC error for an unsupported method' do
response = rpc('not/a/real/method')
assert_equal(-32601, response['error']['code'])
end
end
end