Skip to content

Commit 3b8ff50

Browse files
Add fields param to search_code and get_file_contents (#2775)
* 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> * Gate fields param behind fields_param flag and add usage telemetry Register search_code and get_file_contents as two mutually exclusive variants gated by the new `fields_param` feature flag, following the existing dual-variant flag pattern: - The flag-enabled variant advertises the optional `fields` parameter and filters each result to the requested subset. It owns the `<tool>_ff_fields_param` toolsnap. - The Legacy* variant exposes the original schema with no `fields` parameter and never filters, acting as a kill switch when the flag is off. It owns the canonical toolsnap. Add best-effort, low-cardinality telemetry at each tool's filter point to measure adoption and realized savings: - `mcp.fields.tool_call` (increment) tagged by tool and whether the response was filtered. - `mcp.fields.bytes_full` / `bytes_sent` / `bytes_saved` (counters) tagged by tool, emitted only when a response was filtered. Tags are limited to `tool` and `filtered` to bound cardinality; repo, owner, user, query, and the requested field list are never tagged. The local server discards these via the noop metrics sink, while hosted deployments inject a real sink. Metrics accessors now fall back to a noop sink when no exporter is configured so emitting telemetry never panics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Drop mcp.fields.bytes_saved metric Remove the mcp.fields.bytes_saved counter. It is derivable on the dashboard from the two remaining byte counters, since sum(bytes_full) - sum(bytes_sent) equals the total saved at any rollup, so emitting it separately is redundant. Keeping only bytes_full and bytes_sent shrinks the emitted telemetry surface. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a718d37 commit 3b8ff50

14 files changed

Lines changed: 918 additions & 33 deletions

docs/feature-flags.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,24 @@ runtime behavior (such as output formatting) won't appear here.
338338
- 'blocked_by' - the subject issue is blocked by the related issue.
339339
- 'blocking' - the subject issue blocks the related issue. (string, required)
340340

341+
### `fields_param`
342+
343+
- **get_file_contents** - Get file or directory contents
344+
- **Required OAuth Scopes**: `repo`
345+
- `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)
346+
- `owner`: Repository owner (username or organization) (string, required)
347+
- `path`: Path to file/directory (string, optional)
348+
- `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
349+
- `repo`: Repository name (string, required)
350+
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
351+
352+
- **search_code** - Search code
353+
- **Required OAuth Scopes**: `repo`
354+
- `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)
355+
- `order`: Sort order for results (string, optional)
356+
- `page`: Page number for pagination (min 1) (number, optional)
357+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
358+
- `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required)
359+
- `sort`: Sort field ('indexed' only) (string, optional)
360+
341361
<!-- END AUTOMATED FEATURE FLAG TOOLS -->
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "Get file or directory contents"
6+
},
7+
"description": "Get the contents of a file or directory from a GitHub repository",
8+
"inputSchema": {
9+
"properties": {
10+
"fields": {
11+
"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'.",
12+
"items": {
13+
"enum": [
14+
"type",
15+
"name",
16+
"path",
17+
"size",
18+
"sha",
19+
"url",
20+
"git_url",
21+
"html_url",
22+
"download_url"
23+
],
24+
"type": "string"
25+
},
26+
"type": "array"
27+
},
28+
"owner": {
29+
"description": "Repository owner (username or organization)",
30+
"type": "string"
31+
},
32+
"path": {
33+
"default": "/",
34+
"description": "Path to file/directory",
35+
"type": "string"
36+
},
37+
"ref": {
38+
"description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`",
39+
"type": "string"
40+
},
41+
"repo": {
42+
"description": "Repository name",
43+
"type": "string"
44+
},
45+
"sha": {
46+
"description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
47+
"type": "string"
48+
}
49+
},
50+
"required": [
51+
"owner",
52+
"repo"
53+
],
54+
"type": "object"
55+
},
56+
"name": "get_file_contents"
57+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "Search code"
6+
},
7+
"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.",
8+
"inputSchema": {
9+
"properties": {
10+
"fields": {
11+
"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.",
12+
"items": {
13+
"enum": [
14+
"name",
15+
"path",
16+
"sha",
17+
"repository",
18+
"text_matches"
19+
],
20+
"type": "string"
21+
},
22+
"type": "array"
23+
},
24+
"order": {
25+
"description": "Sort order for results",
26+
"enum": [
27+
"asc",
28+
"desc"
29+
],
30+
"type": "string"
31+
},
32+
"page": {
33+
"description": "Page number for pagination (min 1)",
34+
"minimum": 1,
35+
"type": "number"
36+
},
37+
"perPage": {
38+
"description": "Results per page for pagination (min 1, max 100)",
39+
"maximum": 100,
40+
"minimum": 1,
41+
"type": "number"
42+
},
43+
"query": {
44+
"description": "Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.",
45+
"type": "string"
46+
},
47+
"sort": {
48+
"description": "Sort field ('indexed' only)",
49+
"type": "string"
50+
}
51+
},
52+
"required": [
53+
"query"
54+
],
55+
"type": "object"
56+
},
57+
"name": "search_code"
58+
}

pkg/github/dependencies.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ func (d BaseDeps) Logger(_ context.Context) *slog.Logger {
193193

194194
// Metrics implements ToolDependencies.
195195
func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics {
196+
if d.Obsv == nil {
197+
return metrics.NewNoopMetrics()
198+
}
196199
return d.Obsv.Metrics(ctx)
197200
}
198201

@@ -423,6 +426,9 @@ func (d *RequestDeps) Logger(_ context.Context) *slog.Logger {
423426

424427
// Metrics implements ToolDependencies.
425428
func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
429+
if d.obsv == nil {
430+
return metrics.NewNoopMetrics()
431+
}
426432
return d.obsv.Metrics(ctx)
427433
}
428434

pkg/github/feature_flags.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ const FeatureFlagFileBlame = "file_blame"
2323
// unless explicitly opted in.
2424
const FeatureFlagIssueDependencies = "issue_dependencies"
2525

26+
// FeatureFlagFieldsParam is the feature flag name for the optional `fields`
27+
// parameter on selected read tools (for example search_code and
28+
// get_file_contents). When enabled, those tools advertise `fields` and filter
29+
// each result to the requested subset, reducing response size. It is gated so
30+
// the feature can be rolled out gradually and disabled as a kill switch without
31+
// a redeploy.
32+
const FeatureFlagFieldsParam = "fields_param"
33+
2634
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
2735
// by users via --features CLI flag or X-MCP-Features HTTP header.
2836
// Only flags in this list are accepted; unknown flags are silently ignored.
@@ -35,6 +43,7 @@ var AllowedFeatureFlags = []string{
3543
FeatureFlagPullRequestsGranular,
3644
FeatureFlagFileBlame,
3745
FeatureFlagIssueDependencies,
46+
FeatureFlagFieldsParam,
3847
}
3948

4049
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/github/github-mcp-server/pkg/translations"
8+
"github.com/google/jsonschema-go/jsonschema"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// Test_FieldsParamVariants_MutuallyExclusive guards the dual-variant
14+
// registration for the fields_param feature flag. The flag-enabled tools
15+
// (search_code, get_file_contents) and their Legacy* counterparts share a tool
16+
// name, so exactly one of each pair must survive inventory filtering for any
17+
// flag state. If both ever leaked, a client could be offered two tools with the
18+
// same name. This asserts that each gated tool is present exactly once,
19+
// advertising the `fields` parameter only when fields_param is enabled.
20+
func Test_FieldsParamVariants_MutuallyExclusive(t *testing.T) {
21+
gatedTools := []string{"search_code", "get_file_contents"}
22+
23+
for _, tc := range []struct {
24+
name string
25+
flagEnabled bool
26+
expectFields bool
27+
featureChecks func(context.Context, string) (bool, error)
28+
}{
29+
{
30+
name: "flag off registers the legacy variant without fields",
31+
flagEnabled: false,
32+
expectFields: false,
33+
featureChecks: featureCheckerFor(), // fields_param disabled
34+
},
35+
{
36+
name: "flag on registers the fields variant with fields",
37+
flagEnabled: true,
38+
expectFields: true,
39+
featureChecks: featureCheckerFor(FeatureFlagFieldsParam),
40+
},
41+
} {
42+
t.Run(tc.name, func(t *testing.T) {
43+
inv, err := NewInventory(translations.NullTranslationHelper).
44+
WithToolsets([]string{"all"}).
45+
WithFeatureChecker(tc.featureChecks).
46+
Build()
47+
require.NoError(t, err)
48+
49+
available := inv.AvailableTools(context.Background())
50+
51+
counts := make(map[string]int, len(available))
52+
for _, tool := range available {
53+
counts[tool.Tool.Name]++
54+
}
55+
56+
// Each gated tool must be present exactly once (never both variants)
57+
// and advertise `fields` only when the flag is enabled.
58+
for _, name := range gatedTools {
59+
require.Equalf(t, 1, counts[name], "expected exactly one %q for flagEnabled=%v; dual variants must be mutually exclusive", name, tc.flagEnabled)
60+
61+
tool := requireToolByName(t, available, name)
62+
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
63+
require.Truef(t, ok, "%q InputSchema should be *jsonschema.Schema", name)
64+
65+
if tc.expectFields {
66+
assert.Containsf(t, schema.Properties, "fields", "%q should advertise fields when flag is on", name)
67+
assert.Equalf(t, FeatureFlagFieldsParam, tool.FeatureFlagEnable, "%q should be the flag-enabled variant", name)
68+
} else {
69+
assert.NotContainsf(t, schema.Properties, "fields", "%q must not advertise fields when flag is off", name)
70+
assert.Containsf(t, tool.FeatureFlagDisable, FeatureFlagFieldsParam, "%q should be the legacy (flag-disabled) variant", name)
71+
}
72+
}
73+
})
74+
}
75+
}

pkg/github/fields_telemetry.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"strconv"
6+
)
7+
8+
// Metric names for the optional `fields` response-filtering feature. They let a
9+
// dashboard answer two questions on real traffic: how often the model actually
10+
// filters (adoption) and how many bytes that filtering removes (effectiveness).
11+
//
12+
// Cardinality is kept deliberately low: the only tags ever attached are `tool`
13+
// (a small fixed set of tool names) and `filtered` (a boolean). Unbounded values
14+
// such as repository, owner, user, the query, or the requested field list are
15+
// never used as tags.
16+
//
17+
// The realized savings (bytes_full - bytes_sent) is intentionally not emitted as
18+
// its own metric: it is derivable on the dashboard from the two byte counters,
19+
// since sum(bytes_full) - sum(bytes_sent) equals the total saved at any rollup.
20+
const (
21+
metricFieldsToolCall = "mcp.fields.tool_call"
22+
metricFieldsBytesFull = "mcp.fields.bytes_full"
23+
metricFieldsBytesSent = "mcp.fields.bytes_sent"
24+
)
25+
26+
// recordFieldsUsage emits telemetry for a single call to a tool that supports
27+
// the `fields` parameter. It is best-effort: the local server wires a no-op
28+
// metrics sink, while hosted deployments inject a real sink.
29+
//
30+
// Every call increments mcp.fields.tool_call tagged by tool and whether the
31+
// response was filtered, which yields the adoption rate (filtered / total). When
32+
// the response was filtered, it also records the unfiltered (fullBytes) and
33+
// returned (sentBytes) payload sizes. Byte counters are only emitted for
34+
// filtered calls so that "percent saved" (1 - bytes_sent / bytes_full) is
35+
// computed over the population where filtering actually applied.
36+
func recordFieldsUsage(ctx context.Context, deps ToolDependencies, tool string, filtered bool, fullBytes, sentBytes int) {
37+
m := deps.Metrics(ctx)
38+
if m == nil {
39+
return
40+
}
41+
42+
m.Increment(metricFieldsToolCall, map[string]string{
43+
"tool": tool,
44+
"filtered": strconv.FormatBool(filtered),
45+
})
46+
47+
if !filtered {
48+
return
49+
}
50+
51+
toolTag := map[string]string{"tool": tool}
52+
m.Counter(metricFieldsBytesFull, toolTag, int64(fullBytes))
53+
m.Counter(metricFieldsBytesSent, toolTag, int64(sentBytes))
54+
}

0 commit comments

Comments
 (0)