first commit
This commit is contained in:
583
app/mcp/server.go
Normal file
583
app/mcp/server.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user