diff --git a/.air.toml b/.air.toml index 768b1fc..674c920 100644 --- a/.air.toml +++ b/.air.toml @@ -12,6 +12,11 @@ tmp_dir = "tmp" exclude_dir = ["tmp", "bin", ".git", "repos"] delay = 500 stop_on_error = true + # Poll for changes instead of relying on fsnotify: filesystem events do NOT + # cross the Windows host -> Linux container bind mount, so watch-based reload + # silently never fires. Polling is the reliable option in Docker on Windows. + poll = true + poll_interval = 500 [log] time = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f63249..1353d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,3 +52,20 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. stop cluttering repos. Decisions locked: cooperative pull-first handoff; Gitea writes enabled (confirmed per §1.4); MCP over HTTP `/mcp`. - **Affects:** `AGENT.md`, `.env.example` (architecture/contract only — no code). + +## 2026-09-19 — Service layer + MCP server (Claude integration, read tools) +- **What:** Slice 1 — extracted `internal/service`, the one capability layer both + the HTTP API and the MCP server call (§1.7); the `/api/repos` and `/api/repo` + handlers now route through it. Slice 2 — added `internal/mcp`: an MCP server + (`github.com/modelcontextprotocol/go-sdk` v1.8.0) served over Streamable HTTP at + `/mcp`, with read tools `list_repos` and `get_repo` as thin adapters over the + service. Added a round-trip test (`internal/mcp/mcp_test.go`) using a real temp + git repo + the in-memory MCP transport. Verified the HTTP `/mcp` handshake + locally and in Docker. +- **Why:** First step of the two-way Claude integration (AGENT.md §8.1) — prove + Claude can connect to the app over MCP before building deeper features on it. +- **Affects:** `internal/service` (new), `internal/mcp` (new), `cmd/server/main.go`, + `go.mod`/`go.sum`, `.air.toml`. +- **Gotcha:** Docker-on-Windows bind mounts do NOT deliver filesystem events, so + air's watch-based reload silently never fired. Fixed by enabling air polling + (`poll = true`, `poll_interval = 500` in `.air.toml`). diff --git a/cmd/server/main.go b/cmd/server/main.go index 35cea98..a86ac66 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/go.mod b/go.mod index a331a89..c25cedd 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,23 @@ go 1.26 require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.15.4 + github.com/modelcontextprotocol/go-sdk v1.8.0 ) require ( + github.com/google/jsonschema-go v0.4.3 // indirect github.com/labstack/gommon v0.5.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/go.sum b/go.sum index bd6070d..e18dcf3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= @@ -10,23 +16,37 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..c021d25 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,63 @@ +// Package mcp exposes GitManager's capabilities to Claude as an MCP server over +// Streamable HTTP (AGENT.md §8.1). The tool handlers are THIN ADAPTERS over the +// shared service layer (§1.7) — no Git/forge logic lives here. Read tools only +// for now; acting/destructive tools arrive with the service methods that back +// them, carrying the §1.4 confirmation contract. +package mcp + +import ( + "context" + "fmt" + "net/http" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitmanager/internal/repos" + "gitmanager/internal/service" +) + +// getRepoInput is the argument schema for the get_repo tool. +type getRepoInput struct { + Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"` +} + +// NewServer builds the MCP server and registers the (currently read-only) tools. +func NewServer(svc *service.Service, version string) *mcpsdk.Server { + s := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "gitmanager", + Title: "GitManager", + Version: version, + Description: "Discover and inspect the user's local Git repositories.", + }, nil) + + // list_repos — no arguments (empty struct = object schema with no properties). + mcpsdk.AddTool(s, &mcpsdk.Tool{ + Name: "list_repos", + Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.", + }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, []repos.State, error) { + return nil, svc.ListRepos(), nil + }) + + // get_repo — details for one already-discovered repository. + mcpsdk.AddTool(s, &mcpsdk.Tool{ + Name: "get_repo", + Description: "Get details for one repository: its local branches (with upstreams), recent commits, and remote URLs. The path must be one returned by list_repos.", + }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRepoInput) (*mcpsdk.CallToolResult, repos.Detail, error) { + detail, ok := svc.RepoDetail(ctx, in.Path) + if !ok { + return nil, repos.Detail{}, fmt.Errorf("unknown repository %q — call list_repos for valid paths", in.Path) + } + return nil, detail, nil + }) + + return s +} + +// Handler serves the MCP server over Streamable HTTP. Mount it at /mcp. Like the +// rest of the app it is localhost-bound and unauthenticated (§8.1) — the same +// server instance backs every session. +func Handler(s *mcpsdk.Server) http.Handler { + return mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return s + }, nil) +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..0970f35 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,125 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitmanager/internal/git" + "gitmanager/internal/repos" + "gitmanager/internal/service" +) + +// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner -> +// service -> MCP tools, called by an in-memory MCP client. +func TestMCPRoundTrip(t *testing.T) { + root := t.TempDir() + repoPath := filepath.Join(root, "myrepo") + if err := os.Mkdir(repoPath, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repoPath, "init", "-b", "main") + runGit(t, repoPath, "config", "user.email", "test@example.com") + runGit(t, repoPath, "config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repoPath, "add", "-A") + runGit(t, repoPath, "commit", "-m", "first commit") + + // Populate the index via the real scanner, then build service + MCP server. + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + g := git.New("git") + scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) + scanner.Refresh(context.Background()) + + svc := service.New(g, scanner.Index) + srv := NewServer(svc, "test") + + // Wire an in-memory client<->server session. + ctx := context.Background() + clientT, serverT := mcpsdk.NewInMemoryTransports() + serverSession, err := srv.Connect(ctx, serverT, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer serverSession.Close() + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil) + cs, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + // list_repos should find our one repo. + res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"}) + if err != nil { + t.Fatalf("list_repos: %v", err) + } + var states []repos.State + decodeResult(t, res, &states) + if len(states) != 1 { + t.Fatalf("expected 1 repo, got %d: %+v", len(states), states) + } + if states[0].Name != "myrepo" || states[0].Branch != "main" { + t.Fatalf("unexpected repo state: %+v", states[0]) + } + + // get_repo should return detail including the commit we made. + res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "get_repo", + Arguments: map[string]any{"path": states[0].Path}, + }) + if err != nil { + t.Fatalf("get_repo: %v", err) + } + var detail repos.Detail + decodeResult(t, res, &detail) + if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" { + t.Fatalf("unexpected detail commits: %+v", detail.Commits) + } + + // get_repo with a bad path is a tool error, not a protocol error. + res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "get_repo", + Arguments: map[string]any{"path": filepath.Join(root, "nope")}, + }) + if err != nil { + t.Fatalf("get_repo(bad) protocol error: %v", err) + } + if !res.IsError { + t.Fatalf("expected IsError for unknown repo, got success") + } +} + +// decodeResult unmarshals the JSON text content of a tool result into v. +func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) { + t.Helper() + for _, c := range res.Content { + if tc, ok := c.(*mcpsdk.TextContent); ok { + if err := json.Unmarshal([]byte(tc.Text), v); err != nil { + t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text) + } + return + } + } + t.Fatalf("no text content in result: %+v", res.Content) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..4342d80 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,47 @@ +// Package service is the ONE capability layer behind both the HTTP API and the +// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin +// adapters that call these methods; Git/forge logic never lives in a handler. +// Everything here goes through the internal/git (and later internal/forge) +// boundaries and obeys the safety rules (§1.4). +package service + +import ( + "context" + "path/filepath" + + "gitmanager/internal/git" + "gitmanager/internal/repos" +) + +// Service holds the shared dependencies the capabilities need. +type Service struct { + git *git.CLI + index *repos.Index +} + +// New builds a Service over the git boundary and the scanner's repo index. +func New(g *git.CLI, index *repos.Index) *Service { + return &Service{git: g, index: index} +} + +// ListRepos returns a snapshot of every discovered repository. +func (s *Service) ListRepos() []repos.State { + return s.index.List() +} + +// GetRepo returns the cached state for one repo, or false if it is not indexed. +// The path is cleaned so separator style does not defeat the exact-match lookup. +func (s *Service) GetRepo(path string) (repos.State, bool) { + return s.index.Get(filepath.Clean(path)) +} + +// RepoDetail returns the enriched detail (branches, commits, remotes) for one +// repo. The second return is false when the path is not an indexed repository — +// we never run git against an arbitrary caller-supplied path (§1.3). +func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) { + base, ok := s.index.Get(filepath.Clean(path)) + if !ok { + return repos.Detail{}, false + } + return repos.BuildDetail(ctx, s.git, base), true +}