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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) | |
Expand Down Expand Up @@ -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/) | — | — |
Expand Down
119 changes: 119 additions & 0 deletions internal/server/cache_hints_test.go
Original file line number Diff line number Diff line change
@@ -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"])
})
}
}
33 changes: 33 additions & 0 deletions internal/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))

Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions internal/server/mcp_routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions specs/058-mcp-2026-upgrade/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading