Skip to content

Commit 37090e8

Browse files
joebutler2claude
andcommitted
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 <noreply@anthropic.com>
1 parent 181fd5b commit 37090e8

3 files changed

Lines changed: 134 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ docs/**/*
1515
*.zip
1616
assets/stylesheets/components/_environment.scss
1717
assets/stylesheets/global/_icons.scss
18+
.mcp.json

lib/mcp/server.rb

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@ module Server
66
TOOLS = [
77
{
88
'name' => 'devdocs_list_docsets',
9-
'description' => 'List documentation sets available on this DevDocs instance.',
10-
'inputSchema' => { 'type' => 'object', 'properties' => {}, 'additionalProperties' => false },
9+
'description' => 'List documentation sets available on this DevDocs instance. Returns paginated results with optional filtering.',
10+
'inputSchema' => {
11+
'type' => 'object',
12+
'properties' => {
13+
'offset' => { 'type' => 'integer', 'description' => 'Number of results to skip (default: 0)', 'minimum' => 0 },
14+
'limit' => { 'type' => 'integer', 'description' => 'Maximum results to return (default: 50, max: 500)', 'minimum' => 1, 'maximum' => 500 },
15+
'query' => { 'type' => 'string', 'description' => 'Filter by slug or name (case-insensitive substring match)' },
16+
},
17+
'additionalProperties' => false,
18+
},
1119
},
1220
{
1321
'name' => 'devdocs_search',
@@ -62,8 +70,8 @@ def self.call_tool(request, app_settings)
6270
params = request['params']
6371
case params['name']
6472
when 'devdocs_list_docsets'
65-
docsets = app_settings.docs.values
66-
as_text_result(request, docsets)
73+
result = list_docsets(app_settings, params['arguments'] || {})
74+
as_text_result(request, result)
6775
when 'devdocs_search'
6876
entries = search_docset(app_settings, params['arguments']['slug'], params['arguments']['query'])
6977
as_text_result(request, entries)
@@ -73,6 +81,39 @@ def self.call_tool(request, app_settings)
7381
end
7482
end
7583

84+
def self.list_docsets(app_settings, args)
85+
offset = (args['offset'] || 0).to_i
86+
limit = [(args['limit'] || 50).to_i, 500].min
87+
query = args['query']&.downcase
88+
89+
all_docsets = app_settings.docs.values.map do |docset|
90+
{
91+
'slug' => docset['slug'],
92+
'name' => docset['name'],
93+
'version' => docset['version'],
94+
}
95+
end
96+
97+
filtered = if query
98+
all_docsets.select do |docset|
99+
docset['slug'].downcase.include?(query) || docset['name'].downcase.include?(query)
100+
end
101+
else
102+
all_docsets
103+
end
104+
105+
total_count = filtered.length
106+
paginated = filtered.drop(offset).take(limit)
107+
108+
{
109+
'docsets' => paginated,
110+
'offset' => offset,
111+
'limit' => limit,
112+
'total' => total_count,
113+
'returned' => paginated.length,
114+
}
115+
end
116+
76117
def self.get_page(app_settings, slug, path)
77118
db_path = File.join(app_settings.docs_path, slug, 'db.json')
78119
db = JSON.parse(File.read(db_path))
@@ -83,8 +124,10 @@ def self.get_page(app_settings, slug, path)
83124
def self.search_docset(app_settings, slug, query)
84125
index_path = File.join(app_settings.docs_path, slug, 'index.json')
85126
index = JSON.parse(File.read(index_path))
86-
q = query.downcase
87-
index['entries'].select { |e| e['name'].downcase.include?(q) || e['path'].downcase.include?(q) }
127+
query_lower = query.downcase
128+
index['entries'].select do |entry|
129+
entry['name'].downcase.include?(query_lower) || entry['path'].downcase.include?(query_lower)
130+
end
88131
end
89132

90133
def self.as_text_result(request, data)

test/mcp_test.rb

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,96 @@ def rpc(method, params = nil, id: 1)
3535
assert_includes names, 'devdocs_get_page'
3636
end
3737

38-
it 'calls devdocs_list_docsets and returns the configured doc sets' do
38+
it 'calls devdocs_list_docsets and returns paginated docsets in condensed format' do
3939
result = rpc('tools/call', { 'name' => 'devdocs_list_docsets', 'arguments' => {} })['result']
40-
docsets = JSON.parse(result['content'].first['text'])
40+
response = JSON.parse(result['content'].first['text'])
41+
42+
assert response.key?('docsets')
43+
assert response.key?('offset')
44+
assert response.key?('limit')
45+
assert response.key?('total')
46+
assert response.key?('returned')
47+
48+
docsets = response['docsets']
49+
assert docsets.length > 0
50+
first = docsets.first
51+
assert first.key?('slug')
52+
assert first.key?('name')
53+
assert first.key?('version')
54+
refute first.key?('release_date'), 'should not include release_date'
55+
refute first.key?('mtime'), 'should not include mtime'
56+
4157
slugs = docsets.map { |d| d['slug'] }
4258
assert_includes slugs, 'css'
4359
assert_includes slugs, 'html~5'
4460
end
4561

62+
it 'paginates results with offset and limit' do
63+
result = rpc('tools/call', {
64+
'name' => 'devdocs_list_docsets',
65+
'arguments' => { 'offset' => 0, 'limit' => 2 }
66+
})['result']
67+
response = JSON.parse(result['content'].first['text'])
68+
69+
assert_equal 0, response['offset']
70+
assert_equal 2, response['limit']
71+
assert_equal 2, response['returned']
72+
assert response['total'] > 2
73+
assert_equal 2, response['docsets'].length
74+
end
75+
76+
it 'respects offset to skip results' do
77+
first_page = rpc('tools/call', {
78+
'name' => 'devdocs_list_docsets',
79+
'arguments' => { 'offset' => 0, 'limit' => 2 }
80+
})['result']
81+
first_docsets = JSON.parse(first_page['content'].first['text'])['docsets'].map { |d| d['slug'] }
82+
83+
second_page = rpc('tools/call', {
84+
'name' => 'devdocs_list_docsets',
85+
'arguments' => { 'offset' => 2, 'limit' => 2 }
86+
})['result']
87+
second_docsets = JSON.parse(second_page['content'].first['text'])['docsets'].map { |d| d['slug'] }
88+
89+
assert first_docsets != second_docsets
90+
end
91+
92+
it 'filters docsets by query string' do
93+
result = rpc('tools/call', {
94+
'name' => 'devdocs_list_docsets',
95+
'arguments' => { 'query' => 'css' }
96+
})['result']
97+
response = JSON.parse(result['content'].first['text'])
98+
99+
docsets = response['docsets']
100+
assert docsets.length > 0
101+
assert docsets.all? { |d| d['slug'].downcase.include?('css') || d['name'].downcase.include?('css') }
102+
end
103+
104+
it 'filters case-insensitively' do
105+
result = rpc('tools/call', {
106+
'name' => 'devdocs_list_docsets',
107+
'arguments' => { 'query' => 'CSS' }
108+
})['result']
109+
response = JSON.parse(result['content'].first['text'])
110+
111+
docsets = response['docsets']
112+
assert docsets.length > 0
113+
assert docsets.any? { |d| d['slug'] == 'css' }
114+
end
115+
116+
it 'returns empty docsets for non-matching query' do
117+
result = rpc('tools/call', {
118+
'name' => 'devdocs_list_docsets',
119+
'arguments' => { 'query' => 'nonexistentdocthing' }
120+
})['result']
121+
response = JSON.parse(result['content'].first['text'])
122+
123+
assert_equal 0, response['returned']
124+
assert_equal [], response['docsets']
125+
assert response['total'] == 0
126+
end
127+
46128
it 'calls devdocs_search and returns matching entries for a doc set' do
47129
args = { 'slug' => 'mcp_fixture', 'query' => 'push' }
48130
result = rpc('tools/call', { 'name' => 'devdocs_search', 'arguments' => args })['result']

0 commit comments

Comments
 (0)