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.
; ---------------------------------------------------------------------------