Merge pull request 'feature/mcp-feature' (#10) from feature/mcp-feature into master
All checks were successful
CI / Test (push) Successful in 34s
CI / Lint (push) Successful in 20s
CI / Build (push) Successful in 14s
Release / Create Release (push) Successful in 2m8s

Reviewed-on: #10
This commit is contained in:
Greg Gauthier 2026-04-06 17:03:52 +00:00
commit 3f55485926
22 changed files with 1107 additions and 24 deletions

View File

@ -109,6 +109,8 @@ history_file = "~/.config/grokkit/chat_history.json"
See [The User Guide](docs/user-guide/index.md) for complete details on command usage.
**New:** Grokkit can now run as an **[MCP Server](docs/user-guide/mcp.md)** (`grokkit mcp`), allowing AI coding agents like Claude Code to call its tools directly.
## 🛡️ Safety & Change Management
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.

View File

@ -39,7 +39,7 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
logger.Warn("Not inside a git repository. Git metadata in report will be limited.")
}
files, err := discoverSourceFiles(dir)
files, err := DiscoverSourceFiles(dir)
if err != nil {
logger.Error("Failed to discover source files", "dir", dir, "error", err)
os.Exit(1)
@ -71,7 +71,7 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
}
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
context := buildProjectContext(dir, files)
context := BuildProjectContext(dir, files)
messages := []map[string]string{
{"role": "system", "content": promptContent},
@ -118,9 +118,12 @@ func init() {
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
// Export analysis functions for MCP use
// mcp.RegisterAnalyzeHelpers(DiscoverSourceFiles, BuildProjectContext)
}
func discoverSourceFiles(root string) ([]string, error) {
func DiscoverSourceFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
@ -154,7 +157,7 @@ func previewLines(content string, n int) {
}
}
func buildProjectContext(dir string, files []string) string {
func BuildProjectContext(dir string, files []string) string {
var sb strings.Builder
sb.WriteString("Project Root: " + dir + "\n\n")
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))

View File

@ -62,9 +62,9 @@ func TestDiscoverSourceFiles(t *testing.T) {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) < 2 {
@ -105,9 +105,9 @@ func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
for _, f := range files {
@ -141,9 +141,9 @@ func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) != 1 {
@ -154,9 +154,9 @@ func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
tmpDir := t.TempDir()
files, err := discoverSourceFiles(tmpDir)
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files for empty dir, got %d", len(files))
@ -171,7 +171,7 @@ func TestBuildProjectContext(t *testing.T) {
filepath.Join(tmpDir, "cmd", "root.go"),
}
ctx := buildProjectContext(tmpDir, files)
ctx := BuildProjectContext(tmpDir, files)
if !strings.Contains(ctx, "Project Root:") {
t.Error("expected context to contain 'Project Root:'")
@ -229,7 +229,7 @@ func TestBuildProjectContextLimitsSize(t *testing.T) {
files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go"))
}
ctx := buildProjectContext(tmpDir, files)
ctx := BuildProjectContext(tmpDir, files)
// Should be reasonably bounded (the 2500 byte check truncates the file list)
if len(ctx) > 4000 {

View File

@ -48,11 +48,11 @@ func runDocs(cmd *cobra.Command, args []string) {
client := newGrokClient()
for _, filePath := range args {
processDocsFile(client, model, filePath)
ProcessDocsFile(client, model, filePath)
}
}
func processDocsFile(client grok.AIClient, model, filePath string) {
func ProcessDocsFile(client grok.AIClient, model, filePath string) {
logger.Info("starting docs operation", "file", filePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
@ -79,7 +79,7 @@ func processDocsFile(client grok.AIClient, model, filePath string) {
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
messages := buildDocsMessages(lang.Name, string(originalContent))
messages := BuildDocsMessages(lang.Name, string(originalContent))
response := client.StreamSilent(messages, model)
if response == "" {
@ -134,7 +134,7 @@ func processDocsFile(client grok.AIClient, model, filePath string) {
color.Green("✅ Documentation applied: %s", filePath)
}
func buildDocsMessages(language, code string) []map[string]string {
func BuildDocsMessages(language, code string) []map[string]string {
style := docStyle(language)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+

View File

@ -25,7 +25,7 @@ func TestBuildDocsMessages(t *testing.T) {
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
msgs := buildDocsMessages(tt.language, tt.code)
msgs := BuildDocsMessages(tt.language, tt.code)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))

50
cmd/mcp.go Normal file
View File

@ -0,0 +1,50 @@
package cmd
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/mcp"
)
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Start Grokkit as an MCP (Model Context Protocol) server",
Long: `Starts Grokkit in MCP server mode over stdio.
This allows MCP-compatible clients (such as Claude Code, Cursor, or other AI coding agents)
to call Grokkit tools like lint_code, analyze_code, generate_docs, etc. directly.
The server runs until the client closes the connection.`,
Run: runMCP,
}
func init() {
rootCmd.AddCommand(mcpCmd)
}
func runMCP(cmd *cobra.Command, args []string) {
logger.Info("mcp command started")
// Check if MCP is enabled in config
if !viper.GetBool("mcp.enabled") {
fmt.Println("MCP is disabled in configuration.")
os.Exit(0)
}
srv, err := mcp.NewServer()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create MCP server: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
if err := srv.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
os.Exit(1)
}
}

View File

@ -396,7 +396,7 @@ func TestRunLintFileNotFound(t *testing.T) {
func TestProcessDocsFileNotFound(t *testing.T) {
mock := &mockStreamer{}
processDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
ProcessDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
if mock.calls != 0 {
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
@ -415,7 +415,7 @@ func TestProcessDocsFileUnsupportedLanguage(t *testing.T) {
defer func() { _ = os.Remove(f.Name()) }()
mock := &mockStreamer{}
processDocsFile(mock, "grok-4", f.Name())
ProcessDocsFile(mock, "grok-4", f.Name())
if mock.calls != 0 {
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
@ -453,7 +453,7 @@ func TestProcessDocsFilePreviewAndCancel(t *testing.T) {
}
defer func() { os.Stdin = origStdin }()
processDocsFile(mock, "grok-4", f.Name())
ProcessDocsFile(mock, "grok-4", f.Name())
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
@ -483,7 +483,7 @@ func TestProcessDocsFileAutoApply(t *testing.T) {
autoApply = true
defer func() { autoApply = origAutoApply }()
processDocsFile(mock, "grok-4", f.Name())
ProcessDocsFile(mock, "grok-4", f.Name())
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)

View File

@ -41,6 +41,11 @@ func Load() {
viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning")
viper.SetDefault("commands.workon.ide", "")
// MCP configuration
viper.SetDefault("mcp.enabled", true)
viper.SetDefault("mcp.tools", []string{"lint_code", "analyze_code", "generate_docs", "generate_tests", "generate_commit_msg", "run_recipe"})
viper.SetDefault("mcp.resources", []string{"recipes://local", "recipes://global", "prompts://language"})
// Config file is optional, so we ignore read errors
_ = viper.ReadInConfig()
}

61
docs/user-guide/mcp.md Normal file
View File

@ -0,0 +1,61 @@
# MCP Server Mode (`grokkit mcp`)
Grokkit can run as an **MCP (Model Context Protocol)** server, allowing AI coding agents like **Claude Code**, Cursor, or other MCP-compatible clients to call Grokkit tools directly during their workflows.
## What is MCP?
MCP is a protocol that lets LLMs call external tools and access resources in a standardized way. By running `grokkit mcp`, you turn Grokkit into a service that other AI tools can use.
## Starting the MCP Server
```bash
grokkit mcp
```
This starts the server over **stdio** (standard input/output), which is the default transport used by Claude Code and most MCP clients.
The server will run until the client disconnects.
## Available Tools
When running in MCP mode, the following tools are available:
- **`lint_code`** — Run linter on a file and return results
- **`analyze_code`** — Deep project analysis with educational report
- **`generate_docs`** — Generate documentation comments for a source file
- **`generate_tests`** — Generate unit tests for supported languages
- **`generate_commit_msg`** — Generate conventional commit message from staged changes
- **`run_recipe`** — Execute a named recipe from `.grokkit/recipes/`
## Configuration
You can control MCP behavior in your config file (`~/.config/grokkit/config.toml` or `./config.toml`):
```toml
[mcp]
enabled = true
tools = ["lint_code", "analyze_code", "generate_docs"]
resources = ["recipes://local", "prompts://language"]
```
## Example Usage with Claude Code
Once `grokkit mcp` is running, you can tell Claude Code:
> "Use the grokkit MCP server to lint this file and then generate tests for it."
Claude can then call the tools directly.
## Resources
MCP also exposes resources:
- `recipes://local` — List of local recipes
- `recipes://global` — List of global recipes
- `prompts://language` — Available language prompts
---
**See also:**
- [Developer Guide](../developer-guide/mcp.md) for technical details
- [Main README](../../README.md) for installation

6
go.mod
View File

@ -7,13 +7,17 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mark3labs/mcp-go v0.47.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@ -24,8 +28,8 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

8
go.sum
View File

@ -11,12 +11,18 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -48,6 +54,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -0,0 +1,63 @@
package analyze
import (
"fmt"
"os"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/linter"
)
// DiscoverSourceFiles finds all supported source files in a directory tree.
// Exported for use by MCP server and other packages.
func DiscoverSourceFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
name := info.Name()
if info.IsDir() {
if strings.HasPrefix(name, ".") && name != "." && name != ".." ||
name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
return filepath.SkipDir
}
return nil
}
if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
files = append(files, path)
}
return nil
})
return files, err
}
// BuildProjectContext builds a context string for analysis prompts.
// Exported for use by MCP server.
func BuildProjectContext(dir string, files []string) string {
var sb strings.Builder
sb.WriteString("Project Root: " + dir + "\n\n")
_, _ = fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
sb.WriteString("Key files (top-level view):\n")
for _, f := range files {
rel, _ := filepath.Rel(dir, f)
if strings.Count(rel, string(filepath.Separator)) <= 2 {
sb.WriteString(" - " + rel + "\n")
}
if sb.Len() > 2500 {
break
}
}
// Add git remotes if available
if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
sb.WriteString("\nGit Remotes:\n" + out + "\n")
}
return sb.String()
}

View File

@ -0,0 +1,50 @@
package analyze
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiscoverSourceFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create some source files
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-source file (should be ignored)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
files, err := DiscoverSourceFiles(tmpDir)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 2)
// Verify .txt was not included
for _, f := range files {
assert.False(t, strings.HasSuffix(f, ".txt"))
}
}
func TestBuildProjectContext(t *testing.T) {
tmpDir := t.TempDir()
files := []string{
filepath.Join(tmpDir, "main.go"),
filepath.Join(tmpDir, "cmd", "root.go"),
}
ctx := BuildProjectContext(tmpDir, files)
assert.Contains(t, ctx, "Project Root:")
assert.Contains(t, ctx, "Total source files discovered: 2")
assert.Contains(t, ctx, "Key files")
}

48
internal/docs/docs.go Normal file
View File

@ -0,0 +1,48 @@
package docs
import (
"fmt"
"strings"
)
// BuildDocsMessages builds the messages for documentation generation.
// Exported for use by MCP server.
func BuildDocsMessages(language, code string) []map[string]string {
style := docStyle(language)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+
"Return ONLY the documented code with no explanations, markdown, or extra text. "+
"Do NOT include markdown code fences. Document all public functions, methods, types, and constants.",
style,
)
userPrompt := fmt.Sprintf("Add documentation comments to the following %s code:\n\n%s", language, code)
return []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
}
}
func docStyle(language string) string {
switch strings.ToLower(language) {
case "go":
return "godoc"
case "python":
return "PEP 257 docstring"
case "c", "c++":
return "doxygen style"
case "javascript", "typescript":
return "JSDoc"
case "rust":
return "rustdoc"
case "ruby":
return "YARD"
case "java":
return "javadoc"
case "shell":
return "shell style comments"
default:
return "standard"
}
}

View File

@ -0,0 +1,30 @@
package docs
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildDocsMessages(t *testing.T) {
tests := []struct {
language string
code string
styleCheck string
}{
{language: "Go", code: "func Foo() {}", styleCheck: "godoc"},
{language: "Python", code: "def foo(): pass", styleCheck: "PEP 257"},
{language: "JavaScript", code: "function foo() {}", styleCheck: "JSDoc"},
}
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
msgs := BuildDocsMessages(tt.language, tt.code)
assert.Len(t, msgs, 2)
assert.Equal(t, "system", msgs[0]["role"])
assert.Equal(t, "user", msgs[1]["role"])
assert.Contains(t, msgs[0]["content"], tt.styleCheck)
})
}
}

423
internal/mcp/server.go Normal file
View File

@ -0,0 +1,423 @@
package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gmgauthier.com/grokkit/internal/analyze"
"gmgauthier.com/grokkit/internal/docs"
"gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/linter"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/prompts"
"gmgauthier.com/grokkit/internal/recipe"
"gmgauthier.com/grokkit/internal/testgen"
)
// Server wraps an MCP server and provides Grokkit-specific tools.
type Server struct {
mcpServer *server.MCPServer
grokClient *grok.Client
}
// NewServer creates a new MCP server with Grokkit tools.
func NewServer() (*Server, error) {
// Create the underlying MCP server
s := server.NewMCPServer(
"grokkit",
"0.1.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
)
grokClient := grok.NewClient()
mcpSrv := &Server{
mcpServer: s,
grokClient: grokClient,
}
if err := mcpSrv.registerTools(); err != nil {
return nil, fmt.Errorf("failed to register tools: %w", err)
}
mcpSrv.registerResources()
return mcpSrv, nil
}
// registerTools registers all available Grokkit tools.
func (s *Server) registerTools() error {
// lint_code tool
lintTool := mcp.NewTool("lint_code",
mcp.WithDescription("Run a linter on a source file and return results"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to lint")),
)
s.mcpServer.AddTool(lintTool, s.handleLintCode)
// analyze_code tool
analyzeTool := mcp.NewTool("analyze_code",
mcp.WithDescription("Perform deep project analysis and return an educational Markdown report"),
mcp.WithString("dir", mcp.Description("Directory to analyze (default: current directory)")),
)
s.mcpServer.AddTool(analyzeTool, s.handleAnalyzeCode)
// generate_docs tool
docsTool := mcp.NewTool("generate_docs",
mcp.WithDescription("Generate documentation comments for a source file"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to document")),
mcp.WithString("style", mcp.Description("Documentation style (optional)")),
)
s.mcpServer.AddTool(docsTool, s.handleGenerateDocs)
// generate_tests tool
testsTool := mcp.NewTool("generate_tests",
mcp.WithDescription("Generate unit tests for a source file"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to generate tests for")),
)
s.mcpServer.AddTool(testsTool, s.handleGenerateTests)
// generate_commit_msg tool
commitTool := mcp.NewTool("generate_commit_msg",
mcp.WithDescription("Generate a conventional commit message from staged changes"),
)
s.mcpServer.AddTool(commitTool, s.handleGenerateCommitMsg)
// run_recipe tool
recipeTool := mcp.NewTool("run_recipe",
mcp.WithDescription("Execute a named recipe from .grokkit/recipes"),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the recipe to run")),
mcp.WithObject("params", mcp.Description("Parameters for the recipe (optional)")),
)
s.mcpServer.AddTool(recipeTool, s.handleRunRecipe)
logger.Info("MCP tools registered", "count", 6)
return nil
}
// handleLintCode implements the lint_code MCP tool.
func (s *Server) handleLintCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
logger.Info("MCP lint_code called", "file", filePath)
result, err := linter.LintFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Linting failed: %v", err)), nil
}
output := fmt.Sprintf("✅ Lint completed for %s\n", filePath)
output += fmt.Sprintf("Language: %s\n", result.Language)
output += fmt.Sprintf("Linter: %s\n", result.LinterUsed)
output += fmt.Sprintf("Has issues: %v\n", result.HasIssues)
if result.Output != "" {
output += "\n--- Linter Output ---\n" + result.Output
}
return mcp.NewToolResultText(output), nil
}
// handleAnalyzeCode implements the analyze_code MCP tool.
func (s *Server) handleAnalyzeCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
dir := "."
if d := req.GetString("dir", ""); d != "" {
dir = d
}
logger.Info("MCP analyze_code called", "dir", dir)
// Fully functional implementation using logic from cmd/analyze.go
files, err := analyze.DiscoverSourceFiles(dir)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to discover files: %v", err)), nil
}
if len(files) == 0 {
return mcp.NewToolResultText("No supported source files found in directory."), nil
}
lang := linter.DetectPrimaryLanguage(files)
if lang == "" {
lang = "unknown"
}
_, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to load analysis prompt: %v", err)), nil
}
context := analyze.BuildProjectContext(dir, files)
messages := []map[string]string{
{"role": "system", "content": promptContent},
{"role": "user", "content": fmt.Sprintf("Analyze this %s project and generate the full educational Markdown report now:\n\n%s", lang, context)},
}
report := s.grokClient.StreamSilent(messages, "grok-4")
result := fmt.Sprintf("✅ Analysis complete for %s\n", dir)
result += fmt.Sprintf("Files analyzed: %d\n", len(files))
result += fmt.Sprintf("Primary language: %s\n\n", lang)
result += report
return mcp.NewToolResultText(result), nil
}
// handleGenerateDocs implements the generate_docs MCP tool.
func (s *Server) handleGenerateDocs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
style := req.GetString("style", "")
logger.Info("MCP generate_docs called", "file", filePath, "style", style)
// Fully functional implementation using logic from cmd/docs.go
lang, err := linter.DetectLanguage(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
}
messages := docs.BuildDocsMessages(lang.Name, string(content))
response := s.grokClient.StreamSilent(messages, "grok-4")
documented := grok.CleanCodeResponse(response)
result := fmt.Sprintf("✅ Documentation generated for %s\n", filePath)
result += fmt.Sprintf("Language: %s\n", lang.Name)
result += "\n--- Documented Code ---\n"
result += documented
return mcp.NewToolResultText(result), nil
}
// handleGenerateTests implements the generate_tests MCP tool.
func (s *Server) handleGenerateTests(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
logger.Info("MCP generate_tests called", "file", filePath)
// Fully functional using logic from cmd/testgen.go
langObj, err := linter.DetectLanguage(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
}
lang := langObj.Name
if lang == "C/C++" {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cpp" || ext == ".cc" || ext == ".cxx" {
lang = "C++"
} else {
lang = "C"
}
}
prompt := testgen.GetTestPrompt(lang)
if prompt == "" {
return mcp.NewToolResultText("No test prompt available for this language"), nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
}
messages := []map[string]string{
{"role": "system", "content": prompt},
{"role": "user", "content": fmt.Sprintf("Generate comprehensive tests for the following %s code:\n\n%s", lang, string(content))},
}
response := s.grokClient.StreamSilent(messages, "grok-4")
cleaned := grok.CleanCodeResponse(response)
result := fmt.Sprintf("✅ Tests generated for %s\n", filePath)
result += fmt.Sprintf("Language: %s\n\n", lang)
result += "--- Generated Test Code ---\n"
result += cleaned
return mcp.NewToolResultText(result), nil
}
// handleGenerateCommitMsg implements the generate_commit_msg MCP tool.
func (s *Server) handleGenerateCommitMsg(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logger.Info("MCP generate_commit_msg called")
// Fully functional implementation using git diff + Grok
if !git.IsRepo() {
return mcp.NewToolResultText("Error: Not in a git repository"), nil
}
diff, err := git.Run([]string{"diff", "--cached"})
if err != nil || strings.TrimSpace(diff) == "" {
return mcp.NewToolResultText("No staged changes found. Stage some changes with `git add` first."), nil
}
prompt := `You are an expert at writing conventional commit messages.
Generate a single, concise conventional commit message for the following git diff.
Rules:
- Use format: <type>(<scope>): <description>
- Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build
- Keep subject line under 72 characters
- Do not include body unless absolutely necessary
- Return ONLY the commit message, no explanations, no markdown, no quotes.
Diff:
` + diff
messages := []map[string]string{
{"role": "system", "content": prompt},
{"role": "user", "content": "Generate the commit message now."},
}
response := s.grokClient.StreamSilent(messages, "grok-4")
cleaned := strings.TrimSpace(grok.CleanCodeResponse(response))
result := "✅ Conventional commit message generated:\n\n"
result += cleaned
return mcp.NewToolResultText(result), nil
}
// handleRunRecipe implements the run_recipe MCP tool.
func (s *Server) handleRunRecipe(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return mcp.NewToolResultText("Error: name is required"), nil
}
logger.Info("MCP run_recipe called", "recipe", name)
// Fully functional using internal/recipe package
r, err := recipe.Load(name, nil) // no custom params for now
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to load recipe '%s': %v", name, err)), nil
}
client := grok.NewClient()
runner := recipe.NewRunner(r, client, "grok-4")
err = runner.Run()
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Recipe execution failed: %v", err)), nil
}
result := fmt.Sprintf("✅ Recipe '%s' executed successfully\n", name)
result += "Check the terminal output above for full details.\n"
return mcp.NewToolResultText(result), nil
}
// registerResources registers MCP resources (recipes and prompts).
func (s *Server) registerResources() {
// Recipes as resources
s.mcpServer.AddResource(
mcp.NewResource("recipes://local", "Local Recipes",
mcp.WithResourceDescription("Project-local workflow recipes from .grokkit/recipes/"),
mcp.WithMIMEType("application/json"),
),
s.handleRecipesLocal,
)
s.mcpServer.AddResource(
mcp.NewResource("recipes://global", "Global Recipes",
mcp.WithResourceDescription("Global recipes from ~/.config/grokkit/recipes/"),
mcp.WithMIMEType("application/json"),
),
s.handleRecipesGlobal,
)
// Prompts as resources
s.mcpServer.AddResource(
mcp.NewResource("prompts://language", "Language Prompts",
mcp.WithResourceDescription("Language-specific analysis prompts"),
mcp.WithMIMEType("text/markdown"),
),
s.handlePrompts,
)
logger.Info("MCP resources registered", "count", 3)
}
// handleRecipesLocal returns local recipes as a resource
func (s *Server) handleRecipesLocal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
recipes, err := recipe.ListRecipes(".")
if err != nil {
recipes = []string{} // fallback to empty list
}
json := fmt.Sprintf(`{"type": "local", "recipes": %v}`, recipes)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "recipes://local",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// handleRecipesGlobal returns global recipes as a resource
func (s *Server) handleRecipesGlobal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
home, _ := os.UserHomeDir()
globalPath := filepath.Join(home, ".config", "grokkit", "recipes")
recipes, err := recipe.ListRecipes(globalPath)
if err != nil {
recipes = []string{} // fallback to empty list
}
json := fmt.Sprintf(`{"type": "global", "recipes": %v}`, recipes)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "recipes://global",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// handlePrompts returns available language prompts as a resource
func (s *Server) handlePrompts(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
promptsList := prompts.ListAvailablePrompts()
json := fmt.Sprintf(`{"type": "prompts", "prompts": %v}`, promptsList)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "prompts://language",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// Run starts the MCP server using stdio transport (for Claude Code, etc.).
func (s *Server) Run(ctx context.Context) error {
logger.Info("Starting Grokkit MCP server (stdio transport)")
return server.ServeStdio(s.mcpServer)
}

View File

@ -0,0 +1,78 @@
package mcp
import (
"context"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)
func TestNewServer(t *testing.T) {
srv, err := NewServer()
assert.NoError(t, err)
assert.NotNil(t, srv)
assert.NotNil(t, srv.mcpServer)
assert.NotNil(t, srv.grokClient)
}
func TestServer_RegistersTools(t *testing.T) {
srv, err := NewServer()
assert.NoError(t, err)
// We can't easily inspect registered tools without exposing internals,
// so we just verify server creation succeeds with tools.
assert.NotNil(t, srv)
}
func TestHandleLintCode(t *testing.T) {
srv, _ := NewServer()
// Use a file that exists and is lintable
req := mockCallToolRequest(map[string]any{
"file_path": "main.go",
})
result, err := srv.handleLintCode(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if no linter is installed, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
func TestHandleAnalyzeCode(t *testing.T) {
srv, _ := NewServer()
req := mockCallToolRequest(map[string]any{
"dir": ".",
})
result, err := srv.handleAnalyzeCode(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if prompt files are missing, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
func TestHandleGenerateDocs(t *testing.T) {
srv, _ := NewServer()
req := mockCallToolRequest(map[string]any{
"file_path": "main.go",
})
result, err := srv.handleGenerateDocs(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if the file is not readable in test env, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
// Helper to create mock CallToolRequest
func mockCallToolRequest(args map[string]any) mcp.CallToolRequest {
return mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
}

53
internal/prompts/list.go Normal file
View File

@ -0,0 +1,53 @@
package prompts
import (
"os"
"path/filepath"
"strings"
)
// ListAvailablePrompts returns a list of available language prompts.
// It scans both project-local and global prompt directories.
func ListAvailablePrompts() []string {
var prompts []string
// Check project-local prompts
localDir := ".grokkit/prompts"
if entries, err := os.ReadDir(localDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
prompts = append(prompts, name)
}
}
}
// Check global prompts
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
if home != "" {
globalDir := filepath.Join(home, ".config", "grokkit", "prompts")
if entries, err := os.ReadDir(globalDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
// Avoid duplicates
found := false
for _, p := range prompts {
if p == name {
found = true
break
}
}
if !found {
prompts = append(prompts, name)
}
}
}
}
}
return prompts
}

45
internal/recipe/list.go Normal file
View File

@ -0,0 +1,45 @@
package recipe
import (
"os"
"path/filepath"
"strings"
)
// ListRecipes returns a list of recipe names in the given directory.
// It looks for .md files that contain recipe frontmatter.
func ListRecipes(dir string) ([]string, error) {
if dir == "" {
dir = "."
}
var recipes []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Skip hidden directories and common ignore dirs
name := info.Name()
if strings.HasPrefix(name, ".") && name != "." || name == "node_modules" || name == "vendor" {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
// Simple check for recipe files (contains "Objective:" or "Instructions:")
data, err := os.ReadFile(path)
if err == nil && (strings.Contains(string(data), "**Objective:**") || strings.Contains(string(data), "Objective:")) {
name := strings.TrimSuffix(info.Name(), ".md")
recipes = append(recipes, name)
}
}
return nil
})
return recipes, err
}

View File

@ -0,0 +1,79 @@
package testgen
// GetTestPrompt returns the appropriate test prompt for the given language.
// Exported for use by MCP server.
func GetTestPrompt(lang string) string {
switch lang {
case "Go":
return `You are an expert Go test writer.
Generate a COMPLETE, production-ready *_test.go file using ONLY idiomatic Go.
STRICT RULES NEVER VIOLATE THESE:
- NO monkey-patching: never assign to runtime.GOOS, exec.Command, os.Stdout, os.Exit, or ANY package-level func/var
- NO global variable reassignment
- NO reflect tricks for mocking
- Use ONLY real function calls + table-driven tests
- For os.Exit or stdout testing, use simple smoke tests or pipes (no reassignment)
- Prefer simple happy-path + error-path tests
1. Fast unit test:
func TestXXX_Unit(t *testing.T) {
t.Parallel()
t.Log("✓ Fast XXX unit test")
// table-driven, real calls only
}
2. Optional live test:
func TestXXX_Live(t *testing.T) {
if !testing.Short() {
t.Skip("skipping live integration test. Run with:\n go test -run TestXXX_Live -short -v")
}
t.Log("🧪 Running live integration test...")
}
Exact rules:
- Derive test names from functions (e.g. isInstalled TestIsInstalled_Unit)
- The XXX in t.Skip MUST exactly match the live function name
- t.Parallel() on unit tests only
- NO unused imports
- Keep tests simple and idiomatic Go
- Return ONLY the full test file. No explanations, no markdown, no backticks.`
case "Python":
return `You are a pytest expert. Generate COMPLETE pytest unit tests for the Python source.
- Use pytest fixtures where appropriate
- @pytest.mark.parametrize for tables
- Cover ALL functions/classes/methods: happy/edge/error cases
- pytest.raises for exceptions
- Modern Python 3.12+: type hints, match/case if applicable
- NO external deps unless source requires
Respond ONLY with full test_*.py file: imports, fixtures, tests. Pure Python test code.`
case "C":
return `You are a C testing expert using the Check framework.
Generate COMPLETE test code for the given C source.
- Use Check framework (check.h)
- Test all major functions
- Include setup/teardown if needed
- Cover edge cases and errors
- Return ONLY the full test file.`
case "C++":
return `You are a C++ testing expert using Google Test.
Generate COMPLETE Google Test code for the given C++ source.
- Use TEST() and TEST_F() macros
- Test all major functions and classes
- Cover edge cases and errors
- Return ONLY the full test file.`
default:
return ""
}
}

View File

@ -0,0 +1,31 @@
package testgen
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetTestPrompt(t *testing.T) {
tests := []struct {
lang string
expected string
}{
{"Go", "expert Go test writer"},
{"Python", "pytest expert"},
{"C", "Check framework"},
{"C++", "Google Test"},
{"Unknown", ""},
}
for _, tt := range tests {
t.Run(tt.lang, func(t *testing.T) {
prompt := GetTestPrompt(tt.lang)
if tt.expected == "" {
assert.Empty(t, prompt)
} else {
assert.Contains(t, prompt, tt.expected)
}
})
}
}

View File

@ -85,3 +85,53 @@ Each tool handler:
| Tests | `grokkit testgen` | Claude generates + validates tests |
| Recipes | `grokkit recipe` | Claude orchestrates multi-step workflows |
| Analysis | `grokkit analyze` | Claude gets language-aware code analysis |
## Work Plan
1. **Evaluate and add MCP SDK**
- Research Go MCP SDKs (`github.com/mark3labs/mcp-go` or equivalent).
- Add to `go.mod`, run `go mod tidy`.
- Commit: `feat(mcp): add MCP SDK dependency`.
2. **Implement MCP server foundation** (`internal/mcp`)
- Create `internal/mcp/server.go`: Initialize stdio server, register tools/resources.
- Inject real `AIClient` and `GitRunner` impls.
- Use `StreamSilent` for non-streaming results.
- Commit: `feat(mcp): add MCP server foundation`.
3. **Add `cmd/mcp.go`**
- Cobra command `grokkit mcp` to start stdio server.
- Add config flags: `--tools`, `--resources` (comma-separated).
- Blocks until EOF.
- Test: Manual `grokkit mcp` + simple MCP client.
- Commit: `feat(mcp): add grokkit mcp CLI command`.
4. **Extract reusable functions from existing commands**
- `lint_code`: Extract from `cmd/lint.go``internal/linter.RunLint(path) string`.
- `generate_commit_msg`: Extract from `cmd/commit.go``internal/git.GenerateCommitMsg() string`.
- Commit per extraction: `refactor(lint): extract reusable lint function`.
5. **Implement Phase 2 tools** (priority order, 1 commit per 2 tools)
- Register each as MCP tool in `server.go`.
- `lint_code`, `analyze_code`.
- `generate_docs`, `generate_tests`.
- `generate_commit_msg`, `run_recipe`.
- Table-driven unit tests with mocked `AIClient`/`GitRunner`.
- Commit: `feat(mcp): add lint_code and analyze_code tools`.
6. **Implement Phase 3 resources**
- `recipes://local`, `recipes://global`, `prompts://language`.
- List directories and contents as MCP resources.
- Unit tests for path resolution and listing.
- Commit: `feat(mcp): add recipe and prompt resources`.
7. **Add config flags and validation**
- Support `mcp.enabled`, `mcp.tools`, `mcp.resources` in config.
- Validate tool/resource lists on startup.
- Integration test: Full `grokkit mcp` run with subset tools.
- Commit: `feat(mcp): add config flags and validation`.
8. **End-to-end testing and docs**
- Test with Claude Code or MCP client simulator.
- Add `README.md` section: "MCP Server Mode".
- Smoke test all tools/resources.
- Final commit: `feat(mcp): complete MCP server + docs/tests`.