// Package mcpsrv is dolt.sr.ht's Model Context Protocol surface: the read-only // tools of docs/DESIGN.mcp.md ch. 9 an agent calls to read a hosted Dolt // database — and the beads tracker inside it — served over streamable HTTP at // /mcp on the daemon's web listener. // // The donor is cov.sr.ht's mcpsrv/, the newest of the four this instance already // runs, and the family convention is to copy the pattern rather than import it: // the SDK server built in New, the tools registered through the generic // mcp.AddTool so every schema is derived from a Go struct, the streamable // handler mounted on the service's own router, and the Host allowlist that // replaces the SDK's DNS-rebinding guard. // // # A surface, not a second service // // Nothing here re-derives what the rest of the service already decides. // Visibility is core.Allowed over the grant the metadata store resolves, the // beads fingerprint is beads.Applies, the default branch is // browse.DefaultBranch. Two surfaces that each grew their own copy is how they // start answering one question differently, quietly, months later — so the // browse handlers' dance (web/router.go's loadRepoForBrowse) is reproduced here // call for call rather than re-thought. // // # No engine, no writes // // The whole pure-Go build stands on one fact: this service never starts the SQL // engine, because a bare NBS store has no working set to start it against // (browse/open.go). This surface changes nothing about that. There is no // query(sql) tool and there will not be one, there is no mutation of any kind, // and neither is a rule anybody has to remember: ports.go names no seam that // could reach either, so a handler here cannot write what it has no way to // call. // // # Identity // // This is the only bearer surface the service has, so the credential middleware // lives here rather than in the daemon (which is the one departure from the // donor, whose siblings share a resolver in front of three surfaces). A request // carrying no Authorization header is anonymous, and anonymous is a normal // caller: it reads what anonymity may read. A bearer token that fails to resolve // is a refusal and never a downgrade to anonymous, with authn/bearer.go's two // error classes rendered as 401, 403 and 503 (resolveCaller). // // How that caller reaches a tool handler is decided by the SDK's transport and // is why this one runs stateless; the constant below carries the argument and // the measurement made against the version go.mod pins. package mcpsrv import ( "context" "errors" "log/slog" "net" "net/http" "runtime/debug" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-ecore/instconf" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) const ( // ServerName is the implementation name reported in the MCP handshake, and // the realm of every 401 this surface writes. // // It is the service's config-section name spelled as a literal rather than // read from a config section: a client listing several SourceHut MCP // endpoints tells them apart by this string, so it is part of this surface's // contract and does not follow a key that may move for reasons of its own. ServerName = "dolt.sr.ht" // stateless puts the streamable transport in stateless mode, and that is an // authentication decision rather than a performance one // (docs/DESIGN.mcp.md §5). // // The SDK connects a session with the context of the HTTP request that // created it, and every tool call that session handles then runs under that // one context. In stateful mode the creating request is the *initialize* // handshake, so the caller resolved for the handshake answers every later // tools/call on that session — and the consequence is worse than a stale // authority: the session id becomes a bearer credential in its own right, // issued by a service that issues none, and the credential presented on the // call is not consulted at all. Anyone holding that id — a proxy log, a crash // report, a shared client's state file — then reads as the caller who opened // the session, and a token revoked mid-session keeps working until the client // reconnects. // // Stateless mode connects a temporary session per POST, so a tool handler's // context descends from the request that carried the call, credential // middleware included, and identity is per call — which is what a bearer // surface means. // // The measurement behind this is the donor's, and a paragraph is not a // measurement: TestIdentityIsPerCallAndNotPerSession re-makes it here, // against the SDK version go.mod pins, by swapping the credential // mid-session. Flipping this constant to false and running it gives, for one // session whose handshake carried no credential and one owner with six // databases of which four are public: // // stateless: handshake anonymous, call with the OWNER's token -> 6 // handshake anonymous, call with NO credential -> 4 // stateful: handshake anonymous, call with the OWNER's token -> 4 // i.e. the token on the call is not consulted at all // // and the mirror image — a handshake that *did* carry a token — is the same // fact the other way round: the session answers as its opener to a caller // presenting nothing. // // What it costs is the server->client half of the protocol: no standalone SSE // stream, so no server-initiated requests, and a GET is answered 405. Every // tool of ch. 9 is a read that answers in one response — none samples, // elicits or reports progress — so there is nothing to give up. stateless = true // privateVary is what every answer of this endpoint actually depends on, and // it names one header rather than the donor's two: /mcp is bearer-only // (docs/DESIGN.mcp.md §4.1). The unified-login cookie is the web UI's plane // and is not read here, so promising a cache that answers vary by it would be // a promise about a header this surface never looks at. privateVary = "Authorization" // refusalCacheControl is the private-cache pair every refusal carries. refusalCacheControl = "private, no-store" // answerCacheControl is that pair plus the one directive the SDK sets for its // own reasons. The 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 is kept; no-cache is // replaced, because it permits a cache to *store* the body and merely // revalidate — which is exactly what no-store forbids and what an answer // carrying a PRIVATE database's contents may not allow. answerCacheControl = refusalCacheControl + ", no-transform" ) // bearerChallenge is the RFC 7235 challenge every 401 of this surface carries. // bearer.Challenge assembles it, so the quoting RFC 9110 §11.6.1 requires of a // realm is done once for the instance rather than by hand in six services. var bearerChallenge = bearer.Challenge(ServerName) // A Server is the MCP surface: the SDK server with the tools registered, // wrapped in the HTTP chain the daemon mounts at /mcp. // // It holds no request state and is safe for concurrent use — the caller's // identity travels in the request context, never on the server — which is what // lets one instance serve every session. type Server struct { repos Repos opener BrowseOpener // validator verifies a tokens.sr.ht working token. It may be nil, and a nil // one is a configuration rather than a degradation: an instance whose // config.ini carries no [tokens.sr.ht] origin has no such daemon, so meta // PATs and anonymity keep working and a working token is refused rather than // guessed at (authn.ResolveBearer documents the contract, including that a // *typed* nil is not it). validator authn.InstanceValidator // mcp is the protocol server the tools are registered on, kept so that a test // can connect an in-memory transport to it without going through HTTP // (Connect). mcp *mcp.Server // http is the whole handler chain. It is built once, in New, because // mcp.NewStreamableHTTPHandler owns transport state and two of them would be // two servers. http http.Handler } // A Server is an http.Handler: the daemon mounts it with r.Handle("/mcp", s) — // Handle and not Mount, because the streamable handler serves that exact path // (docs/DESIGN.mcp.md §3). var _ http.Handler = (*Server)(nil) // New builds the MCP surface over its seams. // // origin is [dolt.sr.ht]origin — the instance's public base URL — and it is // required: it is the Host allowlist this endpoint is guarded by (allowHosts). // An origin with no host is a wiring error and is refused here rather than // warned about and then served unguarded. // // validator may be nil (see Server.validator). repos and opener may not: a // surface that answered every call "internal error" because a seam was never // wired would be a daemon that starts and does not work, and the daemon that // wired it is not an operator to be warned, it is a bug. func New(repos Repos, opener BrowseOpener, validator authn.InstanceValidator, origin string) (*Server, error) { if repos == nil { return nil, culpa.New("mcpsrv: nil Repos") } if opener == nil { return nil, culpa.New("mcpsrv: nil BrowseOpener") } // instconf.OriginHost is the instance's one reading of "what host does this // origin name": the name without the port, "" for anything that does not // parse — never a guessed "localhost", which would make every malformed // origin agree with a local client on the one code path where that decides // an allowlist. host := instconf.OriginHost(origin) if host == "" { return nil, culpa.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin) } s := &Server{repos: repos, opener: opener, validator: validator} s.mcp = mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: serverVersion()}, nil) s.register() // The SDK's DNS-rebinding guard is disabled deliberately, and disabling a // security default usually is not defensible, so here is why this one is. // // The guard refuses any request that arrives on a loopback address carrying a // non-loopback Host header. That is precisely this deployment: the daemon // binds localhost and Traefik/nginx forwards with the instance's public Host // (docs/DESIGN.mcp.md §3, §6). Every genuine request would be a 403 — and // only in production, because a local client sends a loopback Host and // passes. // // It is not that the guard has nothing to catch: a browser running on the // daemon's own host could reach the loopback port directly with an attacker's // Host. The guard simply cannot tell that request from the proxy's — both // arrive from loopback with a non-loopback Host — and the SDK offers no // allowlist to separate them. So it is disabled and *replaced*, in the same // constructor, by a stricter check. handler := mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { return s.mcp }, &mcp.StreamableHTTPOptions{ DisableLocalhostProtection: true, Stateless: stateless, }, ) // The order of the wrappers is the order of the questions, outermost first, // and each one is where it is for a reason: // // privateCache every answer AND every refusal is unstorable, so it // wraps the lot — including the Host 403, which is // written before the SDK is reached at all. // allowHosts a request naming somebody else's host is refused // before its credential is even parsed: there is no // reason to spend an HMAC, or a meta lookup, on a // request this endpoint will not answer. // resolveCaller who is calling, once per request, put in the context // the SDK will hand every tool handler. // requireReadGrant what that credential covers, asked after it resolved // and before a session is negotiated or a tool named. s.http = privateCache(allowHosts(s.resolveCaller(requireReadGrant(handler)), host)) return s, nil } // ServeHTTP serves the streamable MCP transport behind the chain New built. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) } // Connect attaches the protocol server to a transport directly, for a caller // that speaks MCP without HTTP — the in-process client the tools are tested // with (docs/DESIGN.mcp.md §12). // // It exists so that a test drives the very server the daemon serves: the tools, // their schemas and their handlers are registered once, in New, and a second // registration path for tests would be a second surface to keep in agreement. // The caller comes from ctx exactly as it does over HTTP — the SDK connects the // session with the context it is given and every handler descends from it (see // stateless). func (s *Server) Connect(ctx context.Context, t mcp.Transport) (*mcp.ServerSession, error) { return s.mcp.Connect(ctx, t, nil) } type contextKey struct{ name string } // bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate. // // The identity itself goes where the rest of the service looks for it // (authn.WithCaller), so a tool handler reads a caller the same way a web // handler does. What has no house-wide home is the tokens.sr.ht grant set: it // exists only on a working token, only this surface asks about it, and putting // it on the shared AuthContext would subject it to core-go's entirely different // OAuth gate (authn/bearer.go says why). So it stays here, private to this // package, read by requireReadGrant alone. var bearerCallerKey = &contextKey{"mcpsrv.bearerCaller"} func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context { return context.WithValue(ctx, bearerCallerKey, bc) } func bearerCallerFrom(ctx context.Context) *authn.BearerCaller { bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller) return bc } // callerOf is how a tool handler learns who is asking: the core.Caller the // access matrix is written against, nil for an anonymous caller. // // It reads the context and nothing else. There is no field on Server holding a // caller and there must not be: one Server answers every session, and identity // that lived on it would be the last caller's rather than this call's. func callerOf(ctx context.Context) *core.Caller { return authn.AsCoreCaller(authn.CallerFromContext(ctx)) } // resolveCaller is this surface's credential middleware: the bearer plane of // docs/DESIGN.mcp.md §4.1, and the only one it accepts. // // It lives here rather than in the daemon because /mcp is the only bearer // surface this service has — the web UI resolves a cookie, the remotesapi // resolves Basic and a dolt JWT, and each does it in its own place. A middleware // mounted globally would be a fourth plane in front of three surfaces that do // not want it. // // No Authorization header is anonymous, and anonymous is a normal caller: it // falls through with nothing in the context, which CallerFromContext already // reads as "not signed in". A header naming another scheme is likewise no // bearer token (authn.ParseBearer), not a refusal — Basic belongs to dolt's // remote flow and is not this plane's to reject. // // A presented token that does not resolve is a refusal and never a downgrade to // anonymous, which is the rule the whole design rests on: an agent whose token // expired must be told so, not quietly served the public half of the instance // and left to conclude its databases were deleted. The classes are // authn/bearer.go's, unchanged: // // ErrMissingGrant 403 — the credential is good, the caller is known, and // what is missing is a permission. A 401 here would send // them round a loop that cannot end: a token does not grow // a grant by being presented twice. // ErrInvalidToken 401 + the challenge — forged, expired, revoked, or a // working token on an instance that configures no // tokens.sr.ht to verify it against. // anything else 503 — the credential could not be *checked*. "I could not // decide" is not "your token is bad", and answering 401 to a // restart of meta.sr.ht would tell every agent on the // instance to re-mint credentials that were never broken. // // The messages are written here, from what the caller already knows, and never // from the error's own text: authn's errors name usernames, hosts and token // ids. The cause is logged instead, on the arm where an operator needs it. func (s *Server) resolveCaller(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { presented := authn.ParseBearer(r) if presented == "" { next.ServeHTTP(w, r) return } bc, err := authn.ResolveBearer(r.Context(), s.validator, presented) if err != nil { switch { case errors.Is(err, authn.ErrMissingGrant): // Asked before ErrInvalidToken: ResolveBearer joins the two, so // that a caller who only knows the permanent/transient split // still answers 401, while one that can say 403 asks for this // sentinel first. This surface can. http.Error(w, "this credential does not grant read access to "+ServerName+" databases", http.StatusForbidden) case errors.Is(err, authn.ErrInvalidToken): w.Header().Set("WWW-Authenticate", bearerChallenge) http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized) default: slog.Error("a bearer credential could not be checked", scribe.Err(err)) http.Error(w, "the credential could not be verified, try again", http.StatusServiceUnavailable) } return } ctx := authn.WithCaller(r.Context(), bc.AuthContext) ctx = withBearerCaller(ctx, bc) next.ServeHTTP(w, r.WithContext(ctx)) }) } // requireReadGrant is the grant gate of docs/DESIGN.mcp.md §4.2: a tokens.sr.ht // working token must carry core.GrantRead to reach any of this surface. // // It is one check at the boundary rather than one per tool because every tool // registered here is a read, so the surface has exactly one action, and checking // it per tool would be one chance per tool to forget the next one. A write tool // added here must NOT rely on this: it would be admitted by a read grant, which // is not what a read grant says. Give it its own check against a // core.GrantWrite that does not exist yet, in its handler, where the action it // performs is finally known. // // A meta PAT and an anonymous caller pass, and neither is a hole. // BearerCaller.Authorize already encodes that: a PAT carries no tokens.sr.ht // grants at all — the vocabularies do not overlap — and its scoping was applied // at resolve time by the same gate the clone path applies; anonymity carries no // credential to scope. What either may then see is core.Allowed's answer and // not this gate's. func requireReadGrant(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if bc := bearerCallerFrom(r.Context()); bc != nil { if err := bc.Authorize(core.GrantRead); err != nil { // 403 and not 401, for resolveCaller's reason: the credential is // good and the caller is known. // // No cache headers here: this runs inside privateCache, which // marks everything this endpoint writes. http.Error(w, "this token does not carry the "+core.GrantRead+" grant", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) } // privateCache marks every answer this endpoint writes as one no cache may keep, // and states what it depends on. // // Setting the headers before the handler runs is not enough: 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. They 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 rather than replaces what the SDK asked for: answerCacheControl keeps // its no-transform and drops only the directive that contradicts no-store. 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. // // Unwrap is what keeps the streamable transport working through it: // http.NewResponseController follows it 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. 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) } func (w *cacheWriter) commit() { if w.committed { return } w.committed = true w.Header().Set("Cache-Control", answerCacheControl) w.Header().Set("Vary", privateVary) } func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } // allowHosts is this endpoint's DNS-rebinding protection in the form the // deployment needs: Host must be the instance's own hostname, or a loopback name // for local development (an MCP client on the same machine as a dev daemon). // // It is a wrapper rather than a check inside ServeHTTP so that the refusal // happens before the SDK sees a byte of the body. func allowHosts(next http.Handler, want string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !hostAllowed(r.Host, want) { http.Error(w, "unexpected Host header", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // hostAllowed compares a request's Host against the expected hostname, ignoring // any port and IPv6 brackets. 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 } } // serverVersion is the implementation version reported in the MCP handshake. // // It is read from the build info rather than declared as a constant, because a // constant would be a number somebody has to remember to bump and would // therefore be wrong: the daemon has no version string of its own, and the one // thing that does change per build is the module version the toolchain stamps // in. // // A binary built with no module information — a `go test` binary is the usual // one — reports "(devel)", the spelling the Go toolchain itself uses for an // unstamped build. It is a display string in a handshake and nothing branches // on it. func serverVersion() string { info, ok := debug.ReadBuildInfo() if !ok || info.Main.Version == "" { return "(devel)" } return info.Main.Version }