~bigbes/sr-ht-spec

ref: 824788ab8269bd6c58de5848bc4545450fac7aaf sr-ht-spec/mcpsrv/cache_internal_test.go -rw-r--r-- 6.9 KiB
824788ab — Eugene Blikh mcpsrv: mark /mcp uncacheable, fail closed on origin, split tool errors from faults 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
package mcpsrv

import (
	"bufio"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// The wrapper privateCache hands down, tested where it can be reached. The
// endpoint-level statement — that every answer of /mcp carries these two headers
// — is in cache_test.go; what is here is the three ways a handler can commit a
// response, because a wrapper that catches only one of them passes every header
// test while setting no header at all on the path the SDK actually takes.

// TestHeadersLandOnAnExplicitWriteHeader is the ordinary path.
func TestHeadersLandOnAnExplicitWriteHeader(t *testing.T) {
	h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusAccepted)
	}))

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))

	assert.Equal(t, http.StatusAccepted, rec.Code)
	assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control"))
	assert.Equal(t, cacheVary, rec.Header().Get("Vary"))
}

// TestHeadersLandOnAnImplicitCommit covers the handler that never calls
// WriteHeader at all. net/http commits on the first Write and drops every header
// set after that point, silently.
func TestHeadersLandOnAnImplicitCommit(t *testing.T) {
	h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"jsonrpc":"2.0"}`))
	}))

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))

	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control"))
	assert.Equal(t, cacheVary, rec.Header().Get("Vary"))
	assert.Equal(t, `{"jsonrpc":"2.0"}`, rec.Body.String())
}

// TestHeadersLandOnAFlush is the third commit, and the one a stream takes: an
// SSE handler writes nothing before its first flush, so a wrapper that hooks
// Write and WriteHeader alone leaves the response uncached-marked exactly on the
// answers that stay open longest.
func TestHeadersLandOnAFlush(t *testing.T) {
	h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		require.NoError(t, http.NewResponseController(w).Flush())
	}))

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/mcp", nil))

	assert.True(t, rec.Flushed)
	assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control"))
	assert.Equal(t, cacheVary, rec.Header().Get("Vary"))
}

// TestTheSDKsOwnDirectivesAreOverridden is why this is a wrapper and not a
// middleware that sets the headers on the way in. The streamable transport sets
// Cache-Control itself, with Set, from inside the handler, so anything written
// before it runs loses. The handler here does exactly what the SDK does.
func TestTheSDKsOwnDirectivesAreOverridden(t *testing.T) {
	h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Cache-Control", "no-cache, no-transform")
		w.WriteHeader(http.StatusOK)
	}))

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))

	assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control"),
		"no-cache lets a cache store the body and revalidate, which is the thing no-store forbids")
	assert.Equal(t, cacheVary, rec.Header().Get("Vary"))
}

// TestOnlyTheFirstCommitWrites checks the committed flag: a handler that writes
// after committing must not have its headers re-Set on every Write.
func TestOnlyTheFirstCommitWrites(t *testing.T) {
	h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("a"))
		_, _ = w.Write([]byte("b"))
	}))

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))

	assert.Equal(t, []string{cacheControl}, rec.Header().Values("Cache-Control"))
	assert.Equal(t, []string{cacheVary}, rec.Header().Values("Vary"))
	assert.Equal(t, "ab", rec.Body.String())
}

// TestUnwrapReachesTheWriterBelow is the test the Unwrap method exists for, and
// it deliberately does not measure flushing.
//
// The sentence usually attached to Unwrap on a writer like this — that without
// it the flusher is hidden and SSE breaks — is not true of this type: Flush is a
// method on cacheWriter, so a controller finds that one first and never unwraps.
// (Measured, not assumed: with Unwrap deleted every other test in this file, and
// every MCP session the rest of this package opens, stays green.) What stops
// working is everything cacheWriter does not implement itself, and the deadlines
// are the reachable half of that — so this is written against a deadline, which
// is the call that actually goes ErrNotSupported.
//
// It runs over a real server because httptest.ResponseRecorder supports no
// deadline at all, and a recorder therefore cannot tell the two cases apart.
func TestUnwrapReachesTheWriterBelow(t *testing.T) {
	var deadlineErr error
	srv := httptest.NewServer(privateCache(http.HandlerFunc(
		func(w http.ResponseWriter, _ *http.Request) {
			deadlineErr = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(time.Minute))
		})))
	t.Cleanup(srv.Close)

	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
	require.NoError(t, err)
	resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })

	require.NoError(t, deadlineErr, "a controller must reach the writer this one wraps")
}

// TestAStreamReachesTheClientBeforeTheHandlerReturns is the same property
// measured rather than asserted: over a real connection, with a real client,
// bytes flushed inside the handler have to arrive while the handler is still
// running. A recorder cannot tell a flush that worked from one that was buffered
// until the end — and a broken flush here is what would turn every MCP session
// on this endpoint into a hang rather than an error.
func TestAStreamReachesTheClientBeforeTheHandlerReturns(t *testing.T) {
	release := make(chan struct{})
	srv := httptest.NewServer(privateCache(http.HandlerFunc(
		func(w http.ResponseWriter, _ *http.Request) {
			w.Header().Set("Content-Type", "text/event-stream")
			_, _ = w.Write([]byte("data: first\n\n"))
			if err := http.NewResponseController(w).Flush(); err != nil {
				t.Errorf("flush: %v", err)
			}
			<-release
		})))
	t.Cleanup(func() {
		close(release)
		srv.Close()
	})

	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
	require.NoError(t, err)
	resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })

	assert.Equal(t, cacheControl, resp.Header.Get("Cache-Control"))
	assert.Equal(t, cacheVary, resp.Header.Get("Vary"))

	line, err := bufio.NewReader(resp.Body).ReadString('\n')
	require.NoError(t, err, "the first event must arrive while the handler is still blocked")
	assert.Equal(t, "data: first\n", line)
}