| 1 |
|
package mcpsrv |
| 2 |
|
|
| 3 |
|
import "net/http" |
| 4 |
|
|
| 5 |
|
// What this endpoint tells a cache, and why it is said here rather than by the |
| 6 |
|
// router's middleware. |
| 7 |
|
// |
| 8 |
|
// # The shared helper this is a copy of |
| 9 |
|
// |
| 10 |
|
// sr-ht-ecore/mcphttp.PrivateCache is this function, one package up, written for |
| 11 |
|
// the services on the instance that mount an MCP endpoint. It is not imported |
| 12 |
|
// because it is not resolvable: the ecore commit that adds mcphttp is not |
| 13 |
|
// published, and this module pins an ecore from before it. So the code is here, |
| 14 |
|
// deliberately identical in behaviour and in header values, and the swap when |
| 15 |
|
// mcphttp is reachable is a delete and an import with no observable change to |
| 16 |
|
// any response — which is the reason the constants below are byte for byte what |
| 17 |
|
// mcphttp emits rather than a spelling of this package's own. |
| 18 |
|
const ( |
| 19 |
|
// cacheControl is what every response of this endpoint carries. |
| 20 |
|
// |
| 21 |
|
// private and no-store are the instance's pair for an answer that depends |
| 22 |
|
// entirely on the credential the request carried and says nothing about it in |
| 23 |
|
// its URL — which is every answer here. no-cache would not do: it still |
| 24 |
|
// permits a shared cache to *store* the body and merely revalidate, which is |
| 25 |
|
// the one thing no-store forbids. |
| 26 |
|
// |
| 27 |
|
// no-transform is the SDK's own, kept. Its streamable transport writes |
| 28 |
|
// `no-cache, no-transform` on every response it produces; no-transform |
| 29 |
|
// protects the SSE framing from an intermediary that would recompress or |
| 30 |
|
// rechunk it, and there is no reason to drop it. Only no-cache is replaced. |
| 31 |
|
cacheControl = "private, no-store, no-transform" |
| 32 |
|
|
| 33 |
|
// cacheVary names what an answer here depends on, and it names both planes |
| 34 |
|
// because on this service both planes genuinely reach /mcp. |
| 35 |
|
// |
| 36 |
|
// That is worth stating, because the sibling that mounts the same surface |
| 37 |
|
// decided the other way: dolt.sr.ht varies on Authorization alone, arguing |
| 38 |
|
// that its /mcp is bearer-only and that naming Cookie would promise a cache |
| 39 |
|
// a dependency the surface never reads. The argument is right and does not |
| 40 |
|
// apply here. authn.Resolver.Resolve prefers a bearer token when one is |
| 41 |
|
// present, but falls through to login.UsernameFromRequest when none is — |
| 42 |
|
// and an owner cookie resolves to KindOwner, which is exactly what Gate's |
| 43 |
|
// CanRead admits. So on this service the cookie is not an unread header: it |
| 44 |
|
// is the difference between the whole corpus and a 401, which is the |
| 45 |
|
// strongest reason a response can have to vary on something. |
| 46 |
|
cacheVary = "Cookie, Authorization" |
| 47 |
|
) |
| 48 |
|
|
| 49 |
|
// privateCache marks every response this endpoint writes as one no cache may |
| 50 |
|
// keep, and states what it depends on. |
| 51 |
|
// |
| 52 |
|
// It cannot be a middleware that sets the headers before the handler runs, which |
| 53 |
|
// is how the rest of this service does it. The SDK's streamable transport sets |
| 54 |
|
// Cache-Control itself, with Set, from inside the handler — so a value written |
| 55 |
|
// on the way in is overwritten on the way out, and the response leaves with |
| 56 |
|
// `no-cache, no-transform` and no Vary at all. The headers are therefore written |
| 57 |
|
// at the last moment they still can be: when the status line is committed and |
| 58 |
|
// every Set the handler was going to make has been made. |
| 59 |
|
// |
| 60 |
|
// It wraps everything Handler owns, the Host allowlist's 403 included. Gate's |
| 61 |
|
// 401 is written outside this chain — it has to be, since Gate runs inside the |
| 62 |
|
// resolver middleware and Handler runs inside Gate — so Gate sets the same two |
| 63 |
|
// headers itself. |
| 64 |
24 |
func privateCache(next http.Handler) http.Handler { |
| 65 |
25 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 |
25 |
next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r) |
| 67 |
25 |
}) |
| 68 |
|
} |
| 69 |
|
|
| 70 |
|
// cacheWriter is the http.ResponseWriter privateCache hands down: it sets the |
| 71 |
|
// two headers when the response is committed, by whichever of the three routes |
| 72 |
|
// the handler takes. |
| 73 |
|
type cacheWriter struct { |
| 74 |
|
http.ResponseWriter |
| 75 |
|
committed bool |
| 76 |
|
} |
| 77 |
|
|
| 78 |
15 |
func (w *cacheWriter) WriteHeader(status int) { |
| 79 |
15 |
w.commit() |
| 80 |
15 |
w.ResponseWriter.WriteHeader(status) |
| 81 |
15 |
} |
| 82 |
|
|
| 83 |
|
// Write commits first: net/http commits the response on the first Write and |
| 84 |
|
// drops every header set after that, silently. A handler that never calls |
| 85 |
|
// WriteHeader is the ordinary case and not an exotic one. |
| 86 |
19 |
func (w *cacheWriter) Write(b []byte) (int, error) { |
| 87 |
19 |
w.commit() |
| 88 |
19 |
return w.ResponseWriter.Write(b) |
| 89 |
19 |
} |
| 90 |
|
|
| 91 |
|
// Flush is the third commit and the one an event stream takes. It has to be a |
| 92 |
|
// method on this type: http.ResponseController prefers a Flush on the writer it |
| 93 |
|
// was handed over one reached through Unwrap, so without this the flush would |
| 94 |
|
// commit the response at the writer below and the two headers would never be |
| 95 |
|
// written — on exactly the answers that stay open longest, and with every other |
| 96 |
|
// test in this package still green (TestHeadersLandOnAFlush). |
| 97 |
9 |
func (w *cacheWriter) Flush() { |
| 98 |
9 |
w.commit() |
| 99 |
9 |
// http.Flusher.Flush reports nothing, and the controller's error can only be |
| 100 |
9 |
// "this writer does not support flushing" — which, if it happens, is a writer |
| 101 |
9 |
// that could not have streamed through any wrapper. |
| 102 |
9 |
_ = http.NewResponseController(w.ResponseWriter).Flush() |
| 103 |
9 |
} |
| 104 |
|
|
| 105 |
43 |
func (w *cacheWriter) commit() { |
| 106 |
43 |
if w.committed { |
| 107 |
19 |
return |
| 108 |
19 |
} |
| 109 |
24 |
w.committed = true |
| 110 |
24 |
w.Header().Set("Cache-Control", cacheControl) |
| 111 |
24 |
w.Header().Set("Vary", cacheVary) |
| 112 |
|
} |
| 113 |
|
|
| 114 |
|
// Unwrap is what http.ResponseController follows to reach the real writer for |
| 115 |
|
// everything this type does not implement itself: the deadlines, Hijack, |
| 116 |
|
// EnableFullDuplex. Without it a controller handed this writer answers |
| 117 |
|
// ErrNotSupported to all of them, because cacheWriter embeds the |
| 118 |
|
// http.ResponseWriter *interface* and so promotes nothing of the writer below. |
| 119 |
|
// |
| 120 |
|
// It is worth being exact about what it does *not* do, because the sentence it |
| 121 |
|
// is usually given — "without Unwrap the wrapper hides the flusher and SSE |
| 122 |
|
// streaming breaks" — is not true of this type and is easy to keep repeating. |
| 123 |
|
// It is true of a wrapper whose only method is WriteHeader; here Flush above is |
| 124 |
|
// a method on cacheWriter, so a controller finds that one and never needs to |
| 125 |
|
// unwrap to flush. Deleting Unwrap leaves every flush, every header and every |
| 126 |
|
// MCP session in this package's tests working, which is measured rather than |
| 127 |
|
// asserted: TestUnwrapReachesTheWriterBelow pins the deadline call, which is the |
| 128 |
|
// thing that actually stops working, and it is the test that goes red. |
| 129 |
|
// |
| 130 |
|
// The SDK asks for none of those today — its streamable transport calls Flush |
| 131 |
|
// and nothing else — so this method is here for the wrapper to be a wrapper |
| 132 |
|
// rather than to keep a feature alive. |
| 133 |
1 |
func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } |