// 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"` } // listReposOutput wraps the repository list. MCP structured output must be a JSON // object, so the SDK-inferred outputSchema has to be type "object" — returning a // bare slice yields type "array", which Claude Desktop rejects at tools/list. type listReposOutput struct { Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"` } // 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, listReposOutput, error) { return nil, listReposOutput{Repos: 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) }