diff --git a/.gitignore b/.gitignore index aac9f85ba5..512f2478c0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/**/* *.zip assets/stylesheets/components/_environment.scss assets/stylesheets/global/_icons.scss +.mcp.json diff --git a/lib/app.rb b/lib/app.rb index 3b59b526e1..3874ae2a09 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,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) + 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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb new file mode 100644 index 0000000000..6d887e7678 --- /dev/null +++ b/lib/mcp/server.rb @@ -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'] || {} + + 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) + + 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_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." + 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 + + 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 diff --git a/test/files/docs.json b/test/files/docs.json index 7f795c4356..7bad70a576 100644 --- a/test/files/docs.json +++ b/test/files/docs.json @@ -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}] 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":"
Appends & returns the array.
","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..b5c86535c9 --- /dev/null +++ b/test/mcp_test.rb @@ -0,0 +1,245 @@ +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 paginated matching entries' do + args = { 'slug' => 'mcp_fixture', 'query' => 'push' } + result = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result'] + response = JSON.parse(result['content'].first['text']) + + assert response.key?('entries') + assert response.key?('offset') + assert response.key?('limit') + assert response.key?('total') + assert response.key?('returned') + + entries = response['entries'] + assert_equal 1, entries.length + assert_equal 'array/push', entries.first['path'] + end + + it 'returns error for empty search query' do + args = { 'slug' => 'mcp_fixture', 'query' => '' } + response = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args }) + assert response.key?('error') + assert_equal(-32603, response['error']['code']) + assert_includes response['error']['message'].downcase, 'empty' + end + + it 'paginates search results with offset and limit' do + result = rpc('tools/call', { + 'name' => 'devdocs_search', + 'arguments' => { 'slug' => 'mcp_fixture', 'query' => 'a', 'offset' => 0, 'limit' => 1 } + })['result'] + response = JSON.parse(result['content'].first['text']) + + assert_equal 0, response['offset'] + assert_equal 1, response['limit'] + assert response['total'] > 0 + assert_equal 1, response['returned'] + 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, '