Skip to content

Commit f678e03

Browse files
authored
Merge pull request #2124 from dgageot/board/look-at-the-mcp-gateway-code-and-find-pl-0251e8c8
Simplify MCP catalog loading: single fetch per run with ETag caching
2 parents 84a4bce + d6db654 commit f678e03

1 file changed

Lines changed: 104 additions & 153 deletions

File tree

pkg/gateway/catalog.go

Lines changed: 104 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package gateway
33
import (
44
"context"
55
"encoding/json"
6-
"errors"
76
"fmt"
87
"log/slog"
98
"net/http"
@@ -12,14 +11,19 @@ import (
1211
"strings"
1312
"sync"
1413
"time"
14+
15+
"github.com/docker/docker-agent/pkg/paths"
1516
)
1617

1718
const (
1819
DockerCatalogURL = "https://desktop.docker.com/mcp/catalog/v3/catalog.yaml"
1920
catalogCacheFileName = "mcp_catalog.json"
20-
catalogCacheDuration = 24 * time.Hour
21+
fetchTimeout = 15 * time.Second
2122
)
2223

24+
// catalogJSON is the URL we actually fetch (JSON is ~3x faster to parse than YAML).
25+
var catalogJSON = strings.Replace(DockerCatalogURL, ".yaml", ".json", 1)
26+
2327
func RequiredEnvVars(ctx context.Context, serverName string) ([]Secret, error) {
2428
server, err := ServerSpec(ctx, serverName)
2529
if err != nil {
@@ -36,211 +40,158 @@ func RequiredEnvVars(ctx context.Context, serverName string) ([]Secret, error) {
3640
return server.Secrets, nil
3741
}
3842

39-
func ServerSpec(_ context.Context, serverName string) (Server, error) {
40-
server, ok := getCatalogServer(serverName)
43+
func ServerSpec(ctx context.Context, serverName string) (Server, error) {
44+
catalog, err := loadCatalog(ctx)
45+
if err != nil {
46+
return Server{}, err
47+
}
48+
49+
server, ok := catalog[serverName]
4150
if !ok {
4251
return Server{}, fmt.Errorf("MCP server %q not found in MCP catalog", serverName)
4352
}
53+
4454
return server, nil
4555
}
4656

57+
// cachedCatalog is the on-disk cache format.
4758
type cachedCatalog struct {
48-
Catalog Catalog `json:"catalog"`
49-
CachedAt time.Time `json:"cached_at"`
59+
Catalog Catalog `json:"catalog"`
60+
ETag string `json:"etag,omitempty"`
5061
}
5162

52-
var (
53-
catalogMu sync.RWMutex
54-
catalogData Catalog
55-
catalogLoaded bool
56-
catalogStale bool
57-
refreshOnce sync.Once
58-
)
59-
60-
// getCatalogServer returns a server from the catalog, refreshing if needed.
61-
// If server is not found in cache, it will try to fetch fresh data from network
62-
// in case it's a newly added server.
63-
func getCatalogServer(serverName string) (Server, bool) {
64-
// First, ensure catalog is loaded
65-
ensureCatalogLoaded()
66-
67-
catalogMu.RLock()
68-
server, ok := catalogData[serverName]
69-
stale := catalogStale
70-
catalogMu.RUnlock()
71-
72-
if ok {
73-
// Found in cache. If stale, trigger background refresh for next time.
74-
if stale {
75-
triggerBackgroundRefresh()
76-
}
77-
return server, true
78-
}
79-
80-
// Server not found in cache. Try fetching fresh data in case it's a new server.
81-
if refreshCatalogFromNetwork() {
82-
catalogMu.RLock()
83-
server, ok = catalogData[serverName]
84-
catalogMu.RUnlock()
85-
return server, ok
86-
}
87-
88-
return Server{}, false
63+
// catalogOnce guards one-shot catalog loading.
64+
// We use sync.OnceValues so that:
65+
// - the catalog is fetched at most once per process, and
66+
// - we detach from the caller's context to avoid permanently
67+
// caching a context-cancellation error.
68+
var catalogOnce = sync.OnceValues(func() (Catalog, error) {
69+
return fetchAndCache(context.Background())
70+
})
71+
72+
// loadCatalog returns the catalog, fetching it at most once per process run.
73+
// On network failure it falls back to the disk cache.
74+
func loadCatalog(_ context.Context) (Catalog, error) {
75+
return catalogOnce()
8976
}
9077

91-
// ensureCatalogLoaded loads the catalog from cache or network on first access.
92-
func ensureCatalogLoaded() {
93-
catalogMu.RLock()
94-
loaded := catalogLoaded
95-
catalogMu.RUnlock()
78+
// fetchAndCache tries to fetch the catalog from the network (using ETag for
79+
// conditional requests) and falls back to the disk cache on any failure.
80+
func fetchAndCache(ctx context.Context) (Catalog, error) {
81+
cacheFile := cacheFilePath()
82+
cached := loadFromDisk(cacheFile)
9683

97-
if loaded {
98-
return
99-
}
100-
101-
catalogMu.Lock()
102-
defer catalogMu.Unlock()
103-
104-
// Double-check after acquiring write lock
105-
if catalogLoaded {
106-
return
107-
}
108-
109-
cacheFile := getCacheFilePath()
110-
111-
// Try loading from local cache first
112-
if cached, cacheAge, err := loadCatalogFromCache(cacheFile); err == nil {
113-
slog.Debug("Loaded MCP catalog from cache", "file", cacheFile, "age", cacheAge.Round(time.Second))
114-
catalogData = cached
115-
catalogLoaded = true
116-
catalogStale = cacheAge > catalogCacheDuration
117-
return
84+
catalog, newETag, err := fetchFromNetwork(ctx, cached.ETag)
85+
if err != nil {
86+
slog.Debug("Failed to fetch MCP catalog from network, using cache", "error", err)
87+
if cached.Catalog != nil {
88+
return cached.Catalog, nil
89+
}
90+
return nil, fmt.Errorf("fetching MCP catalog: %w (no cached copy available)", err)
11891
}
11992

120-
// Cache miss or invalid, fetch from network
121-
catalog, err := fetchCatalogFromNetwork()
122-
if err != nil {
123-
slog.Error("Failed to fetch MCP catalog", "error", err)
124-
return
93+
// A nil catalog means 304 Not Modified — the cached copy is still valid.
94+
if catalog == nil {
95+
slog.Debug("MCP catalog not modified (ETag match)")
96+
return cached.Catalog, nil
12597
}
12698

127-
catalogData = catalog
128-
catalogLoaded = true
129-
catalogStale = false
99+
slog.Debug("MCP catalog fetched from network")
100+
saveToDisk(cacheFile, catalog, newETag)
130101

131-
// Save to cache (best effort)
132-
if err := saveCatalogToCache(cacheFile, catalog); err != nil {
133-
slog.Warn("Failed to save MCP catalog to cache", "error", err)
134-
}
102+
return catalog, nil
135103
}
136104

137-
// triggerBackgroundRefresh starts a background goroutine to refresh the catalog.
138-
// Only one background refresh will run at a time.
139-
func triggerBackgroundRefresh() {
140-
refreshOnce.Do(func() {
141-
go func() {
142-
refreshCatalogFromNetwork()
143-
// Reset refreshOnce so future stale reads can trigger another refresh
144-
refreshOnce = sync.Once{}
145-
}()
146-
})
105+
func cacheFilePath() string {
106+
return filepath.Join(paths.GetCacheDir(), catalogCacheFileName)
147107
}
148108

149-
// refreshCatalogFromNetwork fetches fresh catalog data and updates the cache.
150-
// Returns true if refresh was successful.
151-
func refreshCatalogFromNetwork() bool {
152-
catalog, err := fetchCatalogFromNetwork()
109+
func loadFromDisk(path string) cachedCatalog {
110+
data, err := os.ReadFile(path)
153111
if err != nil {
154-
slog.Debug("Background catalog refresh failed", "error", err)
155-
return false
112+
return cachedCatalog{}
156113
}
157114

158-
catalogMu.Lock()
159-
catalogData = catalog
160-
catalogStale = false
161-
catalogMu.Unlock()
162-
163-
// Save to cache (best effort)
164-
if err := saveCatalogToCache(getCacheFilePath(), catalog); err != nil {
165-
slog.Warn("Failed to save refreshed MCP catalog to cache", "error", err)
115+
var cached cachedCatalog
116+
if err := json.Unmarshal(data, &cached); err != nil {
117+
return cachedCatalog{}
166118
}
167119

168-
slog.Debug("MCP catalog refreshed from network")
169-
return true
120+
return cached
170121
}
171122

172-
func getCacheFilePath() string {
173-
homeDir, err := os.UserHomeDir()
123+
func saveToDisk(path string, catalog Catalog, etag string) {
124+
data, err := json.Marshal(cachedCatalog{Catalog: catalog, ETag: etag})
174125
if err != nil {
175-
return ""
126+
slog.Warn("Failed to marshal MCP catalog cache", "error", err)
127+
return
176128
}
177-
return filepath.Join(homeDir, ".cagent", catalogCacheFileName)
178-
}
179129

180-
func loadCatalogFromCache(cacheFile string) (Catalog, time.Duration, error) {
181-
if cacheFile == "" {
182-
return nil, 0, errors.New("no cache file path")
130+
dir := filepath.Dir(path)
131+
if err := os.MkdirAll(dir, 0o755); err != nil {
132+
slog.Warn("Failed to create MCP catalog cache directory", "error", err)
133+
return
183134
}
184135

185-
data, err := os.ReadFile(cacheFile)
136+
// Write to a temp file and rename so readers never see a partial file.
137+
tmp, err := os.CreateTemp(dir, ".mcp_catalog_*.tmp")
186138
if err != nil {
187-
return nil, 0, fmt.Errorf("failed to read cache file: %w", err)
139+
slog.Warn("Failed to create MCP catalog temp file", "error", err)
140+
return
188141
}
142+
tmpName := tmp.Name()
189143

190-
var cached cachedCatalog
191-
if err := json.Unmarshal(data, &cached); err != nil {
192-
return nil, 0, fmt.Errorf("failed to unmarshal cached data: %w", err)
144+
if _, err := tmp.Write(data); err != nil {
145+
tmp.Close()
146+
os.Remove(tmpName)
147+
slog.Warn("Failed to write MCP catalog temp file", "error", err)
148+
return
193149
}
194-
195-
cacheAge := time.Since(cached.CachedAt)
196-
return cached.Catalog, cacheAge, nil
197-
}
198-
199-
func saveCatalogToCache(cacheFile string, catalog Catalog) error {
200-
if cacheFile == "" {
201-
return nil
150+
if err := tmp.Close(); err != nil {
151+
os.Remove(tmpName)
152+
slog.Warn("Failed to close MCP catalog temp file", "error", err)
153+
return
202154
}
203155

204-
// Ensure directory exists
205-
if err := os.MkdirAll(filepath.Dir(cacheFile), 0o755); err != nil {
206-
return fmt.Errorf("failed to create cache directory: %w", err)
156+
if err := os.Rename(tmpName, path); err != nil {
157+
os.Remove(tmpName)
158+
slog.Warn("Failed to rename MCP catalog cache file", "error", err)
207159
}
160+
}
208161

209-
cached := cachedCatalog{
210-
Catalog: catalog,
211-
CachedAt: time.Now(),
212-
}
162+
// fetchFromNetwork fetches the catalog, using the ETag for conditional requests.
163+
// It returns (nil, "", nil) when the server responds with 304 Not Modified.
164+
func fetchFromNetwork(ctx context.Context, etag string) (Catalog, string, error) {
165+
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
166+
defer cancel()
213167

214-
data, err := json.Marshal(cached)
168+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, catalogJSON, http.NoBody)
215169
if err != nil {
216-
return fmt.Errorf("failed to marshal cached data: %w", err)
170+
return nil, "", err
217171
}
218172

219-
if err := os.WriteFile(cacheFile, data, 0o644); err != nil {
220-
return fmt.Errorf("failed to write cache file: %w", err)
173+
if etag != "" {
174+
req.Header.Set("If-None-Match", etag)
221175
}
222176

223-
return nil
224-
}
225-
226-
func fetchCatalogFromNetwork() (Catalog, error) {
227-
// Use the JSON version because it's 3x time faster to parse than YAML.
228-
url := strings.Replace(DockerCatalogURL, ".yaml", ".json", 1)
229-
230-
resp, err := http.Get(url)
177+
resp, err := http.DefaultClient.Do(req)
231178
if err != nil {
232-
return nil, err
179+
return nil, "", err
233180
}
234181
defer resp.Body.Close()
235182

183+
if resp.StatusCode == http.StatusNotModified {
184+
return nil, "", nil
185+
}
186+
236187
if resp.StatusCode != http.StatusOK {
237-
return nil, fmt.Errorf("failed to fetch URL: %s, status: %s", url, resp.Status)
188+
return nil, "", fmt.Errorf("unexpected status fetching MCP catalog: %s", resp.Status)
238189
}
239190

240-
var topLevel topLevel
241-
if err := json.NewDecoder(resp.Body).Decode(&topLevel); err != nil {
242-
return nil, err
191+
var top topLevel
192+
if err := json.NewDecoder(resp.Body).Decode(&top); err != nil {
193+
return nil, "", err
243194
}
244195

245-
return topLevel.Catalog, nil
196+
return top.Catalog, resp.Header.Get("ETag"), nil
246197
}

0 commit comments

Comments
 (0)