Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,24 @@ runtime behavior (such as output formatting) won't appear here.
- 'blocked_by' - the subject issue is blocked by the related issue.
- 'blocking' - the subject issue blocks the related issue. (string, required)

### `fields_param`

- **get_file_contents** - Get file or directory contents
- **Required OAuth Scopes**: `repo`
- `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)
- `owner`: Repository owner (username or organization) (string, required)
- `path`: Path to file/directory (string, optional)
- `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
- `repo`: Repository name (string, required)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)

- **search_code** - Search code
- **Required OAuth Scopes**: `repo`
- `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)
- `order`: Sort order for results (string, optional)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `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)
- `sort`: Sort field ('indexed' only) (string, optional)

<!-- END AUTOMATED FEATURE FLAG TOOLS -->
57 changes: 57 additions & 0 deletions pkg/github/__toolsnaps__/get_file_contents_ff_fields_param.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "Get file or directory contents"
},
"description": "Get the contents of a file or directory from a GitHub repository",
"inputSchema": {
"properties": {
"fields": {
"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'.",
"items": {
"enum": [
"type",
"name",
"path",
"size",
"sha",
"url",
"git_url",
"html_url",
"download_url"
],
"type": "string"
},
"type": "array"
},
"owner": {
"description": "Repository owner (username or organization)",
"type": "string"
},
"path": {
"default": "/",
"description": "Path to file/directory",
"type": "string"
},
"ref": {
"description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
},
"sha": {
"description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
"type": "string"
}
},
"required": [
"owner",
"repo"
],
"type": "object"
},
"name": "get_file_contents"
}
58 changes: 58 additions & 0 deletions pkg/github/__toolsnaps__/search_code_ff_fields_param.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "Search code"
},
"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.",
"inputSchema": {
"properties": {
"fields": {
"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.",
"items": {
"enum": [
"name",
"path",
"sha",
"repository",
"text_matches"
],
"type": "string"
},
"type": "array"
},
"order": {
"description": "Sort order for results",
"enum": [
"asc",
"desc"
],
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"query": {
"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`.",
"type": "string"
},
"sort": {
"description": "Sort field ('indexed' only)",
"type": "string"
}
},
"required": [
"query"
],
"type": "object"
},
"name": "search_code"
}
6 changes: 6 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ func (d BaseDeps) Logger(_ context.Context) *slog.Logger {

// Metrics implements ToolDependencies.
func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics {
if d.Obsv == nil {
return metrics.NewNoopMetrics()
}
return d.Obsv.Metrics(ctx)
}

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

// Metrics implements ToolDependencies.
func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
if d.obsv == nil {
return metrics.NewNoopMetrics()
}
return d.obsv.Metrics(ctx)
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const FeatureFlagFileBlame = "file_blame"
// unless explicitly opted in.
const FeatureFlagIssueDependencies = "issue_dependencies"

// FeatureFlagFieldsParam is the feature flag name for the optional `fields`
// parameter on selected read tools (for example search_code and
// get_file_contents). When enabled, those tools advertise `fields` and filter
// each result to the requested subset, reducing response size. It is gated so
// the feature can be rolled out gradually and disabled as a kill switch without
// a redeploy.
const FeatureFlagFieldsParam = "fields_param"

// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
// by users via --features CLI flag or X-MCP-Features HTTP header.
// Only flags in this list are accepted; unknown flags are silently ignored.
Expand All @@ -35,6 +43,7 @@ var AllowedFeatureFlags = []string{
FeatureFlagPullRequestsGranular,
FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
FeatureFlagFieldsParam,
}

// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
Expand Down
75 changes: 75 additions & 0 deletions pkg/github/fields_param_gating_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package github

import (
"context"
"testing"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/jsonschema-go/jsonschema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Test_FieldsParamVariants_MutuallyExclusive guards the dual-variant
// registration for the fields_param feature flag. The flag-enabled tools
// (search_code, get_file_contents) and their Legacy* counterparts share a tool
// name, so exactly one of each pair must survive inventory filtering for any
// flag state. If both ever leaked, a client could be offered two tools with the
// same name. This asserts that each gated tool is present exactly once,
// advertising the `fields` parameter only when fields_param is enabled.
func Test_FieldsParamVariants_MutuallyExclusive(t *testing.T) {
gatedTools := []string{"search_code", "get_file_contents"}

for _, tc := range []struct {
name string
flagEnabled bool
expectFields bool
featureChecks func(context.Context, string) (bool, error)
}{
{
name: "flag off registers the legacy variant without fields",
flagEnabled: false,
expectFields: false,
featureChecks: featureCheckerFor(), // fields_param disabled
},
{
name: "flag on registers the fields variant with fields",
flagEnabled: true,
expectFields: true,
featureChecks: featureCheckerFor(FeatureFlagFieldsParam),
},
} {
t.Run(tc.name, func(t *testing.T) {
inv, err := NewInventory(translations.NullTranslationHelper).
WithToolsets([]string{"all"}).
WithFeatureChecker(tc.featureChecks).
Build()
require.NoError(t, err)

available := inv.AvailableTools(context.Background())

counts := make(map[string]int, len(available))
for _, tool := range available {
counts[tool.Tool.Name]++
}

// Each gated tool must be present exactly once (never both variants)
// and advertise `fields` only when the flag is enabled.
for _, name := range gatedTools {
require.Equalf(t, 1, counts[name], "expected exactly one %q for flagEnabled=%v; dual variants must be mutually exclusive", name, tc.flagEnabled)

tool := requireToolByName(t, available, name)
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
require.Truef(t, ok, "%q InputSchema should be *jsonschema.Schema", name)

if tc.expectFields {
assert.Containsf(t, schema.Properties, "fields", "%q should advertise fields when flag is on", name)
assert.Equalf(t, FeatureFlagFieldsParam, tool.FeatureFlagEnable, "%q should be the flag-enabled variant", name)
} else {
assert.NotContainsf(t, schema.Properties, "fields", "%q must not advertise fields when flag is off", name)
assert.Containsf(t, tool.FeatureFlagDisable, FeatureFlagFieldsParam, "%q should be the legacy (flag-disabled) variant", name)
}
}
})
}
}
54 changes: 54 additions & 0 deletions pkg/github/fields_telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package github

import (
"context"
"strconv"
)

// Metric names for the optional `fields` response-filtering feature. They let a
// dashboard answer two questions on real traffic: how often the model actually
// filters (adoption) and how many bytes that filtering removes (effectiveness).
//
// Cardinality is kept deliberately low: the only tags ever attached are `tool`
// (a small fixed set of tool names) and `filtered` (a boolean). Unbounded values
// such as repository, owner, user, the query, or the requested field list are
// never used as tags.
const (
metricFieldsToolCall = "mcp.fields.tool_call"
metricFieldsBytesFull = "mcp.fields.bytes_full"
metricFieldsBytesSent = "mcp.fields.bytes_sent"
metricFieldsBytesSaved = "mcp.fields.bytes_saved"
)

// recordFieldsUsage emits telemetry for a single call to a tool that supports
// the `fields` parameter. It is best-effort: the local server wires a no-op
// metrics sink, while hosted deployments inject a real sink.
//
// Every call increments mcp.fields.tool_call tagged by tool and whether the
// response was filtered, which yields the adoption rate (filtered / total). When
// the response was filtered, it also records the unfiltered (fullBytes) and
// returned (sentBytes) payload sizes plus their difference, which yields the
// realized savings. Byte counters are only emitted for filtered calls so that
// "percent saved" (bytes_saved / bytes_full) is computed over the population
// where filtering actually applied.
func recordFieldsUsage(ctx context.Context, deps ToolDependencies, tool string, filtered bool, fullBytes, sentBytes int) {
m := deps.Metrics(ctx)
if m == nil {
return
}

m.Increment(metricFieldsToolCall, map[string]string{
"tool": tool,
"filtered": strconv.FormatBool(filtered),
})

if !filtered {
return
}

toolTag := map[string]string{"tool": tool}
saved := max(fullBytes-sentBytes, 0)
m.Counter(metricFieldsBytesFull, toolTag, int64(fullBytes))
m.Counter(metricFieldsBytesSent, toolTag, int64(sentBytes))
m.Counter(metricFieldsBytesSaved, toolTag, int64(saved))
}
Loading
Loading