From 964716696bd588e1fe9d67c5a8b9ff2cd6a08613 Mon Sep 17 00:00:00 2001 From: bigbes Date: Wed, 22 Jul 2026 18:17:54 +0300 Subject: [PATCH] feat(cmd): mount the read plane, MCP and GraphQL surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three Phase 2 surfaces were built but never served: each was written under an instruction not to touch cmd/, so every one reported its mounting call and none of them wired it. The daemon answered /healthz and 404'd everything else. They share one search.Index, because bleve is single-writer and a second Open on the same directory is wrong rather than merely wasteful. Route order is load-bearing: /mcp and /query register before the web UI mounts at /, which would otherwise swallow them as document paths — the router has no reason to think 'mcp' is not a space name. The MCP handler takes the configured origin as its Host allowlist, which is why Traefik must pass the Host header through. --- cmd/specsrht/main.go | 125 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/cmd/specsrht/main.go b/cmd/specsrht/main.go index 2f7a44356fbdb226325a5d279248b102eb1583e0..61d04182e4489c6c18d9115c237cf3d06d417b8d 100644 --- a/cmd/specsrht/main.go +++ b/cmd/specsrht/main.go @@ -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. //