feature/mcp-feature #10
@ -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.
|
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
|
## 🛡️ Safety & Change Management
|
||||||
|
|
||||||
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
|
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
|
||||||
|
|||||||
@ -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.")
|
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 {
|
if err != nil {
|
||||||
logger.Error("Failed to discover source files", "dir", dir, "error", err)
|
logger.Error("Failed to discover source files", "dir", dir, "error", err)
|
||||||
os.Exit(1)
|
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)
|
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
|
||||||
|
|
||||||
context := buildProjectContext(dir, files)
|
context := BuildProjectContext(dir, files)
|
||||||
|
|
||||||
messages := []map[string]string{
|
messages := []map[string]string{
|
||||||
{"role": "system", "content": promptContent},
|
{"role": "system", "content": promptContent},
|
||||||
@ -118,9 +118,12 @@ func init() {
|
|||||||
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
|
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
|
||||||
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
|
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
|
||||||
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
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
|
var files []string
|
||||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString("Project Root: " + dir + "\n\n")
|
sb.WriteString("Project Root: " + dir + "\n\n")
|
||||||
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
|
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
|
||||||
|
|||||||
@ -62,9 +62,9 @@ func TestDiscoverSourceFiles(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := discoverSourceFiles(tmpDir)
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("discoverSourceFiles failed: %v", err)
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) < 2 {
|
if len(files) < 2 {
|
||||||
@ -105,9 +105,9 @@ func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := discoverSourceFiles(tmpDir)
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("discoverSourceFiles failed: %v", err)
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
@ -141,9 +141,9 @@ func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := discoverSourceFiles(tmpDir)
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("discoverSourceFiles failed: %v", err)
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) != 1 {
|
if len(files) != 1 {
|
||||||
@ -154,9 +154,9 @@ func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
|
|||||||
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
|
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
files, err := discoverSourceFiles(tmpDir)
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("discoverSourceFiles failed: %v", err)
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(files) != 0 {
|
if len(files) != 0 {
|
||||||
t.Errorf("expected 0 files for empty dir, got %d", len(files))
|
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"),
|
filepath.Join(tmpDir, "cmd", "root.go"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := buildProjectContext(tmpDir, files)
|
ctx := BuildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
if !strings.Contains(ctx, "Project Root:") {
|
if !strings.Contains(ctx, "Project Root:") {
|
||||||
t.Error("expected context to contain '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"))
|
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)
|
// Should be reasonably bounded (the 2500 byte check truncates the file list)
|
||||||
if len(ctx) > 4000 {
|
if len(ctx) > 4000 {
|
||||||
|
|||||||
@ -48,11 +48,11 @@ func runDocs(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
client := newGrokClient()
|
client := newGrokClient()
|
||||||
for _, filePath := range args {
|
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)
|
logger.Info("starting docs operation", "file", filePath)
|
||||||
|
|
||||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
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)
|
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
|
||||||
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
|
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)
|
response := client.StreamSilent(messages, model)
|
||||||
|
|
||||||
if response == "" {
|
if response == "" {
|
||||||
@ -134,7 +134,7 @@ func processDocsFile(client grok.AIClient, model, filePath string) {
|
|||||||
color.Green("✅ Documentation applied: %s", filePath)
|
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)
|
style := docStyle(language)
|
||||||
systemPrompt := fmt.Sprintf(
|
systemPrompt := fmt.Sprintf(
|
||||||
"You are a documentation expert. Add %s documentation comments to the provided code. "+
|
"You are a documentation expert. Add %s documentation comments to the provided code. "+
|
||||||
|
|||||||
@ -25,7 +25,7 @@ func TestBuildDocsMessages(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.language, func(t *testing.T) {
|
t.Run(tt.language, func(t *testing.T) {
|
||||||
msgs := buildDocsMessages(tt.language, tt.code)
|
msgs := BuildDocsMessages(tt.language, tt.code)
|
||||||
|
|
||||||
if len(msgs) != 2 {
|
if len(msgs) != 2 {
|
||||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||||
|
|||||||
50
cmd/mcp.go
Normal file
50
cmd/mcp.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -396,7 +396,7 @@ func TestRunLintFileNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestProcessDocsFileNotFound(t *testing.T) {
|
func TestProcessDocsFileNotFound(t *testing.T) {
|
||||||
mock := &mockStreamer{}
|
mock := &mockStreamer{}
|
||||||
processDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
|
ProcessDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
|
||||||
|
|
||||||
if mock.calls != 0 {
|
if mock.calls != 0 {
|
||||||
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
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()) }()
|
defer func() { _ = os.Remove(f.Name()) }()
|
||||||
|
|
||||||
mock := &mockStreamer{}
|
mock := &mockStreamer{}
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 0 {
|
if mock.calls != 0 {
|
||||||
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
|
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 }()
|
defer func() { os.Stdin = origStdin }()
|
||||||
|
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
@ -483,7 +483,7 @@ func TestProcessDocsFileAutoApply(t *testing.T) {
|
|||||||
autoApply = true
|
autoApply = true
|
||||||
defer func() { autoApply = origAutoApply }()
|
defer func() { autoApply = origAutoApply }()
|
||||||
|
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
|
|||||||
@ -41,6 +41,11 @@ func Load() {
|
|||||||
viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning")
|
viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning")
|
||||||
viper.SetDefault("commands.workon.ide", "")
|
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
|
// Config file is optional, so we ignore read errors
|
||||||
_ = viper.ReadInConfig()
|
_ = viper.ReadInConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
61
docs/user-guide/mcp.md
Normal file
61
docs/user-guide/mcp.md
Normal 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
6
go.mod
@ -7,13 +7,17 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.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/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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
8
go.sum
8
go.sum
@ -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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
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=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
63
internal/analyze/analyze.go
Normal file
63
internal/analyze/analyze.go
Normal 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()
|
||||||
|
}
|
||||||
48
internal/docs/docs.go
Normal file
48
internal/docs/docs.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
400
internal/mcp/server.go
Normal file
400
internal/mcp/server.go
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder handlers for resources (to be implemented)
|
||||||
|
func (s *Server) handleRecipesLocal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "recipes://local",
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: `{"status": "placeholder - local recipes not yet implemented"}`,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRecipesGlobal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "recipes://global",
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: `{"status": "placeholder - global recipes not yet implemented"}`,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePrompts(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "prompts://language",
|
||||||
|
MIMEType: "text/markdown",
|
||||||
|
Text: `# Language Prompts\n\nPlaceholder - language prompts not yet implemented.`,
|
||||||
|
},
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
79
internal/testgen/testgen.go
Normal file
79
internal/testgen/testgen.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user