diff --git a/lib/app.rb b/lib/app.rb index 3b59b526e1..decc6ee883 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -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 @@ -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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb new file mode 100644 index 0000000000..8159e41e22 --- /dev/null +++ b/lib/mcp/server.rb @@ -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 diff --git a/test/files/docs/mcp_fixture/db.json b/test/files/docs/mcp_fixture/db.json new file mode 100644 index 0000000000..cdac1098b9 --- /dev/null +++ b/test/files/docs/mcp_fixture/db.json @@ -0,0 +1 @@ +{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

"} diff --git a/test/files/docs/mcp_fixture/index.json b/test/files/docs/mcp_fixture/index.json new file mode 100644 index 0000000000..9439baa211 --- /dev/null +++ b/test/files/docs/mcp_fixture/index.json @@ -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":[]} diff --git a/test/mcp_test.rb b/test/mcp_test.rb new file mode 100644 index 0000000000..b8ef1bc4b5 --- /dev/null +++ b/test/mcp_test.rb @@ -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, '

' + 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