diff --git a/ROADMAP.md b/ROADMAP.md index ed40fdc3e..921a0ffd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -889,7 +889,7 @@ graph LR | Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | | | Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | | | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | -| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 19/81 (23%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | | +| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 21/81 (26%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | | | Planning/docs truth automation | In progress | P2 | — | | | | Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | | | tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | — | [101-tpa-db](./specs/101-tpa-db/) | | @@ -992,7 +992,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [055-frontend-major-upgrades](./specs/055-frontend-major-upgrades/) | `shipped` | 23/24 (96%) | | [056-output-schema-validation](./specs/056-output-schema-validation/) | `in-flight` | 22/24 (92%) | | [057-in-proxy-profiles](./specs/057-in-proxy-profiles/) | `in-flight` | 20/25 (80%) | -| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 19/81 (23%) | +| [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | `in-flight` | 21/81 (26%) | | [059-output-sanitisation](./specs/059-output-sanitisation/) | `shipped` | 24/25 (96%) | | [060-settings-page](./specs/060-settings-page/) | `shipped` | 16/16 (100%) | | [064-glass-cockpit](./specs/064-glass-cockpit/) | — | — | diff --git a/internal/server/cache_hints_test.go b/internal/server/cache_hints_test.go new file mode 100644 index 000000000..cf89badca --- /dev/null +++ b/internal/server/cache_hints_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// Spec 058 FR-019 / Spec 113-f: list and read results must carry ttlMs and a +// cacheScope. Every mcpproxy listing is caller-dependent (profile, agent-token +// scope, quarantine state), so the scope must be private and the TTL short. +// +// The production client-facing transport is pinned to the legacy era +// (FR-028), where the hints are not emitted, so this test drives a server built +// with the production hint options over an unpinned transport. + +func cacheHintTestServer(t *testing.T) *httptest.Server { + t.Helper() + opts := append([]mcpserver.ServerOption{ + mcpserver.WithToolCapabilities(true), + mcpserver.WithResourceCapabilities(true, true), + mcpserver.WithPromptCapabilities(true), + }, cacheHintServerOptions()...) + srv := mcpserver.NewMCPServer("cache-hint-probe", "1.0.0", opts...) + srv.AddTool(mcp.NewTool("probe"), func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + }) + hs := httptest.NewServer(mcpserver.NewStreamableHTTPServer(srv)) + t.Cleanup(hs.Close) + return hs +} + +func postModernCacheProbe(t *testing.T, url string, method mcp.MCPMethod, params map[string]any) map[string]any { + t.Helper() + params["_meta"] = map[string]any{ + mcp.MetaKeyProtocolVersion: mcp.ProtocolVersion20260728, + mcp.MetaKeyClientInfo: map[string]any{"name": "t", "version": "1"}, + mcp.MetaKeyClientCapabilities: map[string]any{}, + } + body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(mcp.HeaderProtocolVersion, mcp.ProtocolVersion20260728) + req.Header.Set(mcp.HeaderMethod, string(method)) + raw, _ := json.Marshal(params) + if name, ok := mcp.ExtractHeaderName(method, raw); ok { + enc, ok := mcp.EncodeHeaderValue(name) + require.True(t, ok) + req.Header.Set(mcp.HeaderName, enc) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + var msg map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msg)) + result, ok := msg["result"].(map[string]any) + require.True(t, ok, "expected result for %s, got %v", method, msg) + return result +} + +func TestListResultsCarryPrivateShortCacheHints(t *testing.T) { + hs := cacheHintTestServer(t) + + for _, method := range []mcp.MCPMethod{ + mcp.MethodToolsList, + mcp.MethodPromptsList, + mcp.MethodResourcesList, + mcp.MethodResourcesTemplatesList, + } { + t.Run(string(method), func(t *testing.T) { + result := postModernCacheProbe(t, hs.URL, method, map[string]any{}) + assert.Equal(t, string(mcp.CacheScopePrivate), result["cacheScope"], + "mcpproxy listings depend on profile/token scope, so they must never be public") + assert.Equal(t, float64(mcpListCacheTTLMs), result["ttlMs"]) + }) + } +} + +func TestCacheHintTTLIsShort(t *testing.T) { + assert.Positive(t, mcpListCacheTTLMs) + assert.LessOrEqual(t, mcpListCacheTTLMs, int64(10_000), + "listings change with quarantine/profile state; keep the freshness window conservative") +} + +// TestRoutingModeServersCarryCacheHints pins that the real servers behind +// /mcp, /mcp/all, /mcp/code and /mcp/call (not just p.server) advertise the +// private hints. Served over an unpinned transport because the production +// transport is pinned to the legacy era (FR-028). +func TestRoutingModeServersCarryCacheHints(t *testing.T) { + proxy, _ := newStoredScriptProxyCfg(t, nil) + for _, mode := range []string{ + config.RoutingModeRetrieveTools, + config.RoutingModeCodeExecution, + config.RoutingModeDirect, + } { + t.Run(mode, func(t *testing.T) { + srv := proxy.GetMCPServerForMode(mode) + require.NotNil(t, srv) + hs := httptest.NewServer(mcpserver.NewStreamableHTTPServer(srv)) + t.Cleanup(hs.Close) + result := postModernCacheProbe(t, hs.URL, mcp.MethodToolsList, map[string]any{}) + assert.Equal(t, string(mcp.CacheScopePrivate), result["cacheScope"]) + assert.Equal(t, float64(mcpListCacheTTLMs), result["ttlMs"]) + }) + } +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index b43048f14..17fe848df 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -613,6 +613,9 @@ func NewMCPProxyServer( capabilities = append(capabilities, mcpserver.WithPromptCapabilities(true)) } + // Spec 058 FR-019: explicit, private, short-lived cache hints. + capabilities = append(capabilities, cacheHintServerOptions()...) + // Add server instructions for AI agent guidance capabilities = append(capabilities, mcpserver.WithInstructions(resolveInstructions(config.Instructions))) @@ -8348,3 +8351,33 @@ func forwardHeadersWriteError(names []string, static map[string]string) error { } return fmt.Errorf("invalid forward_headers: %s", strings.Join(msgs, "; ")) } + +// mcpListCacheTTLMs is the freshness window (SEP-2549 ttlMs) advertised on +// mcpproxy's list and read results. Listings change with quarantine approvals, +// profile switches and upstream reconnects (list_changed covers connected +// sessions), so the window is deliberately short. +const mcpListCacheTTLMs int64 = 5000 + +// cacheHintServerOptions returns the SEP-2549 cache-hint options for the proxy +// MCP server (Spec 058 FR-019). Every cacheable surface is private: tools/list, +// prompts/list and resources/* are filtered per caller by profile (Spec 108), +// agent-token scope (Spec 105) and per-tool quarantine, so no result is +// provably caller-independent and none may be shared across authorization +// contexts. Per-method hints are set explicitly so a change to the library +// default cannot silently widen the scope. +func cacheHintServerOptions() []mcpserver.ServerOption { + opts := []mcpserver.ServerOption{ + mcpserver.WithCacheHints(mcpListCacheTTLMs, mcp.CacheScopePrivate), + } + for _, m := range []mcp.MCPMethod{ + mcp.MethodToolsList, + mcp.MethodPromptsList, + mcp.MethodResourcesList, + mcp.MethodResourcesTemplatesList, + mcp.MethodResourcesRead, + mcp.MethodServerDiscover, + } { + opts = append(opts, mcpserver.WithMethodCacheHints(m, mcpListCacheTTLMs, mcp.CacheScopePrivate)) + } + return opts +} diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go index b88b5e374..4a1b2dac5 100644 --- a/internal/server/mcp_routing.go +++ b/internal/server/mcp_routing.go @@ -1191,6 +1191,9 @@ func (p *MCPProxyServer) initRoutingModeServers() { mcpserver.WithToolCapabilities(true), mcpserver.WithRecovery(), } + // Spec 058 FR-019: private short-lived cache hints on every routing-mode + // server (shared opts, so directOpts inherits them too). + opts = append(opts, cacheHintServerOptions()...) if p.hooks != nil { // Spec 105 FR-010 D13/gap G6: mark which real JSON-RPC method // produced this request — mcp-go calls these with the SAME ctx it diff --git a/specs/058-mcp-2026-upgrade/tasks.md b/specs/058-mcp-2026-upgrade/tasks.md index b3ce07614..ec3b933f2 100644 --- a/specs/058-mcp-2026-upgrade/tasks.md +++ b/specs/058-mcp-2026-upgrade/tasks.md @@ -153,8 +153,8 @@ Single Go project. Backend paths are repo-relative: `internal/`, `cmd/`, `bench/ **Independent test**: inspect list/read responses for valid hints; compare repeated `tools/list` results for stable ordering; confirm trace ids reach activity records. -- [ ] T063 [P] [US5] Write failing tests that list/read responses carry `ttlMs` and an identity-appropriate `cacheScope`, in `internal/server/cache_hints_test.go` -- [ ] T064 [US5] Configure cache hints — private for identity-scoped surfaces, public for shared ones — in `internal/server/mcp_routing.go` and `internal/server/mcp.go` +- [x] T063 [P] [US5] Write failing tests that list/read responses carry `ttlMs` and an identity-appropriate `cacheScope`, in `internal/server/cache_hints_test.go` +- [x] T064 [US5] Configure cache hints — private for identity-scoped surfaces, public for shared ones — in `internal/server/mcp_routing.go` and `internal/server/mcp.go` - [ ] T065 [P] [US5] Write a failing test that repeated `tools/list` calls are byte-identical under unchanged state, in `internal/server/stateless_list_test.go` - [ ] T066 [US5] Make tool ordering deterministic wherever map iteration currently leaks in, in `internal/server/mcp_routing.go` - [ ] T067 [P] [US5] Write a failing test that external `$ref`s are not dereferenced and that schemas validate as JSON Schema 2020-12, in `internal/server/mcp_input_validation_test.go`