401 lines
13 KiB
Go
401 lines
13 KiB
Go
|
|
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)
|
||
|
|
}
|