M go.mod => go.mod +8 -1
@@ 1,9 1,10 @@
module sourcecraft.dev/bigbes/sr-ht-ecore
-go 1.24
+go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.1
+ github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/stretchr/testify v1.10.0
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
@@ 15,10 16,16 @@ require (
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect
+ github.com/google/jsonschema-go v0.4.3 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
github.com/vektah/gqlparser/v2 v2.5.8 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
M go.sum => go.sum +14 -0
@@ 46,6 46,8 @@ github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutV
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@@ 69,6 71,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ 78,6 82,10 @@ github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ 94,10 102,16 @@ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYM
github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4=
github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
A mcphttp/cache.go => mcphttp/cache.go +93 -0
@@ 0,0 1,93 @@
+package mcphttp
+
+import "net/http"
+
+const (
+ // cacheControl is what every response of an MCP endpoint carries.
+ //
+ // private and no-store are middleware.SetPrivateCache's pair, unchanged and
+ // for its reason: an answer here depends entirely on the credential the
+ // request carried and says nothing about it in the URL, and it may be a
+ // PRIVATE repository's data. no-cache would not do — it still permits a cache
+ // to *store* the body and merely revalidate, which is the thing no-store
+ // forbids.
+ //
+ // no-transform is the SDK's own, kept. The 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"
+
+ // vary names what an answer here actually depends on: the unified-login
+ // cookie and the bearer token. Same two names the rest of the instance varies
+ // on, because it is the same statement.
+ vary = "Cookie, Authorization"
+)
+
+// PrivateCache marks every response an MCP endpoint writes as one no cache may
+// keep, and states what it depends on.
+//
+// It is not middleware.PrivateCache and cannot be. That one sets the headers
+// before the handler runs, which is right for a router whose handlers do not
+// touch Cache-Control; 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.
+//
+// The result is not a replacement of what the SDK asked for but a narrowing of
+// it — see cacheControl.
+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, whether that is an explicit
+// WriteHeader or the implicit one of the first Write.
+type cacheWriter struct {
+ http.ResponseWriter
+ committed bool
+}
+
+func (w *cacheWriter) WriteHeader(status int) {
+ w.commit()
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *cacheWriter) Write(b []byte) (int, error) {
+ w.commit()
+ return w.ResponseWriter.Write(b)
+}
+
+// Flush commits before flushing so a handler that streams without ever calling
+// WriteHeader still leaves with the headers. http.NewResponseController prefers a
+// Flush on the writer it is handed over one reached through Unwrap, so this
+// method is what it finds — without it the flush would commit the response at the
+// writer below and the two headers would never be written.
+func (w *cacheWriter) Flush() {
+ w.commit()
+ //nolint:errcheck // http.Flusher.Flush reports nothing; the controller's error
+ // is only about the writer not supporting flush, which Unwrap guarantees it does.
+ _ = 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", vary)
+}
+
+// Unwrap is load-bearing, not boilerplate: it is what keeps the streamable
+// transport working through this wrapper. http.NewResponseController follows
+// Unwrap to reach the real writer's Flush, and an SSE stream that could not be
+// flushed would be a response no client sees until the handler returns — which,
+// for a stream, is a response nobody sees at all. Deleting this method breaks
+// streaming while leaving every status code and header test green, so
+// TestUnwrapReachesTheUnderlyingFlusher exists to fail instead.
+func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
A mcphttp/cache_test.go => mcphttp/cache_test.go +146 -0
@@ 0,0 1,146 @@
+package mcphttp_test
+
+import (
+ "bufio"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/mcphttp"
+)
+
+const (
+ wantCacheControl = "private, no-store, no-transform"
+ wantVary = "Cookie, Authorization"
+)
+
+// TestHeadersLandOnAnExplicitWriteHeader is the ordinary path: a handler that
+// commits with WriteHeader.
+func TestHeadersLandOnAnExplicitWriteHeader(t *testing.T) {
+ h := mcphttp.PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ }))
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
+
+ assert.Equal(t, http.StatusAccepted, rec.Code)
+ assert.Equal(t, wantCacheControl, rec.Header().Get("Cache-Control"))
+ assert.Equal(t, wantVary, rec.Header().Get("Vary"))
+}
+
+// TestHeadersLandOnAnImplicitCommit covers the handler that never calls
+// WriteHeader at all. net/http commits on the first Write, and headers set after
+// that point are dropped silently — so the wrapper has to catch Write too.
+func TestHeadersLandOnAnImplicitCommit(t *testing.T) {
+ h := mcphttp.PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"jsonrpc":"2.0"}`))
+ }))
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, wantCacheControl, rec.Header().Get("Cache-Control"))
+ assert.Equal(t, wantVary, rec.Header().Get("Vary"))
+ assert.Equal(t, `{"jsonrpc":"2.0"}`, rec.Body.String())
+}
+
+// TestTheSDKsOwnDirectivesAreOverridden is the reason this wrapper exists rather
+// than middleware.PrivateCache. The streamable transport sets Cache-Control with
+// Set from inside the handler, so anything written on the way in loses. What
+// arrives here is a handler doing exactly what the SDK does.
+func TestTheSDKsOwnDirectivesAreOverridden(t *testing.T) {
+ h := mcphttp.PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Cache-Control", "no-cache, no-transform")
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
+
+ assert.Equal(t, wantCacheControl, rec.Header().Get("Cache-Control"),
+ "no-cache permits a cache to store the body and revalidate, which is what no-store forbids")
+ assert.Equal(t, wantVary, rec.Header().Get("Vary"))
+}
+
+// TestOnlyTheFirstCommitWrites checks the committed flag: a handler that writes
+// its own header after committing is doing something net/http would ignore
+// anyway, and the wrapper must not undo a status that is already on the wire by
+// re-running its Set on every Write.
+func TestOnlyTheFirstCommitWrites(t *testing.T) {
+ h := mcphttp.PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("a"))
+ _, _ = w.Write([]byte("b"))
+ }))
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
+
+ assert.Equal(t, []string{wantCacheControl}, rec.Header().Values("Cache-Control"))
+ assert.Equal(t, []string{wantVary}, rec.Header().Values("Vary"))
+ assert.Equal(t, "ab", rec.Body.String())
+}
+
+// TestUnwrapReachesTheUnderlyingFlusher is the test the Unwrap method exists
+// for.
+//
+// cacheWriter embeds the http.ResponseWriter *interface*, so it promotes no
+// Flush of its own; http.NewResponseController can only reach the real writer's
+// through Unwrap. Delete the method and this fails with ErrNotSupported while
+// every status-code and header test above stays green — which is precisely why
+// it is written as a test and not as a comment.
+func TestUnwrapReachesTheUnderlyingFlusher(t *testing.T) {
+ var flushErr error
+ h := mcphttp.PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("event: message\n"))
+ flushErr = http.NewResponseController(w).Flush()
+ }))
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/mcp", nil))
+
+ require.NoError(t, flushErr, "an SSE stream that cannot be flushed is a response no client sees")
+ assert.True(t, rec.Flushed)
+ assert.Equal(t, wantCacheControl, rec.Header().Get("Cache-Control"))
+}
+
+// TestAStreamReachesTheClientBeforeTheHandlerReturns is the same property
+// measured rather than asserted: over a real connection, with a real client,
+// bytes written and flushed inside the handler have to arrive while the handler
+// is still running. A recorder cannot tell a flush that worked from one that was
+// buffered until the end.
+func TestAStreamReachesTheClientBeforeTheHandlerReturns(t *testing.T) {
+ release := make(chan struct{})
+ srv := httptest.NewServer(mcphttp.PrivateCache(http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: first\n\n"))
+ if err := http.NewResponseController(w).Flush(); err != nil {
+ t.Errorf("flush: %v", err)
+ }
+ <-release
+ })))
+ t.Cleanup(func() {
+ close(release)
+ srv.Close()
+ })
+
+ req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
+ require.NoError(t, err)
+ resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = resp.Body.Close() })
+
+ assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control"))
+ assert.Equal(t, wantVary, resp.Header.Get("Vary"))
+
+ line, err := bufio.NewReader(resp.Body).ReadString('\n')
+ require.NoError(t, err, "the first event must arrive while the handler is still blocked")
+ assert.Equal(t, "data: first\n", line)
+}
A mcphttp/hostguard.go => mcphttp/hostguard.go +95 -0
@@ 0,0 1,95 @@
+package mcphttp
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
+)
+
+// ErrNoOriginHost is returned by [HostGuard] when the origin it was handed names
+// no host to build an allowlist from — it is empty, it does not parse, or it
+// parses to no hostname. Callers match it with errors.Is; the wrapped error
+// quotes the origin.
+var ErrNoOriginHost = errors.New("mcphttp: origin names no host")
+
+// HostGuard is an MCP endpoint's DNS-rebinding protection in the form this
+// deployment needs: Host must name the instance's own origin, or be a loopback
+// name for local development.
+//
+// It is a wrapper rather than a check inside the handler so that the refusal
+// happens before the SDK sees a byte of the body. It replaces the SDK's own
+// guard, which [StreamableOptions] turns off — see there for why that trade is
+// the safe direction.
+//
+// The expected host is [instconf.OriginHost]'s, which is the instance's one
+// reading of "what host does this origin name": the name without the port, and
+// "" for anything that does not parse. Never a guessed "localhost", which is
+// what one earlier copy answered and which would have made every malformed
+// origin agree with a local client on the one code path that decides an
+// allowlist.
+//
+// # Fail-closed, and the stance that lost
+//
+// An origin with no host is a construction error here, not a warning. The two
+// donors disagreed about this and the disagreement is worth recording rather
+// than quietly resolving.
+//
+// cov.sr.ht refuses to build the surface at all: an origin is a required
+// configuration key, config validation already requires it to parse and to carry
+// a host, so a daemon that reached this call has one and a caller that did not
+// is a bug rather than an operator to be warned. bench.sr.ht logs "Host
+// validation on the MCP endpoint is DISABLED" and serves the endpoint
+// unguarded, reasoning that refusing every request is a worse answer to a
+// misconfiguration than checking no request.
+//
+// This package takes cov's side. The failure bench's stance produces is silent
+// in exactly the deployment where it matters: the warning is one line at startup
+// among many, and what follows it is a service that works — it answers every
+// request, including the rebinding attack's. A service that refuses to start
+// says the same thing in the one register nobody can scroll past. And the
+// premise that made bench's choice cheap is the premise that makes it
+// unnecessary: if config validation already requires an origin, the open path is
+// unreachable by any real daemon, so keeping it buys nothing and costs a
+// security property. bench has a test pinning its behaviour
+// (TestNoOriginLeavesTheEndpointOpen) and retrofitting that is bench's to do,
+// not this package's to force.
+func HostGuard(next http.Handler, origin string) (http.Handler, error) {
+ want := instconf.OriginHost(origin)
+ if want == "" {
+ return nil, fmt.Errorf("%w: %q has no host to guard the MCP endpoint with", ErrNoOriginHost, origin)
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !hostAllowed(r.Host, want) {
+ http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ }), nil
+}
+
+// hostAllowed compares a request's Host against the expected hostname, ignoring
+// any port and IPv6 brackets.
+//
+// Loopback names stay allowed on purpose: a developer running the daemon by hand
+// and a local MCP client pointed at it address it as localhost, and those names
+// cannot be a rebinding attack's — an attacker's page has to carry a name it
+// controls.
+func hostAllowed(reqHost, want string) bool {
+ h := reqHost
+ if stripped, _, err := net.SplitHostPort(h); err == nil {
+ h = stripped
+ }
+ h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
+ switch {
+ case strings.EqualFold(h, want):
+ return true
+ case h == "localhost", h == "127.0.0.1", h == "::1":
+ return true
+ default:
+ return false
+ }
+}
A mcphttp/hostguard_test.go => mcphttp/hostguard_test.go +148 -0
@@ 0,0 1,148 @@
+package mcphttp_test
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/mcphttp"
+)
+
+const testOrigin = "https://mcp.example.org"
+
+// okHandler is what the guard protects: anything that reaches it answers 200, so
+// a test's status code says whether the guard let the request through.
+func okHandler(reached *bool) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ if reached != nil {
+ *reached = true
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+}
+
+func guard(t *testing.T, origin string, reached *bool) http.Handler {
+ t.Helper()
+ h, err := mcphttp.HostGuard(okHandler(reached), origin)
+ require.NoError(t, err)
+ return h
+}
+
+// TestProxiedHostIsAccepted pins the deployment shape, which is invisible
+// otherwise: the daemon listens on loopback and nginx forwards the instance's
+// public Host. Without this the endpoint could be guarded into refusing every
+// production request while passing every local check.
+func TestProxiedHostIsAccepted(t *testing.T) {
+ for _, host := range []string{"mcp.example.org", "mcp.example.org:443", "MCP.EXAMPLE.ORG"} {
+ t.Run(host, func(t *testing.T) {
+ reached := false
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+
+ guard(t, testOrigin, &reached).ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.True(t, reached, "the guard must pass the instance's own host through")
+ })
+ }
+}
+
+// TestUnexpectedHostIsRefused is the guard doing its job: a rebinding attack
+// carries a name the attacker controls, and the suffix case is here because a
+// check written with HasSuffix instead of an equality would let it in.
+func TestUnexpectedHostIsRefused(t *testing.T) {
+ for _, host := range []string{
+ "evil.example",
+ "mcp.example.org.evil.example",
+ "evilmcp.example.org",
+ "10.0.0.5",
+ "10.0.0.5:5093",
+ "",
+ } {
+ t.Run(host, func(t *testing.T) {
+ reached := false
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+
+ guard(t, testOrigin, &reached).ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.False(t, reached, "a refused request must not reach the handler behind the guard")
+ })
+ }
+}
+
+// TestLoopbackHostsStayAllowed is the concession the guard makes on purpose: a
+// developer running the daemon by hand, and a local MCP client pointed at it,
+// address it by a loopback name, and no attacker's page can carry one.
+func TestLoopbackHostsStayAllowed(t *testing.T) {
+ for _, host := range []string{"localhost", "localhost:5093", "127.0.0.1", "127.0.0.1:5093", "[::1]", "[::1]:5093"} {
+ t.Run(host, func(t *testing.T) {
+ reached := false
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+
+ guard(t, testOrigin, &reached).ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.True(t, reached)
+ })
+ }
+}
+
+// TestAnOriginWithNoHostIsAConstructionError is the fail-closed stance, pinned.
+// The alternative a donor took — warn and serve unguarded — is a service that
+// works, which is exactly why nobody discovers it; this must be an error a
+// daemon cannot start through.
+func TestAnOriginWithNoHostIsAConstructionError(t *testing.T) {
+ for name, origin := range map[string]string{
+ "empty": "",
+ "blank": " ",
+ "schemeless": "mcp.example.org",
+ "path only": "/mcp",
+ "unparseable": "://mcp.example.org",
+ "scheme alone": "https://",
+ } {
+ t.Run(name, func(t *testing.T) {
+ h, err := mcphttp.HostGuard(okHandler(nil), origin)
+
+ require.Error(t, err)
+ assert.Nil(t, h, "a refused guard must not hand back a handler somebody could mount anyway")
+ assert.ErrorIs(t, err, mcphttp.ErrNoOriginHost)
+ assert.Contains(t, err.Error(), origin, "the error must quote the origin an operator has to fix")
+ })
+ }
+}
+
+// TestErrNoOriginHostIsMatchable states the contract callers rely on: they
+// classify with errors.Is, not by reading the message.
+func TestErrNoOriginHostIsMatchable(t *testing.T) {
+ _, err := mcphttp.HostGuard(okHandler(nil), "")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, mcphttp.ErrNoOriginHost))
+}
+
+// TestARefusalByHostnameIsUncacheable pins the composition the package
+// documents: PrivateCache wraps HostGuard, so a 403 written before the SDK is
+// reached at all still carries the directives. A cached 403 would be its own
+// bug, and the ordering that prevents it is easy to reverse by accident.
+func TestARefusalByHostnameIsUncacheable(t *testing.T) {
+ h := mcphttp.PrivateCache(guard(t, testOrigin, nil))
+
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Host = "evil.example"
+ rec := httptest.NewRecorder()
+
+ h.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ assert.Equal(t, "private, no-store, no-transform", rec.Header().Get("Cache-Control"))
+ assert.Equal(t, "Cookie, Authorization", rec.Header().Get("Vary"))
+}
A mcphttp/mcphttp.go => mcphttp/mcphttp.go +99 -0
@@ 0,0 1,99 @@
+// 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,
+ }
+}
A mcphttp/options_test.go => mcphttp/options_test.go +42 -0
@@ 0,0 1,42 @@
+package mcphttp_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/mcphttp"
+)
+
+// TestStatelessSessions pins the option that decides where a tool handler's
+// identity comes from. With Stateless off, the SDK hands every call the context
+// of the request that initialised the session, so the session id becomes a
+// credential nobody minted, scoped or can revoke. That is a security change that
+// would show up as no compile error and no failing tool test.
+func TestStatelessSessions(t *testing.T) {
+ opts := mcphttp.StreamableOptions()
+ require.NotNil(t, opts)
+
+ assert.True(t, opts.Stateless, "a stateful session authenticates by session id")
+}
+
+// TestTheSDKGuardIsDisabled pins the other half of the deployment decision. It is
+// only defensible together with HostGuard, so read it next to
+// TestUnexpectedHostIsRefused: this option removes a check and that test is the
+// replacement.
+func TestTheSDKGuardIsDisabled(t *testing.T) {
+ assert.True(t, mcphttp.StreamableOptions().DisableLocalhostProtection,
+ "the SDK guard refuses every request nginx forwards, and only in production")
+}
+
+// TestEachCallGetsItsOwnValue keeps one endpoint from reconfiguring another: the
+// SDK takes a pointer and would happily hold a shared one.
+func TestEachCallGetsItsOwnValue(t *testing.T) {
+ a, b := mcphttp.StreamableOptions(), mcphttp.StreamableOptions()
+
+ require.NotSame(t, a, b)
+
+ a.Stateless = false
+ assert.True(t, b.Stateless, "one caller's mutation must not reach another's options")
+}