Skip to content

Commit eb8c3fc

Browse files
joshfreeCopilot
andcommitted
Add ETag conditional requests to the REST transport
Every REST request was issued unconditionally: the ETag returned by the GitHub API was never stored or replayed, so repeated tool calls that read the same resource (for example pull request reads, file and commit listings, and reviews) re-downloaded the full response each time. This adds an ETagTransport round tripper that caches the ETag and body of cacheable GET responses and sends If-None-Match on the next identical request. When the API answers 304 Not Modified, the cached body is served instead of re-downloading it. The transport is inserted below the user-agent and auth layers in createGitHubClients(), so cached entries are scoped by the request's Authorization header and never shared across tokens. The cache is bounded (LRU) and safe for concurrent use. Every request is still sent to the server, so responses are always revalidated and never served stale. Per the GitHub REST API docs, a 304 Not Modified response does not count against the token's primary rate limit, so repeated reads conserve rate-limit budget and bandwidth while returning identical data. Rate-limit headers are surfaced from the live 304 response so downstream rate-limit accounting stays correct. Closes #3025 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
1 parent 3778a41 commit eb8c3fc

4 files changed

Lines changed: 410 additions & 1 deletion

File tree

internal/ghmcp/server.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
6666
// authenticate via BearerAuthTransport and skip go-github's WithAuthToken:
6767
// the latter installs its own round tripper that would pin the static token
6868
// and shadow the dynamic one.
69+
//
70+
// ETagTransport sits below the user-agent (and auth) layers so that, by the
71+
// time it runs, the Authorization header is set and can scope the
72+
// conditional-request cache per token. It adds ETag/If-None-Match handling
73+
// so unchanged resources are revalidated with a 304 instead of being
74+
// re-downloaded in full.
6975
restUATransport := &transport.UserAgentTransport{
70-
Transport: http.DefaultTransport,
76+
Transport: &transport.ETagTransport{Transport: http.DefaultTransport},
7177
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
7278
}
7379
var restClient *gogithub.Client

pkg/http/headers/headers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const (
99
AcceptHeader = "Accept"
1010
// UserAgentHeader is a standard HTTP Header.
1111
UserAgentHeader = "User-Agent"
12+
// ETagHeader is a standard HTTP Header carrying a response entity tag.
13+
ETagHeader = "ETag"
14+
// IfNoneMatchHeader is a standard HTTP Header used to make a request conditional on an entity tag.
15+
IfNoneMatchHeader = "If-None-Match"
1216

1317
// ContentTypeJSON is the standard MIME type for JSON.
1418
ContentTypeJSON = "application/json"

pkg/http/transport/etag.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package transport
2+
3+
import (
4+
"bytes"
5+
"container/list"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"sync"
12+
13+
"github.com/github/github-mcp-server/pkg/http/headers"
14+
)
15+
16+
// defaultETagCacheSize bounds the number of cached conditional responses held
17+
// in memory by an ETagTransport.
18+
const defaultETagCacheSize = 512
19+
20+
// rateLimitHeaders are copied from the live 304 response onto a cache-served
21+
// response so downstream rate-limit accounting observes the current state.
22+
var rateLimitHeaders = []string{
23+
"X-RateLimit-Limit",
24+
"X-RateLimit-Remaining",
25+
"X-RateLimit-Used",
26+
"X-RateLimit-Reset",
27+
"X-RateLimit-Resource",
28+
"Retry-After",
29+
"Date",
30+
}
31+
32+
// etagEntry is a cached response body and headers keyed by an ETag.
33+
type etagEntry struct {
34+
etag string
35+
status int
36+
header http.Header
37+
body []byte
38+
}
39+
40+
// response reconstructs an *http.Response from a cached entry, layering the
41+
// live 304 response's rate-limit and timing headers on top so the caller sees
42+
// the current rate-limit state while receiving the cached body.
43+
func (e etagEntry) response(live *http.Response) *http.Response {
44+
h := e.header.Clone()
45+
for _, name := range rateLimitHeaders {
46+
if values := live.Header.Values(name); len(values) > 0 {
47+
h.Del(name)
48+
for _, v := range values {
49+
h.Add(name, v)
50+
}
51+
}
52+
}
53+
return &http.Response{
54+
Status: fmt.Sprintf("%d %s", e.status, http.StatusText(e.status)),
55+
StatusCode: e.status,
56+
Proto: live.Proto,
57+
ProtoMajor: live.ProtoMajor,
58+
ProtoMinor: live.ProtoMinor,
59+
Header: h,
60+
Body: io.NopCloser(bytes.NewReader(e.body)),
61+
ContentLength: int64(len(e.body)),
62+
Request: live.Request,
63+
}
64+
}
65+
66+
type lruItem struct {
67+
key string
68+
entry etagEntry
69+
}
70+
71+
// ETagTransport is an http.RoundTripper that adds HTTP conditional-request
72+
// support (ETag / If-None-Match) to GET requests. For each cacheable GET it
73+
// stores the response ETag and body; on a subsequent identical request it sends
74+
// If-None-Match and, when the server answers 304 Not Modified, serves the
75+
// cached body instead of re-downloading it.
76+
//
77+
// Every request is still sent to the server, so responses are always
78+
// revalidated and never served stale. A 304 Not Modified does not count against
79+
// the GitHub REST API primary rate limit, so revalidated requests conserve
80+
// rate-limit budget and bandwidth.
81+
//
82+
// Cached entries are scoped by the request's Authorization header so responses
83+
// are never shared across tokens. The cache is bounded (LRU) and safe for
84+
// concurrent use.
85+
type ETagTransport struct {
86+
Transport http.RoundTripper
87+
88+
// MaxEntries bounds the number of cached responses. When zero,
89+
// defaultETagCacheSize is used.
90+
MaxEntries int
91+
92+
mu sync.Mutex
93+
ll *list.List
94+
items map[string]*list.Element
95+
}
96+
97+
func (t *ETagTransport) RoundTrip(req *http.Request) (*http.Response, error) {
98+
rt := t.Transport
99+
if rt == nil {
100+
rt = http.DefaultTransport
101+
}
102+
103+
// Only cache GET requests, and never override a caller-supplied conditional
104+
// header.
105+
if req.Method != http.MethodGet || req.Header.Get(headers.IfNoneMatchHeader) != "" {
106+
return rt.RoundTrip(req)
107+
}
108+
109+
key := cacheKey(req)
110+
cached, ok := t.get(key)
111+
112+
req = req.Clone(req.Context())
113+
if ok {
114+
req.Header.Set(headers.IfNoneMatchHeader, cached.etag)
115+
}
116+
117+
resp, err := rt.RoundTrip(req)
118+
if err != nil {
119+
return resp, err
120+
}
121+
122+
if resp.StatusCode == http.StatusNotModified && ok {
123+
// Discard the empty 304 body and serve the cached response instead.
124+
if resp.Body != nil {
125+
_, _ = io.Copy(io.Discard, resp.Body)
126+
resp.Body.Close()
127+
}
128+
return cached.response(resp), nil
129+
}
130+
131+
if resp.StatusCode == http.StatusOK {
132+
if etag := resp.Header.Get(headers.ETagHeader); etag != "" {
133+
body, readErr := io.ReadAll(resp.Body)
134+
resp.Body.Close()
135+
if readErr != nil {
136+
return nil, readErr
137+
}
138+
t.add(key, etagEntry{
139+
etag: etag,
140+
status: resp.StatusCode,
141+
header: resp.Header.Clone(),
142+
body: body,
143+
})
144+
resp.Body = io.NopCloser(bytes.NewReader(body))
145+
resp.ContentLength = int64(len(body))
146+
}
147+
}
148+
149+
return resp, nil
150+
}
151+
152+
func cacheKey(req *http.Request) string {
153+
sum := sha256.Sum256([]byte(req.Header.Get(headers.AuthorizationHeader)))
154+
return req.Method + " " + req.URL.String() + " " + hex.EncodeToString(sum[:8])
155+
}
156+
157+
func (t *ETagTransport) get(key string) (etagEntry, bool) {
158+
t.mu.Lock()
159+
defer t.mu.Unlock()
160+
if t.items == nil {
161+
return etagEntry{}, false
162+
}
163+
el, ok := t.items[key]
164+
if !ok {
165+
return etagEntry{}, false
166+
}
167+
t.ll.MoveToFront(el)
168+
return el.Value.(*lruItem).entry, true
169+
}
170+
171+
func (t *ETagTransport) add(key string, entry etagEntry) {
172+
t.mu.Lock()
173+
defer t.mu.Unlock()
174+
if t.items == nil {
175+
t.items = make(map[string]*list.Element)
176+
t.ll = list.New()
177+
}
178+
if el, ok := t.items[key]; ok {
179+
el.Value.(*lruItem).entry = entry
180+
t.ll.MoveToFront(el)
181+
return
182+
}
183+
el := t.ll.PushFront(&lruItem{key: key, entry: entry})
184+
t.items[key] = el
185+
186+
max := t.MaxEntries
187+
if max <= 0 {
188+
max = defaultETagCacheSize
189+
}
190+
for t.ll.Len() > max {
191+
oldest := t.ll.Back()
192+
if oldest == nil {
193+
break
194+
}
195+
t.ll.Remove(oldest)
196+
delete(t.items, oldest.Value.(*lruItem).key)
197+
}
198+
}

0 commit comments

Comments
 (0)