Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pkgs/mcpx/docs/spec/conformance-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`<br>`daemon.TestSaveCacheSkipsPrivatePools@2026-07-28`<br>`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 |
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/conformance/coverage_stateful.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"},
)
}
127 changes: 127 additions & 0 deletions pkgs/mcpx/internal/daemon/cachescope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions pkgs/mcpx/internal/daemon/callerr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 24 additions & 3 deletions pkgs/mcpx/internal/daemon/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
Expand All @@ -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
Comment on lines +293 to +294

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the previous cache when all catalogues become private

If a server previously persisted a public catalogue and a later refresh returns a private catalogue, skipping this entry can leave cf.Servers empty. The existing empty-map guard then returns without replacing or removing the previous cache file. On restart, loadCache restores that superseded public catalogue and, if its original deadline is still in the future, serves it as fresh instead of fetching the current catalogue. Persist an empty cache or remove the existing file when no entries remain eligible.

Useful? React with 👍 / 👎.

}
cf.Servers[name] = &cachedEntry{
Tools: tools, Resources: res, Prompts: prompts,
Instructions: p.Instructions(), FetchedAt: at,
Deadline: deadline, Scope: "public",
}
}
r.mu.RUnlock()
Expand Down
107 changes: 107 additions & 0 deletions pkgs/mcpx/internal/pool/cachescope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading