@@ -3,7 +3,6 @@ package gateway
33import (
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
1718const (
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+
2327func 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.
4758type 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