diff --git a/pkgs/mcpx/docs/spec/conformance-matrix.md b/pkgs/mcpx/docs/spec/conformance-matrix.md index 8c74df998..b42bf2f37 100644 --- a/pkgs/mcpx/docs/spec/conformance-matrix.md +++ b/pkgs/mcpx/docs/spec/conformance-matrix.md @@ -2,7 +2,7 @@ Generated by `internal/conformance` (`MCPX_UPDATE_MATRIX=1 go test ./internal/conformance -run TestMatrix`); do not edit. -Generated from inputs `ad5f12fd46e96bac` (sha256 of docs/spec/requirements.md, internal/conformance/overrides/*.json, internal/conformance/coverage*.go). `TestMatrix` regenerates this file and fails when it differs, so it describes the tree it is committed in. +Generated from inputs `32a7633a66634a16` (sha256 of docs/spec/requirements.md, internal/conformance/overrides/*.json, internal/conformance/coverage*.go). `TestMatrix` regenerates this file and fails when it differs, so it describes the tree it is committed in. Every normative requirement of every revision (catalogue: `docs/spec/requirements.md`), for mcpx as a server and as a client. A requirement is *tested* when a named test verifies it in that revision, *n/a* when it does not bind mcpx in that role, and a *gap* when it binds mcpx and mcpx does not meet it yet, with the issue that tracks it. Run exactly the covering tests with `go run ./internal/conformance/cmd/covering`. @@ -65,7 +65,7 @@ Every normative requirement of every revision (catalogue: `docs/spec/requirement | 2026-07-28 | server | MAY | 31 | 29 | 2 | 0 | 30 | | 2026-07-28 | server | other | 23 | 22 | 1 | 0 | 4 | | 2026-07-28 | client | MUST | 91 | 63 | 28 | 0 | 133 | -| 2026-07-28 | client | MUST NOT | 26 | 22 | 4 | 0 | 38 | +| 2026-07-28 | client | MUST NOT | 26 | 23 | 3 | 0 | 38 | | 2026-07-28 | client | SHOULD | 75 | 52 | 23 | 0 | 85 | | 2026-07-28 | client | SHOULD NOT | 13 | 11 | 2 | 0 | 7 | | 2026-07-28 | client | MAY | 15 | 11 | 4 | 0 | 46 | @@ -1905,7 +1905,7 @@ Every normative requirement of every revision (catalogue: `docs/spec/requirement | [caching-ttl-not-poll](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#freshness-calculation) | SHOULD NOT / MUST | client | n/a: addresses the other side | tested: `conformance.TestStatefulClient/2026-07-28/caching/caching-ttl-not-poll` | | [caching-may-refetch-early-serve-stale](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#freshness-calculation) | MAY | client | n/a: addresses the other side | **gap** #200: the client ignores ttlMs and cacheScope; the pool keeps upstream lists until list_changed or restart | | [caching-public-shareable](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-scope-field) | MAY | both | n/a: catalogue marks it n/a | n/a: informational -- permits sharing a public result across users; imposes nothing | -| [caching-private-no-cross-auth](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-scope-field) | MUST NOT | both (any cache) | n/a: catalogue marks it n/a | **gap** #200: cacheScope is ignored, so a private upstream list is served to every downstream caller | +| [caching-private-no-cross-auth](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-scope-field) | MUST NOT | both (any cache) | n/a: catalogue marks it n/a | tested: `daemon.TestLoadCacheNeverSeedsPrivateEntry@2026-07-28`
`daemon.TestSaveCacheSkipsPrivatePools@2026-07-28`
`pool.TestPoolCacheStaleServedOnRefetchError@2026-07-28` | | [caching-notification-invalidates](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-notifications) | descr. (lowercase should) | client | n/a: addresses the other side | **gap** #257: unverified since subscriptions/listen landed upstream (#239): nothing tests that a modern list_changed empties the pool list cache | | [caching-page-refetch](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-pagination) | SHOULD | client | n/a: addresses the other side | **gap** #200: the client ignores ttlMs and cacheScope; the pool keeps upstream lists until list_changed or restart | | [caching-page-same-scope](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-pagination) | MUST | server | tested: `mcpserver.TestModernEnvelope/2026-07-28/caching/every-page-has-the-same-cacheScope` | n/a: addresses the other side | diff --git a/pkgs/mcpx/internal/conformance/coverage_stateful.go b/pkgs/mcpx/internal/conformance/coverage_stateful.go index d984c434c..cbc72e629 100644 --- a/pkgs/mcpx/internal/conformance/coverage_stateful.go +++ b/pkgs/mcpx/internal/conformance/coverage_stateful.go @@ -20,6 +20,7 @@ func init() { C("caching-ttl-absent-zero", "pool.TestPoolCacheTTLAbsentStale@2026-07-28", "mcpclient.TestListPromptsWithHintsAbsent@2026-07-28"), C("caching-ttl-negative-zero", "pool.TestPoolCacheTTLNegativeStale@2026-07-28"), C("caching-stale-refetch", "pool.TestPoolCacheConcurrentExpiredCollapse@2026-07-28", "pool.TestPoolCacheTTLZeroStale@2026-07-28"), + C("caching-private-no-cross-auth", "daemon.TestSaveCacheSkipsPrivatePools@2026-07-28", "daemon.TestLoadCacheNeverSeedsPrivateEntry@2026-07-28", "pool.TestPoolCacheStaleServedOnRefetchError@2026-07-28"), S("caching-server-must-hint", "mcpserver.TestModernEnvelope/2026-07-28/caching/tools-list-has-ttlMs-and-cacheScope", "mcpserver.TestModernEnvelope/2026-07-28/caching/resources-read-has-ttlMs-and-cacheScope", "mcpserver.TestModernEnvelope/2026-07-28/caching/server-discover-has-ttlMs-and-cacheScope"), S("cache-ttl-scope-required", "mcpserver.TestModernEnvelope/2026-07-28/caching/server-discover-has-ttlMs-and-cacheScope", @@ -118,7 +119,6 @@ func init() { Gap{ID: "caching-may-refetch-early-serve-stale", Side: Client, Issue: "#200", Why: "the client ignores ttlMs and cacheScope; the pool keeps upstream lists until list_changed or restart"}, Gap{ID: "caching-page-refetch", Side: Client, Issue: "#200", Why: "the client ignores ttlMs and cacheScope; the pool keeps upstream lists until list_changed or restart"}, Gap{ID: "caching-key-method-params", Side: Client, Issue: "#200", Why: "the client ignores ttlMs and cacheScope; the pool keeps upstream lists until list_changed or restart"}, - Gap{ID: "caching-private-no-cross-auth", Side: Client, Issue: "#200", Why: "cacheScope is ignored, so a private upstream list is served to every downstream caller"}, Gap{ID: "caching-notification-invalidates", Side: Client, Issue: "#257", Why: "unverified since subscriptions/listen landed upstream (#239): nothing tests that a modern list_changed empties the pool list cache"}, ) } diff --git a/pkgs/mcpx/internal/daemon/cachescope_test.go b/pkgs/mcpx/internal/daemon/cachescope_test.go new file mode 100644 index 000000000..9c74ded14 --- /dev/null +++ b/pkgs/mcpx/internal/daemon/cachescope_test.go @@ -0,0 +1,127 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/config" + "github.com/dezren39/mcpx/internal/mcpclient" +) + +// #1603: cacheScope at the disk boundary. SaveCache never persists a +// private catalogue and writes a public one with its absolute deadline +// and scope; loadCache never seeds a private entry, even from a file +// this daemon did not write, and seeds a public entry's deadline so a +// past-deadline seed starts expired. + +func cacheScopeRegistry(t *testing.T, dir string) (*Registry, *config.Config) { + t.Helper() + cfg := &config.Config{MCPServers: map[string]*config.Server{ + "pub": {Name: "pub", Command: filepath.Join(dir, "never-run")}, + "priv": {Name: "priv", Command: filepath.Join(dir, "never-run-either")}, + }} + r, err := NewRegistry(cfg, Paths{State: dir, Cache: dir}, nil) + if err != nil { + t.Fatal(err) + } + return r, cfg +} + +func TestSaveCacheSkipsPrivatePools(t *testing.T) { + dir := t.TempDir() + r, _ := cacheScopeRegistry(t, dir) + fetched := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) + deadline := fetched.Add(2 * time.Hour) + + pub, _ := r.Pool("pub") + pub.SetSchemas([]mcpclient.Tool{{Name: "pub-tool"}}, nil, nil, "", fetched, deadline, false) + priv, _ := r.Pool("priv") + priv.SetSchemas([]mcpclient.Tool{{Name: "priv-tool"}}, nil, nil, "", fetched, deadline, true) + + if err := r.SaveCache(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(r.paths.SchemaCachePath(r.hash)) + if err != nil { + t.Fatal(err) + } + var cf cacheFile + if err := json.Unmarshal(b, &cf); err != nil { + t.Fatal(err) + } + if cf.Version != cacheVersion { + t.Fatalf("cache file version = %d, want %d", cf.Version, cacheVersion) + } + if _, ok := cf.Servers["priv"]; ok { + t.Fatal("a private catalogue was persisted to the schema cache") + } + entry, ok := cf.Servers["pub"] + if !ok { + t.Fatal("the public catalogue is missing from the schema cache") + } + if entry.Scope != "public" { + t.Fatalf("public entry scope = %q, want %q", entry.Scope, "public") + } + if !entry.Deadline.Equal(deadline) { + t.Fatalf("public entry deadline = %v, want the absolute %v", entry.Deadline, deadline) + } + if len(entry.Tools) != 1 || entry.Tools[0].Name != "pub-tool" { + t.Fatalf("public entry tools = %v, want the seeded catalogue", entry.Tools) + } +} + +func TestLoadCacheNeverSeedsPrivateEntry(t *testing.T) { + dir := t.TempDir() + r, cfg := cacheScopeRegistry(t, dir) + fetched := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) + past := fetched.Add(-time.Hour) + + // A file this daemon's SaveCache would never write: a private + // entry for pub, and a public entry for priv whose deadline has + // already passed. + cf := cacheFile{ + Version: cacheVersion, ConfigHash: r.hash, SavedAt: fetched, + Servers: map[string]*cachedEntry{ + "pub": { + Tools: []mcpclient.Tool{{Name: "sneaky-tool"}}, + FetchedAt: fetched, Deadline: fetched.Add(2 * time.Hour), + Scope: "private", + }, + "priv": { + Tools: []mcpclient.Tool{{Name: "seeded-tool"}}, + FetchedAt: fetched, Deadline: past, + Scope: "public", + }, + }, + } + b, err := json.Marshal(cf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(r.paths.SchemaCachePath(r.hash), b, 0o600); err != nil { + t.Fatal(err) + } + + next, err := NewRegistry(cfg, Paths{State: dir, Cache: dir}, nil) + if err != nil { + t.Fatal(err) + } + pub, _ := next.Pool("pub") + if _, _, _, at, _, _ := pub.CachedAll(); !at.IsZero() { + t.Fatal("a private cache entry was seeded from disk") + } + privPool, _ := next.Pool("priv") + tools, _, _, _, deadline, private := privPool.CachedAll() + if len(tools) != 1 || tools[0].Name != "seeded-tool" { + t.Fatalf("public entry seeded tools = %v, want the file's catalogue", tools) + } + if private { + t.Fatal("a public entry was seeded as private") + } + if !deadline.Equal(past) { + t.Fatalf("seeded deadline = %v, want the file's past deadline %v (the seed starts expired)", deadline, past) + } +} diff --git a/pkgs/mcpx/internal/daemon/callerr_test.go b/pkgs/mcpx/internal/daemon/callerr_test.go index 729265046..a94f3fa1e 100644 --- a/pkgs/mcpx/internal/daemon/callerr_test.go +++ b/pkgs/mcpx/internal/daemon/callerr_test.go @@ -154,9 +154,9 @@ func schemaRegistry(t *testing.T) *Registry { b, _ := json.Marshal(map[string]any{"type": "object", "properties": props, "required": required}) return b } - p.SetSchemas([]mcpclient.Tool{{Name: "create_issue", InputSchema: schema("title")}}, nil, nil, "", time.Now()) + p.SetSchemas([]mcpclient.Tool{{Name: "create_issue", InputSchema: schema("title")}}, nil, nil, "", time.Now(), time.Time{}, false) r.ObserveCatalog() - p.SetSchemas([]mcpclient.Tool{{Name: "create_issue", InputSchema: schema("title", "repo")}}, nil, nil, "", time.Now()) + p.SetSchemas([]mcpclient.Tool{{Name: "create_issue", InputSchema: schema("title", "repo")}}, nil, nil, "", time.Now(), time.Time{}, false) r.ObserveCatalog() return r } diff --git a/pkgs/mcpx/internal/daemon/registry.go b/pkgs/mcpx/internal/daemon/registry.go index 0b225a64e..34b702899 100644 --- a/pkgs/mcpx/internal/daemon/registry.go +++ b/pkgs/mcpx/internal/daemon/registry.go @@ -119,13 +119,21 @@ type cachedEntry struct { Prompts []mcpclient.Prompt `json:"prompts"` Instructions string `json:"instructions,omitempty"` FetchedAt time.Time `json:"fetchedAt"` + // Deadline and Scope are the catalogue's freshness and visibility + // as fetched (#1603). Deadline is absolute — a TTL would let a + // restart extend freshness — and Scope is the cacheScope wire + // value ("private" or "public"; empty means public). SaveCache + // never writes a private entry and loadCache never seeds one; the + // fields exist so a public entry round-trips its deadline/scope. + Deadline time.Time `json:"deadline,omitempty"` + Scope string `json:"scope,omitempty"` } // cacheVersion is bumped whenever a cached entry gains a field. Adding one // parses cleanly against an old file and leaves it empty, which presented as // the destructive-call policy silently never firing: the annotations were // there upstream and absent from the cache nobody had reason to invalidate. -const cacheVersion = 5 +const cacheVersion = 6 // NewRegistry builds pools from config and seeds them from the disk cache. func NewRegistry(cfg *config.Config, paths Paths, logf func(string, ...any)) (*Registry, error) { @@ -257,8 +265,14 @@ func (r *Registry) loadCache() { return } for name, e := range cf.Servers { + // #1603: a private entry is never seeded, even from a foreign + // or hand-written file — SaveCache does not write them, so one + // present on disk did not come from this daemon's own save. + if e.Scope == "private" { + continue + } if p, ok := r.pools[name]; ok { - p.SetSchemas(e.Tools, e.Resources, e.Prompts, e.Instructions, e.FetchedAt) + p.SetSchemas(e.Tools, e.Resources, e.Prompts, e.Instructions, e.FetchedAt, e.Deadline, false) } } r.logf("loaded schema cache for %d servers", len(cf.Servers)) @@ -269,13 +283,20 @@ func (r *Registry) SaveCache() error { cf := cacheFile{Version: cacheVersion, ConfigHash: r.hash, SavedAt: time.Now(), Servers: map[string]*cachedEntry{}} r.mu.RLock() for name, p := range r.pools { - tools, res, prompts, at := p.CachedAll() + tools, res, prompts, at, deadline, private := p.CachedAll() if at.IsZero() { continue } + // #1603: a private catalogue never touches disk — it was + // fetched under one caller's authority and must not be served + // to every caller of a restarted daemon. + if private { + continue + } cf.Servers[name] = &cachedEntry{ Tools: tools, Resources: res, Prompts: prompts, Instructions: p.Instructions(), FetchedAt: at, + Deadline: deadline, Scope: "public", } } r.mu.RUnlock() diff --git a/pkgs/mcpx/internal/pool/cachescope_test.go b/pkgs/mcpx/internal/pool/cachescope_test.go new file mode 100644 index 000000000..e0a48c304 --- /dev/null +++ b/pkgs/mcpx/internal/pool/cachescope_test.go @@ -0,0 +1,107 @@ +package pool_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/config" + "github.com/dezren39/mcpx/internal/mcpclient" + "github.com/dezren39/mcpx/internal/pool" +) + +// #1603: the disk boundary for cacheScope. A seed carries the absolute +// deadline and scope it was saved with; a past deadline starts expired, +// and a failed re-read of an expired cache serves stale instead of +// erroring — while a failed first fetch keeps erroring. + +func badPool(t *testing.T) *pool.Pool { + t.Helper() + cfg := &config.Config{MCPServers: map[string]*config.Server{ + "bad": {Name: "bad", Command: filepath.Join(t.TempDir(), "never-run")}, + }} + r, err := cfg.Resolve("bad") + if err != nil { + t.Fatal(err) + } + p := pool.New(r) + t.Cleanup(func() { p.Close() }) + return p +} + +func TestPoolCacheSeededPastDeadlineRefetches(t *testing.T) { + p, s := ttlPool(t, "modern", map[string]string{"ERAMCP_TTL_MS": "60000"}) + now := time.Now() + p.SetSchemas( + []mcpclient.Tool{{Name: "seeded-marker"}}, nil, nil, "", + now, now.Add(-time.Minute), false, + ) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + tools, _, err := p.Schemas(ctx) + if err != nil { + t.Fatal(err) + } + if n := listCalls(t, s); n != 1 { + t.Fatalf("seeded past its deadline: tools/list called %d times, want 1 (seed starts expired)", n) + } + for _, tool := range tools { + if tool.Name == "seeded-marker" { + t.Fatal("the expired seed was served as fresh instead of being re-read") + } + } +} + +func TestPoolCacheSeededPrivateScopeStored(t *testing.T) { + p, _ := ttlPool(t, "modern", nil) + now := time.Now() + p.SetSchemas(nil, nil, nil, "", now, now.Add(time.Hour), true) + if !p.SchemaPrivate() { + t.Fatal("a seed's private scope was not carried onto the pool cache") + } + _, _, _, _, deadline, private := p.CachedAll() + if !private || !deadline.Equal(now.Add(time.Hour)) { + t.Fatalf("CachedAll = deadline %v, private %v; want the seed's deadline and scope", deadline, private) + } +} + +func TestPoolCacheStaleServedOnRefetchError(t *testing.T) { + p := badPool(t) + at := time.Now().Add(-2 * time.Hour) + p.SetSchemas( + []mcpclient.Tool{{Name: "stale-tool"}}, nil, nil, "", + at, time.Now().Add(-time.Hour), false, + ) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + for i := 0; i < 2; i++ { + tools, _, err := p.Schemas(ctx) + if err != nil { + t.Fatalf("call %d: expired cache + failing upstream must serve stale, got error %v", i, err) + } + if len(tools) != 1 || tools[0].Name != "stale-tool" { + t.Fatalf("call %d: served %v, want the stale catalogue", i, tools) + } + } + if p.Status().LastError == "" { + t.Fatal("the failed refresh was not recorded in the pool status") + } + _, _, gotAt := p.CachedSchemas() + if !gotAt.Equal(at) { + t.Fatalf("cache timestamp moved to %v; a stale serve must leave it at %v so the next read retries", gotAt, at) + } +} + +func TestPoolCacheFirstFetchFailureStillErrors(t *testing.T) { + p := badPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if _, _, err := p.Schemas(ctx); err == nil { + t.Fatal("a failed first fetch must return its error") + } + _, _, at := p.CachedSchemas() + if !at.IsZero() { + t.Fatalf("a failed first fetch recorded timestamp %v; it must stay unset", at) + } +} diff --git a/pkgs/mcpx/internal/pool/pool.go b/pkgs/mcpx/internal/pool/pool.go index 622c7c904..2255ac4d5 100644 --- a/pkgs/mcpx/internal/pool/pool.go +++ b/pkgs/mcpx/internal/pool/pool.go @@ -177,7 +177,8 @@ type Pool struct { schemaErr error // schemaDeadline is when a modern upstream's ttlMs hints say the // cached catalogue goes stale; zero means "never expires" (legacy - // upstreams, and caches seeded by SetSchemas). schemaPrivate records + // upstreams; disk seeds carry the deadline they were saved with). + // schemaPrivate records // that some fetched list declared cacheScope "private" (#1601 Phase 1 // stores the scope; enforcing it is Phase 2). schemaDeadline time.Time @@ -837,10 +838,13 @@ func (p *Pool) Schemas(ctx context.Context) ([]mcpclient.Tool, []mcpclient.Resou // RefreshSchemas re-reads tools/resources from a live instance. // -// On failure the cache timestamp is deliberately left unset. Recording a -// timestamp would make a server that never started look like one that -// genuinely exposes no tools, and that state would then be persisted and -// reloaded, permanently hiding the failure. +// On failure of a FIRST fetch the cache timestamp is deliberately left +// unset. Recording a timestamp would make a server that never started +// look like one that genuinely exposes no tools, and that state would +// then be persisted and reloaded, permanently hiding the failure. A +// failed re-read of an expired cache is different (#1603): the stale +// catalogue is served, the error is recorded, and the timestamp and +// deadline are left untouched so the next read retries. func (p *Pool) RefreshSchemas(ctx context.Context) ([]mcpclient.Tool, []mcpclient.Resource, error) { return p.refreshSchemas(ctx, nil) } @@ -870,21 +874,14 @@ func (p *Pool) refreshSchemas(ctx context.Context, staleGen *time.Time) ([]mcpcl key := p.schemaKey() lease, err := p.Acquire(ctx, key) if err != nil { - p.schemaMu.Lock() - p.schemaErr = err - p.schemaMu.Unlock() - return nil, nil, err + return p.refreshFailed(staleGen, err) } defer lease.Release() cl := lease.Client() tools, toolsHints, terr := cl.ListToolsWithHints(ctx) if terr != nil { - err := fmt.Errorf("server %q tools/list: %w", p.cfg.Name, terr) - p.schemaMu.Lock() - p.schemaErr = err - p.schemaMu.Unlock() - return nil, nil, err + return p.refreshFailed(staleGen, fmt.Errorf("server %q tools/list: %w", p.cfg.Name, terr)) } var resources []mcpclient.Resource var resHints mcpclient.CacheHints @@ -966,6 +963,22 @@ func (p *Pool) refreshSchemas(ctx context.Context, staleGen *time.Time) ([]mcpcl return tools, resources, nil } +// refreshFailed records a failed refresh (#1603). When the caller was +// re-reading an expired cache generation, the stale catalogue is served +// and nil is returned: the error lives in schemaErr (surfaced via +// Status), and schemaAt/schemaDeadline are left untouched so the next +// read retries the refresh. Otherwise — a failed first fetch — the +// error is returned and, per RefreshSchemas, no timestamp is recorded. +func (p *Pool) refreshFailed(staleGen *time.Time, err error) ([]mcpclient.Tool, []mcpclient.Resource, error) { + p.schemaMu.Lock() + defer p.schemaMu.Unlock() + p.schemaErr = err + if staleGen != nil && !p.schemaAt.IsZero() && p.schemaAt.Equal(*staleGen) { + return p.tools, p.resources, nil + } + return nil, nil, err +} + // schemaKey is the instance a catalogue read borrows. func (p *Pool) schemaKey() string { key, _ := p.cfg.Scope.Key(config.CallContext{CallID: "schema"}) @@ -973,12 +986,16 @@ func (p *Pool) schemaKey() string { } // SetSchemas seeds the cache from disk so the daemon can answer discovery -// queries without starting a single child process. -func (p *Pool) SetSchemas(tools []mcpclient.Tool, resources []mcpclient.Resource, prompts []mcpclient.Prompt, instructions string, at time.Time) { +// queries without starting a single child process. The deadline and +// scope are the ones the persisted entry carried (#1603): the deadline +// is absolute, so a seed whose deadline has already passed starts +// expired and the first Schemas call re-reads instead of serving it as +// fresh. +func (p *Pool) SetSchemas(tools []mcpclient.Tool, resources []mcpclient.Resource, prompts []mcpclient.Prompt, instructions string, at time.Time, deadline time.Time, private bool) { p.schemaMu.Lock() p.tools, p.resources, p.prompts = tools, resources, prompts p.instructions, p.schemaAt, p.schemaErr = instructions, at, nil - p.schemaDeadline, p.schemaPrivate = time.Time{}, false + p.schemaDeadline, p.schemaPrivate = deadline, private p.schemaMu.Unlock() } @@ -999,11 +1016,13 @@ func (p *Pool) CachedSchemas() ([]mcpclient.Tool, []mcpclient.Resource, time.Tim // CachedAll is everything a catalogue read produced, for the disk cache. // Prompts are part of it: a cache that held only tools and resources left a // restarted daemon serving no prompts at all, because it counted itself warm -// and never listed them again. -func (p *Pool) CachedAll() ([]mcpclient.Tool, []mcpclient.Resource, []mcpclient.Prompt, time.Time) { +// and never listed them again. The deadline and scope ride along (#1603): +// the disk cache persists them for public catalogues and uses the scope +// to keep private ones off disk entirely. +func (p *Pool) CachedAll() ([]mcpclient.Tool, []mcpclient.Resource, []mcpclient.Prompt, time.Time, time.Time, bool) { p.schemaMu.RLock() defer p.schemaMu.RUnlock() - return p.tools, p.resources, p.prompts, p.schemaAt + return p.tools, p.resources, p.prompts, p.schemaAt, p.schemaDeadline, p.schemaPrivate } // Invalidate forgets the cached schema, so the next read fetches it afresh.