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)
}
}
})
}
}
31 changes: 31 additions & 0 deletions pkg/github/fields_telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package github

import (
"context"
)

// metricFieldsBytesFull is the size, in bytes, of the unfiltered response
// payload for a tool call that applied `fields` filtering. It is emitted only
// when filtering actually happened and is tagged solely by `tool` to keep
// cardinality low.
//
// This is the one `fields` signal that can only be measured inside the tool
// handler: the unfiltered payload exists briefly at the filter point and is gone
// by the time the response leaves the process. The companion signals — adoption
// (whether the model requested `fields`) and the filtered response size
// (bytes_sent) — are observable from outside the handler and are emitted by the
// hosted deployment's request middleware, so they are intentionally not emitted
// here. On the dashboard, bytes_full and bytes_sent combine into the realized
// savings (1 - sum(bytes_sent) / sum(bytes_full)) over filtered calls.
const metricFieldsBytesFull = "mcp.fields.bytes_full"

// recordFieldsFullBytes emits mcp.fields.bytes_full for a tool call that applied
// `fields` filtering. It is best-effort: the local server wires a no-op metrics
// sink, while hosted deployments inject a real sink.
func recordFieldsFullBytes(ctx context.Context, deps ToolDependencies, tool string, fullBytes int) {
m := deps.Metrics(ctx)
if m == nil {
return
}
m.Counter(metricFieldsBytesFull, map[string]string{"tool": tool}, int64(fullBytes))
}
83 changes: 83 additions & 0 deletions pkg/github/fields_telemetry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package github

import (
"context"
"log/slog"
"sync"
"testing"
"time"

"github.com/github/github-mcp-server/pkg/observability"
"github.com/github/github-mcp-server/pkg/observability/metrics"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// recordingMetrics is a metrics.Metrics implementation that captures emitted
// counters so tests can assert on telemetry. It is safe for concurrent use.
type recordingMetrics struct {
mu sync.Mutex
counters []recordedMetric
}

type recordedMetric struct {
key string
tags map[string]string
value int64
}

func (m *recordingMetrics) Increment(_ string, _ map[string]string) {}

func (m *recordingMetrics) Counter(key string, tags map[string]string, value int64) {
m.mu.Lock()
defer m.mu.Unlock()
m.counters = append(m.counters, recordedMetric{key: key, tags: tags, value: value})
}

func (m *recordingMetrics) Distribution(_ string, _ map[string]string, _ float64) {}
func (m *recordingMetrics) DistributionMs(_ string, _ map[string]string, _ time.Duration) {}
func (m *recordingMetrics) WithTags(_ map[string]string) metrics.Metrics { return m }

// counter returns the recorded counter for the given key, or false if absent.
func (m *recordingMetrics) counter(key string) (recordedMetric, bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, c := range m.counters {
if c.key == key {
return c, true
}
}
return recordedMetric{}, false
}

// depsWithRecordingMetrics returns BaseDeps wired with a recording metrics sink
// plus the sink for assertions.
func depsWithRecordingMetrics(t *testing.T, base BaseDeps) (BaseDeps, *recordingMetrics) {
t.Helper()
rec := &recordingMetrics{}
exporters, err := observability.NewExporters(slog.New(slog.DiscardHandler), rec)
require.NoError(t, err)
base.Obsv = exporters
return base, rec
}

func Test_recordFieldsFullBytes(t *testing.T) {
deps, rec := depsWithRecordingMetrics(t, BaseDeps{})

recordFieldsFullBytes(context.Background(), deps, "search_code", 100)

full, ok := rec.counter(metricFieldsBytesFull)
require.True(t, ok)
assert.Equal(t, int64(100), full.value)
assert.Equal(t, "search_code", full.tags["tool"])
// The only tag is `tool`; adoption and filtered/unfiltered distinctions are
// emitted by the hosted request middleware, not here.
assert.NotContains(t, full.tags, "filtered")
}

func Test_recordFieldsFullBytes_NilExporterDoesNotPanic(t *testing.T) {
// BaseDeps with no Obsv falls back to a noop sink rather than panicking.
assert.NotPanics(t, func() {
recordFieldsFullBytes(context.Background(), BaseDeps{}, "search_code", 100)
})
}
Loading
Loading