coverage~bigbes/sr-ht-dolt3523280ccmd/doltsrht/mcp.go

Coverage
75.0% 18/24 statements
Δ
+0.0
Blob
707e1a8
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 "os"
7
8 "github.com/vaughan0/go-ini"
9
10 "go.bigb.es/auxilia/culpa"
11
12 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
13 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
14
15 "sourcecraft.dev/bigbes/sr-ht-dolt/authn"
16 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
17 "sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv"
18 "sourcecraft.dev/bigbes/sr-ht-dolt/web"
19 )
20
21 // mcpRoute is where the MCP surface answers (docs/DESIGN.mcp.md §3). One exact
22 // path and not a subtree: the streamable transport is a single endpoint, which
23 // is why mountRoutes registers it with Handle rather than Mount.
24 const mcpRoute = "/mcp"
25
26 // tokensSection is the section tokens.sr.ht occupies in the shared config.ini.
27 // Its origin is the whole configuration of the working-token plane; there is no
28 // key of ours that enables or disables /mcp (docs/DESIGN.mcp.md §10).
29 const tokensSection = "tokens.sr.ht"
30
31 // mcpBrowseOpener satisfies mcpsrv.BrowseOpener over browse.Open, the way
32 // web.BrowseAdapter satisfies web's own — the same *browse.DB answers both
33 // method sets, and each package declares the seam it consumes.
34 //
35 // It lives here rather than in mcpsrv/ because a package that named browse.Open
36 // itself could not be driven over fakes, which is the whole point of the seam.
37 type mcpBrowseOpener struct{}
38
39 var _ mcpsrv.BrowseOpener = mcpBrowseOpener{}
40
41 0 func (mcpBrowseOpener) Open(ctx context.Context, diskPath string) (mcpsrv.BrowseSession, error) {
42 0 dbh, err := browse.Open(ctx, diskPath)
43 0 if err != nil {
44 0 return nil, err
45 0 }
46 0 return dbh, nil
47 }
48
49 // newMCPServer assembles the MCP surface over the seams the daemon already has:
50 // the metadata store through web's request-scoped adapter — the one every browse
51 // handler reads through, so both surfaces answer one question one way — and the
52 // bare-store reader above.
53 //
54 // It is built before the router and its failures are fatal. An origin that names
55 // no host leaves /mcp with nothing to guard itself with (mcpsrv.New), and a
56 // [tokens.sr.ht] section that does not parse is an operator's typo rather than a
57 // plane to drop quietly: both are boot failures, not requests answered 500 later.
58 //
59 // The one thing that is not a failure is the absence of that section. It is
60 // reported once here, at the level an operator reads, so that "this instance
61 // refuses working tokens" is a startup line rather than something deduced from
62 // the first 401 an agent reports.
63 7 func newMCPServer(conf ini.File, cfg settings) (*mcpsrv.Server, error) {
64 7 validator, err := newBearerValidator(conf)
65 7 if err != nil {
66 0 return nil, err
67 0 }
68 7 if validator == nil {
69 1 slog.Warn("no tokens.sr.ht origin is configured; /mcp serves anonymous and meta-PAT callers and refuses every working token",
70 1 "component", "mcp", "key", "["+tokensSection+"] origin")
71 1 }
72
73 7 return mcpsrv.New(web.DBAdapter{}, mcpBrowseOpener{}, validator, cfg.origin)
74 }
75
76 // newBearerValidator builds the tokens.sr.ht working-token validator, or nil
77 // when this instance runs no such daemon.
78 //
79 // The return type is the interface and not *bearer.Validator, and that is
80 // load-bearing rather than a style: a nil *bearer.Validator handed to
81 // authn.ResolveBearer as an InstanceValidator is a *typed* nil, which is not the
82 // contract that function documents and which panics on the first working token
83 // presented. Returning the interface makes the absent plane a genuinely nil one.
84 //
85 // A missing section is a supported configuration and not a degradation
86 // (docs/DESIGN.mcp.md §10): meta PATs and anonymous callers keep working, and a
87 // working token is refused because a credential this instance cannot verify is
88 // refused rather than guessed at. Refusing to boot instead would turn "agents
89 // cannot authenticate" into "the service is down", on an instance that may
90 // deliberately run no tokens.sr.ht at all.
91 //
92 // The origin is read in its internal form — [tokens.sr.ht] internal-origin
93 // falling back to origin, which is what instconf.InternalOrigin means — so the
94 // revocation check of the tokens SPEC ch. 6 step 4 goes container to container
95 // instead of out through the reverse proxy and back.
96 //
97 // ClientID and NodeID identify *us* to that endpoint. They are labels rather
98 // than credentials — the internal guard admits every service on the instance
99 // equally — and their job is to be right in a log line when the revocation cache
100 // misbehaves, which is why the node name is taken from the OS and never
101 // invented: a fleet of daemons all calling themselves the same made-up name is
102 // exactly the diagnostic this field exists to provide.
103 17 func newBearerValidator(conf ini.File) (authn.InstanceValidator, error) {
104 17 origin := instconf.InternalOrigin(conf, tokensSection)
105 17 if origin == "" {
106 3 return nil, nil
107 3 }
108
109 14 node, err := os.Hostname()
110 14 if err != nil {
111 0 return nil, culpa.Wrap(err, "reading the hostname for the tokens.sr.ht node id")
112 0 }
113
114 14 v, err := bearer.New(bearer.Options{
115 14 Origin: origin,
116 14 ClientID: serviceName,
117 14 NodeID: node,
118 14 })
119 14 if err != nil {
120 1 return nil, culpa.Wrapf(err, "assembling the %s plane", tokensSection)
121 1 }
122 13 return v, nil
123 }
124
125 // tokensDescription is what the startup line says about the working-token
126 // plane, so that "the instance credential is not accepted here" is visible in
127 // the journal rather than deduced from the first refusal somebody reports.
128 3 func tokensDescription(conf ini.File) string {
129 3 origin := instconf.InternalOrigin(conf, tokensSection)
130 3 if origin == "" {
131 1 return "disabled ([" + tokensSection + "] origin is unset)"
132 1 }
133 2 return origin
134 }