first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:40:14 +03:00
commit e04ba85564
129 changed files with 17541 additions and 0 deletions

583
app/mcp/server.go Normal file
View File

@@ -0,0 +1,583 @@
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()
}