mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
Merge the latest main-v2 baseline and resolve the release-note, bundle-budget, and repolint conflicts that left PR #9547 unmergeable. Negotiate MCP Apps in both directions, prefer stable nested UI metadata, preserve complete bounded CallToolResult payloads, support unambiguous legacy push elicitation, and cancel App-owned calls with their instance lifecycle. Add protocol-version, metadata, result-shape, and lifecycle coverage and wire the MCP App frontend contract test into CI. Verified with Go 1.26.6 tests and vet for both modules, targeted race tests, frontend production build and protocol tests, golangci-lint including cross-platform tags, govulncheck, repolint, release-note validation, and release workflow contracts.
388 lines
14 KiB
Go
388 lines
14 KiB
Go
// Package-internal cache for MCP handshake results. The handshake (initialize +
|
|
// listTools, plus optional listPrompts/listResources) costs hundreds of ms to a
|
|
// few seconds per server on cold start. We persist the tool schema +
|
|
// capabilities under the user cache dir keyed by load-bearing, non-secret Spec
|
|
// fields, so the next launch can register tools optimistically
|
|
// without waiting for the network/subprocess. Caching is purely an
|
|
// optimisation: any failure (missing dir, bad JSON, key mismatch) silently
|
|
// degrades to a fresh handshake.
|
|
package plugin
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// cacheableToolsOf extracts the persistable subset of remote tools so Start()
|
|
// can hand them to SaveCachedSchema. Non-remote tools are skipped — Start
|
|
// only feeds remote ones at the call site, but the type-assert is defensive.
|
|
func cacheableToolsOf(tools []tool.Tool) []CachedTool {
|
|
out := make([]CachedTool, 0, len(tools))
|
|
for _, t := range tools {
|
|
rt, ok := t.(*remoteTool)
|
|
if !ok {
|
|
continue
|
|
}
|
|
declaredReadOnly, _, destructive := rt.securitySnapshot()
|
|
out = append(out, CachedTool{
|
|
Name: rt.rawName,
|
|
Description: rt.desc,
|
|
Schema: rt.schema,
|
|
OutputSchema: rt.outputSchema,
|
|
ReadOnly: declaredReadOnly,
|
|
Destructive: destructive,
|
|
Visibility: append([]string(nil), rt.visibility...),
|
|
UIResourceURI: rt.uiResourceURI,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cacheVersion bumps whenever CachedSchema shape changes incompatibly. Old
|
|
// files with a smaller version are treated as a miss so a stale layout never
|
|
// crashes a reader. Version 2 is the legacy shared layout; profiles that
|
|
// declare capabilities beyond the legacy surface write version 3 into a
|
|
// profile-isolated file instead (see SaveCachedSchemaForProfile).
|
|
const cacheVersion = 2
|
|
|
|
const enhancedCacheVersion = 3
|
|
|
|
// CachedSchema is the persisted snapshot of one server's handshake result.
|
|
// CacheKey gates reuse — Capabilities/Tools are only trusted when the
|
|
// caller's expectedKey (from SchemaCacheKey of the current Spec) matches,
|
|
// so renaming env vars or swapping a command never serves stale tools.
|
|
type CachedSchema struct {
|
|
Version int `json:"version"`
|
|
// Keep the historical JSON key so older versions can reuse the same
|
|
// best-effort cache during rolling upgrades.
|
|
CacheKey string `json:"spec_hash"`
|
|
Capabilities map[string]bool `json:"capabilities"`
|
|
Tools []CachedTool `json:"tools"`
|
|
LastValidated time.Time `json:"last_validated"`
|
|
// Profile records which host capability profile produced this catalog.
|
|
// Only set in enhanced (v3) caches; empty in the legacy shared layout.
|
|
Profile string `json:"profile,omitempty"`
|
|
}
|
|
|
|
// CachedTool mirrors the subset of an MCP tool definition we need to register
|
|
// a placeholder before the real handshake completes: Name (raw, server-local),
|
|
// Description (model-visible), Schema (raw JSON for input validation),
|
|
// ReadOnly and Destructive (drive Plan/read-only safety classification).
|
|
type CachedTool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Schema json.RawMessage `json:"schema"`
|
|
OutputSchema json.RawMessage `json:"output_schema,omitempty"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Destructive bool `json:"destructive,omitempty"`
|
|
// Visibility is the MCP Apps tool visibility ("model", "app"). Zero value
|
|
// means the spec default ["model","app"]. Enhanced caches only.
|
|
Visibility []string `json:"visibility,omitempty"`
|
|
// UIResourceURI is the _meta.ui.resourceUri an App uses to render. Empty
|
|
// for tools without an App surface. Enhanced caches only.
|
|
UIResourceURI string `json:"ui_resource_uri,omitempty"`
|
|
}
|
|
|
|
// ToolIsModelVisible reports whether a cached tool may enter the model catalog.
|
|
// App-only tools stay in the server-private App catalog.
|
|
func (t CachedTool) ToolIsModelVisible() bool {
|
|
if len(t.Visibility) == 0 {
|
|
return true
|
|
}
|
|
return slices.Contains(t.Visibility, "model")
|
|
}
|
|
|
|
// SchemaCacheKey hashes the load-bearing, non-secret parts of a Spec. Secret
|
|
// values in env and headers are intentionally excluded: credential rotation
|
|
// must not leak through a stable digest or force an unrelated trust review.
|
|
// Their sorted key names remain identity-bearing, so adding/removing a runtime
|
|
// input still invalidates the cached schema.
|
|
func SchemaCacheKey(s Spec) string {
|
|
return schemaCacheKeyForURL(s, normalizeIdentityURL(s.URL))
|
|
}
|
|
|
|
// legacySchemaCacheKey recomputes the cache key with the
|
|
// pre-credential-aware URL normalization, or ("", false) when it cannot
|
|
// differ. LoadCachedSchemaForSpec uses it to upgrade old cache entries in
|
|
// place; remove together with legacyNormalizeIdentityURL.
|
|
func legacySchemaCacheKey(s Spec) (string, bool) {
|
|
legacyURL := legacyNormalizeIdentityURL(s.URL)
|
|
if strings.TrimSpace(s.URL) == "" || legacyURL == normalizeIdentityURL(s.URL) {
|
|
return "", false
|
|
}
|
|
return schemaCacheKeyForURL(s, legacyURL), true
|
|
}
|
|
|
|
func schemaCacheKeyForURL(s Spec, urlValue string) string {
|
|
h := sha256.New()
|
|
writeField(h, "name", s.Name)
|
|
writeField(h, "type", s.Type)
|
|
writeField(h, "command", s.Command)
|
|
writeField(h, "url", urlValue)
|
|
writeField(h, "dir", s.Dir)
|
|
for _, a := range s.Args {
|
|
writeField(h, "arg", a)
|
|
}
|
|
writeKeys(h, "env", s.Env)
|
|
writeKeys(h, "headers", s.Headers)
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// LoadCachedSchema returns the cached schema for name iff it exists, parses,
|
|
// and matches expectedKey. Any error → (nil, false): cache is best-effort,
|
|
// a corrupt file just means we re-handshake. Returning an error here would
|
|
// only invite callers to log it on every launch — silence is intentional.
|
|
func LoadCachedSchema(name, expectedKey string) (*CachedSchema, bool) {
|
|
cs, ok, keyOK := LoadCachedSchemaAny(name, expectedKey)
|
|
if !ok || !keyOK {
|
|
return nil, false
|
|
}
|
|
return cs, true
|
|
}
|
|
|
|
// LoadCachedSchemaForSpec returns the cached schema matching the spec's
|
|
// current key, transparently rewriting an entry still saved under the legacy
|
|
// URL key. Without the in-place upgrade, credential rotation or
|
|
// the credential-aware normalization rollout would force a pointless
|
|
// re-handshake even though nothing observable changed.
|
|
func LoadCachedSchemaForSpec(s Spec) (*CachedSchema, bool) {
|
|
current := SchemaCacheKey(s)
|
|
if cs, ok := LoadCachedSchema(s.Name, current); ok {
|
|
return cs, true
|
|
}
|
|
legacy, ok := legacySchemaCacheKey(s)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
cs, ok := LoadCachedSchema(s.Name, legacy)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
cs.CacheKey = current
|
|
_ = SaveCachedSchema(s.Name, *cs)
|
|
return cs, true
|
|
}
|
|
|
|
// LoadCachedSchemaAny returns the cached schema regardless of cache-key match,
|
|
// plus whether the key matched expectedKey. Catalog building uses it so a
|
|
// mismatched cache can still surface tools as stale candidates;
|
|
// execution paths must keep using LoadCachedSchema, which refuses mismatches.
|
|
func LoadCachedSchemaAny(name, expectedKey string) (cs *CachedSchema, ok bool, keyOK bool) {
|
|
p := cachePath(name)
|
|
if p == "" {
|
|
return nil, false, false
|
|
}
|
|
b, err := fileencoding.ReadFileUTF8(p)
|
|
if err != nil {
|
|
return nil, false, false
|
|
}
|
|
var out CachedSchema
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return nil, false, false
|
|
}
|
|
if out.Version != cacheVersion {
|
|
return nil, false, false
|
|
}
|
|
out.Tools = filterValidCachedTools(out.Tools)
|
|
return &out, true, out.CacheKey == expectedKey
|
|
}
|
|
|
|
func filterValidCachedTools(tools []CachedTool) []CachedTool {
|
|
out := make([]CachedTool, 0, len(tools))
|
|
for _, t := range tools {
|
|
schema, err := normalizeAndValidateToolSchema(t.Schema)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
t.Schema = schema
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SaveCachedSchema atomically writes cs under name. Best-effort: an error is
|
|
// logged at debug level and returned. The shared replacement helper preserves
|
|
// overwrite semantics on Windows as well as crash safety on Unix.
|
|
func SaveCachedSchema(name string, cs CachedSchema) error {
|
|
p := cachePath(name)
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
cs.Version = cacheVersion
|
|
if cs.LastValidated.IsZero() {
|
|
cs.LastValidated = time.Now().UTC()
|
|
}
|
|
b, err := json.MarshalIndent(cs, "", " ")
|
|
if err != nil {
|
|
slog.Debug("plugin cache: marshal", "name", name, "err", err)
|
|
return err
|
|
}
|
|
if err := fileutil.AtomicWriteFile(p, b, 0o600); err != nil {
|
|
slog.Debug("plugin cache: atomic write", "name", name, "err", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cachePath returns "<config.CacheDir()>/mcp/<slug(name)>.json". Returns ""
|
|
// when CacheDir is unavailable (no-op caching).
|
|
func cachePath(name string) string {
|
|
base := config.CacheDir()
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(base, "mcp", slug(name)+".json")
|
|
}
|
|
|
|
// SaveCachedSchemaForProfile writes the handshake snapshot under the profile's
|
|
// cache identity. The legacy profile keeps writing the shared v2 file so old
|
|
// binaries keep working during rolling upgrades; capability-declaring profiles
|
|
// write an isolated v3 file (<slug>.host-<hash>.json) whose catalog reflects
|
|
// the tools/list the server returned for exactly those declared capabilities.
|
|
// The two writers never touch each other's files.
|
|
func SaveCachedSchemaForProfile(profile HostProfile, name string, cs CachedSchema) error {
|
|
profile = profile.Normalize()
|
|
p := cachePathForProfile(name, profile)
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
if profile.UsesEnhancedCache() {
|
|
cs.Version = enhancedCacheVersion
|
|
cs.Profile = profile.String()
|
|
} else {
|
|
cs.Version = cacheVersion
|
|
cs.Profile = ""
|
|
}
|
|
if cs.LastValidated.IsZero() {
|
|
cs.LastValidated = time.Now().UTC()
|
|
}
|
|
b, err := json.MarshalIndent(cs, "", " ")
|
|
if err != nil {
|
|
slog.Debug("plugin cache: marshal", "name", name, "err", err)
|
|
return err
|
|
}
|
|
if err := fileutil.AtomicWriteFile(p, b, 0o600); err != nil {
|
|
slog.Debug("plugin cache: atomic write", "name", name, "err", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cachePathForProfile maps a profile onto its cache file. The legacy profile
|
|
// keeps the shared <slug>.json; enhanced profiles get
|
|
// <slug>.host-<profileHash>.json.
|
|
func cachePathForProfile(name string, profile HostProfile) string {
|
|
base := config.CacheDir()
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
if !profile.UsesEnhancedCache() {
|
|
return cachePath(name)
|
|
}
|
|
return filepath.Join(base, "mcp", slug(name)+".host-"+profile.ProfileCacheHash()+".json")
|
|
}
|
|
|
|
// LoadCachedSchemaForSpecProfile loads the handshake snapshot for the
|
|
// profile's cache identity. An enhanced profile treats the legacy file as a
|
|
// miss — the old catalog was negotiated under different client capabilities
|
|
// and must not pose as this profile's catalog. A future version is a miss for
|
|
// every reader.
|
|
func LoadCachedSchemaForSpecProfile(s Spec, profile HostProfile) (*CachedSchema, bool) {
|
|
profile = profile.Normalize()
|
|
if !profile.UsesEnhancedCache() {
|
|
return LoadCachedSchemaForSpec(s)
|
|
}
|
|
current := SchemaCacheKey(s)
|
|
p := cachePathForProfile(s.Name, profile)
|
|
if p == "" {
|
|
return nil, false
|
|
}
|
|
b, err := fileencoding.ReadFileUTF8(p)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var out CachedSchema
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return nil, false
|
|
}
|
|
if out.Version != enhancedCacheVersion || out.Profile != profile.String() || out.CacheKey != current {
|
|
return nil, false
|
|
}
|
|
out.Tools = filterValidCachedTools(out.Tools)
|
|
return &out, true
|
|
}
|
|
|
|
// slugReplace strips characters that aren't safe in a filename across the
|
|
// OSes we target. We lowercase first so the slug is stable regardless of
|
|
// the user's display capitalisation.
|
|
var slugReplace = regexp.MustCompile(`[^a-z0-9_-]+`)
|
|
|
|
// windowsReservedDeviceNames are DOS device names Windows reserves as file
|
|
// stems (with or without an extension), matched case-insensitively.
|
|
var windowsReservedDeviceNames = map[string]bool{
|
|
"con": true, "prn": true, "aux": true, "nul": true,
|
|
"com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
|
|
"com6": true, "com7": true, "com8": true, "com9": true,
|
|
"lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
|
|
"lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
|
|
}
|
|
|
|
// slug sanitises name for use as a filename. Names changed by sanitization —
|
|
// and Windows-reserved device stems such as "con" or "com1", which would name
|
|
// a device rather than a file — get a strong suffix so confusable names cannot
|
|
// make one MCP server consume another server's cached schemas, stats, or
|
|
// private state directory. Ordinary safe names stay byte-identical.
|
|
func slug(name string) string {
|
|
s := slugReplace.ReplaceAllString(strings.ToLower(name), "-")
|
|
s = strings.Trim(s, "-")
|
|
if s == "" {
|
|
s = "_"
|
|
}
|
|
if s != name || windowsReservedDeviceNames[s] {
|
|
sum := sha256.Sum256([]byte(name))
|
|
s += "-" + hex.EncodeToString(sum[:6])
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeField feeds a single tagged field into h with explicit separators so
|
|
// the boundary between (key, value) and the next field can't collide via
|
|
// concatenation (e.g. "command" + "foo" vs "comm" + "andfoo").
|
|
func writeField(h io.Writer, key, val string) {
|
|
_, _ = h.Write([]byte(key))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(val))
|
|
_, _ = h.Write([]byte{1})
|
|
}
|
|
|
|
// writeKeys hashes only sorted map keys, so Go's randomised iteration order
|
|
// cannot perturb the non-secret identity digest.
|
|
func writeKeys(h io.Writer, key string, m map[string]string) {
|
|
if len(m) == 0 {
|
|
writeField(h, key, "")
|
|
return
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
writeField(h, key+"."+k, "present")
|
|
}
|
|
}
|