From 181fd5b7b36f2540b9bf049d04d37f6d83cd4ee8 Mon Sep 17 00:00:00 2001 From: joebutler2 Date: Thu, 3 Sep 2026 14:47:37 -0500 Subject: [PATCH 1/3] Add MCP server endpoint for AI agent doc access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 freeCodeCamp/devdocs#2420. --- lib/app.rb | 9 +++ lib/mcp/server.rb | 98 ++++++++++++++++++++++++++ test/files/docs/mcp_fixture/db.json | 1 + test/files/docs/mcp_fixture/index.json | 1 + test/mcp_test.rb | 68 ++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 lib/mcp/server.rb create mode 100644 test/files/docs/mcp_fixture/db.json create mode 100644 test/files/docs/mcp_fixture/index.json create mode 100644 test/mcp_test.rb 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 From 37090e89e635bb0cdcffa390e6ae65fc60c3b523 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:29:48 -0500 Subject: [PATCH 2/3] Optimize devdocs_list_docsets MCP tool for context efficiency - Add pagination support (offset/limit) to reduce response size - Return condensed format (slug, name, version only) instead of full metadata - Add query parameter for filtering by slug or name (case-insensitive) - Include pagination metadata (offset, limit, total, returned) in responses - Add comprehensive unit tests for all new features (7 new test cases) All 11 MCP tests passing with 45 assertions. Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 1 + lib/mcp/server.rb | 55 ++++++++++++++++++++++++++---- test/mcp_test.rb | 86 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 134 insertions(+), 8 deletions(-) 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/mcp/server.rb b/lib/mcp/server.rb index 8159e41e22..1eba545865 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -6,8 +6,16 @@ module Server TOOLS = [ { 'name' => 'devdocs_list_docsets', - 'description' => 'List documentation sets available on this DevDocs instance.', - 'inputSchema' => { 'type' => 'object', 'properties' => {}, 'additionalProperties' => false }, + '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', @@ -62,8 +70,8 @@ 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) + result = list_docsets(app_settings, params['arguments'] || {}) + as_text_result(request, result) when 'devdocs_search' entries = search_docset(app_settings, params['arguments']['slug'], params['arguments']['query']) as_text_result(request, entries) @@ -73,6 +81,39 @@ def self.call_tool(request, app_settings) 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.get_page(app_settings, slug, path) db_path = File.join(app_settings.docs_path, slug, 'db.json') db = JSON.parse(File.read(db_path)) @@ -83,8 +124,10 @@ def self.get_page(app_settings, slug, path) 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) } + query_lower = query.downcase + index['entries'].select do |entry| + entry['name'].downcase.include?(query_lower) || entry['path'].downcase.include?(query_lower) + end end def self.as_text_result(request, data) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index b8ef1bc4b5..754bce5bd8 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -35,14 +35,96 @@ def rpc(method, params = nil, id: 1) assert_includes names, 'devdocs_get_page' end - it 'calls devdocs_list_docsets and returns the configured doc sets' do + it 'calls devdocs_list_docsets and returns paginated docsets in condensed format' do result = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => {} })['result'] - docsets = JSON.parse(result['content'].first['text']) + 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'] From b4067a0c601a2026d90180c6a965523bad601620 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:15:54 -0500 Subject: [PATCH 3/3] Add slug validation and error handling for production deployment - Validate docset slugs against configured docs to prevent path traversal attacks - Handle missing index.json and db.json files gracefully in production - Return descriptive JSON-RPC errors when files are unavailable - Add tests for path traversal protection and missing file handling - Add mcp_fixture to test docs manifest for proper test coverage This addresses GitHub review concerns about: 1. Security: Path traversal vulnerability (slug with ..) 2. Production: Missing index.json and db.json in hosted deployments Co-Authored-By: Claude Haiku 4.5 --- lib/mcp/server.rb | 36 ++++++++++++++++++++++++++++++++---- test/files/docs.json | 2 +- test/mcp_test.rb | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 1eba545865..4bb9e71eb7 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -73,11 +73,23 @@ def self.call_tool(request, app_settings) result = list_docsets(app_settings, params['arguments'] || {}) as_text_result(request, result) when 'devdocs_search' - entries = search_docset(app_settings, params['arguments']['slug'], params['arguments']['query']) - as_text_result(request, entries) + 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' - text = get_page(app_settings, params['arguments']['slug'], params['arguments']['path']) - respond(request, { 'content' => [{ 'type' => 'text', 'text' => text }] }) + 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 @@ -114,15 +126,31 @@ def self.list_docsets(app_settings, args) } 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)) html = db[path] + raise "Page not found: #{path}" unless html Nokogiri::HTML::DocumentFragment.parse(html).text.squeeze(' ').strip 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| 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/mcp_test.rb b/test/mcp_test.rb index 754bce5bd8..9308cc3150 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -142,6 +142,40 @@ def rpc(method, params = nil, id: 1) refute_includes text, '

' 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'])