返回 DeepSeek-Reasonix
cache.go
根目录 / internal / plugin / cache.go
1 // Package-internal cache for MCP handshake results. The handshake (initialize +
2 // listTools, plus optional listPrompts/listResources) costs hundreds of ms to a
3 // few seconds per server on cold start. We persist the tool schema +
4 // capabilities under the user cache dir keyed by load-bearing, non-secret Spec
5 // fields, so the next launch can register tools optimistically
6 // without waiting for the network/subprocess. Caching is purely an
7 // optimisation: any failure (missing dir, bad JSON, key mismatch) silently
8 // degrades to a fresh handshake.
9 package plugin
10
11 import (
12 "crypto/sha256"
13 "encoding/hex"
14 "encoding/json"
15 "io"
16 "log/slog"
17 "path/filepath"
18 "regexp"
19 "slices"
20 "sort"
21 "strings"
22 "time"
23
24 "reasonix/internal/config"
25 "reasonix/internal/fileutil"
26 fileencoding "reasonix/internal/fileutil/encoding"
27 "reasonix/internal/tool"
28 )
29
30 // cacheableToolsOf extracts the persistable subset of remote tools so Start()
31 // can hand them to SaveCachedSchema. Non-remote tools are skipped — Start
32 // only feeds remote ones at the call site, but the type-assert is defensive.
33 func cacheableToolsOf(tools []tool.Tool) []CachedTool {
34 out := make([]CachedTool, 0, len(tools))
35 for _, t := range tools {
36 rt, ok := t.(*remoteTool)
37 if !ok {
38 continue
39 }
40 declaredReadOnly, _, destructive := rt.securitySnapshot()
41 out = append(out, CachedTool{
42 Name: rt.rawName,
43 Description: rt.desc,
44 Schema: rt.schema,
45 OutputSchema: rt.outputSchema,
46 ReadOnly: declaredReadOnly,
47 Destructive: destructive,
48 Visibility: append([]string(nil), rt.visibility...),
49 UIResourceURI: rt.uiResourceURI,
50 })
51 }
52 return out
53 }
54
55 // cacheVersion bumps whenever CachedSchema shape changes incompatibly. Old
56 // files with a smaller version are treated as a miss so a stale layout never
57 // crashes a reader. Version 2 is the legacy shared layout; profiles that
58 // declare capabilities beyond the legacy surface write version 3 into a
59 // profile-isolated file instead (see SaveCachedSchemaForProfile).
60 const cacheVersion = 2
61
62 const enhancedCacheVersion = 3
63
64 // CachedSchema is the persisted snapshot of one server's handshake result.
65 // CacheKey gates reuse — Capabilities/Tools are only trusted when the
66 // caller's expectedKey (from SchemaCacheKey of the current Spec) matches,
67 // so renaming env vars or swapping a command never serves stale tools.
68 type CachedSchema struct {
69 Version int `json:"version"`
70 // Keep the historical JSON key so older versions can reuse the same
71 // best-effort cache during rolling upgrades.
72 CacheKey string `json:"spec_hash"`
73 Capabilities map[string]bool `json:"capabilities"`
74 Tools []CachedTool `json:"tools"`
75 LastValidated time.Time `json:"last_validated"`
76 // Profile records which host capability profile produced this catalog.
77 // Only set in enhanced (v3) caches; empty in the legacy shared layout.
78 Profile string `json:"profile,omitempty"`
79 }
80
81 // CachedTool mirrors the subset of an MCP tool definition we need to register
82 // a placeholder before the real handshake completes: Name (raw, server-local),
83 // Description (model-visible), Schema (raw JSON for input validation),
84 // ReadOnly and Destructive (drive Plan/read-only safety classification).
85 type CachedTool struct {
86 Name string `json:"name"`
87 Description string `json:"description"`
88 Schema json.RawMessage `json:"schema"`
89 OutputSchema json.RawMessage `json:"output_schema,omitempty"`
90 ReadOnly bool `json:"read_only"`
91 Destructive bool `json:"destructive,omitempty"`
92 // Visibility is the MCP Apps tool visibility ("model", "app"). Zero value
93 // means the spec default ["model","app"]. Enhanced caches only.
94 Visibility []string `json:"visibility,omitempty"`
95 // UIResourceURI is the _meta.ui.resourceUri an App uses to render. Empty
96 // for tools without an App surface. Enhanced caches only.
97 UIResourceURI string `json:"ui_resource_uri,omitempty"`
98 }
99
100 // ToolIsModelVisible reports whether a cached tool may enter the model catalog.
101 // App-only tools stay in the server-private App catalog.
102 func (t CachedTool) ToolIsModelVisible() bool {
103 if len(t.Visibility) == 0 {
104 return true
105 }
106 return slices.Contains(t.Visibility, "model")
107 }
108
109 // SchemaCacheKey hashes the load-bearing, non-secret parts of a Spec. Secret
110 // values in env and headers are intentionally excluded: credential rotation
111 // must not leak through a stable digest or force an unrelated trust review.
112 // Their sorted key names remain identity-bearing, so adding/removing a runtime
113 // input still invalidates the cached schema.
114 func SchemaCacheKey(s Spec) string {
115 return schemaCacheKeyForURL(s, normalizeIdentityURL(s.URL))
116 }
117
118 // legacySchemaCacheKey recomputes the cache key with the
119 // pre-credential-aware URL normalization, or ("", false) when it cannot
120 // differ. LoadCachedSchemaForSpec uses it to upgrade old cache entries in
121 // place; remove together with legacyNormalizeIdentityURL.
122 func legacySchemaCacheKey(s Spec) (string, bool) {
123 legacyURL := legacyNormalizeIdentityURL(s.URL)
124 if strings.TrimSpace(s.URL) == "" || legacyURL == normalizeIdentityURL(s.URL) {
125 return "", false
126 }
127 return schemaCacheKeyForURL(s, legacyURL), true
128 }
129
130 func schemaCacheKeyForURL(s Spec, urlValue string) string {
131 h := sha256.New()
132 writeField(h, "name", s.Name)
133 writeField(h, "type", s.Type)
134 writeField(h, "command", s.Command)
135 writeField(h, "url", urlValue)
136 writeField(h, "dir", s.Dir)
137 for _, a := range s.Args {
138 writeField(h, "arg", a)
139 }
140 writeKeys(h, "env", s.Env)
141 writeKeys(h, "headers", s.Headers)
142 return hex.EncodeToString(h.Sum(nil))
143 }
144
145 // LoadCachedSchema returns the cached schema for name iff it exists, parses,
146 // and matches expectedKey. Any error → (nil, false): cache is best-effort,
147 // a corrupt file just means we re-handshake. Returning an error here would
148 // only invite callers to log it on every launch — silence is intentional.
149 func LoadCachedSchema(name, expectedKey string) (*CachedSchema, bool) {
150 cs, ok, keyOK := LoadCachedSchemaAny(name, expectedKey)
151 if !ok || !keyOK {
152 return nil, false
153 }
154 return cs, true
155 }
156
157 // LoadCachedSchemaForSpec returns the cached schema matching the spec's
158 // current key, transparently rewriting an entry still saved under the legacy
159 // URL key. Without the in-place upgrade, credential rotation or
160 // the credential-aware normalization rollout would force a pointless
161 // re-handshake even though nothing observable changed.
162 func LoadCachedSchemaForSpec(s Spec) (*CachedSchema, bool) {
163 current := SchemaCacheKey(s)
164 if cs, ok := LoadCachedSchema(s.Name, current); ok {
165 return cs, true
166 }
167 legacy, ok := legacySchemaCacheKey(s)
168 if !ok {
169 return nil, false
170 }
171 cs, ok := LoadCachedSchema(s.Name, legacy)
172 if !ok {
173 return nil, false
174 }
175 cs.CacheKey = current
176 _ = SaveCachedSchema(s.Name, *cs)
177 return cs, true
178 }
179
180 // LoadCachedSchemaAny returns the cached schema regardless of cache-key match,
181 // plus whether the key matched expectedKey. Catalog building uses it so a
182 // mismatched cache can still surface tools as stale candidates;
183 // execution paths must keep using LoadCachedSchema, which refuses mismatches.
184 func LoadCachedSchemaAny(name, expectedKey string) (cs *CachedSchema, ok bool, keyOK bool) {
185 p := cachePath(name)
186 if p == "" {
187 return nil, false, false
188 }
189 b, err := fileencoding.ReadFileUTF8(p)
190 if err != nil {
191 return nil, false, false
192 }
193 var out CachedSchema
194 if err := json.Unmarshal(b, &out); err != nil {
195 return nil, false, false
196 }
197 if out.Version != cacheVersion {
198 return nil, false, false
199 }
200 out.Tools = filterValidCachedTools(out.Tools)
201 return &out, true, out.CacheKey == expectedKey
202 }
203
204 func filterValidCachedTools(tools []CachedTool) []CachedTool {
205 out := make([]CachedTool, 0, len(tools))
206 for _, t := range tools {
207 schema, err := normalizeAndValidateToolSchema(t.Schema)
208 if err != nil {
209 continue
210 }
211 t.Schema = schema
212 out = append(out, t)
213 }
214 return out
215 }
216
217 // SaveCachedSchema atomically writes cs under name. Best-effort: an error is
218 // logged at debug level and returned. The shared replacement helper preserves
219 // overwrite semantics on Windows as well as crash safety on Unix.
220 func SaveCachedSchema(name string, cs CachedSchema) error {
221 p := cachePath(name)
222 if p == "" {
223 return nil
224 }
225 cs.Version = cacheVersion
226 if cs.LastValidated.IsZero() {
227 cs.LastValidated = time.Now().UTC()
228 }
229 b, err := json.MarshalIndent(cs, "", " ")
230 if err != nil {
231 slog.Debug("plugin cache: marshal", "name", name, "err", err)
232 return err
233 }
234 if err := fileutil.AtomicWriteFile(p, b, 0o600); err != nil {
235 slog.Debug("plugin cache: atomic write", "name", name, "err", err)
236 return err
237 }
238 return nil
239 }
240
241 // cachePath returns "<config.CacheDir()>/mcp/<slug(name)>.json". Returns ""
242 // when CacheDir is unavailable (no-op caching).
243 func cachePath(name string) string {
244 base := config.CacheDir()
245 if base == "" {
246 return ""
247 }
248 return filepath.Join(base, "mcp", slug(name)+".json")
249 }
250
251 // SaveCachedSchemaForProfile writes the handshake snapshot under the profile's
252 // cache identity. The legacy profile keeps writing the shared v2 file so old
253 // binaries keep working during rolling upgrades; capability-declaring profiles
254 // write an isolated v3 file (<slug>.host-<hash>.json) whose catalog reflects
255 // the tools/list the server returned for exactly those declared capabilities.
256 // The two writers never touch each other's files.
257 func SaveCachedSchemaForProfile(profile HostProfile, name string, cs CachedSchema) error {
258 profile = profile.Normalize()
259 p := cachePathForProfile(name, profile)
260 if p == "" {
261 return nil
262 }
263 if profile.UsesEnhancedCache() {
264 cs.Version = enhancedCacheVersion
265 cs.Profile = profile.String()
266 } else {
267 cs.Version = cacheVersion
268 cs.Profile = ""
269 }
270 if cs.LastValidated.IsZero() {
271 cs.LastValidated = time.Now().UTC()
272 }
273 b, err := json.MarshalIndent(cs, "", " ")
274 if err != nil {
275 slog.Debug("plugin cache: marshal", "name", name, "err", err)
276 return err
277 }
278 if err := fileutil.AtomicWriteFile(p, b, 0o600); err != nil {
279 slog.Debug("plugin cache: atomic write", "name", name, "err", err)
280 return err
281 }
282 return nil
283 }
284
285 // cachePathForProfile maps a profile onto its cache file. The legacy profile
286 // keeps the shared <slug>.json; enhanced profiles get
287 // <slug>.host-<profileHash>.json.
288 func cachePathForProfile(name string, profile HostProfile) string {
289 base := config.CacheDir()
290 if base == "" {
291 return ""
292 }
293 if !profile.UsesEnhancedCache() {
294 return cachePath(name)
295 }
296 return filepath.Join(base, "mcp", slug(name)+".host-"+profile.ProfileCacheHash()+".json")
297 }
298
299 // LoadCachedSchemaForSpecProfile loads the handshake snapshot for the
300 // profile's cache identity. An enhanced profile treats the legacy file as a
301 // miss — the old catalog was negotiated under different client capabilities
302 // and must not pose as this profile's catalog. A future version is a miss for
303 // every reader.
304 func LoadCachedSchemaForSpecProfile(s Spec, profile HostProfile) (*CachedSchema, bool) {
305 profile = profile.Normalize()
306 if !profile.UsesEnhancedCache() {
307 return LoadCachedSchemaForSpec(s)
308 }
309 current := SchemaCacheKey(s)
310 p := cachePathForProfile(s.Name, profile)
311 if p == "" {
312 return nil, false
313 }
314 b, err := fileencoding.ReadFileUTF8(p)
315 if err != nil {
316 return nil, false
317 }
318 var out CachedSchema
319 if err := json.Unmarshal(b, &out); err != nil {
320 return nil, false
321 }
322 if out.Version != enhancedCacheVersion || out.Profile != profile.String() || out.CacheKey != current {
323 return nil, false
324 }
325 out.Tools = filterValidCachedTools(out.Tools)
326 return &out, true
327 }
328
329 // slugReplace strips characters that aren't safe in a filename across the
330 // OSes we target. We lowercase first so the slug is stable regardless of
331 // the user's display capitalisation.
332 var slugReplace = regexp.MustCompile(`[^a-z0-9_-]+`)
333
334 // windowsReservedDeviceNames are DOS device names Windows reserves as file
335 // stems (with or without an extension), matched case-insensitively.
336 var windowsReservedDeviceNames = map[string]bool{
337 "con": true, "prn": true, "aux": true, "nul": true,
338 "com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
339 "com6": true, "com7": true, "com8": true, "com9": true,
340 "lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
341 "lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
342 }
343
344 // slug sanitises name for use as a filename. Names changed by sanitization —
345 // and Windows-reserved device stems such as "con" or "com1", which would name
346 // a device rather than a file — get a strong suffix so confusable names cannot
347 // make one MCP server consume another server's cached schemas, stats, or
348 // private state directory. Ordinary safe names stay byte-identical.
349 func slug(name string) string {
350 s := slugReplace.ReplaceAllString(strings.ToLower(name), "-")
351 s = strings.Trim(s, "-")
352 if s == "" {
353 s = "_"
354 }
355 if s != name || windowsReservedDeviceNames[s] {
356 sum := sha256.Sum256([]byte(name))
357 s += "-" + hex.EncodeToString(sum[:6])
358 }
359 return s
360 }
361
362 // writeField feeds a single tagged field into h with explicit separators so
363 // the boundary between (key, value) and the next field can't collide via
364 // concatenation (e.g. "command" + "foo" vs "comm" + "andfoo").
365 func writeField(h io.Writer, key, val string) {
366 _, _ = h.Write([]byte(key))
367 _, _ = h.Write([]byte{0})
368 _, _ = h.Write([]byte(val))
369 _, _ = h.Write([]byte{1})
370 }
371
372 // writeKeys hashes only sorted map keys, so Go's randomised iteration order
373 // cannot perturb the non-secret identity digest.
374 func writeKeys(h io.Writer, key string, m map[string]string) {
375 if len(m) == 0 {
376 writeField(h, key, "")
377 return
378 }
379 keys := make([]string, 0, len(m))
380 for k := range m {
381 keys = append(keys, k)
382 }
383 sort.Strings(keys)
384 for _, k := range keys {
385 writeField(h, key+"."+k, "present")
386 }
387 }
388
388 lines GO