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 }