package mcp import ( "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "sort" "strings" mcpModels "ginimageApi/app/mcp/models" "ginimageApi/configs" "github.com/gin-gonic/gin" ) type HTTPRequest struct { JSONRPC string `json:"jsonrpc" example:"2.0"` ID interface{} `json:"id,omitempty" swaggertype:"object"` Method string `json:"method" example:"tools/list"` Params map[string]interface{} `json:"params,omitempty"` } type HTTPResponse struct { JSONRPC string `json:"jsonrpc" example:"2.0"` ID interface{} `json:"id,omitempty" swaggertype:"object"` Result map[string]interface{} `json:"result,omitempty"` Error map[string]interface{} `json:"error,omitempty"` } type UploadGuideResponse struct { Message string `json:"message" example:"markdown guide uploaded"` Guide string `json:"guide" example:"my-guide.md"` Path string `json:"path" example:"docs/mcp-tools/my-guide.md"` } type UploadGuideErrorResponse struct { Error string `json:"error" example:"file must be a markdown (.md) file"` } const ( mdGuidesDir = "docs/mcp-tools" maxGuideSize = 64 * 1024 defaultDepth = 2 maxDepth = 5 ) // HTTPHandler godoc // @Summary MCP JSON-RPC endpoint // @Description MCP isteklerini JSON-RPC 2.0 formatinda kabul eder. // @Tags mcp // @Accept json // @Produce json // @Param request body HTTPRequest true "MCP JSON-RPC request" // @Success 200 {object} HTTPResponse // @Failure 400 {object} HTTPResponse // @Security BearerAuth // @Router /api/v1/mcp [post] func HTTPHandler() gin.HandlerFunc { return gin.WrapH(getMCPGoHTTPHandler()) } // StreamableHTTPGETHandler implements MCP Streamable HTTP GET. func StreamableHTTPGETHandler() gin.HandlerFunc { return gin.WrapH(getMCPGoHTTPHandler()) } // StreamableHTTPDELETEHandler godoc // @Summary MCP streamable DELETE endpoint // @Description Stateless MCP server icin session teardown desteklenmez, 405 doner. // @Tags mcp // @Produce json // @Success 405 {string} string "Method Not Allowed" // @Security BearerAuth // @Router /api/v1/mcp [delete] func StreamableHTTPDELETEHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Allow", "POST, GET") c.Status(http.StatusMethodNotAllowed) } } // UploadGuideHandler godoc // @Summary MCP markdown rehberi yukler // @Description `.md` dosyasini `docs/mcp-tools` altina kaydeder ve MCP tool'lari tarafindan okunabilir hale getirir. // @Tags mcp // @Accept mpfd // @Produce json // @Param file formData file true "Yuklenecek markdown dosyasi" // @Param overwrite formData boolean false "Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)" // @Success 200 {object} UploadGuideResponse // @Failure 400 {object} UploadGuideErrorResponse // @Failure 409 {object} UploadGuideErrorResponse // @Failure 500 {object} UploadGuideErrorResponse // @Security BearerAuth // @Router /api/v1/mcp/guides/upload [post] func UploadGuideHandler() gin.HandlerFunc { return func(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"}) return } name := strings.TrimSpace(file.Filename) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "dosya adi bos olamaz"}) return } cleanName := filepath.Base(name) if cleanName != name || strings.Contains(cleanName, "/") || strings.Contains(cleanName, "\\") { c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz dosya adi"}) return } if !strings.HasSuffix(strings.ToLower(cleanName), ".md") { c.JSON(http.StatusBadRequest, gin.H{"error": "yalnizca .md dosyasi yuklenebilir"}) return } src, err := file.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"}) return } defer src.Close() data, err := io.ReadAll(src) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"}) return } if len(data) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "bos dosya yuklenemez"}) return } if len(data) > maxGuideSize { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("dosya boyutu %d byte sinirini asamaz", maxGuideSize)}) return } if err := os.MkdirAll(mdGuidesDir, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber klasoru olusturulamadi"}) return } targetPath := filepath.Join(mdGuidesDir, cleanName) overwrite := strings.EqualFold(strings.TrimSpace(c.PostForm("overwrite")), "true") || strings.TrimSpace(c.PostForm("overwrite")) == "1" if !overwrite { if _, statErr := os.Stat(targetPath); statErr == nil { c.JSON(http.StatusConflict, gin.H{"error": "ayni isimde rehber zaten var, overwrite=true gonder"}) return } } if err := os.WriteFile(targetPath, data, 0o644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber kaydedilemedi"}) return } c.JSON(http.StatusOK, UploadGuideResponse{ Message: "markdown guide uploaded", Guide: cleanName, Path: toSlashPath(targetPath), }) // Yeni guide icin MCP tool'larini yeniden yukle go reloadMCPGoServer() } } func getToolStats(limit int) (string, error) { if configs.DB == nil { return "", fmt.Errorf("database is not available") } if limit <= 0 { limit = 10 } if limit > 50 { limit = 50 } type statRow struct { ToolName string TotalRuns int64 SuccessRuns int64 ErrorRuns int64 AvgDurationMs float64 } rows := make([]statRow, 0) err := configs.DB.Model(&mcpModels.ToolRun{}). Select(`tool_name, COUNT(*) as total_runs, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_runs, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_runs, AVG(duration_ms) as avg_duration_ms`). Group("tool_name"). Order("total_runs DESC"). Limit(limit). Scan(&rows).Error if err != nil { return "", fmt.Errorf("failed to query tool stats") } if len(rows) == 0 { return "No tool run records yet.", nil } var b strings.Builder b.WriteString("MCP tool stats\n") b.WriteString(fmt.Sprintf("Limit: %d\n\n", limit)) for _, row := range rows { b.WriteString(fmt.Sprintf("- %s: total=%d success=%d error=%d avg_ms=%.1f\n", row.ToolName, row.TotalRuns, row.SuccessRuns, row.ErrorRuns, row.AvgDurationMs, )) } return b.String(), nil } func listMDGuides() ([]string, error) { entries, err := os.ReadDir(mdGuidesDir) if err != nil { if os.IsNotExist(err) { return []string{}, nil } return nil, err } guides := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if strings.HasSuffix(strings.ToLower(name), ".md") { guides = append(guides, name) } } sort.Strings(guides) return guides, nil } func readMDGuide(guide string) (string, error) { name := strings.TrimSpace(guide) if name == "" { return "", fmt.Errorf("guide is required") } if strings.Contains(name, "/") || strings.Contains(name, "\\") { return "", fmt.Errorf("invalid guide name") } if !strings.HasSuffix(strings.ToLower(name), ".md") { return "", fmt.Errorf("guide must end with .md") } cleanName := filepath.Base(name) if cleanName != name { return "", fmt.Errorf("invalid guide name") } fullPath := filepath.Join(mdGuidesDir, cleanName) data, err := os.ReadFile(fullPath) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("guide not found") } return "", fmt.Errorf("unable to read guide") } if len(data) > maxGuideSize { return "", fmt.Errorf("guide is too large") } return string(data), nil } func buildCodebaseMap(focus string, depth int) (string, error) { cleanFocus, err := sanitizeFocus(focus) if err != nil { return "", err } if depth <= 0 { depth = defaultDepth } if depth > maxDepth { depth = maxDepth } basePath := "." headerFocus := "./" if cleanFocus != "" { basePath = cleanFocus headerFocus = cleanFocus } entries, err := os.ReadDir(basePath) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("focus not found") } return "", fmt.Errorf("unable to scan focus") } dirs := make([]string, 0) files := make([]string, 0) for _, entry := range entries { name := entry.Name() if strings.HasPrefix(name, ".") { continue } fullPath := filepath.Join(basePath, name) if entry.IsDir() { dirs = append(dirs, toSlashPath(fullPath)) continue } files = append(files, toSlashPath(fullPath)) } sort.Strings(dirs) sort.Strings(files) allFiles, err := collectFiles(basePath, depth) if err != nil { return "", fmt.Errorf("unable to map files") } keyFiles := pickKeyFiles(allFiles) moduleHints := buildModuleHints(allFiles) var b strings.Builder b.WriteString("Codebase map\n") b.WriteString(fmt.Sprintf("Focus: %s\n", headerFocus)) b.WriteString(fmt.Sprintf("Depth: %d\n\n", depth)) b.WriteString("Top directories:\n") if len(dirs) == 0 { b.WriteString("- (none)\n") } else { for _, d := range dirs { b.WriteString("- " + d + "\n") } } b.WriteString("\nTop files:\n") if len(files) == 0 { b.WriteString("- (none)\n") } else { limit := min(8, len(files)) for _, f := range files[:limit] { b.WriteString("- " + f + "\n") } if len(files) > limit { b.WriteString(fmt.Sprintf("- ... (%d more)\n", len(files)-limit)) } } b.WriteString("\nKey files:\n") if len(keyFiles) == 0 { b.WriteString("- (none)\n") } else { for _, f := range keyFiles { b.WriteString("- " + f + "\n") } } b.WriteString("\nModule hints:\n") if len(moduleHints) == 0 { b.WriteString("- (no module hints found)\n") } else { for _, hint := range moduleHints { b.WriteString("- " + hint + "\n") } } return b.String(), nil } func sanitizeFocus(focus string) (string, error) { f := strings.TrimSpace(strings.ReplaceAll(focus, "\\", "/")) if f == "" || f == "." || f == "./" { return "", nil } if strings.HasPrefix(f, "/") || strings.Contains(f, "..") { return "", fmt.Errorf("invalid focus") } clean := filepath.Clean(f) if clean == "." || clean == "" { return "", nil } return clean, nil } func collectFiles(basePath string, depth int) ([]string, error) { files := make([]string, 0) baseDepth := pathDepth(basePath) err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if path == basePath { return nil } name := d.Name() if strings.HasPrefix(name, ".") { if d.IsDir() { return fs.SkipDir } return nil } currentDepth := pathDepth(path) - baseDepth if currentDepth > depth { if d.IsDir() { return fs.SkipDir } return nil } if !d.IsDir() { files = append(files, toSlashPath(path)) } return nil }) if err != nil { return nil, err } sort.Strings(files) return files, nil } func pickKeyFiles(allFiles []string) []string { priority := []string{ "go.mod", "main.go", "routers/router.go", "app/mcp/server.go", "configs/db.go", "configs/redis.go", } chosen := make([]string, 0, 10) seen := make(map[string]bool) for _, p := range priority { for _, f := range allFiles { if f == p || strings.HasSuffix(f, "/"+p) { if !seen[f] { chosen = append(chosen, f) seen[f] = true } } } } for _, f := range allFiles { if strings.Contains(f, "/handlers/") && strings.HasSuffix(f, ".go") { if !seen[f] { chosen = append(chosen, f) seen[f] = true } } if len(chosen) >= 10 { break } } if len(chosen) > 10 { return chosen[:10] } return chosen } func buildModuleHints(allFiles []string) []string { modules := make(map[string]bool) for _, f := range allFiles { parts := strings.Split(f, "/") if len(parts) < 2 { continue } if parts[0] == "app" && len(parts) >= 2 { modules[parts[0]+"/"+parts[1]] = true } if parts[0] == "pkg" && len(parts) >= 2 { modules[parts[0]+"/"+parts[1]] = true } } if len(modules) == 0 { return []string{} } keys := make([]string, 0, len(modules)) for k := range modules { keys = append(keys, k) } sort.Strings(keys) return keys } func pathDepth(path string) int { clean := strings.Trim(toSlashPath(path), "/") if clean == "" { return 0 } return strings.Count(clean, "/") + 1 } func toSlashPath(path string) string { return strings.TrimPrefix(filepath.ToSlash(path), "./") } func min(a, b int) int { if a < b { return a } return b } func apiOverviewText() string { return strings.TrimSpace(` GinImage API (base: /api/v1) Public auth: - POST /auth/register - POST /auth/login - POST /auth/refresh Public blog: - GET /blogs - GET /blogs/categories - GET /blogs/categories/:slug - GET /blogs/tags - GET /blogs/tags/:slug - GET /blogs/:slug Protected (Bearer token gerekli): - GET /me - POST /images/process - GET /images - GET /images/:id Admin: - POST /users/:id/admin - POST /blogs - PUT /blogs/:id - DELETE /blogs/:id - POST /blogs/categories - PUT /blogs/categories/:id - DELETE /blogs/categories/:id - POST /blogs/tags - PUT /blogs/tags/:id - DELETE /blogs/tags/:id `) } func ensurePathPrefix(path string) string { if strings.HasPrefix(path, "/") { return path } return "/" + path } func RunFromEnv() error { return runMCPGoFromEnv() }