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 }