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
}