coverage~bigbes/sr-ht-doltede3b0bbmcpsrv/mcpsrv.go

Coverage
98.6% 71/72 statements
Δ
Blob
fd86daa
Uncovered L523-L524
1 // Package mcpsrv is dolt.sr.ht's Model Context Protocol surface: the read-only
2 // tools of docs/DESIGN.mcp.md ch. 9 an agent calls to read a hosted Dolt
3 // database — and the beads tracker inside it — served over streamable HTTP at
4 // /mcp on the daemon's web listener.
5 //
6 // The donor is cov.sr.ht's mcpsrv/, the newest of the four this instance already
7 // runs, and the family convention is to copy the pattern rather than import it:
8 // the SDK server built in New, the tools registered through the generic
9 // mcp.AddTool so every schema is derived from a Go struct, the streamable
10 // handler mounted on the service's own router, and the Host allowlist that
11 // replaces the SDK's DNS-rebinding guard.
12 //
13 // # A surface, not a second service
14 //
15 // Nothing here re-derives what the rest of the service already decides.
16 // Visibility is core.Allowed over the grant the metadata store resolves, the
17 // beads fingerprint is beads.Applies, the default branch is
18 // browse.DefaultBranch. Two surfaces that each grew their own copy is how they
19 // start answering one question differently, quietly, months later — so the
20 // browse handlers' dance (web/router.go's loadRepoForBrowse) is reproduced here
21 // call for call rather than re-thought.
22 //
23 // # No engine, no writes
24 //
25 // The whole pure-Go build stands on one fact: this service never starts the SQL
26 // engine, because a bare NBS store has no working set to start it against
27 // (browse/open.go). This surface changes nothing about that. There is no
28 // query(sql) tool and there will not be one, there is no mutation of any kind,
29 // and neither is a rule anybody has to remember: ports.go names no seam that
30 // could reach either, so a handler here cannot write what it has no way to
31 // call.
32 //
33 // # Identity
34 //
35 // This is the only bearer surface the service has, so the credential middleware
36 // lives here rather than in the daemon (which is the one departure from the
37 // donor, whose siblings share a resolver in front of three surfaces). A request
38 // carrying no Authorization header is anonymous, and anonymous is a normal
39 // caller: it reads what anonymity may read. A bearer token that fails to resolve
40 // is a refusal and never a downgrade to anonymous, with authn/bearer.go's two
41 // error classes rendered as 401, 403 and 503 (resolveCaller).
42 //
43 // How that caller reaches a tool handler is decided by the SDK's transport and
44 // is why this one runs stateless; the constant below carries the argument and
45 // the measurement made against the version go.mod pins.
46 package mcpsrv
47
48 import (
49 "context"
50 "errors"
51 "log/slog"
52 "net"
53 "net/http"
54 "runtime/debug"
55 "strings"
56
57 "github.com/modelcontextprotocol/go-sdk/mcp"
58 "go.bigb.es/auxilia/culpa"
59 "go.bigb.es/auxilia/scribe"
60
61 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
62 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
63
64 "sourcecraft.dev/bigbes/sr-ht-dolt/authn"
65 "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
66 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
67 )
68
69 const (
70 // ServerName is the implementation name reported in the MCP handshake, and
71 // the realm of every 401 this surface writes.
72 //
73 // It is the service's config-section name spelled as a literal rather than
74 // read from a config section: a client listing several SourceHut MCP
75 // endpoints tells them apart by this string, so it is part of this surface's
76 // contract and does not follow a key that may move for reasons of its own.
77 ServerName = "dolt.sr.ht"
78
79 // stateless puts the streamable transport in stateless mode, and that is an
80 // authentication decision rather than a performance one
81 // (docs/DESIGN.mcp.md §5).
82 //
83 // The SDK connects a session with the context of the HTTP request that
84 // created it, and every tool call that session handles then runs under that
85 // one context. In stateful mode the creating request is the *initialize*
86 // handshake, so the caller resolved for the handshake answers every later
87 // tools/call on that session — and the consequence is worse than a stale
88 // authority: the session id becomes a bearer credential in its own right,
89 // issued by a service that issues none, and the credential presented on the
90 // call is not consulted at all. Anyone holding that id — a proxy log, a crash
91 // report, a shared client's state file — then reads as the caller who opened
92 // the session, and a token revoked mid-session keeps working until the client
93 // reconnects.
94 //
95 // Stateless mode connects a temporary session per POST, so a tool handler's
96 // context descends from the request that carried the call, credential
97 // middleware included, and identity is per call — which is what a bearer
98 // surface means.
99 //
100 // The measurement behind this is the donor's, and a paragraph is not a
101 // measurement: TestIdentityIsPerCallAndNotPerSession re-makes it here,
102 // against the SDK version go.mod pins, by swapping the credential
103 // mid-session. Flipping this constant to false and running it gives, for one
104 // session whose handshake carried no credential and one owner with six
105 // databases of which four are public:
106 //
107 // stateless: handshake anonymous, call with the OWNER's token -> 6
108 // handshake anonymous, call with NO credential -> 4
109 // stateful: handshake anonymous, call with the OWNER's token -> 4
110 // i.e. the token on the call is not consulted at all
111 //
112 // and the mirror image — a handshake that *did* carry a token — is the same
113 // fact the other way round: the session answers as its opener to a caller
114 // presenting nothing.
115 //
116 // What it costs is the server->client half of the protocol: no standalone SSE
117 // stream, so no server-initiated requests, and a GET is answered 405. Every
118 // tool of ch. 9 is a read that answers in one response — none samples,
119 // elicits or reports progress — so there is nothing to give up.
120 stateless = true
121
122 // privateVary is what every answer of this endpoint actually depends on, and
123 // it names one header rather than the donor's two: /mcp is bearer-only
124 // (docs/DESIGN.mcp.md §4.1). The unified-login cookie is the web UI's plane
125 // and is not read here, so promising a cache that answers vary by it would be
126 // a promise about a header this surface never looks at.
127 privateVary = "Authorization"
128
129 // refusalCacheControl is the private-cache pair every refusal carries.
130 refusalCacheControl = "private, no-store"
131
132 // answerCacheControl is that pair plus the one directive the SDK sets for its
133 // own reasons. The transport writes `no-cache, no-transform` on every
134 // response it produces: no-transform protects the SSE framing from an
135 // intermediary that would recompress or rechunk it, and is kept; no-cache is
136 // replaced, because it permits a cache to *store* the body and merely
137 // revalidate — which is exactly what no-store forbids and what an answer
138 // carrying a PRIVATE database's contents may not allow.
139 answerCacheControl = refusalCacheControl + ", no-transform"
140 )
141
142 // bearerChallenge is the RFC 7235 challenge every 401 of this surface carries.
143 // bearer.Challenge assembles it, so the quoting RFC 9110 §11.6.1 requires of a
144 // realm is done once for the instance rather than by hand in six services.
145 var bearerChallenge = bearer.Challenge(ServerName)
146
147 // A Server is the MCP surface: the SDK server with the tools registered,
148 // wrapped in the HTTP chain the daemon mounts at /mcp.
149 //
150 // It holds no request state and is safe for concurrent use — the caller's
151 // identity travels in the request context, never on the server — which is what
152 // lets one instance serve every session.
153 type Server struct {
154 repos Repos
155 opener BrowseOpener
156
157 // validator verifies a tokens.sr.ht working token. It may be nil, and a nil
158 // one is a configuration rather than a degradation: an instance whose
159 // config.ini carries no [tokens.sr.ht] origin has no such daemon, so meta
160 // PATs and anonymity keep working and a working token is refused rather than
161 // guessed at (authn.ResolveBearer documents the contract, including that a
162 // *typed* nil is not it).
163 validator authn.InstanceValidator
164
165 // mcp is the protocol server the tools are registered on, kept so that a test
166 // can connect an in-memory transport to it without going through HTTP
167 // (Connect).
168 mcp *mcp.Server
169
170 // ready is ready_work's projection cache: one ready set per database, gated
171 // on that database's head hash and expiring on beads.ReadyCacheTTL. It is
172 // the one piece of state that outlives a call here, and it is built once, per
173 // server — a cache created per call is not a cache, and the head-hash gate it
174 // exists to enforce would never fire.
175 //
176 // It holds projections and never an open store, which is what makes holding
177 // it across calls compatible with opening a session per call: an open store
178 // is a file handle and a memory mapping, and that is precisely what the
179 // per-call discipline exists not to hoard (beads.ReadyCache).
180 ready *beads.ReadyCache
181
182 // http is the whole handler chain. It is built once, in New, because
183 // mcp.NewStreamableHTTPHandler owns transport state and two of them would be
184 // two servers.
185 http http.Handler
186 }
187
188 // A Server is an http.Handler: the daemon mounts it with r.Handle("/mcp", s) —
189 // Handle and not Mount, because the streamable handler serves that exact path
190 // (docs/DESIGN.mcp.md §3).
191 var _ http.Handler = (*Server)(nil)
192
193 // New builds the MCP surface over its seams.
194 //
195 // origin is [dolt.sr.ht]origin — the instance's public base URL — and it is
196 // required: it is the Host allowlist this endpoint is guarded by (allowHosts).
197 // An origin with no host is a wiring error and is refused here rather than
198 // warned about and then served unguarded.
199 //
200 // validator may be nil (see Server.validator). repos and opener may not: a
201 // surface that answered every call "internal error" because a seam was never
202 // wired would be a daemon that starts and does not work, and the daemon that
203 // wired it is not an operator to be warned, it is a bug.
204 132 func New(repos Repos, opener BrowseOpener, validator authn.InstanceValidator, origin string) (*Server, error) {
205 132 if repos == nil {
206 1 return nil, culpa.New("mcpsrv: nil Repos")
207 1 }
208 131 if opener == nil {
209 1 return nil, culpa.New("mcpsrv: nil BrowseOpener")
210 1 }
211 // instconf.OriginHost is the instance's one reading of "what host does this
212 // origin name": the name without the port, "" for anything that does not
213 // parse — never a guessed "localhost", which would make every malformed
214 // origin agree with a local client on the one code path where that decides
215 // an allowlist.
216 130 host := instconf.OriginHost(origin)
217 130 if host == "" {
218 4 return nil, culpa.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin)
219 4 }
220
221 126 s := &Server{repos: repos, opener: opener, validator: validator, ready: beads.NewReadyCache()}
222 126 s.mcp = mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: serverVersion()}, nil)
223 126 s.register()
224 126
225 126 // The SDK's DNS-rebinding guard is disabled deliberately, and disabling a
226 126 // security default usually is not defensible, so here is why this one is.
227 126 //
228 126 // The guard refuses any request that arrives on a loopback address carrying a
229 126 // non-loopback Host header. That is precisely this deployment: the daemon
230 126 // binds localhost and Traefik/nginx forwards with the instance's public Host
231 126 // (docs/DESIGN.mcp.md §3, §6). Every genuine request would be a 403 — and
232 126 // only in production, because a local client sends a loopback Host and
233 126 // passes.
234 126 //
235 126 // It is not that the guard has nothing to catch: a browser running on the
236 126 // daemon's own host could reach the loopback port directly with an attacker's
237 126 // Host. The guard simply cannot tell that request from the proxy's — both
238 126 // arrive from loopback with a non-loopback Host — and the SDK offers no
239 126 // allowlist to separate them. So it is disabled and *replaced*, in the same
240 126 // constructor, by a stricter check.
241 126 handler := mcp.NewStreamableHTTPHandler(
242 126 func(*http.Request) *mcp.Server { return s.mcp },
243 &mcp.StreamableHTTPOptions{
244 DisableLocalhostProtection: true,
245 Stateless: stateless,
246 },
247 )
248
249 // The order of the wrappers is the order of the questions, outermost first,
250 // and each one is where it is for a reason:
251 //
252 // privateCache every answer AND every refusal is unstorable, so it
253 // wraps the lot — including the Host 403, which is
254 // written before the SDK is reached at all.
255 // allowHosts a request naming somebody else's host is refused
256 // before its credential is even parsed: there is no
257 // reason to spend an HMAC, or a meta lookup, on a
258 // request this endpoint will not answer.
259 // resolveCaller who is calling, once per request, put in the context
260 // the SDK will hand every tool handler.
261 // requireReadGrant what that credential covers, asked after it resolved
262 // and before a session is negotiated or a tool named.
263 126 s.http = privateCache(allowHosts(s.resolveCaller(requireReadGrant(handler)), host))
264 126 return s, nil
265 }
266
267 // ServeHTTP serves the streamable MCP transport behind the chain New built.
268 48 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }
269
270 // Connect attaches the protocol server to a transport directly, for a caller
271 // that speaks MCP without HTTP — the in-process client the tools are tested
272 // with (docs/DESIGN.mcp.md §12).
273 //
274 // It exists so that a test drives the very server the daemon serves: the tools,
275 // their schemas and their handlers are registered once, in New, and a second
276 // registration path for tests would be a second surface to keep in agreement.
277 // The caller comes from ctx exactly as it does over HTTP — the SDK connects the
278 // session with the context it is given and every handler descends from it (see
279 // stateless).
280 257 func (s *Server) Connect(ctx context.Context, t mcp.Transport) (*mcp.ServerSession, error) {
281 257 return s.mcp.Connect(ctx, t, nil)
282 257 }
283
284 type contextKey struct{ name string }
285
286 // bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate.
287 //
288 // The identity itself goes where the rest of the service looks for it
289 // (authn.WithCaller), so a tool handler reads a caller the same way a web
290 // handler does. What has no house-wide home is the tokens.sr.ht grant set: it
291 // exists only on a working token, only this surface asks about it, and putting
292 // it on the shared AuthContext would subject it to core-go's entirely different
293 // OAuth gate (authn/bearer.go says why). So it stays here, private to this
294 // package, read by requireReadGrant alone.
295 var bearerCallerKey = &contextKey{"mcpsrv.bearerCaller"}
296
297 23 func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context {
298 23 return context.WithValue(ctx, bearerCallerKey, bc)
299 23 }
300
301 39 func bearerCallerFrom(ctx context.Context) *authn.BearerCaller {
302 39 bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller)
303 39 return bc
304 39 }
305
306 // callerOf is how a tool handler learns who is asking: the core.Caller the
307 // access matrix is written against, nil for an anonymous caller.
308 //
309 // It reads the context and nothing else. There is no field on Server holding a
310 // caller and there must not be: one Server answers every session, and identity
311 // that lived on it would be the last caller's rather than this call's.
312 334 func callerOf(ctx context.Context) *core.Caller {
313 334 return authn.AsCoreCaller(authn.CallerFromContext(ctx))
314 334 }
315
316 // resolveCaller is this surface's credential middleware: the bearer plane of
317 // docs/DESIGN.mcp.md §4.1, and the only one it accepts.
318 //
319 // It lives here rather than in the daemon because /mcp is the only bearer
320 // surface this service has — the web UI resolves a cookie, the remotesapi
321 // resolves Basic and a dolt JWT, and each does it in its own place. A middleware
322 // mounted globally would be a fourth plane in front of three surfaces that do
323 // not want it.
324 //
325 // No Authorization header is anonymous, and anonymous is a normal caller: it
326 // falls through with nothing in the context, which CallerFromContext already
327 // reads as "not signed in". A header naming another scheme is likewise no
328 // bearer token (authn.ParseBearer), not a refusal — Basic belongs to dolt's
329 // remote flow and is not this plane's to reject.
330 //
331 // A presented token that does not resolve is a refusal and never a downgrade to
332 // anonymous, which is the rule the whole design rests on: an agent whose token
333 // expired must be told so, not quietly served the public half of the instance
334 // and left to conclude its databases were deleted. The classes are
335 // authn/bearer.go's, unchanged:
336 //
337 // ErrMissingGrant 403 — the credential is good, the caller is known, and
338 // what is missing is a permission. A 401 here would send
339 // them round a loop that cannot end: a token does not grow
340 // a grant by being presented twice.
341 // ErrInvalidToken 401 + the challenge — forged, expired, revoked, or a
342 // working token on an instance that configures no
343 // tokens.sr.ht to verify it against.
344 // anything else 503 — the credential could not be *checked*. "I could not
345 // decide" is not "your token is bad", and answering 401 to a
346 // restart of meta.sr.ht would tell every agent on the
347 // instance to re-mint credentials that were never broken.
348 //
349 // The messages are written here, from what the caller already knows, and never
350 // from the error's own text: authn's errors name usernames, hosts and token
351 // ids. The cause is logged instead, on the arm where an operator needs it.
352 126 func (s *Server) resolveCaller(next http.Handler) http.Handler {
353 126 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
354 44 presented := authn.ParseBearer(r)
355 44 if presented == "" {
356 16 next.ServeHTTP(w, r)
357 16 return
358 16 }
359
360 28 bc, err := authn.ResolveBearer(r.Context(), s.validator, presented)
361 28 if err != nil {
362 5 switch {
363 1 case errors.Is(err, authn.ErrMissingGrant):
364 1 // Asked before ErrInvalidToken: ResolveBearer joins the two, so
365 1 // that a caller who only knows the permanent/transient split
366 1 // still answers 401, while one that can say 403 asks for this
367 1 // sentinel first. This surface can.
368 1 http.Error(w, "this credential does not grant read access to "+ServerName+" databases",
369 1 http.StatusForbidden)
370 3 case errors.Is(err, authn.ErrInvalidToken):
371 3 w.Header().Set("WWW-Authenticate", bearerChallenge)
372 3 http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized)
373 1 default:
374 1 slog.Error("a bearer credential could not be checked", scribe.Err(err))
375 1 http.Error(w, "the credential could not be verified, try again",
376 1 http.StatusServiceUnavailable)
377 }
378 5 return
379 }
380
381 23 ctx := authn.WithCaller(r.Context(), bc.AuthContext)
382 23 ctx = withBearerCaller(ctx, bc)
383 23 next.ServeHTTP(w, r.WithContext(ctx))
384 })
385 }
386
387 // requireReadGrant is the grant gate of docs/DESIGN.mcp.md §4.2: a tokens.sr.ht
388 // working token must carry core.GrantRead to reach any of this surface.
389 //
390 // It is one check at the boundary rather than one per tool because every tool
391 // registered here is a read, so the surface has exactly one action, and checking
392 // it per tool would be one chance per tool to forget the next one. A write tool
393 // added here must NOT rely on this: it would be admitted by a read grant, which
394 // is not what a read grant says. Give it its own check against a
395 // core.GrantWrite that does not exist yet, in its handler, where the action it
396 // performs is finally known.
397 //
398 // A meta PAT and an anonymous caller pass, and neither is a hole.
399 // BearerCaller.Authorize already encodes that: a PAT carries no tokens.sr.ht
400 // grants at all — the vocabularies do not overlap — and its scoping was applied
401 // at resolve time by the same gate the clone path applies; anonymity carries no
402 // credential to scope. What either may then see is core.Allowed's answer and
403 // not this gate's.
404 126 func requireReadGrant(next http.Handler) http.Handler {
405 126 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
406 39 if bc := bearerCallerFrom(r.Context()); bc != nil {
407 23 if err := bc.Authorize(core.GrantRead); err != nil {
408 1 // 403 and not 401, for resolveCaller's reason: the credential is
409 1 // good and the caller is known.
410 1 //
411 1 // No cache headers here: this runs inside privateCache, which
412 1 // marks everything this endpoint writes.
413 1 http.Error(w, "this token does not carry the "+core.GrantRead+" grant",
414 1 http.StatusForbidden)
415 1 return
416 1 }
417 }
418 38 next.ServeHTTP(w, r)
419 })
420 }
421
422 // privateCache marks every answer this endpoint writes as one no cache may keep,
423 // and states what it depends on.
424 //
425 // Setting the headers before the handler runs is not enough: the SDK's
426 // streamable transport sets Cache-Control itself, with Set, from inside the
427 // handler — so a value written on the way in is overwritten on the way out, and
428 // the response leaves with `no-cache, no-transform` and no Vary. They are
429 // therefore written at the last moment they still can be, when the status line
430 // is committed and every Set the handler was going to make has been made.
431 //
432 // It wraps rather than replaces what the SDK asked for: answerCacheControl keeps
433 // its no-transform and drops only the directive that contradicts no-store.
434 126 func privateCache(next http.Handler) http.Handler {
435 126 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
436 48 next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
437 48 })
438 }
439
440 // cacheWriter is the http.ResponseWriter privateCache hands down: it sets the
441 // two headers when the response is committed, whether that is an explicit
442 // WriteHeader or the implicit one of the first Write.
443 //
444 // Unwrap is what keeps the streamable transport working through it:
445 // http.NewResponseController follows it to reach the real writer's Flush, and an
446 // SSE stream that could not be flushed would be a response no client sees until
447 // the handler returns.
448 type cacheWriter struct {
449 http.ResponseWriter
450 committed bool
451 }
452
453 25 func (w *cacheWriter) WriteHeader(status int) {
454 25 w.commit()
455 25 w.ResponseWriter.WriteHeader(status)
456 25 }
457
458 38 func (w *cacheWriter) Write(b []byte) (int, error) {
459 38 w.commit()
460 38 return w.ResponseWriter.Write(b)
461 38 }
462
463 63 func (w *cacheWriter) commit() {
464 63 if w.committed {
465 15 return
466 15 }
467 48 w.committed = true
468 48 w.Header().Set("Cache-Control", answerCacheControl)
469 48 w.Header().Set("Vary", privateVary)
470 }
471
472 23 func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
473
474 // allowHosts is this endpoint's DNS-rebinding protection in the form the
475 // deployment needs: Host must be the instance's own hostname, or a loopback name
476 // for local development (an MCP client on the same machine as a dev daemon).
477 //
478 // It is a wrapper rather than a check inside ServeHTTP so that the refusal
479 // happens before the SDK sees a byte of the body.
480 126 func allowHosts(next http.Handler, want string) http.Handler {
481 126 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482 48 if !hostAllowed(r.Host, want) {
483 4 http.Error(w, "unexpected Host header", http.StatusForbidden)
484 4 return
485 4 }
486 44 next.ServeHTTP(w, r)
487 })
488 }
489
490 // hostAllowed compares a request's Host against the expected hostname, ignoring
491 // any port and IPv6 brackets.
492 48 func hostAllowed(reqHost, want string) bool {
493 48 h := reqHost
494 48 if stripped, _, err := net.SplitHostPort(h); err == nil {
495 43 h = stripped
496 43 }
497 48 h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
498 48 switch {
499 1 case strings.EqualFold(h, want):
500 1 return true
501 43 case h == "localhost", h == "127.0.0.1", h == "::1":
502 43 return true
503 4 default:
504 4 return false
505 }
506 }
507
508 // serverVersion is the implementation version reported in the MCP handshake.
509 //
510 // It is read from the build info rather than declared as a constant, because a
511 // constant would be a number somebody has to remember to bump and would
512 // therefore be wrong: the daemon has no version string of its own, and the one
513 // thing that does change per build is the module version the toolchain stamps
514 // in.
515 //
516 // A binary built with no module information — a `go test` binary is the usual
517 // one — reports "(devel)", the spelling the Go toolchain itself uses for an
518 // unstamped build. It is a display string in a handshake and nothing branches
519 // on it.
520 126 func serverVersion() string {
521 126 info, ok := debug.ReadBuildInfo()
522 126 if !ok || info.Main.Version == "" {
523 0 return "(devel)"
524 0 }
525 126 return info.Main.Version
526 }