~bigbes/sr-ht-dolt

57adcc0a8a8b4407184b95ca9b710e7ec72fe186 — Eugene Blikh 5 days ago dba72f5
doltsrht: serve /mcp on the web listener
6 files changed, 576 insertions(+), 34 deletions(-)

M README.md
M authn/ctx.go
M cmd/doltsrht/main.go
M cmd/doltsrht/main_test.go
A cmd/doltsrht/mcp.go
M config.example.ini
M README.md => README.md +31 -3
@@ 16,9 16,11 @@ remotes use. PostgreSQL holds metadata (a mirror of meta's users, plus
repositories, ACLs, and dolt keys). Clone URL:
`dolt clone https://dolt.srht.bigb.es/~user/db`.

Two auth flows are supported: username + meta personal access token
(`--user` + `DOLT_REMOTE_PASSWORD`, HTTP Basic) and dolt's Ed25519 keypair flow
(`dolt creds` / `dolt login`, Bearer EdDSA JWT — the git-SSH-key-like UX).
Two auth flows are supported for `dolt clone`/`push`: username + meta personal
access token (`--user` + `DOLT_REMOTE_PASSWORD`, HTTP Basic) and dolt's Ed25519
keypair flow (`dolt creds` / `dolt login`, Bearer EdDSA JWT — the
git-SSH-key-like UX). Agents get a third door, the MCP surface at `/mcp`
described at the end of this file.

## Status



@@ 277,3 279,29 @@ Once the service is up and you are logged into meta:
Anonymous `dolt clone` works for PUBLIC and UNLISTED databases with no
credentials at all; PRIVATE databases return "not found" to unauthorized
callers (their existence is not leaked).

## The MCP surface

An agent reads a hosted database — and the beads tracker inside one — by calling
tools instead of scraping HTML. The endpoint speaks the Model Context Protocol
over streamable HTTP at `/mcp` on the web listener
(`https://dolt.srht.bigb.es/mcp`); there is no second port and no switch that
turns it off, because a surface that is off in production and on in a test is a
surface nobody tests.

It is read-only, and structurally so: there is no `query(sql)` tool and no
mutation of any kind. The tools list databases, branches, tables, rows, commits
and diffs, and — for a database carrying the beads schema — issues, milestones,
memories and the ready set. Writing stays with `bd` in a checkout.

The credential is `Authorization: Bearer <token>`, and nothing else: no cookie
(an MCP client is not a browser) and no HTTP Basic (that is `dolt clone`'s
flow). Two tokens are accepted. A **tokens.sr.ht working token** must carry the
`dolt:read` grant — one grant for the whole surface, because every tool on it is
a read — and it works only on an instance that configures `[tokens.sr.ht]
origin`; without that section there is no daemon to verify it against, so it is
refused with 401 while everything else keeps working. A **meta personal access
token** is accepted with the same `dolt.sr.ht/repos:RO` scoping the clone path
applies. No credential at all is a normal caller: it reads what an anonymous
visitor reads, and a PRIVATE database it may not see is "not found" rather than
"forbidden", exactly as in the web UI.

M authn/ctx.go => authn/ctx.go +6 -2
@@ 1,10 1,14 @@
// Package authn resolves the SourceHut caller for a dolt.sr.ht request across
// the three authentication flows the service accepts:
// the four authentication flows the service accepts:
//
//   - the unified-login cookie (web UI), via OptionalCookieMiddleware;
//   - a meta.sr.ht personal access token (dolt clone/push --user + Basic auth),
//     via ResolveBasic;
//   - a dolt Ed25519 keypair (dolt login / Bearer EdDSA JWT), via ResolveDoltJWT.
//   - a dolt Ed25519 keypair (dolt login / Bearer EdDSA JWT), via ResolveDoltJWT;
//   - an Authorization: Bearer credential on the /mcp surface, via ResolveBearer:
//     either a tokens.sr.ht working token, verified through sr-ht-ecore's shared
//     validator and scoped by core.GrantRead, or a meta PAT presented without a
//     username (bearer.go, docs/DESIGN.mcp.md §4).
//
// It reuses core-go's token/cookie primitives (auth.DecodeBearerToken,
// auth.LookupUser, auth.LookupTokenRevocation, crypto.DecryptWithoutExpiration)

M cmd/doltsrht/main.go => cmd/doltsrht/main.go +109 -29
@@ 5,7 5,10 @@
//     the core-go server's AnonRouter with our own middleware group (config +
//     database + optional unified-login cookie) so anonymous browsing and public
//     clones keep working — we deliberately do NOT use core-go's default
//     middleware, whose auth.Middleware 401s any un-cookied request.
//     middleware, whose auth.Middleware 401s any un-cookied request. The MCP
//     surface rides the same listener at /mcp, above the cookie plane and
//     outside web's same-origin group, because it is bearer-only
//     (docs/DESIGN.mcp.md §3).
//   - the remotesapi (gRPC ChunkStoreService + HTTP chunk data plane, one h2c
//     port) on [dolt.sr.ht]remotesapi-listen (default 127.0.0.1:5306).
//   - the CredentialsService.WhoAmI gRPC server for the `dolt login` keypair


@@ 22,6 25,7 @@ import (
	"database/sql"
	"fmt"
	"log/slog"
	"net/http"
	"os"

	"github.com/go-chi/chi/v5"


@@ 89,6 93,11 @@ type settings struct {
	staticDir      string
	remotesapiAddr string
	credsapiAddr   string
	// origin is the canonical external origin, [dolt.sr.ht]origin as
	// instconf.CanonicalOrigin spells it. It is what /mcp guards its Host header
	// with (mcpsrv.New), and it is kept whole rather than reduced to httpHost
	// below because that check wants the name and this one wants the URL.
	origin string
	// httpHost is the bare authority (host[:port]) of the external origin. It is
	// stamped into sealed chunk-download URLs and seeds the keypair-JWT audience.
	httpHost string


@@ 117,7 126,8 @@ func resolveSettings(conf ini.File) (settings, error) {
	// sealed-URL hosts and two different JWT audiences. "" here means the origin
	// is set but names no host — a scheme-less "dolt.example.org" is a path, not
	// a URL — which is a configuration error and not a reason to guess.
	host := instconf.OriginAuthority(instconf.ExternalOrigin(conf, serviceName))
	origin := instconf.ExternalOrigin(conf, serviceName)
	host := instconf.OriginAuthority(origin)
	if host == "" {
		return settings{}, culpa.WithHint(
			culpa.New(fmt.Sprintf("[%s]origin names no host", serviceName)),


@@ 130,6 140,7 @@ func resolveSettings(conf ini.File) (settings, error) {
		staticDir:      config.GetString(conf, serviceName, "static-dir", defaultStaticDir),
		remotesapiAddr: config.GetString(conf, serviceName, "remotesapi-listen", defaultRemotesapiAddr),
		credsapiAddr:   config.GetString(conf, serviceName, "credsapi-listen", defaultCredsapiAddr),
		origin:         origin,
		httpHost:       host,
	}, nil
}


@@ 200,33 211,22 @@ func main() {
			"component", "web", "keys", instconf.APIOriginKeys())
	}

	// The MCP surface, built before the router: its Host allowlist and its
	// credential plane come out of the config, so a wiring mistake in either
	// stops the boot rather than answering every agent 500 later.
	agents, err := newMCPServer(conf, cfg)
	if err != nil {
		fatal("building the mcp surface", err)
	}

	srv.AnonRouter().Group(func(r chi.Router) {
		// RequestID and RealIP first: the request line below carries the id and
		// the viewer's address, and neither exists until these have run.
		r.Use(chimiddleware.RequestID, chimiddleware.RealIP)
		// The request line as a slog record rather than chi's colourised line on
		// stdout — the one line this daemon emitted that was neither structured
		// nor on stderr, so an operator grepping the journal for a request id
		// found every panic and none of the requests. It goes outermost, above
		// the panic guards, so that the status it reports is the one that
		// actually went out.
		r.Use(chimw.RequestLogger(chimw.SlogFormatter{}))
		r.Use(chimiddleware.Recoverer)
		r.Use(config.Middleware(conf, serviceName), database.Middleware(db))
		r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous

		if err := web.Register(r, web.Config{
			Conf:      conf,
			ReposRoot: cfg.reposRoot,
			StaticDir: cfg.staticDir,
			Stores:    stores,
			Repos:     web.DBAdapter{},
			Browse:    web.BrowseAdapter{},
			Users:     web.MetaUserResolver{},
			Git:       git,
			RepoDiskPath: func(owner, name string) string {
				return storage.RepoDiskPath(cfg.reposRoot, owner, name)
			},
		if err := mountRoutes(r, surfaces{
			conf:   conf,
			db:     db,
			cfg:    cfg,
			stores: stores,
			git:    git,
			mcp:    agents,
		}); err != nil {
			fatal("mounting the web routes", err)
		}


@@ 248,7 248,9 @@ func main() {
	slog.Info("listening",
		"remotesapi", cfg.remotesapiAddr,
		"credentials", cfg.credsapiAddr,
		"web", defaultWebAddr)
		"web", defaultWebAddr,
		"mcp", mcpRoute,
		"instance_tokens", tokensDescription(conf))

	// Blocks until SIGINT, then returns after draining the web listeners.
	srv.Run()


@@ 260,6 262,84 @@ func main() {
	csrv.GracefulStop()
}

// surfaces is everything mountRoutes needs to install the two things this
// listener serves. It is a struct rather than a parameter list so that the boot
// test assembles the daemon's own router — the one whose middleware order and
// mount points are the thing worth testing — without a Postgres, a store on disk
// or core-go's server.New.
type surfaces struct {
	conf   ini.File
	db     *sql.DB
	cfg    settings
	stores web.StoreManager
	git    web.GitDescriber
	mcp    http.Handler
}

// mountRoutes installs the web listener's surfaces on r: /mcp for agents, and
// everything a browser reaches under it.
//
// r must be a chi Group and not a bare router. server.New has already frozen the
// AnonRouter for direct middleware registration, and a Group is a fresh inline
// mux over the same routing tree — which is where middleware and routes can
// still be attached together.
func mountRoutes(r chi.Router, s surfaces) error {
	// RequestID and RealIP first: the request line below carries the id and the
	// viewer's address, and neither exists until these have run.
	r.Use(chimiddleware.RequestID, chimiddleware.RealIP)
	// The request line as a slog record rather than chi's colourised line on
	// stdout — the one line this daemon emitted that was neither structured nor
	// on stderr, so an operator grepping the journal for a request id found every
	// panic and none of the requests. It goes outermost, above the panic guards,
	// so that the status it reports is the one that actually went out.
	r.Use(chimw.RequestLogger(chimw.SlogFormatter{}))
	r.Use(chimiddleware.Recoverer)
	r.Use(config.Middleware(s.conf, serviceName), database.Middleware(s.db))

	// The MCP surface, and three things decide where this line is
	// (docs/DESIGN.mcp.md §3, §4.1):
	//
	// Before web.Register, because web claims "/" and wraps everything it mounts
	// in the same-origin CSRF group. /mcp is a bearer surface: no cookie, no
	// Origin header, no browser — the guard would refuse every call it ever
	// receives.
	//
	// Before the cookie middleware below, because the unified-login cookie is the
	// web UI's plane and this one accepts exactly one credential. A chi group
	// takes its middleware chain when its route is registered, so what is
	// installed after this line does not reach /mcp; that is the point of the
	// line's position and not an accident of it.
	//
	// In a Group of its own rather than a bare r.Handle here, because
	// web.Register installs middleware of its own on the router it is handed
	// (web/router.go's mount), and chi refuses a r.Use once any route exists on
	// that mux — "all middlewares must be defined before routes on a mux" is a
	// panic, so registering /mcp directly on r would fail this daemon's boot.
	//
	// Handle and not Mount: the streamable transport serves that exact path.
	// Mount would rewrite the routing path to the empty remainder and would also
	// claim /mcp/*, a subtree this surface does not serve.
	r.Group(func(r chi.Router) {
		r.Handle(mcpRoute, s.mcp)
	})

	r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous

	return web.Register(r, web.Config{
		Conf:      s.conf,
		ReposRoot: s.cfg.reposRoot,
		StaticDir: s.cfg.staticDir,
		Stores:    s.stores,
		Repos:     web.DBAdapter{},
		Browse:    web.BrowseAdapter{},
		Users:     web.MetaUserResolver{},
		Git:       s.git,
		RepoDiskPath: func(owner, name string) string {
			return storage.RepoDiskPath(s.cfg.reposRoot, owner, name)
		},
	})
}

// fatal reports a startup failure and ends the process. slog has no Fatal, on
// the argument that a logging call should not decide a program's lifetime; this
// is the one place in this binary that wants both, so it is written once here

M cmd/doltsrht/main_test.go => cmd/doltsrht/main_test.go +274 -0
@@ 1,16 1,37 @@
package main

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
)

// TestMain seeds the process-global crypto state from sr-ht-ecore's fixed test
// keyset, so that the bearer-token HMAC works in process and a working token can
// be forged here rather than mocked. No network, no Postgres.
func TestMain(m *testing.M) {
	ecoretest.InitCrypto()
	os.Exit(m.Run())
}

// loadConf builds an ini.File directly from a literal, bypassing
// config.LoadConfig so the tests need no config.ini on disk and no
// internal-ipnet parsing.


@@ 148,3 169,256 @@ origin=https://dolt.example.org:8443/
	require.NoError(t, err)
	assert.Equal(t, "dolt.example.org:8443", got.httpHost)
}

// TestResolveSettingsKeepsTheWholeOrigin: /mcp guards its Host header with the
// origin as a URL (mcpsrv.New), while the sealed chunk URLs want the authority,
// so the two live side by side rather than one being derived from the other at
// the call site. Both come off the canonical spelling, so a trailing slash
// cannot make them disagree.
func TestResolveSettingsKeepsTheWholeOrigin(t *testing.T) {
	conf := loadConf(t, `[dolt.sr.ht]
connection-string=postgres://u@localhost/d
origin=https://dolt.example.org:8443/
`)

	got, err := resolveSettings(conf)
	require.NoError(t, err)
	assert.Equal(t, "https://dolt.example.org:8443", got.origin)
	assert.Equal(t, "dolt.example.org:8443", got.httpHost)
}

// --- the tokens.sr.ht plane -------------------------------------------------

// An instance that runs no tokens.sr.ht has no such section, and that is a
// supported configuration rather than a broken one: the validator is absent, and
// absent has to mean a genuinely nil interface. A *typed* nil would satisfy the
// interface and panic on the first working token presented, which is the one
// mistake authn.ResolveBearer's contract calls out by name.
func TestNoTokensSectionYieldsNoValidator(t *testing.T) {
	v, err := newBearerValidator(ecoretest.Config(serviceName, ecoretest.Delete(tokensSection)))

	require.NoError(t, err)
	assert.Nil(t, v)
	assert.True(t, v == nil, "a typed nil would satisfy the interface and panic on the first working token")
}

// The revocation check is daemon to daemon, so the internal address wins where
// an instance has one: that request sits on the hot path of a tool call, and
// routing it out through the reverse proxy and back would put a public hop and
// its TLS handshake between two containers on the same bridge.
func TestTheTokensOriginIsReadInItsInternalForm(t *testing.T) {
	conf := ecoretest.Config(serviceName,
		ecoretest.Set(tokensSection, "origin", "https://tokens.example"),
		ecoretest.Set(tokensSection, "internal-origin", "http://tokens:5010"))

	v, err := newBearerValidator(conf)
	require.NoError(t, err)
	assert.NotNil(t, v)
	assert.Equal(t, "http://tokens:5010", tokensDescription(conf))
}

// A section that exists and does not parse is an operator's typo, not a plane to
// drop quietly: an instance that configured tokens.sr.ht meant to accept its
// tokens, so the daemon says so and stops instead of starting with a surface
// that refuses every working token for a reason nobody can see.
func TestAnUnparseableTokensOriginStopsTheBoot(t *testing.T) {
	conf := ecoretest.Config(serviceName, ecoretest.Set(tokensSection, "origin", "tokens.example"))

	_, err := newBearerValidator(conf)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "absolute http(s) URL")
}

// The startup line says which of the two states this daemon is in, so that "the
// instance credential is not accepted here" is read off the journal rather than
// deduced from the first refusal somebody reports.
func TestTokensDescriptionNamesTheMissingKey(t *testing.T) {
	assert.Equal(t, "disabled ([tokens.sr.ht] origin is unset)",
		tokensDescription(ecoretest.Config(serviceName, ecoretest.Delete(tokensSection))))
	assert.Equal(t, "https://tokens.example", tokensDescription(ecoretest.Config(serviceName)))
}

// --- the assembled listener -------------------------------------------------

// toolsList is one JSON-RPC message, the one an agent sends first after the
// handshake. The transport runs stateless, so a single POST is a whole
// conversation and no session has to be opened to ask this.
const toolsList = `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`

// noStores stands in for the on-disk store lifecycle, which web.Register
// requires and nothing in these tests reaches. It panics rather than returning a
// zero value: a test that started creating stores would be testing something
// else, and should fail loudly instead of quietly passing.
type noStores struct{}

func (noStores) InitStore(context.Context, string, string, string) error {
	panic("cmd/doltsrht: the boot tests must not touch the on-disk stores")
}

func (noStores) DeleteStore(context.Context, string, string) error {
	panic("cmd/doltsrht: the boot tests must not touch the on-disk stores")
}

func (noStores) Evict(string) error {
	panic("cmd/doltsrht: the boot tests must not touch the on-disk stores")
}

// bootConf is the instance config a booted daemon reads: the shared fixture plus
// the one key of ours that has no default.
func bootConf(t *testing.T, overrides ...func(ini.File)) ini.File {
	t.Helper()
	all := append([]func(ini.File){
		ecoretest.Set(serviceName, "connection-string", "postgres://u@localhost/d?sslmode=disable"),
	}, overrides...)
	return ecoretest.Config(serviceName, all...)
}

// boot assembles this daemon's own listener — newMCPServer, then mountRoutes on
// a chi Group, exactly as main does on the AnonRouter — and serves it.
//
// The Group is not a detail of the test: server.New has frozen the AnonRouter by
// the time main reaches it, so production mounts inside one, and a test that
// mounted on a bare router would be testing a different middleware assembly than
// the one that ships.
//
// There is no Postgres and no store on disk. The nil pool reaches the request
// context the way the real one does, and nothing these tests ask for reads it:
// tools/list is answered by the protocol server, and a refused mutation never
// reaches its handler.
func boot(t *testing.T, conf ini.File) *httptest.Server {
	t.Helper()

	cfg, err := resolveSettings(conf)
	require.NoError(t, err)

	agents, err := newMCPServer(conf, cfg)
	require.NoError(t, err)

	root := chi.NewRouter()
	root.Group(func(r chi.Router) {
		require.NoError(t, mountRoutes(r, surfaces{
			conf:   conf,
			cfg:    cfg,
			stores: noStores{},
			mcp:    agents,
		}))
	})

	srv := httptest.NewServer(root)
	t.Cleanup(srv.Close)
	return srv
}

// postMCP sends one JSON-RPC message the way an MCP client does: the two Accept
// types the streamable transport requires, and the credential — if any — in the
// only header this surface reads.
func postMCP(t *testing.T, srv *httptest.Server, token, origin, message string) (*http.Response, string) {
	t.Helper()

	req, err := http.NewRequest(http.MethodPost, srv.URL+mcpRoute, strings.NewReader(message))
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")
	req.Header.Set("MCP-Protocol-Version", "2025-06-18")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	if origin != "" {
		req.Header.Set("Origin", origin)
	}

	resp, err := srv.Client().Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })

	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	return resp, string(body)
}

// forgeWorkingToken builds a credential shaped exactly as tokens.sr.ht seals
// one: the same format and the same HMAC key as a meta PAT, differing only in
// the ClientID — which is the whole routing decision in authn.ResolveBearer.
func forgeWorkingToken(username string) string {
	bt := auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		ClientID: bearer.TokensClientID,
		Username: username,
	}
	return bt.Encode()
}

// The mount itself: /mcp is reachable on the web listener, with no port and no
// process of its own, and it answers the protocol. A tools/list arriving without
// a credential is a normal call — anonymity is a caller here — and the tools it
// names are the ones the daemon registered.
func TestTheMCPSurfaceAnswersOnTheWebListener(t *testing.T) {
	srv := boot(t, bootConf(t))

	resp, body := postMCP(t, srv, "", "", toolsList)

	require.Equal(t, http.StatusOK, resp.StatusCode, body)
	assert.Contains(t, body, "list_databases", body)
}

// The reason /mcp is registered before web.Register and outside its group. An
// MCP client is not a browser: it sends no Origin and no Referer, which is
// precisely the request the same-origin guard refuses — so a /mcp inside that
// group would answer 403 to every agent that ever called it, in production
// only, since nothing else on this surface is a mutation a test would notice.
//
// The second call is the same request as a browser would make it, cross-site
// Origin included: still answered, because this surface's protection is its
// credential and its Host allowlist, not the group it is not in.
func TestTheCSRFGuardDoesNotReachTheMCPSurface(t *testing.T) {
	srv := boot(t, bootConf(t))

	resp, body := postMCP(t, srv, "", "", toolsList)
	require.Equal(t, http.StatusOK, resp.StatusCode, "an agent sends no Origin at all")

	resp, body = postMCP(t, srv, "", "https://evil.example", toolsList)
	assert.Equal(t, http.StatusOK, resp.StatusCode, body)
}

// The other half of that trade, and the one that would be expensive to get
// wrong: putting a route above the same-origin group must not take the group
// down with it. A browser-shaped POST to a web form whose Origin is somebody
// else's is still refused, by the guard and not by the handler.
func TestAWebFormStillRequiresSameOrigin(t *testing.T) {
	srv := boot(t, bootConf(t))

	form := url.Values{"name": {"db"}, "visibility": {"PUBLIC"}}
	req, err := http.NewRequest(http.MethodPost, srv.URL+"/create", strings.NewReader(form.Encode()))
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Origin", "https://evil.example")

	resp, err := srv.Client().Do(req)
	require.NoError(t, err)
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusForbidden, resp.StatusCode, string(body))
	assert.Contains(t, string(body), csrf.Message,
		"the refusal is the group's guard, not a handler that happened to fail")
}

// An instance with no [tokens.sr.ht] section is one that runs no such daemon,
// and it is a startable one (docs/DESIGN.mcp.md §10). The surface is still
// mounted and still answers anonymous callers; what it cannot do is verify a
// working token, so it refuses one — 401 with the challenge, never a downgrade
// to anonymous and never a process that will not boot.
func TestTheDaemonBootsWithoutATokensSection(t *testing.T) {
	srv := boot(t, bootConf(t, ecoretest.Delete(tokensSection)))

	resp, body := postMCP(t, srv, "", "", toolsList)
	require.Equal(t, http.StatusOK, resp.StatusCode, body)
	assert.Contains(t, body, "list_databases")

	resp, body = postMCP(t, srv, forgeWorkingToken("alice"), "", toolsList)
	require.Equal(t, http.StatusUnauthorized, resp.StatusCode, body)
	assert.Contains(t, resp.Header.Get("WWW-Authenticate"), "Bearer",
		"a refused credential is told how to present a better one")
}

A cmd/doltsrht/mcp.go => cmd/doltsrht/mcp.go +134 -0
@@ 0,0 1,134 @@
package main

import (
	"context"
	"log/slog"
	"os"

	"github.com/vaughan0/go-ini"

	"go.bigb.es/auxilia/culpa"

	"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/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv"
	"sourcecraft.dev/bigbes/sr-ht-dolt/web"
)

// mcpRoute is where the MCP surface answers (docs/DESIGN.mcp.md §3). One exact
// path and not a subtree: the streamable transport is a single endpoint, which
// is why mountRoutes registers it with Handle rather than Mount.
const mcpRoute = "/mcp"

// tokensSection is the section tokens.sr.ht occupies in the shared config.ini.
// Its origin is the whole configuration of the working-token plane; there is no
// key of ours that enables or disables /mcp (docs/DESIGN.mcp.md §10).
const tokensSection = "tokens.sr.ht"

// mcpBrowseOpener satisfies mcpsrv.BrowseOpener over browse.Open, the way
// web.BrowseAdapter satisfies web's own — the same *browse.DB answers both
// method sets, and each package declares the seam it consumes.
//
// It lives here rather than in mcpsrv/ because a package that named browse.Open
// itself could not be driven over fakes, which is the whole point of the seam.
type mcpBrowseOpener struct{}

var _ mcpsrv.BrowseOpener = mcpBrowseOpener{}

func (mcpBrowseOpener) Open(ctx context.Context, diskPath string) (mcpsrv.BrowseSession, error) {
	dbh, err := browse.Open(ctx, diskPath)
	if err != nil {
		return nil, err
	}
	return dbh, nil
}

// newMCPServer assembles the MCP surface over the seams the daemon already has:
// the metadata store through web's request-scoped adapter — the one every browse
// handler reads through, so both surfaces answer one question one way — and the
// bare-store reader above.
//
// It is built before the router and its failures are fatal. An origin that names
// no host leaves /mcp with nothing to guard itself with (mcpsrv.New), and a
// [tokens.sr.ht] section that does not parse is an operator's typo rather than a
// plane to drop quietly: both are boot failures, not requests answered 500 later.
//
// The one thing that is not a failure is the absence of that section. It is
// reported once here, at the level an operator reads, so that "this instance
// refuses working tokens" is a startup line rather than something deduced from
// the first 401 an agent reports.
func newMCPServer(conf ini.File, cfg settings) (*mcpsrv.Server, error) {
	validator, err := newBearerValidator(conf)
	if err != nil {
		return nil, err
	}
	if validator == nil {
		slog.Warn("no tokens.sr.ht origin is configured; /mcp serves anonymous and meta-PAT callers and refuses every working token",
			"component", "mcp", "key", "["+tokensSection+"] origin")
	}

	return mcpsrv.New(web.DBAdapter{}, mcpBrowseOpener{}, validator, cfg.origin)
}

// newBearerValidator builds the tokens.sr.ht working-token validator, or nil
// when this instance runs no such daemon.
//
// The return type is the interface and not *bearer.Validator, and that is
// load-bearing rather than a style: a nil *bearer.Validator handed to
// authn.ResolveBearer as an InstanceValidator is a *typed* nil, which is not the
// contract that function documents and which panics on the first working token
// presented. Returning the interface makes the absent plane a genuinely nil one.
//
// A missing section is a supported configuration and not a degradation
// (docs/DESIGN.mcp.md §10): meta PATs and anonymous callers keep working, and a
// working token is refused because a credential this instance cannot verify is
// refused rather than guessed at. Refusing to boot instead would turn "agents
// cannot authenticate" into "the service is down", on an instance that may
// deliberately run no tokens.sr.ht at all.
//
// The origin is read in its internal form — [tokens.sr.ht] internal-origin
// falling back to origin, which is what instconf.InternalOrigin means — so the
// revocation check of the tokens SPEC ch. 6 step 4 goes container to container
// instead of out through the reverse proxy and back.
//
// ClientID and NodeID identify *us* to that endpoint. They are labels rather
// than credentials — the internal guard admits every service on the instance
// equally — and their job is to be right in a log line when the revocation cache
// misbehaves, which is why the node name is taken from the OS and never
// invented: a fleet of daemons all calling themselves the same made-up name is
// exactly the diagnostic this field exists to provide.
func newBearerValidator(conf ini.File) (authn.InstanceValidator, error) {
	origin := instconf.InternalOrigin(conf, tokensSection)
	if origin == "" {
		return nil, nil
	}

	node, err := os.Hostname()
	if err != nil {
		return nil, culpa.Wrap(err, "reading the hostname for the tokens.sr.ht node id")
	}

	v, err := bearer.New(bearer.Options{
		Origin:   origin,
		ClientID: serviceName,
		NodeID:   node,
	})
	if err != nil {
		return nil, culpa.Wrapf(err, "assembling the %s plane", tokensSection)
	}
	return v, nil
}

// tokensDescription is what the startup line says about the working-token
// plane, so that "the instance credential is not accepted here" is visible in
// the journal rather than deduced from the first refusal somebody reports.
func tokensDescription(conf ini.File) string {
	origin := instconf.InternalOrigin(conf, tokensSection)
	if origin == "" {
		return "disabled ([" + tokensSection + "] origin is unset)"
	}
	return origin
}

M config.example.ini => config.example.ini +22 -0
@@ 47,6 47,14 @@ migrate-on-upgrade=yes
; -d flag overrides both — and both apply from the first line of startup, before
; the config below has been read.
log-level=info
;
; There is deliberately no mcp-enabled key. The read-only MCP surface an agent
; calls (docs/DESIGN.mcp.md) is served at /mcp on the web listener above,
; wherever this daemon runs: a surface that is off in production and on in a
; test is a surface nobody tests. What a caller may read there is decided by the
; credential it presents and by the same visibility rules the web UI applies,
; never by a switch here. The origin above doubles as its Host allowlist, so a
; request forwarded under any other name is refused before it is parsed.

; ---------------------------------------------------------------------------
; Shared keys reused in place (owned by other services, listed for reference;


@@ 59,4 67,18 @@ log-level=info
;   [webhooks] private-key    derives the offline Bearer-token HMAC key
;   [meta.sr.ht] origin       meta's URL (login redirects, profile fetch)
;   [meta.sr.ht::api] internal-ipnet   subnets allowed to use internal auth
;   [tokens.sr.ht] origin     where the instance's token daemon answers. It
;                             enables the working-token plane of /mcp: a token
;                             minted by tokens.sr.ht is verified against this
;                             address (the revocation check) and must carry the
;                             dolt:read grant. internal-origin, when set, is
;                             preferred for that check — container to container
;                             rather than out through the reverse proxy and
;                             back. Absent, this instance runs no such daemon:
;                             the daemon still starts, /mcp still answers
;                             anonymous callers and meta personal access tokens,
;                             and a working token is refused with 401 because a
;                             credential we cannot verify is refused rather than
;                             guessed at. The startup line says
;                             instance_tokens=disabled on such an instance.
; ---------------------------------------------------------------------------