From 57adcc0a8a8b4407184b95ca9b710e7ec72fe186 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 09:22:22 +0300 Subject: [PATCH] doltsrht: serve /mcp on the web listener --- README.md | 34 ++++- authn/ctx.go | 8 +- cmd/doltsrht/main.go | 138 +++++++++++++++---- cmd/doltsrht/main_test.go | 274 ++++++++++++++++++++++++++++++++++++++ cmd/doltsrht/mcp.go | 134 +++++++++++++++++++ config.example.ini | 22 +++ 6 files changed, 576 insertions(+), 34 deletions(-) create mode 100644 cmd/doltsrht/mcp.go diff --git a/README.md b/README.md index 8c3c8ffc0925fbecb507e6fc17fb0bfb54900b15..cd19d6509233a3460031658986e7bba035e661f9 100644 --- a/README.md +++ b/README.md @@ -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 `, 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. diff --git a/authn/ctx.go b/authn/ctx.go index f727dc19e8c10509ea01250f417a83f42ce3f3f3..c9609244786d1f5d8a92bb24a42e2c684723f108 100644 --- a/authn/ctx.go +++ b/authn/ctx.go @@ -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) diff --git a/cmd/doltsrht/main.go b/cmd/doltsrht/main.go index ce37ba6d7fb84c443b6d8e04d1b282e3f9c75d18..b95df4cfd21cdd0cbefcf01ced6e0b549181524d 100644 --- a/cmd/doltsrht/main.go +++ b/cmd/doltsrht/main.go @@ -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 diff --git a/cmd/doltsrht/main_test.go b/cmd/doltsrht/main_test.go index e5c7d0b3cbe529e2aa5013aeec2399f98dff23b6..bf4c8dab69d8a202e9ffab8cb064da03fbb98472 100644 --- a/cmd/doltsrht/main_test.go +++ b/cmd/doltsrht/main_test.go @@ -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") +} diff --git a/cmd/doltsrht/mcp.go b/cmd/doltsrht/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..707e1a8e7936f4afde9ccf090eb0d069c827249e --- /dev/null +++ b/cmd/doltsrht/mcp.go @@ -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 +} diff --git a/config.example.ini b/config.example.ini index 048833a87d2fd6d38149cf3cb92462e78b8835f1..931317efb12bd565f24a3ef0d69a22b4b5dc3248 100644 --- a/config.example.ini +++ b/config.example.ini @@ -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. ; ---------------------------------------------------------------------------