Skip to content

Commit de798fe

Browse files
tommaso-moroCopilot
andcommitted
Add fields param to search_code and get_file_contents
Add an optional `fields` array parameter to the `search_code` and `get_file_contents` tools so callers can request only the fields they need, reducing tool response size and context usage. - search_code: filters each result item to the selected fields while preserving the total_count / incomplete_results wrapper. - get_file_contents: filters each directory entry when listing a directory; ignored for single-file responses. Adds shared filterFields / filterEachField helpers and per-tool field enums, plus unit tests and regenerated toolsnaps and docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 63d313a commit de798fe

8 files changed

Lines changed: 255 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,7 @@ The following sets of tools are available:
12551255

12561256
- **get_file_contents** - Get file or directory contents
12571257
- **Required OAuth Scopes**: `repo`
1258+
- `fields`: Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'. (string[], optional)
12581259
- `owner`: Repository owner (username or organization) (string, required)
12591260
- `path`: Path to file/directory (string, optional)
12601261
- `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
@@ -1329,6 +1330,7 @@ The following sets of tools are available:
13291330

13301331
- **search_code** - Search code
13311332
- **Required OAuth Scopes**: `repo`
1333+
- `fields`: Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data. (string[], optional)
13321334
- `order`: Sort order for results (string, optional)
13331335
- `page`: Page number for pagination (min 1) (number, optional)
13341336
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)

pkg/github/__toolsnaps__/get_file_contents.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@
66
"description": "Get the contents of a file or directory from a GitHub repository",
77
"inputSchema": {
88
"properties": {
9+
"fields": {
10+
"description": "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.",
11+
"items": {
12+
"enum": [
13+
"type",
14+
"name",
15+
"path",
16+
"size",
17+
"sha",
18+
"url",
19+
"git_url",
20+
"html_url",
21+
"download_url"
22+
],
23+
"type": "string"
24+
},
25+
"type": "array"
26+
},
927
"owner": {
1028
"description": "Repository owner (username or organization)",
1129
"type": "string"

pkg/github/__toolsnaps__/search_code.snap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@
66
"description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.",
77
"inputSchema": {
88
"properties": {
9+
"fields": {
10+
"description": "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.",
11+
"items": {
12+
"enum": [
13+
"name",
14+
"path",
15+
"sha",
16+
"repository",
17+
"text_matches"
18+
],
19+
"type": "string"
20+
},
21+
"type": "array"
22+
},
923
"order": {
1024
"description": "Sort order for results",
1125
"enum": [

pkg/github/minimal_types.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package github
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57
"net/url"
68
"strconv"
@@ -12,6 +14,57 @@ import (
1214
"github.com/github/github-mcp-server/pkg/sanitize"
1315
)
1416

17+
// codeSearchItemFieldEnum lists the selectable fields for search_code result
18+
// items, matching the JSON field names of MinimalCodeResult. The repository and
19+
// text_matches fields are the heaviest, so omitting them is the main lever for
20+
// shrinking large result sets.
21+
var codeSearchItemFieldEnum = []any{"name", "path", "sha", "repository", "text_matches"}
22+
23+
// fileContentFieldEnum lists the selectable fields for get_file_contents
24+
// directory listings, matching the JSON field names of
25+
// github.RepositoryContent that appear for directory entries. Only applied when
26+
// the requested path is a directory; ignored for single files.
27+
var fileContentFieldEnum = []any{"type", "name", "path", "size", "sha", "url", "git_url", "html_url", "download_url"}
28+
29+
// filterFields marshals v to a JSON object and returns a map containing only the
30+
// requested fields. Fields that are unknown or absent from the JSON (for example
31+
// empty values dropped via omitempty) are skipped.
32+
func filterFields(v any, fields []string) (map[string]any, error) {
33+
data, err := json.Marshal(v)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
decoder := json.NewDecoder(bytes.NewReader(data))
39+
decoder.UseNumber() // preserve integer precision for fields such as IDs
40+
var object map[string]any
41+
if err := decoder.Decode(&object); err != nil {
42+
return nil, err
43+
}
44+
45+
picked := make(map[string]any, len(fields))
46+
for _, field := range fields {
47+
if value, ok := object[field]; ok {
48+
picked[field] = value
49+
}
50+
}
51+
return picked, nil
52+
}
53+
54+
// filterEachField applies filterFields to every item, returning a slice in which
55+
// each element contains only the requested fields.
56+
func filterEachField[T any](items []T, fields []string) ([]map[string]any, error) {
57+
filtered := make([]map[string]any, 0, len(items))
58+
for _, item := range items {
59+
picked, err := filterFields(item, fields)
60+
if err != nil {
61+
return nil, err
62+
}
63+
filtered = append(filtered, picked)
64+
}
65+
return filtered, nil
66+
}
67+
1568
// MinimalUser is the output type for user and organization search results.
1669
type MinimalUser struct {
1770
Login string `json:"login"`

pkg/github/repositories.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
728728
Type: "string",
729729
Description: "Accepts optional commit SHA. If specified, it will be used instead of ref",
730730
},
731+
"fields": {
732+
Type: "array",
733+
Description: "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.",
734+
Items: &jsonschema.Schema{
735+
Type: "string",
736+
Enum: fileContentFieldEnum,
737+
},
738+
},
731739
},
732740
Required: []string{"owner", "repo"},
733741
},
@@ -760,6 +768,11 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
760768
return utils.NewToolResultError(err.Error()), nil, nil
761769
}
762770

771+
fields, err := OptionalStringArrayParam(args, "fields")
772+
if err != nil {
773+
return utils.NewToolResultError(err.Error()), nil, nil
774+
}
775+
763776
client, err := deps.GetClient(ctx)
764777
if err != nil {
765778
return utils.NewToolResultError("failed to get GitHub client"), nil, nil
@@ -883,7 +896,15 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
883896
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
884897
} else if dirContent != nil {
885898
// file content or file SHA is nil which means it's a directory
886-
r, err := json.Marshal(dirContent)
899+
var payload any = dirContent
900+
if len(fields) > 0 {
901+
filtered, err := filterEachField(dirContent, fields)
902+
if err != nil {
903+
return utils.NewToolResultErrorFromErr("failed to filter directory contents", err), nil, nil
904+
}
905+
payload = filtered
906+
}
907+
r, err := json.Marshal(payload)
887908
if err != nil {
888909
return utils.NewToolResultError("failed to marshal response"), nil, nil
889910
}

pkg/github/repositories_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,69 @@ func Test_GetFileContents(t *testing.T) {
480480
}
481481
}
482482

483+
func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) {
484+
mockDirContent := []*github.RepositoryContent{
485+
{
486+
Type: github.Ptr("file"),
487+
Name: github.Ptr("README.md"),
488+
Path: github.Ptr("README.md"),
489+
SHA: github.Ptr("abc123"),
490+
Size: github.Ptr(42),
491+
URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"),
492+
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
493+
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
494+
},
495+
{
496+
Type: github.Ptr("dir"),
497+
Name: github.Ptr("src"),
498+
Path: github.Ptr("src"),
499+
SHA: github.Ptr("def456"),
500+
HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"),
501+
},
502+
}
503+
504+
serverTool := GetFileContents(translations.NullTranslationHelper)
505+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
506+
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
507+
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
508+
GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen(
509+
mockResponse(t, http.StatusOK, mockDirContent),
510+
),
511+
GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen(
512+
mockResponse(t, http.StatusNotFound, nil),
513+
),
514+
}))
515+
deps := BaseDeps{Client: client}
516+
handler := serverTool.Handler(deps)
517+
518+
request := createMCPRequest(map[string]any{
519+
"owner": "owner",
520+
"repo": "repo",
521+
"path": "src/",
522+
"fields": []any{"name", "type"},
523+
})
524+
525+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
526+
require.NoError(t, err)
527+
require.False(t, result.IsError)
528+
529+
textContent := getTextResult(t, result)
530+
531+
// Each directory entry is reduced to the requested fields only; heavier
532+
// fields such as html_url and download_url are dropped.
533+
var returned []map[string]any
534+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned))
535+
require.Len(t, returned, len(mockDirContent))
536+
for _, entry := range returned {
537+
require.Len(t, entry, 2)
538+
assert.Contains(t, entry, "name")
539+
assert.Contains(t, entry, "type")
540+
}
541+
542+
assert.NotContains(t, textContent.Text, "html_url")
543+
assert.NotContains(t, textContent.Text, "download_url")
544+
}
545+
483546
func Test_GetFileContents_IFC_InsidersMode(t *testing.T) {
484547
t.Parallel()
485548

pkg/github/search.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
209209
Description: "Sort order for results",
210210
Enum: []any{"asc", "desc"},
211211
},
212+
"fields": {
213+
Type: "array",
214+
Description: "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.",
215+
Items: &jsonschema.Schema{
216+
Type: "string",
217+
Enum: codeSearchItemFieldEnum,
218+
},
219+
},
212220
},
213221
Required: []string{"query"},
214222
}
@@ -239,6 +247,10 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
239247
if err != nil {
240248
return utils.NewToolResultError(err.Error()), nil, nil
241249
}
250+
fields, err := OptionalStringArrayParam(args, "fields")
251+
if err != nil {
252+
return utils.NewToolResultError(err.Error()), nil, nil
253+
}
242254
pagination, err := OptionalPaginationParams(args)
243255
if err != nil {
244256
return utils.NewToolResultError(err.Error()), nil, nil
@@ -297,7 +309,20 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
297309
Items: minimalItems,
298310
}
299311

300-
r, err := json.Marshal(minimalResult)
312+
var payload any = minimalResult
313+
if len(fields) > 0 {
314+
filteredItems, err := filterEachField(minimalItems, fields)
315+
if err != nil {
316+
return utils.NewToolResultErrorFromErr("failed to filter code search results", err), nil, nil
317+
}
318+
payload = map[string]any{
319+
"total_count": minimalResult.TotalCount,
320+
"incomplete_results": minimalResult.IncompleteResults,
321+
"items": filteredItems,
322+
}
323+
}
324+
325+
r, err := json.Marshal(payload)
301326
if err != nil {
302327
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
303328
}

pkg/github/search_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,63 @@ func Test_SearchCode(t *testing.T) {
509509
}
510510
}
511511

512+
func Test_SearchCode_FieldFiltering(t *testing.T) {
513+
mockSearchResult := &github.CodeSearchResult{
514+
Total: github.Ptr(1),
515+
IncompleteResults: github.Ptr(false),
516+
CodeResults: []*github.CodeResult{
517+
{
518+
Name: github.Ptr("file1.go"),
519+
Path: github.Ptr("path/to/file1.go"),
520+
SHA: github.Ptr("abc123def456"),
521+
Repository: &github.Repository{
522+
Name: github.Ptr("repo"),
523+
FullName: github.Ptr("owner/repo"),
524+
},
525+
TextMatches: []*github.TextMatch{
526+
{Fragment: github.Ptr("func main() {}")},
527+
},
528+
},
529+
},
530+
}
531+
532+
serverTool := SearchCode(translations.NullTranslationHelper)
533+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
534+
GetSearchCode: mockResponse(t, http.StatusOK, mockSearchResult),
535+
}))
536+
deps := BaseDeps{Client: client}
537+
handler := serverTool.Handler(deps)
538+
539+
request := createMCPRequest(map[string]any{
540+
"query": "fmt.Println language:go",
541+
"fields": []any{"name", "path"},
542+
})
543+
544+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
545+
require.NoError(t, err)
546+
require.False(t, result.IsError)
547+
548+
textContent := getTextResult(t, result)
549+
550+
// The wrapper metadata is preserved while each item is reduced to the
551+
// requested fields only; the heavier repository and text_matches data is
552+
// dropped.
553+
var returned struct {
554+
TotalCount int `json:"total_count"`
555+
IncompleteResults bool `json:"incomplete_results"`
556+
Items []map[string]any `json:"items"`
557+
}
558+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned))
559+
assert.Equal(t, 1, returned.TotalCount)
560+
require.Len(t, returned.Items, 1)
561+
require.Len(t, returned.Items[0], 2)
562+
assert.Contains(t, returned.Items[0], "name")
563+
assert.Contains(t, returned.Items[0], "path")
564+
565+
assert.NotContains(t, textContent.Text, "repository")
566+
assert.NotContains(t, textContent.Text, "text_matches")
567+
}
568+
512569
func Test_SearchUsers(t *testing.T) {
513570
// Verify tool definition once
514571
serverTool := SearchUsers(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)