coverage~bigbes/sr-ht-ecoreb36a9272mcphttp/cache.go

Coverage
100.0% 14/14 statements
Δ
+0.0
Blob
b233d21
Uncovered nothing — every instrumented line ran
1 package mcphttp
2
3 import "net/http"
4
5 const (
6 // cacheControl is what every response of an MCP endpoint carries.
7 //
8 // private and no-store are middleware.SetPrivateCache's pair, unchanged and
9 // for its reason: an answer here depends entirely on the credential the
10 // request carried and says nothing about it in the URL, and it may be a
11 // PRIVATE repository's data. no-cache would not do — it still permits a cache
12 // to *store* the body and merely revalidate, which is the thing no-store
13 // forbids.
14 //
15 // no-transform is the SDK's own, kept. The streamable transport writes
16 // `no-cache, no-transform` on every response it produces: no-transform
17 // protects the SSE framing from an intermediary that would recompress or
18 // rechunk it, and there is no reason to drop it. Only no-cache is replaced.
19 cacheControl = "private, no-store, no-transform"
20
21 // vary names what an answer here actually depends on: the unified-login
22 // cookie and the bearer token. Same two names the rest of the instance varies
23 // on, because it is the same statement.
24 vary = "Cookie, Authorization"
25 )
26
27 // PrivateCache marks every response an MCP endpoint writes as one no cache may
28 // keep, and states what it depends on.
29 //
30 // It is not middleware.PrivateCache and cannot be. That one sets the headers
31 // before the handler runs, which is right for a router whose handlers do not
32 // touch Cache-Control; the SDK's streamable transport sets Cache-Control itself,
33 // with Set, from inside the handler, so a value written on the way in is
34 // overwritten on the way out and the response leaves with `no-cache,
35 // no-transform` and no Vary at all. The headers are therefore written at the
36 // last moment they still can be: when the status line is committed and every Set
37 // the handler was going to make has been made.
38 //
39 // The result is not a replacement of what the SDK asked for but a narrowing of
40 // it — see cacheControl.
41 9 func PrivateCache(next http.Handler) http.Handler {
42 9 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43 9 next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
44 9 })
45 }
46
47 // cacheWriter is the http.ResponseWriter [PrivateCache] hands down: it sets the
48 // two headers when the response is committed, whether that is an explicit
49 // WriteHeader or the implicit one of the first Write.
50 type cacheWriter struct {
51 http.ResponseWriter
52 committed bool
53 }
54
55 4 func (w *cacheWriter) WriteHeader(status int) {
56 4 w.commit()
57 4 w.ResponseWriter.WriteHeader(status)
58 4 }
59
60 8 func (w *cacheWriter) Write(b []byte) (int, error) {
61 8 w.commit()
62 8 return w.ResponseWriter.Write(b)
63 8 }
64
65 // Flush commits before flushing so a handler that streams without ever calling
66 // WriteHeader still leaves with the headers. http.NewResponseController prefers a
67 // Flush on the writer it is handed over one reached through Unwrap, so this
68 // method is what it finds — without it the flush would commit the response at the
69 // writer below and the two headers would never be written.
70 3 func (w *cacheWriter) Flush() {
71 3 w.commit()
72 3 //nolint:errcheck // http.Flusher.Flush reports nothing; the controller's error
73 3 // is only about the writer not supporting flush, which Unwrap guarantees it does.
74 3 _ = http.NewResponseController(w.ResponseWriter).Flush()
75 3 }
76
77 15 func (w *cacheWriter) commit() {
78 15 if w.committed {
79 6 return
80 6 }
81 9 w.committed = true
82 9 w.Header().Set("Cache-Control", cacheControl)
83 9 w.Header().Set("Vary", vary)
84 }
85
86 // Unwrap is what http.NewResponseController follows to reach the real writer.
87 //
88 // It is not what keeps flushing working — this type has its own Flush, and the
89 // controller prefers a method on the writer it is handed over one reached by
90 // unwrapping, so the flush path never gets here. That is worth saying because
91 // the comment here used to claim otherwise, and the test named after the claim
92 // passed with the method deleted.
93 //
94 // What does need it is everything else the controller offers: SetWriteDeadline,
95 // SetReadDeadline, Hijack. A long-lived MCP stream is precisely the response
96 // that wants its write deadline pushed out, and without this method that call
97 // is ErrNotSupported. TestUnwrapReachesTheWriterBelow measures that against a
98 // real server, because a recorder supports no deadlines either way.
99 1 func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }