Skip to content

Commit 181fd5b

Browse files
committed
Add MCP server endpoint for AI agent doc access
Adds POST /mcp implementing the Model Context Protocol (JSON-RPC 2.0, non-streaming HTTP) so AI coding agents can query a self-hosted DevDocs instance directly instead of scraping the browser UI. Three tools, all reading from the same public/docs tree the server already uses to serve doc content: - devdocs_list_docsets — the configured doc sets (from settings.docs) - devdocs_search — entries in one doc set matching a query (index.json) - devdocs_get_page — one entry's content as plain text, HTML stripped with Nokogiri (already a dependency) (db.json) Relates to #2420.
1 parent 77abbf2 commit 181fd5b

5 files changed

Lines changed: 177 additions & 0 deletions

File tree

‎lib/app.rb‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class App < Sinatra::Application
105105

106106
configure :test do
107107
set :docs_manifest_path, File.join(root, 'test', 'files', 'docs.json')
108+
set :docs_path, File.join(root, 'test', 'files', 'docs')
108109
end
109110

110111
def self.parse_docs
@@ -275,6 +276,14 @@ def service_worker_cache_name
275276
200
276277
end
277278

279+
require 'mcp/server'
280+
281+
post '/mcp' do
282+
content_type :json
283+
payload = JSON.parse(request.body.read)
284+
Mcp::Server.handle(payload, settings).to_json
285+
end
286+
278287
%w(docs.json application.js application.css).each do |asset|
279288
class_eval <<-CODE, __FILE__, __LINE__ + 1
280289
get '/#{asset}' do

‎lib/mcp/server.rb‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
module Mcp
2+
# Dispatches a single JSON-RPC 2.0 request (already parsed into a Hash with
3+
# string keys) to the appropriate MCP handler and returns a response Hash
4+
# ready to be serialized back to the client.
5+
module Server
6+
TOOLS = [
7+
{
8+
'name' => 'devdocs_list_docsets',
9+
'description' => 'List documentation sets available on this DevDocs instance.',
10+
'inputSchema' => { 'type' => 'object', 'properties' => {}, 'additionalProperties' => false },
11+
},
12+
{
13+
'name' => 'devdocs_search',
14+
'description' => 'Search entry names/paths within one downloaded DevDocs doc set.',
15+
'inputSchema' => {
16+
'type' => 'object',
17+
'properties' => {
18+
'slug' => { 'type' => 'string' },
19+
'query' => { 'type' => 'string' },
20+
},
21+
'required' => %w(slug query),
22+
'additionalProperties' => false,
23+
},
24+
},
25+
{
26+
'name' => 'devdocs_get_page',
27+
'description' => 'Fetch one entry from a DevDocs doc set as plain text.',
28+
'inputSchema' => {
29+
'type' => 'object',
30+
'properties' => {
31+
'slug' => { 'type' => 'string' },
32+
'path' => { 'type' => 'string' },
33+
},
34+
'required' => %w(slug path),
35+
'additionalProperties' => false,
36+
},
37+
},
38+
].freeze
39+
40+
def self.handle(request, app_settings)
41+
case request['method']
42+
when 'initialize'
43+
respond(request, {
44+
'protocolVersion' => '2024-11-05',
45+
'capabilities' => { 'tools' => {} },
46+
'serverInfo' => { 'name' => 'devdocs-mcp', 'version' => '1.0.0' },
47+
})
48+
when 'tools/list'
49+
respond(request, { 'tools' => TOOLS })
50+
when 'tools/call'
51+
call_tool(request, app_settings)
52+
else
53+
error(request, -32601, "Unsupported method: #{request['method']}")
54+
end
55+
end
56+
57+
def self.error(request, code, message)
58+
{ 'jsonrpc' => '2.0', 'id' => request['id'], 'error' => { 'code' => code, 'message' => message } }
59+
end
60+
61+
def self.call_tool(request, app_settings)
62+
params = request['params']
63+
case params['name']
64+
when 'devdocs_list_docsets'
65+
docsets = app_settings.docs.values
66+
as_text_result(request, docsets)
67+
when 'devdocs_search'
68+
entries = search_docset(app_settings, params['arguments']['slug'], params['arguments']['query'])
69+
as_text_result(request, entries)
70+
when 'devdocs_get_page'
71+
text = get_page(app_settings, params['arguments']['slug'], params['arguments']['path'])
72+
respond(request, { 'content' => [{ 'type' => 'text', 'text' => text }] })
73+
end
74+
end
75+
76+
def self.get_page(app_settings, slug, path)
77+
db_path = File.join(app_settings.docs_path, slug, 'db.json')
78+
db = JSON.parse(File.read(db_path))
79+
html = db[path]
80+
Nokogiri::HTML::DocumentFragment.parse(html).text.squeeze(' ').strip
81+
end
82+
83+
def self.search_docset(app_settings, slug, query)
84+
index_path = File.join(app_settings.docs_path, slug, 'index.json')
85+
index = JSON.parse(File.read(index_path))
86+
q = query.downcase
87+
index['entries'].select { |e| e['name'].downcase.include?(q) || e['path'].downcase.include?(q) }
88+
end
89+
90+
def self.as_text_result(request, data)
91+
respond(request, { 'content' => [{ 'type' => 'text', 'text' => data.to_json }] })
92+
end
93+
94+
def self.respond(request, result)
95+
{ 'jsonrpc' => '2.0', 'id' => request['id'], 'result' => result }
96+
end
97+
end
98+
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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>"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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":[]}

‎test/mcp_test.rb‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
require 'test_helper'
2+
require 'rack/test'
3+
require 'app'
4+
5+
class McpTest < Minitest::Spec
6+
include Rack::Test::Methods
7+
8+
def app
9+
App
10+
end
11+
12+
before do
13+
current_session.env('HTTPS', 'on')
14+
end
15+
16+
def rpc(method, params = nil, id: 1)
17+
body = { jsonrpc: '2.0', id: id, method: method }
18+
body[:params] = params if params
19+
post '/mcp', body.to_json, 'CONTENT_TYPE' => 'application/json'
20+
JSON.parse(last_response.body)
21+
end
22+
23+
describe 'POST /mcp' do
24+
it 'responds to initialize with protocol info' do
25+
result = rpc('initialize')['result']
26+
assert_equal '2024-11-05', result['protocolVersion']
27+
assert result['capabilities'].key?('tools')
28+
end
29+
30+
it 'lists the devdocs tools' do
31+
tools = rpc('tools/list')['result']['tools']
32+
names = tools.map { |t| t['name'] }
33+
assert_includes names, 'devdocs_list_docsets'
34+
assert_includes names, 'devdocs_search'
35+
assert_includes names, 'devdocs_get_page'
36+
end
37+
38+
it 'calls devdocs_list_docsets and returns the configured doc sets' do
39+
result = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => {} })['result']
40+
docsets = JSON.parse(result['content'].first['text'])
41+
slugs = docsets.map { |d| d['slug'] }
42+
assert_includes slugs, 'css'
43+
assert_includes slugs, 'html~5'
44+
end
45+
46+
it 'calls devdocs_search and returns matching entries for a doc set' do
47+
args = { 'slug' => 'mcp_fixture', 'query' => 'push' }
48+
result = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result']
49+
entries = JSON.parse(result['content'].first['text'])
50+
assert_equal 1, entries.length
51+
assert_equal 'array/push', entries.first['path']
52+
end
53+
54+
it 'calls devdocs_get_page and returns the entry as plain text' do
55+
args = { 'slug' => 'mcp_fixture', 'path' => 'array/push' }
56+
result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result']
57+
text = result['content'].first['text']
58+
assert_includes text, 'Array#push'
59+
assert_includes text, 'Appends & returns the array.'
60+
refute_includes text, '<h1>'
61+
end
62+
63+
it 'returns a JSON-RPC error for an unsupported method' do
64+
response = rpc('not/a/real/method')
65+
assert_equal(-32601, response['error']['code'])
66+
end
67+
end
68+
end

0 commit comments

Comments
 (0)