Files
Tommaso Moro 3b8ff504c0 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>
2026-07-10 09:50:47 +01:00

124 lines
3.7 KiB
Go

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
// metrics so tests can assert on telemetry. It is safe for concurrent use.
type recordingMetrics struct {
mu sync.Mutex
increments []recordedMetric
counters []recordedMetric
}
type recordedMetric struct {
key string
tags map[string]string
value int64
}
func (m *recordingMetrics) Increment(key string, tags map[string]string) {
m.mu.Lock()
defer m.mu.Unlock()
m.increments = append(m.increments, recordedMetric{key: key, tags: tags, value: 1})
}
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
}
// increment returns the recorded increment for the given key, or false if absent.
func (m *recordingMetrics) increment(key string) (recordedMetric, bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, c := range m.increments {
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_recordFieldsUsage_Filtered(t *testing.T) {
deps, rec := depsWithRecordingMetrics(t, BaseDeps{})
recordFieldsUsage(context.Background(), deps, "search_code", true, 100, 30)
call, ok := rec.increment(metricFieldsToolCall)
require.True(t, ok)
assert.Equal(t, "search_code", call.tags["tool"])
assert.Equal(t, "true", call.tags["filtered"])
full, ok := rec.counter(metricFieldsBytesFull)
require.True(t, ok)
assert.Equal(t, int64(100), full.value)
assert.Equal(t, "search_code", full.tags["tool"])
assert.NotContains(t, full.tags, "filtered")
sent, ok := rec.counter(metricFieldsBytesSent)
require.True(t, ok)
assert.Equal(t, int64(30), sent.value)
}
func Test_recordFieldsUsage_NotFiltered(t *testing.T) {
deps, rec := depsWithRecordingMetrics(t, BaseDeps{})
recordFieldsUsage(context.Background(), deps, "search_code", false, 100, 100)
call, ok := rec.increment(metricFieldsToolCall)
require.True(t, ok)
assert.Equal(t, "false", call.tags["filtered"])
// No byte counters are emitted when the response was not filtered.
_, ok = rec.counter(metricFieldsBytesFull)
assert.False(t, ok)
_, ok = rec.counter(metricFieldsBytesSent)
assert.False(t, ok)
}
func Test_recordFieldsUsage_NilExporterDoesNotPanic(t *testing.T) {
// BaseDeps with no Obsv falls back to a noop sink rather than panicking.
assert.NotPanics(t, func() {
recordFieldsUsage(context.Background(), BaseDeps{}, "search_code", true, 100, 30)
})
}