~bigbes/sr-ht-dolt

ref: bee205061dd2fcfc291046ffad18878ccfcacf5f sr-ht-dolt/mcpsrv/mcpsrv.go -rw-r--r-- 23.9 KiB
bee20506 — Eugene Blikh web: tell a database outage from a missing database 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
// 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/beads"
	"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

	// ready is ready_work's projection cache: one ready set per database, gated
	// on that database's head hash and expiring on beads.ReadyCacheTTL. It is
	// the one piece of state that outlives a call here, and it is built once, per
	// server — a cache created per call is not a cache, and the head-hash gate it
	// exists to enforce would never fire.
	//
	// It holds projections and never an open store, which is what makes holding
	// it across calls compatible with opening a session per call: an open store
	// is a file handle and a memory mapping, and that is precisely what the
	// per-call discipline exists not to hoard (beads.ReadyCache).
	ready *beads.ReadyCache

	// 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, ready: beads.NewReadyCache()}
	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
}