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.