package mcpsrv
import "net/http"
// What this endpoint tells a cache, and why it is said here rather than by the
// router's middleware.
//
// # The shared helper this is a copy of
//
// sr-ht-ecore/mcphttp.PrivateCache is this function, one package up, written for
// the services on the instance that mount an MCP endpoint. It is not imported
// because it is not resolvable: the ecore commit that adds mcphttp is not
// published, and this module pins an ecore from before it. So the code is here,
// deliberately identical in behaviour and in header values, and the swap when
// mcphttp is reachable is a delete and an import with no observable change to
// any response — which is the reason the constants below are byte for byte what
// mcphttp emits rather than a spelling of this package's own.
const (
// cacheControl is what every response of this endpoint carries.
//
// private and no-store are the instance's pair for an answer that depends
// entirely on the credential the request carried and says nothing about it in
// its URL — which is every answer here. no-cache would not do: it still
// permits a shared cache to *store* the body and merely revalidate, which is
// the one thing no-store forbids.
//
// no-transform is the SDK's own, kept. Its streamable transport writes
// `no-cache, no-transform` on every response it produces; no-transform
// protects the SSE framing from an intermediary that would recompress or
// rechunk it, and there is no reason to drop it. Only no-cache is replaced.
cacheControl = "private, no-store, no-transform"
// cacheVary names what an answer here depends on, and it names both planes
// because on this service both planes genuinely reach /mcp.
//
// That is worth stating, because the sibling that mounts the same surface
// decided the other way: dolt.sr.ht varies on Authorization alone, arguing
// that its /mcp is bearer-only and that naming Cookie would promise a cache
// a dependency the surface never reads. The argument is right and does not
// apply here. authn.Resolver.Resolve prefers a bearer token when one is
// present, but falls through to login.UsernameFromRequest when none is —
// and an owner cookie resolves to KindOwner, which is exactly what Gate's
// CanRead admits. So on this service the cookie is not an unread header: it
// is the difference between the whole corpus and a 401, which is the
// strongest reason a response can have to vary on something.
cacheVary = "Cookie, Authorization"
)
// privateCache marks every response this endpoint writes as one no cache may
// keep, and states what it depends on.
//
// It cannot be a middleware that sets the headers before the handler runs, which
// is how the rest of this service does it. The SDK's streamable transport sets
// Cache-Control itself, with Set, from inside the handler — so a value written
// on the way in is overwritten on the way out, and the response leaves with
// `no-cache, no-transform` and no Vary at all. The headers are therefore written
// at the last moment they still can be: when the status line is committed and
// every Set the handler was going to make has been made.
//
// It wraps everything Handler owns, the Host allowlist's 403 included. Gate's
// 401 is written outside this chain — it has to be, since Gate runs inside the
// resolver middleware and Handler runs inside Gate — so Gate sets the same two
// headers itself.
func privateCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
})
}
// cacheWriter is the http.ResponseWriter privateCache hands down: it sets the
// two headers when the response is committed, by whichever of the three routes
// the handler takes.
type cacheWriter struct {
http.ResponseWriter
committed bool
}
func (w *cacheWriter) WriteHeader(status int) {
w.commit()
w.ResponseWriter.WriteHeader(status)
}
// Write commits first: net/http commits the response on the first Write and
// drops every header set after that, silently. A handler that never calls
// WriteHeader is the ordinary case and not an exotic one.
func (w *cacheWriter) Write(b []byte) (int, error) {
w.commit()
return w.ResponseWriter.Write(b)
}
// Flush is the third commit and the one an event stream takes. It has to be a
// method on this type: http.ResponseController prefers a Flush on the writer it
// was handed over one reached through Unwrap, so without this the flush would
// commit the response at the writer below and the two headers would never be
// written — on exactly the answers that stay open longest, and with every other
// test in this package still green (TestHeadersLandOnAFlush).
func (w *cacheWriter) Flush() {
w.commit()
// http.Flusher.Flush reports nothing, and the controller's error can only be
// "this writer does not support flushing" — which, if it happens, is a writer
// that could not have streamed through any wrapper.
_ = http.NewResponseController(w.ResponseWriter).Flush()
}
func (w *cacheWriter) commit() {
if w.committed {
return
}
w.committed = true
w.Header().Set("Cache-Control", cacheControl)
w.Header().Set("Vary", cacheVary)
}
// Unwrap is what http.ResponseController follows to reach the real writer for
// everything this type does not implement itself: the deadlines, Hijack,
// EnableFullDuplex. Without it a controller handed this writer answers
// ErrNotSupported to all of them, because cacheWriter embeds the
// http.ResponseWriter *interface* and so promotes nothing of the writer below.
//
// It is worth being exact about what it does *not* do, because the sentence it
// is usually given — "without Unwrap the wrapper hides the flusher and SSE
// streaming breaks" — is not true of this type and is easy to keep repeating.
// It is true of a wrapper whose only method is WriteHeader; here Flush above is
// a method on cacheWriter, so a controller finds that one and never needs to
// unwrap to flush. Deleting Unwrap leaves every flush, every header and every
// MCP session in this package's tests working, which is measured rather than
// asserted: TestUnwrapReachesTheWriterBelow pins the deadline call, which is the
// thing that actually stops working, and it is the test that goes red.
//
// The SDK asks for none of those today — its streamable transport calls Flush
// and nothing else — so this method is here for the wrapper to be a wrapper
// rather than to keep a feature alive.
func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }