Add service layer and MCP server with read tools

Slice 1: internal/service is the one capability layer both the HTTP API and the MCP server call (AGENT.md 1.7); /api/repos and /api/repo route through it. Slice 2: internal/mcp serves an MCP server over Streamable HTTP at /mcp (go-sdk v1.8.0) with read tools list_repos and get_repo as thin adapters over the service, plus a round-trip test. Enabled air polling so hot reload works across the Docker-on-Windows bind mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 17:18:40 -04:00
parent dfa45de40c
commit 2d7f814a23
8 changed files with 300 additions and 8 deletions
+16 -8
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -18,8 +17,10 @@ import (
"gitmanager/internal/config"
"gitmanager/internal/git"
"gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp"
"gitmanager/internal/render"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
func main() {
@@ -50,6 +51,9 @@ func main() {
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
// The one service layer both the HTTP API and the MCP server call (§1.7).
svc := service.New(g, scanner.Index)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
@@ -79,20 +83,24 @@ func main() {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, scanner.Index.List())
return c.JSON(http.StatusOK, svc.ListRepos())
})
e.GET("/api/repo", func(c echo.Context) error {
// Only serve details for a repo we already discovered — never run git
// against an arbitrary path supplied in the query string. Clean the
// input so separator style (/, \) doesn't defeat the exact-match lookup.
path := filepath.Clean(c.QueryParam("path"))
base, ok := scanner.Index.Get(path)
// The service only serves details for an already-discovered repo — it
// never runs git against an arbitrary caller-supplied path (§1.3).
detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path"))
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
}
return c.JSON(http.StatusOK, repos.BuildDetail(c.Request().Context(), g, base))
return c.JSON(http.StatusOK, detail)
})
// MCP server — Claude connects here as a custom connector (§8.1). Same
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
log.Info("mcp server mounted", "path", "/mcp")
// Serve with graceful shutdown.
go func() {
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {