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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user