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
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
end

%w(docs.json application.js application.css).each do |asset|
class_eval <<-CODE, __FILE__, __LINE__ + 1
get '/#{asset}' do
Expand Down
98 changes: 98 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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.',
'inputSchema' => { 'type' => 'object', 'properties' => {}, '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']
when 'devdocs_list_docsets'
docsets = app_settings.docs.values
as_text_result(request, docsets)
when 'devdocs_search'
entries = search_docset(app_settings, params['arguments']['slug'], params['arguments']['query'])
as_text_result(request, entries)
when 'devdocs_get_page'
text = get_page(app_settings, params['arguments']['slug'], params['arguments']['path'])
respond(request, { 'content' => [{ 'type' => 'text', 'text' => text }] })
end
end

def self.get_page(app_settings, slug, path)
db_path = File.join(app_settings.docs_path, slug, 'db.json')
db = JSON.parse(File.read(db_path))
html = db[path]
Nokogiri::HTML::DocumentFragment.parse(html).text.squeeze(' ').strip
end

def self.search_docset(app_settings, slug, query)
index_path = File.join(app_settings.docs_path, slug, 'index.json')
index = JSON.parse(File.read(index_path))
q = query.downcase
index['entries'].select { |e| e['name'].downcase.include?(q) || e['path'].downcase.include?(q) }
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
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":[]}
68 changes: 68 additions & 0 deletions test/mcp_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 the configured doc sets' do
result = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => {} })['result']
docsets = JSON.parse(result['content'].first['text'])
slugs = docsets.map { |d| d['slug'] }
assert_includes slugs, 'css'
assert_includes slugs, 'html~5'
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 a JSON-RPC error for an unsupported method' do
response = rpc('not/a/real/method')
assert_equal(-32601, response['error']['code'])
end
end
end