返回 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 "sort"
20 "strings"
21 "time"
22
23 "reasonix/internal/config"
24 "reasonix/internal/fileutil"
25 fileencoding "reasonix/internal/fileutil/encoding"
26 "reasonix/internal/tool"
27 )
28
29 // cacheableToolsOf extracts the persistable subset of remote tools so Start()
30 // can hand them to SaveCachedSchema. Non-remote tools are skipped — Start
31 // only feeds remote ones at the call site, but the type-assert is defensive.
32 func cacheableToolsOf(tools []tool.Tool) []CachedTool {
33 out := make([]CachedTool, 0, len(tools))
34 for _, t := range tools {
35 rt, ok := t.(*remoteTool)
36 if !ok {
37 continue
38 }
39 declaredReadOnly, _, destructive := rt.securitySnapshot()
40 out = append(out, CachedTool{
41 Name: rt.rawName,
42 Description: rt.desc,
43 Schema: rt.schema,
44 OutputSchema: rt.outputSchema,
45 ReadOnly: declaredReadOnly,
46 Destructive: destructive,
47 })
48 }
49 return out
50 }
51
52 // cacheVersion bumps whenever CachedSchema shape changes incompatibly. Old
53 // files with a smaller version are treated as a miss so a stale layout never
54 // crashes a reader.
55 const cacheVersion = 2
56
57 // CachedSchema is the persisted snapshot of one server's handshake result.
58 // CacheKey gates reuse — Capabilities/Tools are only trusted when the
59 // caller's expectedKey (from SchemaCacheKey of the current Spec) matches,
60 // so renaming env vars or swapping a command never serves stale tools.
61 type CachedSchema struct {
62 Version int `json:"version"`
63 // Keep the historical JSON key so older versions can reuse the same
64 // best-effort cache during rolling upgrades.
65 CacheKey string `json:"spec_hash"`
66 Capabilities map[string]bool `json:"capabilities"`
67 Tools []CachedTool `json:"tools"`
68 LastValidated time.Time `json:"last_validated"`
69 }
70
71 // CachedTool mirrors the subset of an MCP tool definition we need to register
72 // a placeholder before the real handshake completes: Name (raw, server-local),
73 // Description (model-visible), Schema (raw JSON for input validation),
74 // ReadOnly and Destructive (drive Plan/read-only safety classification).
75 type CachedTool struct {
76 Name string `json:"name"`
77 Description string `json:"description"`
78 Schema json.RawMessage `json:"schema"`
79 OutputSchema json.RawMessage `json:"output_schema,omitempty"`
80 ReadOnly bool `json:"read_only"`
81 Destructive bool `json:"destructive,omitempty"`
82 }
83
84 // SchemaCacheKey hashes the load-bearing, non-secret parts of a Spec. Secret
85 // values in env and headers are intentionally excluded: credential rotation
86 // must not leak through a stable digest or force an unrelated trust review.
87 // Their sorted key names remain identity-bearing, so adding/removing a runtime
88 // input still invalidates the cached schema.
89 func SchemaCacheKey(s Spec) string {
90 return schemaCacheKeyForURL(s, normalizeIdentityURL(s.URL))
91 }
92
93 // legacySchemaCacheKey recomputes the cache key with the
94 // pre-credential-aware URL normalization, or ("", false) when it cannot
95 // differ. LoadCachedSchemaForSpec uses it to upgrade old cache entries in
96 // place; remove together with legacyNormalizeIdentityURL.
97 func legacySchemaCacheKey(s Spec) (string, bool) {
98 legacyURL := legacyNormalizeIdentityURL(s.URL)
99 if strings.TrimSpace(s.URL) == "" || legacyURL == normalizeIdentityURL(s.URL) {
100 return "", false
101 }
102 return schemaCacheKeyForURL(s, legacyURL), true
103 }
104
105 func schemaCacheKeyForURL(s Spec, urlValue string) string {
106 h := sha256.New()
107 writeField(h, "name", s.Name)
108 writeField(h, "type", s.Type)
109 writeField(h, "command", s.Command)
110 writeField(h, "url", urlValue)
111 writeField(h, "dir", s.Dir)
112 for _, a := range s.Args {
113 writeField(h, "arg", a)
114 }
115 writeKeys(h, "env", s.Env)
116 writeKeys(h, "headers", s.Headers)
117 return hex.EncodeToString(h.Sum(nil))
118 }
119
120 // LoadCachedSchema returns the cached schema for name iff it exists, parses,
121 // and matches expectedKey. Any error → (nil, false): cache is best-effort,
122 // a corrupt file just means we re-handshake. Returning an error here would
123 // only invite callers to log it on every launch — silence is intentional.
124 func LoadCachedSchema(name, expectedKey string) (*CachedSchema, bool) {
125 cs, ok, keyOK := LoadCachedSchemaAny(name, expectedKey)
126 if !ok || !keyOK {
127 return nil, false
128 }
129 return cs, true
130 }
131
132 // LoadCachedSchemaForSpec returns the cached schema matching the spec's
133 // current key, transparently rewriting an entry still saved under the legacy
134 // URL key. Without the in-place upgrade, credential rotation or
135 // the credential-aware normalization rollout would force a pointless
136 // re-handshake even though nothing observable changed.
137 func LoadCachedSchemaForSpec(s Spec) (*CachedSchema, bool) {
138 current := SchemaCacheKey(s)
139 if cs, ok := LoadCachedSchema(s.Name, current); ok {
140 return cs, true
141 }
142 legacy, ok := legacySchemaCacheKey(s)
143 if !ok {
144 return nil, false
145 }
146 cs, ok := LoadCachedSchema(s.Name, legacy)
147 if !ok {
148 return nil, false
149 }
150 cs.CacheKey = current
151 _ = SaveCachedSchema(s.Name, *cs)
152 return cs, true
153 }
154
155 // LoadCachedSchemaAny returns the cached schema regardless of cache-key match,
156 // plus whether the key matched expectedKey. Catalog building uses it so a
157 // mismatched cache can still surface tools as stale candidates;
158 // execution paths must keep using LoadCachedSchema, which refuses mismatches.
159 func LoadCachedSchemaAny(name, expectedKey string) (cs *CachedSchema, ok bool, keyOK bool) {
160 p := cachePath(name)
161 if p == "" {
162 return nil, false, false
163 }
164 b, err := fileencoding.ReadFileUTF8(p)
165 if err != nil {
166 return nil, false, false
167 }
168 var out CachedSchema
169 if err := json.Unmarshal(b, &out); err != nil {
170 return nil, false, false
171 }
172 if out.Version != cacheVersion {
173 return nil, false, false
174 }
175 out.Tools = filterValidCachedTools(out.Tools)
176 return &out, true, out.CacheKey == expectedKey
177 }
178
179 func filterValidCachedTools(tools []CachedTool) []CachedTool {
180 out := make([]CachedTool, 0, len(tools))
181 for _, t := range tools {
182 schema, err := normalizeAndValidateToolSchema(t.Schema)
183 if err != nil {
184 continue
185 }
186 t.Schema = schema
187 out = append(out, t)
188 }
189 return out
190 }
191
192 // SaveCachedSchema atomically writes cs under name. Best-effort: an error is
193 // logged at debug level and returned. The shared replacement helper preserves
194 // overwrite semantics on Windows as well as crash safety on Unix.
195 func SaveCachedSchema(name string, cs CachedSchema) error {
196 p := cachePath(name)
197 if p == "" {
198 return nil
199 }
200 cs.Version = cacheVersion
201 if cs.LastValidated.IsZero() {
202 cs.LastValidated = time.Now().UTC()
203 }
204 b, err := json.MarshalIndent(cs, "", " ")
205 if err != nil {
206 slog.Debug("plugin cache: marshal", "name", name, "err", err)
207 return err
208 }
209 if err := fileutil.AtomicWriteFile(p, b, 0o600); err != nil {
210 slog.Debug("plugin cache: atomic write", "name", name, "err", err)
211 return err
212 }
213 return nil
214 }
215
216 // cachePath returns "<config.CacheDir()>/mcp/<slug(name)>.json". Returns ""
217 // when CacheDir is unavailable (no-op caching).
218 func cachePath(name string) string {
219 base := config.CacheDir()
220 if base == "" {
221 return ""
222 }
223 return filepath.Join(base, "mcp", slug(name)+".json")
224 }
225
226 // slugReplace strips characters that aren't safe in a filename across the
227 // OSes we target. We lowercase first so the slug is stable regardless of
228 // the user's display capitalisation.
229 var slugReplace = regexp.MustCompile(`[^a-z0-9_-]+`)
230
231 // windowsReservedDeviceNames are DOS device names Windows reserves as file
232 // stems (with or without an extension), matched case-insensitively.
233 var windowsReservedDeviceNames = map[string]bool{
234 "con": true, "prn": true, "aux": true, "nul": true,
235 "com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
236 "com6": true, "com7": true, "com8": true, "com9": true,
237 "lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
238 "lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
239 }
240
241 // slug sanitises name for use as a filename. Names changed by sanitization —
242 // and Windows-reserved device stems such as "con" or "com1", which would name
243 // a device rather than a file — get a strong suffix so confusable names cannot
244 // make one MCP server consume another server's cached schemas, stats, or
245 // private state directory. Ordinary safe names stay byte-identical.
246 func slug(name string) string {
247 s := slugReplace.ReplaceAllString(strings.ToLower(name), "-")
248 s = strings.Trim(s, "-")
249 if s == "" {
250 s = "_"
251 }
252 if s != name || windowsReservedDeviceNames[s] {
253 sum := sha256.Sum256([]byte(name))
254 s += "-" + hex.EncodeToString(sum[:6])
255 }
256 return s
257 }
258
259 // writeField feeds a single tagged field into h with explicit separators so
260 // the boundary between (key, value) and the next field can't collide via
261 // concatenation (e.g. "command" + "foo" vs "comm" + "andfoo").
262 func writeField(h io.Writer, key, val string) {
263 _, _ = h.Write([]byte(key))
264 _, _ = h.Write([]byte{0})
265 _, _ = h.Write([]byte(val))
266 _, _ = h.Write([]byte{1})
267 }
268
269 // writeKeys hashes only sorted map keys, so Go's randomised iteration order
270 // cannot perturb the non-secret identity digest.
271 func writeKeys(h io.Writer, key string, m map[string]string) {
272 if len(m) == 0 {
273 writeField(h, key, "")
274 return
275 }
276 keys := make([]string, 0, len(m))
277 for k := range m {
278 keys = append(keys, k)
279 }
280 sort.Strings(keys)
281 for _, k := range keys {
282 writeField(h, key+"."+k, "present")
283 }
284 }
285
285 lines GO