// Package mcphttp is the HTTP-level plumbing an MCP endpoint on this instance // needs around the Go SDK's streamable transport: the Host allowlist that // replaces the SDK's own DNS-rebinding guard, the cache directives that keep a // private repository's data out of every cache, and the two transport options // both services set. // // It is deliberately three functions and not a framework. cov.sr.ht and // bench.sr.ht each mount an MCP surface, and an audit of the two found the // genuinely shared part to be about forty lines — everything else differs // because it was meant to. Their tool sets are their own, their service // interfaces are their own, and above all their authentication gates are their // own: cov refuses a caller with no read grant outright, bench serves an // anonymous caller everything PUBLIC. Unifying those would not be deduplication, // it would be a policy change smuggled in as one. So the gates stay in the // services and only what is identical in both, or identical once one of the two // stances is chosen, lives here. // // The three: // // - [HostGuard], the Host-header allowlist. The predicate was already // byte-for-byte identical in both donors. // - [PrivateCache], the response wrapper that writes the cache directives at // the moment the response is committed, because the SDK writes its own on // the way out and a middleware that set them on the way in loses. // - [StreamableOptions], the transport options: stateless sessions, SDK // rebinding guard off because [HostGuard] replaces it. // // A service composes them around its own gate, outermost first: // // h := mcp.NewStreamableHTTPHandler( // func(*http.Request) *mcp.Server { return srv }, // mcphttp.StreamableOptions(), // ) // guarded, err := mcphttp.HostGuard(myAuthGate(h), origin) // if err != nil { // return err // } // r.Handle("/mcp", mcphttp.PrivateCache(guarded)) // // The cache wrapper goes outside the Host guard on purpose: a 403 by hostname is // as unstorable as an answer, and it is written before the SDK is reached at all. package mcphttp import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) // StreamableOptions is the streamable transport's configuration for an MCP // endpoint deployed the way this instance deploys them. Both donors set exactly // these two fields and nothing else, and each of them is load-bearing. // // # Stateless, because a stateful session is itself a credential // // In the SDK's stateful mode a session spans requests, and a tool handler runs // under the context of the request that *initialised* the session — not the one // that carried the call. Every surface here reads the caller off that context. // So the session id becomes the credential: whoever presents it is answered as // whoever opened the session, having presented nothing themselves. A session // opened anonymously keeps answering as anonymous even when a later request // carries a token, and a token revoked mid-session keeps working until the // client reconnects. A session id is not a secret in the way a token is — it // travels in a plain header, lands in proxy logs, crash reports and a client's // state file — and none of the machinery that mints, scopes, expires and revokes // tokens applies to it. // // Stateless mode gives each POST its own throwaway session and its own context, // so the identity a tool sees is the identity of the request that carried it, by // construction rather than by care. What it costs is the server-to-client half // of the protocol: no standalone SSE stream, therefore no server-initiated // requests, and a bare GET is answered 405. Both services' tools are reads that // answer in one response and none of them samples, elicits or reports progress, // so there is nothing to give up. // // # DisableLocalhostProtection, because the SDK's guard cannot see this proxy // // Turning off a security default is usually a mistake, so: the SDK's guard // refuses any request that arrives on a loopback address while carrying a // non-loopback Host. That is precisely this deployment — every daemon binds // 127.0.0.1 behind nginx, which forwards with the instance's public Host — so // every genuine request would be a 403, and only in production, since a local // client sends a loopback Host and passes. // // The guard does have something real to catch: a browser on the daemon's own // host can reach the loopback port directly with an attacker's Host. It simply // cannot tell that request from nginx's, because both arrive from loopback with // a non-loopback Host, and the SDK exposes no allowlist to separate them. So it // is disabled and *replaced* by [HostGuard], which requires Host to name this // instance where the SDK asked only whether it was loopback. Disabling it // without that replacement would be a regression rather than a formality, which // is why the two are documented as one decision. // // A fresh value is returned on every call: the SDK takes a pointer, and a shared // one would let any caller reconfigure every other endpoint. func StreamableOptions() *mcp.StreamableHTTPOptions { return &mcp.StreamableHTTPOptions{ Stateless: true, DisableLocalhostProtection: true, } }