@@ 8,8 8,9 @@
// and not in the hooks because bleve is single-writer and this process holds
// the index, and because the push path and the API must not be able to
// disagree about what is valid.
-// - an HTTP listener on -b (default localhost:5091). Phase 1 serves /healthz
-// and nothing else; the web UI is Phase 2 and mounts where mountWeb says.
+// - an HTTP listener on -b (default localhost:5091), serving /healthz, the
+// read UI, /mcp for agents and /query for GraphQL — one listener, so one
+// reverse-proxy route covers the service.
// - the reconciler, at startup and then periodically, repairing the
// divergence a killed daemon leaves between git refs, Postgres and the
// index.
@@ 53,6 54,7 @@ import (
"net/http"
"os"
"os/signal"
+ "path/filepath"
"strings"
"syscall"
"time"
@@ 67,8 69,12 @@ import (
coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
+ "sourcecraft.dev/bigbes/sr-ht-spec/graph"
"sourcecraft.dev/bigbes/sr-ht-spec/hooks"
+ "sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv"
+ "sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
+ "sourcecraft.dev/bigbes/sr-ht-spec/web"
)
const (
@@ 79,6 85,10 @@ const (
// defaultBind is the address core-go binds when no -b is given.
defaultBind = "localhost:5091"
+ // version is reported in the MCP handshake so a client listing several
+ // SourceHut MCP endpoints can tell which build it is talking to.
+ version = "dev"
+
// pingTimeout bounds the startup connectivity check against Postgres.
pingTimeout = 10 * time.Second
@@ 179,11 189,17 @@ func run(log *slog.Logger) error {
return err
}
+ surf, err := newSurfaces(conf, cfg, svc, version)
+ if err != nil {
+ return err
+ }
+ defer surf.Close()
+
// server.New parses -b/-d/-m/-p and runs crypto.InitCrypto(conf), whose
// two required keys validateConfig already checked, so it cannot fatal
// here for a reason we have not already reported.
srv := coreserver.New(serviceName, defaultBind, conf, os.Args)
- mountRoutes(srv.AnonRouter(), conf)
+ mountRoutes(srv.AnonRouter(), conf, surf)
ctx, stop := context.WithCancel(context.Background())
defer stop()
@@ 307,8 323,8 @@ func refreshHooks(ctx context.Context, log *slog.Logger, svc *service.Service, b
return nil
}
-// mountRoutes installs what Phase 1 serves over HTTP.
-func mountRoutes(router chi.Router, conf ini.File) {
+// mountRoutes installs what the daemon serves over HTTP.
+func mountRoutes(router chi.Router, conf ini.File, surfaces *surfaces) {
// server.New already froze the anonymous router for direct middleware
// registration, so middleware and routes go in together inside a Group —
// which chi permits on a fresh inline mux sharing the same routing tree.
@@ 321,20 337,97 @@ func mountRoutes(router chi.Router, conf ini.File) {
})
})
- mountWeb(router, conf)
+ mountWeb(router, conf, surfaces)
}
-// mountWeb is where Phase 2's web UI attaches.
+// surfaces are the three Phase 2 read surfaces, assembled once at startup.
//
-// It is left empty rather than stubbed with a placeholder route: the read
-// plane is a milestone of its own, and a handler that renders nothing would be
-// indistinguishable from one that is broken. The chain it will need is the one
-// compare.sr.ht and dolt.sr.ht assemble by hand — RealIP, Recoverer, Logger,
-// config.Middleware(conf, serviceName), database.Middleware(pool) and
-// Service.Resolver().Middleware() — and deliberately not core-go's
-// WithDefaultMiddleware, whose auth middleware 401s any un-cookied request and
-// would make the read plane, which is anonymous-capable, unreachable.
-func mountWeb(_ chi.Router, _ ini.File) {}
+// They share one *search.Index deliberately: bleve is single-writer, so a second
+// Open on the same directory is not merely wasteful but wrong.
+type surfaces struct {
+ index *search.Index
+ web *web.Server
+ mcp http.Handler
+ gql http.Handler
+}
+
+// newSurfaces opens the index and builds the three read surfaces over it.
+//
+// All three go through service/ and none of them re-derives addressing, the
+// read contract or the project filter — that shared layer is the whole reason
+// "read SPEC-0007" cannot mean three different things depending on which door
+// you knock on.
+func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, version string) (*surfaces, error) {
+ indexPath := filepath.Join(cfg.Cache, "index")
+ // A crashed rebuild leaves working directories beside the index; clearing
+ // them before Open is always safe, since the index is a pure cache.
+ if err := search.CleanStale(indexPath); err != nil {
+ return nil, fmt.Errorf("clean stale index working dirs: %w", err)
+ }
+ index, err := search.Open(indexPath)
+ if err != nil {
+ return nil, fmt.Errorf("open search index at %s: %w", indexPath, err)
+ }
+
+ site, err := web.New(web.Options{
+ Conf: conf,
+ Reader: web.NewReader(svc),
+ Searcher: index,
+ Resolver: svc.Resolver(),
+ })
+ if err != nil {
+ index.Close()
+ return nil, fmt.Errorf("assemble the web UI: %w", err)
+ }
+
+ // The origin is the /mcp Host allowlist: the MCP SDK's own DNS-rebinding
+ // guard cannot tell a reverse proxy from an attacker (both reach a loopback
+ // listener with a non-loopback Host), so it is replaced by a check against
+ // this value. Traefik must pass the Host header through or every call 403s.
+ mcp, err := mcpsrv.Handler(mcpsrv.Backend{Docs: svc, Index: index}, version, cfg.Origin)
+ if err != nil {
+ index.Close()
+ return nil, fmt.Errorf("assemble the MCP surface: %w", err)
+ }
+
+ gql, err := graph.New(graph.Options{
+ Reader: svc,
+ Searcher: index,
+ Resolver: svc.Resolver(),
+ })
+ if err != nil {
+ index.Close()
+ return nil, fmt.Errorf("assemble the GraphQL surface: %w", err)
+ }
+
+ return &surfaces{index: index, web: site, mcp: mcp, gql: gql.Handler()}, nil
+}
+
+func (s *surfaces) Close() error {
+ if s == nil || s.index == nil {
+ return nil
+ }
+ return s.index.Close()
+}
+
+// mountWeb attaches the three read surfaces.
+//
+// Order is load-bearing: /mcp and /query are registered before the web UI,
+// which mounts at "/" and would otherwise swallow them as document paths —
+// "spec" and "query" are legal space names as far as the router is concerned.
+//
+// Each surface installs its own authentication (they are fail-closed and agree
+// on one ACL), so core-go's WithDefaultMiddleware is deliberately not used: its
+// auth middleware 401s any un-cookied request, which would also block the agent
+// bearer-token path these surfaces exist to serve.
+func mountWeb(router chi.Router, _ ini.File, s *surfaces) {
+ if s == nil {
+ return
+ }
+ router.Handle("/mcp", s.mcp)
+ router.Handle("/query", s.gql)
+ router.Mount("/", s.web.Handler())
+}
// pushNotifier is what the daemon does when a push lands.
//