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