Skip to content

Commit 9ef46dd

Browse files
committed
add response optimisation pkg
1 parent efe9d40 commit 9ef46dd

File tree

2 files changed

+287
-1
lines changed

2 files changed

+287
-1
lines changed

pkg/github/pullrequests.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1717
"github.com/github/github-mcp-server/pkg/inventory"
1818
"github.com/github/github-mcp-server/pkg/octicons"
19+
"github.com/github/github-mcp-server/pkg/response"
1920
"github.com/github/github-mcp-server/pkg/sanitize"
2021
"github.com/github/github-mcp-server/pkg/scopes"
2122
"github.com/github/github-mcp-server/pkg/translations"
@@ -1171,7 +1172,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
11711172
}
11721173
}
11731174

1174-
r, err := json.Marshal(prs)
1175+
r, err := response.MarshalItems(prs)
11751176
if err != nil {
11761177
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
11771178
}

pkg/response/optimize.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package response
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
// defaultFillRateThreshold is the default proportion of items that must have a key for it to survive
10+
const defaultFillRateThreshold = 0.1
11+
12+
// minFillRateRows is the minimum number of items required to apply fill-rate filtering
13+
const minFillRateRows = 3
14+
15+
// preservedFields is a set of keys that are exempt from all destructive strategies except whitespace normalization.
16+
// Keys are matched against post-flatten map keys, so for nested fields like "user.html_url", the dotted key must be
17+
// added explicitly. Empty collections are still dropped. Wins over collectionFieldExtractors.
18+
var preservedFields = map[string]bool{
19+
"html_url": true,
20+
}
21+
22+
// collectionFieldExtractors controls how array fields are handled instead of being summarized as "[N items]".
23+
// - 1 sub-field: comma-joined into a flat string ("bug, enhancement").
24+
// - Multiple sub-fields: keep the array, but trim each element to only those fields.
25+
//
26+
// These are explicitly exempt from fill-rate filtering; if we asked for the extraction, it's likely important
27+
// to preserve the data even if only one item has it.
28+
var collectionFieldExtractors = map[string][]string{
29+
"labels": {"name"},
30+
"requested_reviewers": {"login"},
31+
}
32+
33+
// MarshalItems is the single entry point for response optimization.
34+
// Handles two shapes: plain JSON arrays and wrapped objects with metadata.
35+
func MarshalItems(data any) ([]byte, error) {
36+
raw, err := json.Marshal(data)
37+
if err != nil {
38+
return nil, fmt.Errorf("failed to marshal data: %w", err)
39+
}
40+
41+
switch raw[0] {
42+
case '[':
43+
return optimizeArray(raw)
44+
case '{':
45+
return optimizeObject(raw)
46+
default:
47+
return raw, nil
48+
}
49+
}
50+
51+
// OptimizeItems runs the full optimization pipeline on a slice of items:
52+
// flatten, remove URLs, remove zero-values, normalize whitespace,
53+
// summarize collections, and fill-rate filtering.
54+
func OptimizeItems(items []map[string]any) []map[string]any {
55+
if len(items) == 0 {
56+
return items
57+
}
58+
59+
for i, item := range items {
60+
flattenedItem := flatten(item)
61+
items[i] = optimizeItem(flattenedItem)
62+
}
63+
64+
if len(items) >= minFillRateRows {
65+
items = filterByFillRate(items, defaultFillRateThreshold)
66+
}
67+
68+
return items
69+
}
70+
71+
// flatten promotes values from one-level-deep nested maps into the parent
72+
// using dot-notation keys ("user.login", "user.id"). Nested maps and arrays
73+
// within those nested maps are dropped.
74+
func flatten(item map[string]any) map[string]any {
75+
result := make(map[string]any, len(item))
76+
for key, value := range item {
77+
nested, ok := value.(map[string]any)
78+
if !ok {
79+
result[key] = value
80+
continue
81+
}
82+
83+
for nk, nv := range nested {
84+
switch nv.(type) {
85+
case map[string]any, []any:
86+
// skip complex nested values
87+
default:
88+
result[key+"."+nk] = nv
89+
}
90+
}
91+
}
92+
93+
return result
94+
}
95+
96+
// filterByFillRate drops keys that appear on less than the threshold proportion of items.
97+
// Preserved fields and extractor keys always survive.
98+
func filterByFillRate(items []map[string]any, threshold float64) []map[string]any {
99+
keyCounts := make(map[string]int)
100+
for _, item := range items {
101+
for key := range item {
102+
keyCounts[key]++
103+
}
104+
}
105+
106+
minCount := int(threshold * float64(len(items)))
107+
keepKeys := make(map[string]bool, len(keyCounts))
108+
for key, count := range keyCounts {
109+
_, hasExtractor := collectionFieldExtractors[key]
110+
if count > minCount || preservedFields[key] || hasExtractor {
111+
keepKeys[key] = true
112+
}
113+
}
114+
115+
for i, item := range items {
116+
filtered := make(map[string]any, len(keepKeys))
117+
for key, value := range item {
118+
if keepKeys[key] {
119+
filtered[key] = value
120+
}
121+
}
122+
items[i] = filtered
123+
}
124+
125+
return items
126+
}
127+
128+
// optimizeArray is the entry point for optimizing a raw JSON array.
129+
func optimizeArray(raw []byte) ([]byte, error) {
130+
var items []map[string]any
131+
if err := json.Unmarshal(raw, &items); err != nil {
132+
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
133+
}
134+
return json.Marshal(OptimizeItems(items))
135+
}
136+
137+
// optimizeObject is the entry point for optimizing a raw JSON object.
138+
func optimizeObject(raw []byte) ([]byte, error) {
139+
var wrapper map[string]any
140+
if err := json.Unmarshal(raw, &wrapper); err != nil {
141+
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
142+
}
143+
144+
// find the actual data array within the wrapper; rest is metadata to be preserved as is
145+
var dataKey string
146+
for key, value := range wrapper {
147+
if _, ok := value.([]any); ok {
148+
dataKey = key
149+
break
150+
}
151+
}
152+
// if no data array found, just return the original response
153+
if dataKey == "" {
154+
return raw, nil
155+
}
156+
157+
rawItems := wrapper[dataKey].([]any)
158+
items := make([]map[string]any, 0, len(rawItems))
159+
for _, rawItem := range rawItems {
160+
if m, ok := rawItem.(map[string]any); ok {
161+
items = append(items, m)
162+
}
163+
}
164+
wrapper[dataKey] = OptimizeItems(items)
165+
166+
return json.Marshal(wrapper)
167+
}
168+
169+
// optimizeItem applies per-item strategies in a single pass: remove URLs,
170+
// remove zero-values, normalize whitespace, summarize collections.
171+
// Preserved fields skip everything except whitespace normalization.
172+
func optimizeItem(item map[string]any) map[string]any {
173+
result := make(map[string]any, len(item))
174+
for key, value := range item {
175+
preserved := preservedFields[key]
176+
if !preserved && isURLKey(key) {
177+
continue
178+
}
179+
if !preserved && isZeroValue(value) {
180+
continue
181+
}
182+
183+
switch v := value.(type) {
184+
case string:
185+
result[key] = strings.Join(strings.Fields(v), " ")
186+
case []any:
187+
if len(v) == 0 {
188+
continue
189+
}
190+
191+
if preserved {
192+
result[key] = value
193+
} else if fields, ok := collectionFieldExtractors[key]; ok {
194+
if len(fields) == 1 {
195+
result[key] = extractSubField(v, fields[0])
196+
} else {
197+
result[key] = trimArrayFields(v, fields)
198+
}
199+
} else {
200+
result[key] = fmt.Sprintf("[%d items]", len(v))
201+
}
202+
default:
203+
result[key] = value
204+
}
205+
}
206+
207+
return result
208+
}
209+
210+
// extractSubField pulls a named sub-field from each slice element and joins
211+
// them with ", ". Elements missing the field are silently skipped.
212+
func extractSubField(items []any, field string) string {
213+
var vals []string
214+
for _, item := range items {
215+
m, ok := item.(map[string]any)
216+
if !ok {
217+
continue
218+
}
219+
220+
v, ok := m[field]
221+
if !ok || v == nil {
222+
continue
223+
}
224+
225+
switch s := v.(type) {
226+
case string:
227+
if s != "" {
228+
vals = append(vals, s)
229+
}
230+
default:
231+
vals = append(vals, fmt.Sprintf("%v", v))
232+
}
233+
}
234+
235+
return strings.Join(vals, ", ")
236+
}
237+
238+
// trimArrayFields keeps only the specified fields from each object in a slice.
239+
// The trimmed objects are returned as is, no further strategies are applied.
240+
func trimArrayFields(items []any, fields []string) []any {
241+
result := make([]any, 0, len(items))
242+
for _, item := range items {
243+
m, ok := item.(map[string]any)
244+
if !ok {
245+
continue
246+
}
247+
248+
trimmed := make(map[string]any, len(fields))
249+
for _, f := range fields {
250+
if v, exists := m[f]; exists {
251+
trimmed[f] = v
252+
}
253+
}
254+
255+
if len(trimmed) > 0 {
256+
result = append(result, trimmed)
257+
}
258+
}
259+
260+
return result
261+
}
262+
263+
// isURLKey matches "url", "*_url", and their dot-prefixed variants.
264+
func isURLKey(key string) bool {
265+
base := key
266+
if idx := strings.LastIndex(base, "."); idx >= 0 {
267+
base = base[idx+1:]
268+
}
269+
return base == "url" || strings.HasSuffix(base, "_url")
270+
}
271+
272+
func isZeroValue(v any) bool {
273+
switch val := v.(type) {
274+
case nil:
275+
return true
276+
case string:
277+
return val == ""
278+
case bool:
279+
return !val
280+
case float64:
281+
return val == 0
282+
default:
283+
return false
284+
}
285+
}

0 commit comments

Comments
 (0)