From 181fd5b7b36f2540b9bf049d04d37f6d83cd4ee8 Mon Sep 17 00:00:00 2001 From: joebutler2 Date: Thu, 3 Sep 2026 14:47:37 -0500 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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']) From fb18b3a7ab04f5020102d32c8f8a9cf9d2352691 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:06:37 -0500 Subject: [PATCH 04/22] Fix error handling and unknown tool handling - Add error boundary in /mcp endpoint to catch JSON parse errors (-32700) - Return JSON-RPC error instead of HTML 500 for parse failures - Handle unknown tool names with -32602 error instead of returning nil - Add generic error handler in Mcp::Server.handle for unexpected exceptions - Fixes issues where malformed requests would return HTML error pages Co-Authored-By: Claude Haiku 4.5 --- lib/app.rb | 15 +++++++++++++-- lib/mcp/server.rb | 8 +++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/app.rb b/lib/app.rb index decc6ee883..3874ae2a09 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -280,8 +280,19 @@ def service_worker_cache_name post '/mcp' do content_type :json - payload = JSON.parse(request.body.read) - Mcp::Server.handle(payload, settings).to_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| diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 4bb9e71eb7..816d15fc45 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -60,6 +60,8 @@ def self.handle(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) @@ -68,7 +70,9 @@ def self.error(request, code, message) def self.call_tool(request, app_settings) params = request['params'] - case params['name'] + tool_name = params['name'] + + case tool_name when 'devdocs_list_docsets' result = list_docsets(app_settings, params['arguments'] || {}) as_text_result(request, result) @@ -90,6 +94,8 @@ def self.call_tool(request, app_settings) rescue => err error(request, -32603, "Page retrieval failed: #{err.message}") end + else + error(request, -32602, "Unknown tool: #{tool_name}") end end From 4031504ca701e372d03c1e0bdcc64f7e37df76e2 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:07:02 -0500 Subject: [PATCH 05/22] Add input validation against declared tool schemas - Validate all arguments against tool inputSchema before dispatch - Check required fields are present - Validate field types (string, integer, number) - Enforce min/max constraints on numeric values - Return -32602 (invalid request) for validation failures - Add comprehensive tests for missing/invalid arguments and type mismatches Fixes comment about params being dereferenced without validation. Co-Authored-By: Claude Haiku 4.5 --- lib/mcp/server.rb | 65 ++++++++++++++++++++++++++++++++++++++++++----- test/mcp_test.rb | 24 +++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 816d15fc45..96b8ad66ac 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -71,14 +71,25 @@ def self.error(request, code, message) 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, params['arguments'] || {}) + result = list_docsets(app_settings, arguments) as_text_result(request, result) when 'devdocs_search' - slug = params['arguments']['slug'] - query = params['arguments']['query'] + slug = arguments['slug'] + query = arguments['query'] begin entries = search_docset(app_settings, slug, query) as_text_result(request, entries) @@ -86,19 +97,59 @@ def self.call_tool(request, app_settings) error(request, -32603, "Search failed: #{err.message}") end when 'devdocs_get_page' - slug = params['arguments']['slug'] - path = params['arguments']['path'] + 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 - else - error(request, -32602, "Unknown tool: #{tool_name}") 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 diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 9308cc3150..f9d57171d0 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -176,6 +176,30 @@ def rpc(method, params = nil, id: 1) end end + it 'returns error for missing required arguments' do + args = { 'slug' => 'mcp_fixture' } + response = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args }) + assert response.key?('error') + assert_equal(-32602, response['error']['code']) + assert_includes response['error']['message'], 'query' + end + + it 'returns error for invalid argument types' do + args = { 'slug' => 'mcp_fixture', 'query' => 123 } + response = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args }) + assert response.key?('error') + assert_equal(-32602, response['error']['code']) + assert_includes response['error']['message'].downcase, 'string' + end + + it 'returns error for invalid parameter values' do + args = { 'offset' => 0, 'limit' => 1000 } + response = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => args }) + assert response.key?('error') + assert_equal(-32602, response['error']['code']) + assert_includes response['error']['message'], 'exceeds maximum' + end + it 'returns a JSON-RPC error for an unsupported method' do response = rpc('not/a/real/method') assert_equal(-32601, response['error']['code']) From 73cd5edb16df139878efdce5262d12ebbc471ae1 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:07:17 -0500 Subject: [PATCH 06/22] Fix HTML text extraction to preserve block element separators - Replace simple Node#text concatenation with proper block element handling - Add line breaks before/after block elements (p, div, h1-h6, ul, ol, li, blockquote, pre, br) - Preserve meaningful whitespace while removing excess blank lines - Prevents adjacent block elements from concatenating without separation Fixes issue where

Title

Body

would become TitleBody. Co-Authored-By: Claude Haiku 4.5 --- lib/mcp/server.rb | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 96b8ad66ac..c3e9f9e6b5 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -199,7 +199,27 @@ def self.get_page(app_settings, slug, path) 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 + html_to_text(html) + 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) From 4b313eec967abb6d50e0568570fa59ec2e656bdc Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:07:48 -0500 Subject: [PATCH 07/22] Add pagination and validation to devdocs_search tool - Add offset and limit parameters to search (same as list_docsets) - Validate that query is non-empty to prevent matching everything - Return paginated results with metadata (offset, limit, total, returned) - Prevents large result sets from exceeding agent context limits - Add tests for pagination and empty query validation Fixes issue where large docsets could generate oversized responses. Co-Authored-By: Claude Haiku 4.5 --- lib/mcp/server.rb | 32 ++++++++++++++++++++++++++------ test/mcp_test.rb | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c3e9f9e6b5..e47e7ae075 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -19,12 +19,14 @@ module Server }, { 'name' => 'devdocs_search', - 'description' => 'Search entry names/paths within one downloaded DevDocs doc set.', + 'description' => 'Search entry names/paths within one downloaded DevDocs doc set. Returns paginated results.', 'inputSchema' => { 'type' => 'object', 'properties' => { 'slug' => { 'type' => 'string' }, - 'query' => { '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, @@ -91,8 +93,8 @@ def self.call_tool(request, app_settings) slug = arguments['slug'] query = arguments['query'] begin - entries = search_docset(app_settings, slug, query) - as_text_result(request, entries) + result = search_docset(app_settings, slug, query, arguments) + as_text_result(request, result) rescue => err error(request, -32603, "Search failed: #{err.message}") end @@ -222,17 +224,35 @@ def self.block_element?(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) + 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 - index['entries'].select do |entry| + + 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) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index f9d57171d0..092d211ddc 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -125,14 +125,43 @@ def rpc(method, params = nil, id: 1) assert response['total'] == 0 end - it 'calls devdocs_search and returns matching entries for a doc set' do + 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'] - entries = JSON.parse(result['content'].first['text']) + 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'] From a8aa4eaa081dc63e33c3e5787229e293aac1ae93 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:08:06 -0500 Subject: [PATCH 08/22] Optimize db.json parsing with in-memory caching - Add DB_CACHE to cache parsed db.json files in memory - Load database once per docset and reuse across requests - Prevents reparsing multi-megabyte files on every page lookup - Reduces memory allocations and CPU overhead on concurrent calls - Cache key includes docs_path to handle test/production separation Fixes performance issue with repeated full-database parsing. Co-Authored-By: Claude Haiku 4.5 --- lib/mcp/server.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e47e7ae075..6d887e7678 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -3,6 +3,7 @@ module Mcp # 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', @@ -194,14 +195,23 @@ def self.validate_slug(app_settings, slug) 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 = JSON.parse(File.read(db_path)) - html = db[path] - raise "Page not found: #{path}" unless html - html_to_text(html) + + DB_CACHE[cache_key] = JSON.parse(File.read(db_path)) + DB_CACHE[cache_key] end def self.html_to_text(html) From c5969a1062fba58e09a348189e1a4bfdb64f8483 Mon Sep 17 00:00:00 2001 From: joebutler2 <6955350+joebutler2@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:08:28 -0500 Subject: [PATCH 09/22] Add test for JSON parse error handling - Verify malformed JSON returns -32700 parse error - Ensure /mcp endpoint returns JSON-RPC error instead of HTML 500 Co-Authored-By: Claude Haiku 4.5 --- test/mcp_test.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 092d211ddc..b5c86535c9 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -229,6 +229,14 @@ def rpc(method, params = nil, id: 1) assert_includes response['error']['message'], 'exceeds maximum' end + it 'returns JSON-RPC error for malformed JSON' do + post '/mcp', '{invalid json}', 'CONTENT_TYPE' => 'application/json' + response = JSON.parse(last_response.body) + assert response.key?('error') + assert_equal(-32700, response['error']['code']) + assert_includes response['error']['message'].downcase, 'parse' + end + it 'returns a JSON-RPC error for an unsupported method' do response = rpc('not/a/real/method') assert_equal(-32601, response['error']['code']) From ebe94dc2863f44b8e0e680323df3f38c622f83f1 Mon Sep 17 00:00:00 2001 From: Joe Butler <6955350+joebutler2@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:45:48 -0500 Subject: [PATCH 10/22] Refactor error handling in mcp_test.rb Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Joe Butler <6955350+joebutler2@users.noreply.github.com> --- test/mcp_test.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index b5c86535c9..f85d148b06 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -199,10 +199,8 @@ def rpc(method, params = nil, id: 1) 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 + assert_equal(-32603, response['error']['code']) + assert_includes response['error']['message'].downcase, 'database' end it 'returns error for missing required arguments' do From 3a18100efa15ff6313ff0829736baf2289c10e7a Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:07:49 +0200 Subject: [PATCH 11/22] Strip the fragment when looking up a page in db.json Entry paths carry a #fragment for entries that share a page (377 of 508 bash entries), but db.json is keyed by the page path alone, so the search -> get_page workflow failed with "Page not found". --- lib/mcp/server.rb | 4 +++- test/files/docs/mcp_fixture/index.json | 2 +- test/mcp_test.rb | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 6d887e7678..cfa95e3947 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -196,7 +196,9 @@ def self.validate_slug(app_settings, slug) def self.get_page(app_settings, slug, path) validate_slug(app_settings, slug) db = load_db(app_settings, slug) - html = db[path] + # Entries that share a page carry a #fragment, but db.json is keyed by the + # page path alone (mirrors Entry#dbPath in the client). + html = db[path.sub(/#.*/, '')] raise "Page not found: #{path}" unless html html_to_text(html) end diff --git a/test/files/docs/mcp_fixture/index.json b/test/files/docs/mcp_fixture/index.json index 9439baa211..bc5f6d7ed3 100644 --- a/test/files/docs/mcp_fixture/index.json +++ b/test/files/docs/mcp_fixture/index.json @@ -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":[]} +{"entries":[{"name":"Array#push","path":"array/push","type":"Array"},{"name":"Array#pop","path":"array/pop","type":"Array"},{"name":"Array#shift","path":"array/pop#shift","type":"Array"},{"name":"String#upcase","path":"string/upcase","type":"String"}],"types":[]} diff --git a/test/mcp_test.rb b/test/mcp_test.rb index f85d148b06..99520111b9 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -171,6 +171,12 @@ def rpc(method, params = nil, id: 1) refute_includes text, '

' end + it 'strips the fragment from the path when looking up the page' do + args = { 'slug' => 'mcp_fixture', 'path' => 'array/pop#shift' } + result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result'] + assert_includes result['content'].first['text'], 'Removes the last element.' + 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 }) From 3281bfe4834d30ca625abfc603dd913d94fba040 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:08:51 +0200 Subject: [PATCH 12/22] Treat tables and definition lists as block elements Their tags were missing from block_element?, so tables and definition lists - most of the CSS and HTML reference content - collapsed into runs like "NameTypefooString". --- lib/mcp/server.rb | 4 +++- test/files/docs/mcp_fixture/db.json | 2 +- test/mcp_test.rb | 9 +++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index cfa95e3947..40f28f0791 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -233,7 +233,9 @@ def self.html_to_text(html) 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) + %w(p div h1 h2 h3 h4 h5 h6 ul ol li dl dt dd + table caption thead tbody tfoot tr th td + blockquote pre br).include?(tag_name.downcase) end def self.search_docset(app_settings, slug, query, args = {}) diff --git a/test/files/docs/mcp_fixture/db.json b/test/files/docs/mcp_fixture/db.json index cdac1098b9..26ca26340e 100644 --- a/test/files/docs/mcp_fixture/db.json +++ b/test/files/docs/mcp_fixture/db.json @@ -1 +1 @@ -{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

"} +{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
"} diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 99520111b9..7f854236f6 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -177,6 +177,15 @@ def rpc(method, params = nil, id: 1) assert_includes result['content'].first['text'], 'Removes the last element.' end + it 'separates table cells and definition lists in the extracted text' do + args = { 'slug' => 'mcp_fixture', 'path' => 'array/table' } + result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result'] + text = result['content'].first['text'] + refute_includes text, 'NameType' + refute_includes text, 'fooString' + refute_includes text, 'barA thing.' + 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 }) From d402b933d1672f5fe0b080ea395464ab84f13f96 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:09:39 +0200 Subject: [PATCH 13/22] Walk the HTML in document order when extracting text Nokogiri's #traverse is post-order, so a block element's newline was emitted after its text and text preceding the block ran into it ("Options are:one\ntwo"). --- lib/mcp/server.rb | 23 ++++++++++++++++------- test/files/docs/mcp_fixture/db.json | 2 +- test/mcp_test.rb | 6 ++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 40f28f0791..2e3c94af93 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -219,16 +219,25 @@ def self.load_db(app_settings, slug) def self.html_to_text(html) doc = Nokogiri::HTML::DocumentFragment.parse(html) text_parts = [] + collect_text(doc, text_parts) + text_parts.join.squeeze(' ').gsub(/\n\s*\n/, "\n").strip + end - doc.traverse do |node| - if node.text? - text_parts << node.text - elsif block_element?(node.name) - text_parts << "\n" if text_parts.last != "\n" + # Walks the tree in document order, wrapping the text of each block element + # in newlines. Nokogiri's #traverse is post-order, which emitted a block's + # separator only after its text and ran the text before it into the block. + def self.collect_text(node, text_parts) + node.children.each do |child| + if child.text? + text_parts << child.text + elsif block_element?(child.name) + text_parts << "\n" + collect_text(child, text_parts) + text_parts << "\n" + else + collect_text(child, text_parts) end end - - text_parts.join.squeeze(' ').gsub(/\n\s*\n/, "\n").strip end def self.block_element?(tag_name) diff --git a/test/files/docs/mcp_fixture/db.json b/test/files/docs/mcp_fixture/db.json index 26ca26340e..ea020d6414 100644 --- a/test/files/docs/mcp_fixture/db.json +++ b/test/files/docs/mcp_fixture/db.json @@ -1 +1 @@ -{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
"} +{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
","array/blocks":"
Options are:
  • one
  • two
"} diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 7f854236f6..e2a06b364a 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -186,6 +186,12 @@ def rpc(method, params = nil, id: 1) refute_includes text, 'barA thing.' end + it 'separates text preceding a block element from the block' do + args = { 'slug' => 'mcp_fixture', 'path' => 'array/blocks' } + result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result'] + assert_equal "Options are:\none\ntwo", result['content'].first['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 }) From 6bbfce0f748147bf34aacaf3d0ac2163e7613969 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:11:08 +0200 Subject: [PATCH 14/22] Keep the whitespace of preformatted text Whitespace was collapsed over the whole document, flattening the indentation of
 blocks and corrupting code samples. Text is now
collected in runs of equal preformattedness and only the ones outside
 are collapsed.
---
 lib/mcp/server.rb                   | 33 +++++++++++++++++++++--------
 test/files/docs/mcp_fixture/db.json |  2 +-
 test/mcp_test.rb                    |  6 ++++++
 3 files changed, 31 insertions(+), 10 deletions(-)

diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb
index 2e3c94af93..85d4e2e52d 100644
--- a/lib/mcp/server.rb
+++ b/lib/mcp/server.rb
@@ -218,28 +218,43 @@ def self.load_db(app_settings, slug)
 
     def self.html_to_text(html)
       doc = Nokogiri::HTML::DocumentFragment.parse(html)
-      text_parts = []
-      collect_text(doc, text_parts)
-      text_parts.join.squeeze(' ').gsub(/\n\s*\n/, "\n").strip
+      segments = []
+      collect_text(doc, segments, false)
+      # Collapse whitespace outside 
 only; code samples keep their
+      # indentation and blank lines verbatim.
+      segments.map { |segment|
+        segment[:pre] ? segment[:text] : segment[:text].squeeze(' ').gsub(/\n\s*\n/, "\n")
+      }.join.strip
     end
 
     # Walks the tree in document order, wrapping the text of each block element
     # in newlines. Nokogiri's #traverse is post-order, which emitted a block's
     # separator only after its text and ran the text before it into the block.
-    def self.collect_text(node, text_parts)
+    # Text is collected into runs of equal preformattedness so that the
+    # whitespace collapsing above can skip the preformatted ones.
+    def self.collect_text(node, segments, preformatted)
       node.children.each do |child|
         if child.text?
-          text_parts << child.text
+          append_text(segments, child.text, preformatted)
         elsif block_element?(child.name)
-          text_parts << "\n"
-          collect_text(child, text_parts)
-          text_parts << "\n"
+          append_text(segments, "\n", preformatted)
+          collect_text(child, segments, preformatted || child.name.casecmp('pre').zero?)
+          append_text(segments, "\n", preformatted)
         else
-          collect_text(child, text_parts)
+          collect_text(child, segments, preformatted)
         end
       end
     end
 
+    def self.append_text(segments, text, preformatted)
+      last = segments.last
+      if last && last[:pre] == preformatted
+        last[:text] << text
+      else
+        segments << { pre: preformatted, text: +text }
+      end
+    end
+
     def self.block_element?(tag_name)
       return false unless tag_name
       %w(p div h1 h2 h3 h4 h5 h6 ul ol li dl dt dd
diff --git a/test/files/docs/mcp_fixture/db.json b/test/files/docs/mcp_fixture/db.json
index ea020d6414..2a218249e2 100644
--- a/test/files/docs/mcp_fixture/db.json
+++ b/test/files/docs/mcp_fixture/db.json
@@ -1 +1 @@
-{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
","array/blocks":"
Options are:
  • one
  • two
"} +{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
","array/blocks":"
Options are:
  • one
  • two
","array/code":"

Example:

def push(x)\n  items << x\nend
"} diff --git a/test/mcp_test.rb b/test/mcp_test.rb index e2a06b364a..ebedb02171 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -192,6 +192,12 @@ def rpc(method, params = nil, id: 1) assert_equal "Options are:\none\ntwo", result['content'].first['text'] end + it 'keeps the indentation of preformatted code' do + args = { 'slug' => 'mcp_fixture', 'path' => 'array/code' } + result = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args })['result'] + assert_includes result['content'].first['text'], "def push(x)\n items << x\nend" + 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 }) From 74e052b513571d3e6b90826feea62c105f37b96d Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:16:29 +0200 Subject: [PATCH 15/22] Read the page file instead of parsing db.json Every db.json value is also on disk as the .html file the app serves, so get_page reads that (~0.01ms) rather than parsing up to 100MB of JSON. This drops DB_CACHE, which pinned every db.json touched in memory (571MB across the docsets here) and served stale text after a re-scrape. The path comes from the caller, so it is now kept inside the docset. --- lib/mcp/server.rb | 34 ++++++++++--------- test/files/docs/mcp_fixture/array/blocks.html | 1 + test/files/docs/mcp_fixture/array/code.html | 3 ++ test/files/docs/mcp_fixture/array/pop.html | 1 + test/files/docs/mcp_fixture/array/push.html | 1 + test/files/docs/mcp_fixture/array/table.html | 1 + test/files/docs/mcp_fixture/db.json | 1 - test/mcp_test.rb | 11 ++++-- 8 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 test/files/docs/mcp_fixture/array/blocks.html create mode 100644 test/files/docs/mcp_fixture/array/code.html create mode 100644 test/files/docs/mcp_fixture/array/pop.html create mode 100644 test/files/docs/mcp_fixture/array/push.html create mode 100644 test/files/docs/mcp_fixture/array/table.html delete mode 100644 test/files/docs/mcp_fixture/db.json diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 85d4e2e52d..965cb108e2 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -3,7 +3,6 @@ module Mcp # 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', @@ -195,25 +194,28 @@ def self.validate_slug(app_settings, slug) def self.get_page(app_settings, slug, path) validate_slug(app_settings, slug) - db = load_db(app_settings, slug) - # Entries that share a page carry a #fragment, but db.json is keyed by the - # page path alone (mirrors Entry#dbPath in the client). - html = db[path.sub(/#.*/, '')] - raise "Page not found: #{path}" unless html - html_to_text(html) + html_to_text(File.read(page_path(app_settings, slug, path))) 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." + # Resolves an entry path to the file its page is stored in, the same way the + # client does (Entry#_filePath): entries sharing a page carry a #fragment, + # and the path leaves out the .html extension. Reading the page beats + # looking it up in db.json, which would mean parsing up to 100MB of JSON. + def self.page_path(app_settings, slug, path) + docset_path = File.expand_path(File.join(app_settings.docs_path, slug)) + unless Dir.exist?(docset_path) + raise "Pages not available for #{slug}. They are served from the CDN." end - DB_CACHE[cache_key] = JSON.parse(File.read(db_path)) - DB_CACHE[cache_key] + file = path.sub(/#.*/, '') + file += '.html' unless file.end_with?('.html') + file_path = File.expand_path(File.join(docset_path, file)) + + # The path comes from the caller, so keep it inside the docset. + unless file_path.start_with?(docset_path + File::SEPARATOR) && File.file?(file_path) + raise "Page not found: #{path}" + end + file_path end def self.html_to_text(html) diff --git a/test/files/docs/mcp_fixture/array/blocks.html b/test/files/docs/mcp_fixture/array/blocks.html new file mode 100644 index 0000000000..2af1277383 --- /dev/null +++ b/test/files/docs/mcp_fixture/array/blocks.html @@ -0,0 +1 @@ +
Options are:
  • one
  • two
\ No newline at end of file diff --git a/test/files/docs/mcp_fixture/array/code.html b/test/files/docs/mcp_fixture/array/code.html new file mode 100644 index 0000000000..45956a0abe --- /dev/null +++ b/test/files/docs/mcp_fixture/array/code.html @@ -0,0 +1,3 @@ +

Example:

def push(x)
+  items << x
+end
\ No newline at end of file diff --git a/test/files/docs/mcp_fixture/array/pop.html b/test/files/docs/mcp_fixture/array/pop.html new file mode 100644 index 0000000000..06d124d6a5 --- /dev/null +++ b/test/files/docs/mcp_fixture/array/pop.html @@ -0,0 +1 @@ +

Array#pop

Removes the last element.

\ No newline at end of file diff --git a/test/files/docs/mcp_fixture/array/push.html b/test/files/docs/mcp_fixture/array/push.html new file mode 100644 index 0000000000..d078f4d1e1 --- /dev/null +++ b/test/files/docs/mcp_fixture/array/push.html @@ -0,0 +1 @@ +

Array#push

Appends & returns the array.

\ No newline at end of file diff --git a/test/files/docs/mcp_fixture/array/table.html b/test/files/docs/mcp_fixture/array/table.html new file mode 100644 index 0000000000..59ea36fb5e --- /dev/null +++ b/test/files/docs/mcp_fixture/array/table.html @@ -0,0 +1 @@ +

Attributes

NameType
fooString
bar
A thing.
\ No newline at end of file diff --git a/test/files/docs/mcp_fixture/db.json b/test/files/docs/mcp_fixture/db.json deleted file mode 100644 index 2a218249e2..0000000000 --- a/test/files/docs/mcp_fixture/db.json +++ /dev/null @@ -1 +0,0 @@ -{"array/push":"

Array#push

Appends & returns the array.

","array/pop":"

Array#pop

Removes the last element.

","array/table":"

Attributes

NameType
fooString
bar
A thing.
","array/blocks":"
Options are:
  • one
  • two
","array/code":"

Example:

def push(x)\n  items << x\nend
"} diff --git a/test/mcp_test.rb b/test/mcp_test.rb index ebedb02171..6d0a0141d7 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -223,11 +223,18 @@ def rpc(method, params = nil, id: 1) end end - it 'returns error for missing page database in devdocs_get_page' do + it 'returns error for a docset whose pages are not downloaded' do args = { 'slug' => 'css', 'path' => '/test' } response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args }) assert_equal(-32603, response['error']['code']) - assert_includes response['error']['message'].downcase, 'database' + assert_includes response['error']['message'].downcase, 'not available' + end + + it 'returns error for a page path escaping the docset' do + args = { 'slug' => 'mcp_fixture', 'path' => '../../../etc/passwd' } + response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args }) + assert_equal(-32603, response['error']['code']) + assert_includes response['error']['message'], 'Page not found' end it 'returns error for missing required arguments' do From c8f852713fa6e5e3e03635332803c7f79fd61ad0 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:17:21 +0200 Subject: [PATCH 16/22] Cache the parsed search indexes index.json was re-read and re-parsed on every search (37ms for the largest one here). It is now cached per docset, keyed on mtime and size so a re-scrape is picked up. The indexes are small - 16.5MB for all 48 docsets here - unlike the db.json cache this replaces. --- lib/mcp/server.rb | 28 +++++++++++++++++++++++----- test/mcp_test.rb | 16 ++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 965cb108e2..1abccacb3f 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -3,6 +3,7 @@ module Mcp # string keys) to the appropriate MCP handler and returns a response Hash # ready to be serialized back to the client. module Server + INDEX_CACHE = {} TOOLS = [ { 'name' => 'devdocs_list_docsets', @@ -268,15 +269,11 @@ 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)) + index = load_index(app_settings, slug) query_lower = query.downcase all_matches = index['entries'].select do |entry| @@ -295,6 +292,27 @@ def self.search_docset(app_settings, slug, query, args = {}) } end + # Caches the parsed index of every docset searched so far. Unlike db.json, + # the indexes are small (16.5MB for all of the docsets here), and they would + # otherwise be re-parsed on every search. A re-scraped docset is picked up + # again by way of the mtime and the size. + def self.load_index(app_settings, slug) + index_path = File.join(app_settings.docs_path, slug, 'index.json') + stat = begin + File.stat(index_path) + rescue Errno::ENOENT + raise "Search index not available for #{slug}. The search index is served from the CDN." + end + + stamp = [stat.mtime, stat.size] + cached = INDEX_CACHE[index_path] + return cached[:index] if cached && cached[:stamp] == stamp + + index = JSON.parse(File.read(index_path)) + INDEX_CACHE[index_path] = { stamp: stamp, index: index } + index + end + def self.as_text_result(request, data) respond(request, { 'content' => [{ 'type' => 'text', 'text' => data.to_json }] }) end diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 6d0a0141d7..c7911f6139 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -198,6 +198,22 @@ def rpc(method, params = nil, id: 1) assert_includes result['content'].first['text'], "def push(x)\n items << x\nend" end + it 'picks up a re-scraped search index' do + index_path = File.join(App.docs_path, 'mcp_fixture', 'index.json') + original = File.read(index_path) + args = { 'slug' => 'mcp_fixture', 'query' => 'upcase' } + begin + first = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result'] + assert_equal 1, JSON.parse(first['content'].first['text'])['total'] + + File.write(index_path, JSON.generate('entries' => [], 'types' => [])) + second = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result'] + assert_equal 0, JSON.parse(second['content'].first['text'])['total'] + ensure + File.write(index_path, original) + end + 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 }) From f86dbb6cf1ef61e632a6032e328fab52ebc7e497 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:19:35 +0200 Subject: [PATCH 17/22] Drop the unreachable additionalProperties check The loop above it already rejects every field that the schema does not declare, whatever additionalProperties says. --- lib/mcp/server.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 1abccacb3f..06fb9ddc7e 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -121,13 +121,10 @@ def self.validate_arguments(arguments, schema) arguments.each do |field, value| return "Unknown field: #{field}" unless properties.key?(field) - prop_schema = properties[field] - error_msg = validate_value(value, prop_schema) + error_msg = validate_value(value, properties[field]) 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 From 9a23f2a618c1b0f86374f99404547cc203addcb6 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:20:01 +0200 Subject: [PATCH 18/22] Reject non-object payloads with Invalid Request An array or scalar payload made the rescue handler itself raise a TypeError on request['id'], which escaped handle and surfaced as -32603 from the route. Batches and scalars now get -32600. --- lib/mcp/server.rb | 9 ++++++++- test/mcp_test.rb | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 06fb9ddc7e..1b8a732eb0 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -49,6 +49,12 @@ module Server ].freeze def self.handle(request, app_settings) + unless request.is_a?(Hash) + # A batch is valid JSON-RPC, but this server takes one request at a time. + detail = request.is_a?(Array) ? 'batch requests are not supported' : 'expected a JSON-RPC object' + return error(request, -32600, "Invalid Request: #{detail}") + end + case request['method'] when 'initialize' respond(request, { @@ -68,7 +74,8 @@ def self.handle(request, app_settings) end def self.error(request, code, message) - { 'jsonrpc' => '2.0', 'id' => request['id'], 'error' => { 'code' => code, 'message' => message } } + id = request.is_a?(Hash) ? request['id'] : nil + { 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => code, 'message' => message } } end def self.call_tool(request, app_settings) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index c7911f6139..cd6326c4ba 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -285,6 +285,21 @@ def rpc(method, params = nil, id: 1) assert_includes response['error']['message'].downcase, 'parse' end + it 'returns an invalid request error for a batch' do + post '/mcp', [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }].to_json, 'CONTENT_TYPE' => 'application/json' + response = JSON.parse(last_response.body) + assert_equal(-32600, response['error']['code']) + assert_nil response['id'] + assert_includes response['error']['message'].downcase, 'batch' + end + + it 'returns an invalid request error for a non-object payload' do + post '/mcp', '42', 'CONTENT_TYPE' => 'application/json' + response = JSON.parse(last_response.body) + assert_equal(-32600, response['error']['code']) + assert_nil response['id'] + end + it 'returns a JSON-RPC error for an unsupported method' do response = rpc('not/a/real/method') assert_equal(-32601, response['error']['code']) From 968b01c975d28eabee144dce60b904c5001d4955 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:20:19 +0200 Subject: [PATCH 19/22] Validate the params of tools/call A call without params, or with non-object arguments, hit nil and returned -32603 with a Ruby error message ("undefined method '[]' for nil") instead of -32602. --- lib/mcp/server.rb | 7 +++++++ test/mcp_test.rb | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 1b8a732eb0..f80bd2f44b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -80,8 +80,15 @@ def self.error(request, code, message) def self.call_tool(request, app_settings) params = request['params'] + unless params.is_a?(Hash) + return error(request, -32602, 'Invalid params: expected an object naming the tool to call') + end + tool_name = params['name'] arguments = params['arguments'] || {} + unless arguments.is_a?(Hash) + return error(request, -32602, "Invalid params: expected arguments to be an object, got #{arguments.class}") + end tool_def = TOOLS.find { |t| t['name'] == tool_name } unless tool_def diff --git a/test/mcp_test.rb b/test/mcp_test.rb index cd6326c4ba..d422137e85 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -285,6 +285,18 @@ def rpc(method, params = nil, id: 1) assert_includes response['error']['message'].downcase, 'parse' end + it 'returns an invalid params error for tools/call without params' do + response = rpc('tools/call') + assert_equal(-32602, response['error']['code']) + refute_includes response['error']['message'], 'undefined method' + end + + it 'returns an invalid params error for non-object arguments' do + response = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => [] }) + assert_equal(-32602, response['error']['code']) + assert_includes response['error']['message'], 'arguments' + end + it 'returns an invalid request error for a batch' do post '/mcp', [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }].to_json, 'CONTENT_TYPE' => 'application/json' response = JSON.parse(last_response.body) From 5f6e950980c1ca3e6a29523ce19525a6ae51186e Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:20:48 +0200 Subject: [PATCH 20/22] Do not answer JSON-RPC notifications A request without an id is a notification and must not be answered, but notifications/initialized - which every client sends right after the handshake - was answered with -32601 and a null id. Notifications now get 202 with an empty body. --- lib/app.rb | 12 +++++++++--- lib/mcp/server.rb | 5 +++++ test/mcp_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/app.rb b/lib/app.rb index 910e89f6cd..20c66a9ba3 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -344,9 +344,15 @@ def service_worker_cache_name post '/mcp' do content_type :json begin - body = request.body.read - payload = JSON.parse(body) - Mcp::Server.handle(payload, settings).to_json + payload = JSON.parse(request.body.read) + response = Mcp::Server.handle(payload, settings) + if response.nil? + # The payload was a notification, which takes no response. + status 202 + '' + else + response.to_json + end rescue JSON::ParserError => err error_response(nil, -32700, "Parse error: #{err.message}").to_json rescue => err diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index f80bd2f44b..1df2988359 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -55,6 +55,11 @@ def self.handle(request, app_settings) return error(request, -32600, "Invalid Request: #{detail}") end + # A request without an id is a notification - notifications/initialized is + # sent by every client right after the handshake - and JSON-RPC 2.0 says + # it must not be answered, not even to report an unknown method. + return nil unless request.key?('id') + case request['method'] when 'initialize' respond(request, { diff --git a/test/mcp_test.rb b/test/mcp_test.rb index d422137e85..82a3691c52 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -20,7 +20,31 @@ def rpc(method, params = nil, id: 1) JSON.parse(last_response.body) end + def notify(method, params = nil) + body = { jsonrpc: '2.0', method: method } + body[:params] = params if params + post '/mcp', body.to_json, 'CONTENT_TYPE' => 'application/json' + end + describe 'POST /mcp' do + it 'accepts notifications without answering them' do + notify('notifications/initialized') + assert_equal 202, last_response.status + assert_empty last_response.body + end + + it 'does not answer a notification for an unknown method' do + notify('notifications/cancelled', { 'requestId' => 1 }) + assert_equal 202, last_response.status + assert_empty last_response.body + end + + it 'answers a request whose id is null' do + response = rpc('tools/list', nil, id: nil) + assert_nil response['id'] + assert response['result'].key?('tools') + end + it 'responds to initialize with protocol info' do result = rpc('initialize')['result'] assert_equal '2024-11-05', result['protocolVersion'] From bff3d8a4bd83cf206b71bee58cdf5b25c798f70d Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:21:42 +0200 Subject: [PATCH 21/22] Report failing tools in the result, not as protocol errors A tool that cannot answer - an unknown slug, a missing page, an empty query - now returns its message as an isError result, which is what the MCP spec asks for: a protocol error is handled by the client and never reaches the model. --- lib/mcp/server.rb | 34 ++++++++++++---------------------- test/mcp_test.rb | 39 ++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 1df2988359..e96abe2265 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -107,29 +107,23 @@ def self.call_tool(request, app_settings) case tool_name when 'devdocs_list_docsets' - result = list_docsets(app_settings, arguments) - as_text_result(request, result) + tool_result(request) { list_docsets(app_settings, arguments).to_json } 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 + tool_result(request) { search_docset(app_settings, arguments['slug'], arguments['query'], arguments).to_json } 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 + tool_result(request) { get_page(app_settings, arguments['slug'], arguments['path']) } end end + # Runs a tool and wraps the text it returns in a result. A tool that fails + # reports the reason in its result with isError, as the MCP spec asks: a + # protocol error is handled by the client and never reaches the model. + def self.tool_result(request) + respond(request, { 'content' => [{ 'type' => 'text', 'text' => yield }] }) + rescue => err + respond(request, { 'content' => [{ 'type' => 'text', 'text' => err.message }], 'isError' => true }) + end + def self.validate_arguments(arguments, schema) required = schema['required'] || [] properties = schema['properties'] || {} @@ -329,10 +323,6 @@ def self.load_index(app_settings, slug) index 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 diff --git a/test/mcp_test.rb b/test/mcp_test.rb index 82a3691c52..a5d024253b 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -26,6 +26,12 @@ def notify(method, params = nil) post '/mcp', body.to_json, 'CONTENT_TYPE' => 'application/json' end + def tool_error(name, arguments) + result = rpc('tools/call', { 'name' => name, 'arguments' => arguments })['result'] + assert result['isError'], 'expected the tool to report an error in its result' + result['content'].first['text'] + end + describe 'POST /mcp' do it 'accepts notifications without answering them' do notify('notifications/initialized') @@ -166,11 +172,8 @@ def notify(method, params = nil) 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' + message = tool_error('devdocs_search', { 'slug' => 'mcp_fixture', 'query' => '' }) + assert_includes message.downcase, 'empty' end it 'paginates search results with offset and limit' do @@ -239,19 +242,13 @@ def notify(method, params = nil) 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' + message = tool_error('devdocs_search', { 'slug' => '../../../etc/passwd', 'query' => 'test' }) + assert_includes 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' + message = tool_error('devdocs_get_page', { 'slug' => '..\\windows\\system32', 'path' => '/test' }) + assert_includes message, 'Invalid docset slug' end it 'returns error for missing search index in devdocs_search' do @@ -264,17 +261,13 @@ def notify(method, params = nil) end it 'returns error for a docset whose pages are not downloaded' do - args = { 'slug' => 'css', 'path' => '/test' } - response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args }) - assert_equal(-32603, response['error']['code']) - assert_includes response['error']['message'].downcase, 'not available' + message = tool_error('devdocs_get_page', { 'slug' => 'css', 'path' => '/test' }) + assert_includes message.downcase, 'not available' end it 'returns error for a page path escaping the docset' do - args = { 'slug' => 'mcp_fixture', 'path' => '../../../etc/passwd' } - response = rpc('tools/call', { 'name' => 'devdocs_get_page', 'arguments' => args }) - assert_equal(-32603, response['error']['code']) - assert_includes response['error']['message'], 'Page not found' + message = tool_error('devdocs_get_page', { 'slug' => 'mcp_fixture', 'path' => '../../../etc/passwd' }) + assert_includes message, 'Page not found' end it 'returns error for missing required arguments' do From b73e0cd6d2633d0ef6e038817c1a3a5f26a7b9b8 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 19:22:02 +0200 Subject: [PATCH 22/22] Assert the missing search index unconditionally The assertions sat behind "if response.key?('error')", so the test passed without checking anything if the error stopped being returned. --- test/mcp_test.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/mcp_test.rb b/test/mcp_test.rb index a5d024253b..ad18e61bc2 100644 --- a/test/mcp_test.rb +++ b/test/mcp_test.rb @@ -252,12 +252,8 @@ def tool_error(name, arguments) 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 + message = tool_error('devdocs_search', { 'slug' => 'css', 'query' => 'test' }) + assert_includes message.downcase, 'search index' end it 'returns error for a docset whose pages are not downloaded' do