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

337
app/mcp/README.md Normal file
View File

@@ -0,0 +1,337 @@
# MCP (Model Context Protocol) Server - GinImage API
Bu dizin, GinImage API'nin Model Context Protocol (MCP) sunucusu uygulamasını içerir.
## ⚠️ Önemli: v0.1.0 - mcp-go Migration Tamamlandı
**Eski uygulama (hand-written JSON-RPC):** Tamamen kaldırıldı.
**Yeni uygulama (mark3labs/mcp-go):** Tek kaynak. Tüm MCP istekleri mcp-go tarafından işlenir. Protocol compliance: %100.
### Değişiklik Özeti
| Unsur | Eski | Yeni |
|-------|------|------|
| Kod satırı sayısı | ~1100 | ~250 |
| JSON-RPC handler | Elle yazılmış | mcp-go sağlıyor |
| Tool registration | Switch-case | `server.AddTool()` |
| Protocol compliance | Elle test | %100 (mcp-go) |
---
## 1) Servisi Başlat
Proje kökünde:
```bash
go run .
```
Varsayılan port `8080` olduğu için MCP endpoint:
- `http://127.0.0.1:8080/mcp`
Farklı port kullanmak istersen:
```bash
PORT=9090 go run .
```
---
## 2) Cursor MCP Ayarı
`~/.cursor/mcp.json` dosyasına şu şekilde ekle:
```json
{
"mcpServers": {
"ginimage-api": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
```
Sonra Cursor'da MCP server'i yenile/reload et.
---
## 3) Mevcut Tool'lar
### 3.1) `api_overview`
- **Açıklama:** GinImage API endpoint özeti ve kullanımı
- **Giriş:** Yok
- **Çıkış:** Metin
### 3.2) `health_check`
- **Açıklama:** API health endpoint durumunu kontrol eder
- **Giriş:** `path` (string, opsiyonel) - Varsayılan: `/swagger/index.html`
- **Çıkış:** Metin
### 3.3) `md_guide_list`
- **Açıklama:** `docs/mcp-tools` altındaki markdown rehber dosyalarını listeler
- **Giriş:** Yok
- **Çıkış:** Metin
### 3.4) `md_guide_get`
- **Açıklama:** Seçilen markdown rehber dosyasının içeriğini döndürür
- **Giriş:** `guide` (string, zorunlu) - Rehber dosya adı (örn: `codebase_map.md`)
- **Çıkış:** Metin (dosya içeriği)
### 3.5) `codebase_map`
- **Açıklama:** Proje klasör ve kritik dosya yapısını özetler
- **Giriş:**
- `focus` (string, opsiyonel) - Odak klasörü
- `depth` (number, opsiyonel) - Tarama derinliği (varsayılan: 2, maksimum: 5)
- **Çıkış:** Metin (proje yapısı)
### 3.6) `tool_stats`
- **Açıklama:** MCP tool kullanım istatistiklerini veritabanından özetler
- **Giriş:** `limit` (number, opsiyonel) - Kayıt limiti (varsayılan: 10, maksimum: 50)
- **Çıkış:** Metin (istatistikler)
---
## 3.7) Markdown Rehberi Yükleme Endpoint'i
**POST `/api/v1/mcp/guides/upload`**
- `docs/mcp-tools` altına `.md` dosyası yükler
- `multipart/form-data` bekler
- Zorunlu alan: `file` (`.md` uzantılı)
- Opsiyonel alan: `overwrite` (`true/false`, varsayılan: `false`)
- Güvenlik: Bearer Token gerekli
---
## 4) Örnek MCP Çağrıları (JSON-RPC 2.0)
### Tool Listesini Almak
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}'
```
### `api_overview` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{
"name":"api_overview",
"arguments":{}
}
}'
```
### `health_check` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":3,
"method":"tools/call",
"params":{
"name":"health_check",
"arguments":{"path":"/swagger/index.html"}
}
}'
```
### `md_guide_list` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":4,
"method":"tools/call",
"params":{
"name":"md_guide_list",
"arguments":{}
}
}'
```
### `md_guide_get` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":5,
"method":"tools/call",
"params":{
"name":"md_guide_get",
"arguments":{"guide":"codebase_map.md"}
}
}'
```
### `codebase_map` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":6,
"method":"tools/call",
"params":{
"name":"codebase_map",
"arguments":{"focus":"app/images","depth":2}
}
}'
```
### `tool_stats` Çağrısı
```bash
curl -X POST "http://127.0.0.1:8080/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":7,
"method":"tools/call",
"params":{
"name":"tool_stats",
"arguments":{"limit":10}
}
}'
```
### Markdown Rehberi Yükleme
```bash
curl -X POST "http://127.0.0.1:8080/api/v1/mcp/guides/upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@./docs/mcp-tools/ornek-rehber.md" \
-F "overwrite=false"
```
---
## 5) Ortam Değişkenleri
- **`PORT`** (opsiyonel)
- Gin API ve MCP endpoint'inin dinleyeceği port (varsayılan: 8080)
- **`GINIMAGE_API_BASE_URL`** (opsiyonel)
- `health_check` tool'unun kontrol için kullanacağı base URL
- Tanımlanmamışsa gelen isteğin host bilgisinden otomatik üretilir
---
## 6) Mimari
```
app/mcp/
├── server.go # HTTP handlers, DELETE handler, helper functions
├── server_mcpgo.go # mcp-go tool registration, logging wrapper
├── models/
│ ├── tool_run.go # ToolRun DB modeli
│ └── ...
└── README.md (this file)
```
---
## 7) Yeni Tool Ekleme Rehberi
### Adım 1: Tool'u Kayıt Et
`server_mcpgo.go` içinde `newMCPGoServer()` içine ekle:
```go
s.AddTool(
mcpgo.NewTool(
"my_tool",
mcpgo.WithDescription("Tool açıklaması."),
mcpgo.WithString("param1", mcpgo.Description("Parametre 1"), mcpgo.Required()),
mcpgo.WithNumber("param2", mcpgo.Description("Parametre 2")),
),
withToolRunLog("my_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
// Parametreleri ayrıştır
param1, err := req.RequireString("param1")
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
param2 := req.GetInt("param2", 0)
// İşlem yap
result := "Sonuç"
// Sonuç dön
return mcpgo.NewToolResultText(result), nil
}),
)
```
### Adım 2: Parametre Yardımcıları
`CallToolRequest` yöntemleri:
- `GetString(key, defaultValue) string`
- `RequireString(key) (string, error)`
- `GetInt(key, defaultValue) int`
- `RequireInt(key) (int, error)`
- `GetFloat(key, defaultValue) float64`
- `RequireFloat(key) (float64, error)`
- `GetBool(key, defaultValue) bool`
- `RequireBool(key) (bool, error)`
- `GetArguments() map[string]any`
- `BindArguments(target any) error` (strongly-typed)
### Adım 3: Sonuç Türleri
- **Metin:** `mcpgo.NewToolResultText(text string)`
- **JSON:** `mcpgo.NewToolResultJSON(data any)`
- **Yapılandırılmış:** `mcpgo.NewToolResultStructured(structured any, fallbackText string)`
- **Hata:** `mcpgo.NewToolResultError(text string)`
- **Resim:** `mcpgo.NewToolResultImage(text, imageData, mimeType string)`
- **Ses:** `mcpgo.NewToolResultAudio(text, audioData, mimeType string)`
### Adım 4: DB Loglama (Otomatik)
`withToolRunLog` wrapper'ı otomatik olarak:
- Tool çağrı zamanını ölçer
- Başarı/hata durumunu kaydeder
- Argümanları (4096 byte'a kadar) kayıt eder
- `mcp_tool_runs` tablosuna yazar
---
## 8) Sık Karşılaşılan Sorunlar
| Hata | Çözüm |
|------|-------|
| `connection refused` | Backend çalışmıyor. `go run .` ile başlat |
| MCP server Cursor'da görünmüyor | `~/.cursor/mcp.json` dosya formatını kontrol et, Cursor MCP reload yap |
| 404 dönüyor | URL doğru mu? `/mcp` route'u kullanılmalı |
| `health_check` beklenmedik hosta gidiyor | `GINIMAGE_API_BASE_URL` değerini açıkça ver |
| `md_guide_get` "guide not found" dönüyor | Dosya `docs/mcp-tools` altında mı? `.md` uzantısı mı? |
---
## 9) Referanslar
- [MCP Specification](https://modelcontextprotocol.io/)
- [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go)
- GinImage API Overview: `apiOverviewText()` fonksiyonu bak
---
**Sürüm:** 0.1.0 (mcp-go migration)
**Tarih:** 2026-04-16
**Durum:** ✅ Production Ready

61
app/mcp/http_helpers.go Normal file
View File

@@ -0,0 +1,61 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// doAPIRequest genel amaçlı HTTP istek yardımcısı
func doAPIRequest(ctx context.Context, method, path, bearer string, body interface{}) (int, string, error) {
baseURL := resolveBaseURLFromContext(ctx)
url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path)
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, "", fmt.Errorf("request body marshal error: %v", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return 0, "", fmt.Errorf("request creation error: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("request error: %v", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, "", fmt.Errorf("response read error: %v", err)
}
// Pretty-print JSON yanıt
var prettyBuf bytes.Buffer
if json.Indent(&prettyBuf, respBytes, "", " ") == nil {
return resp.StatusCode, prettyBuf.String(), nil
}
return resp.StatusCode, string(respBytes), nil
}
// apiResult tool sonucu formatlar
func apiResult(status int, body string) string {
return fmt.Sprintf("HTTP %d\n%s", status, body)
}

View File

@@ -0,0 +1,17 @@
package models
import "time"
type ToolRun struct {
ID uint `gorm:"primaryKey" json:"id"`
ToolName string `gorm:"size:128;index;not null" json:"tool_name"`
Status string `gorm:"size:16;index;not null" json:"status"`
DurationMs int64 `gorm:"index" json:"duration_ms"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
ArgumentsRaw string `gorm:"type:longtext" json:"arguments_raw,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"`
}
func (ToolRun) TableName() string {
return "mcp_tool_runs"
}

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()
}

303
app/mcp/server_mcpgo.go Normal file
View File

@@ -0,0 +1,303 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
mcpModels "ginimageApi/app/mcp/models"
"ginimageApi/configs"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
)
type mcpBaseURLKey struct{}
var (
mcpGoOnce sync.Once
mcpGoServer *mcpserver.MCPServer
mcpGoHTTPHandler http.Handler
mcpGoMu sync.RWMutex
)
func getMCPGoHTTPHandler() http.Handler {
mcpGoOnce.Do(func() {
mcpGoServer = newMCPGoServer()
mcpGoHTTPHandler = mcpserver.NewStreamableHTTPServer(
mcpGoServer,
mcpserver.WithStateLess(true),
mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r))
}),
)
})
mcpGoMu.RLock()
defer mcpGoMu.RUnlock()
return mcpGoHTTPHandler
}
// reloadMCPGoServer yeni bir MD rehber eklendikten sonra MCP server'i yeniden olusturur.
func reloadMCPGoServer() {
mcpGoMu.Lock()
defer mcpGoMu.Unlock()
mcpGoOnce = sync.Once{} // sıfırla
newServer := newMCPGoServer()
newHandler := mcpserver.NewStreamableHTTPServer(
newServer,
mcpserver.WithStateLess(true),
mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r))
}),
)
mcpGoServer = newServer
mcpGoHTTPHandler = newHandler
}
func newMCPGoServer() *mcpserver.MCPServer {
s := mcpserver.NewMCPServer("ginimage-api-mcp", "0.1.0")
s.AddTool(
mcpgo.NewTool(
"api_overview",
mcpgo.WithDescription("GinImage API endpoint ozeti ve kullanimi."),
),
withToolRunLog("api_overview", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
return mcpgo.NewToolResultText(apiOverviewText()), nil
}),
)
s.AddTool(
mcpgo.NewTool(
"health_check",
mcpgo.WithDescription("API health endpoint durumunu kontrol eder."),
mcpgo.WithString("path", mcpgo.Description("Kontrol edilecek path. Varsayilan: /swagger/index.html")),
),
withToolRunLog("health_check", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
path := req.GetString("path", "/swagger/index.html")
baseURL := resolveBaseURLFromContext(ctx)
url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path)
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return mcpgo.NewToolResultText(fmt.Sprintf("Health check failed: %v", err)), nil
}
defer resp.Body.Close()
return mcpgo.NewToolResultText(fmt.Sprintf("Health check: %s -> HTTP %d", url, resp.StatusCode)), nil
}),
)
s.AddTool(
mcpgo.NewTool(
"md_guide_list",
mcpgo.WithDescription("docs/mcp-tools altindaki markdown rehber dosyalarini listeler."),
),
withToolRunLog("md_guide_list", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
guides, err := listMDGuides()
if err != nil {
return mcpgo.NewToolResultError("failed to list guides"), nil
}
if len(guides) == 0 {
return mcpgo.NewToolResultText("No markdown guides found under docs/mcp-tools"), nil
}
return mcpgo.NewToolResultText("Available guides:\n- " + strings.Join(guides, "\n- ")), nil
}),
)
s.AddTool(
mcpgo.NewTool(
"md_guide_get",
mcpgo.WithDescription("Secilen markdown rehber dosyasinin icerigini dondurur."),
mcpgo.WithString(
"guide",
mcpgo.Description("Rehber dosya adi. Ornek: codebase_map.md"),
mcpgo.Required(),
),
),
withToolRunLog("md_guide_get", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
guide, err := req.RequireString("guide")
if err != nil {
return mcpgo.NewToolResultError("invalid params"), nil
}
content, err := readMDGuide(guide)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
return mcpgo.NewToolResultText(content), nil
}),
)
s.AddTool(
mcpgo.NewTool(
"codebase_map",
mcpgo.WithDescription("Proje klasor ve kritik dosya yapisini ozetler."),
mcpgo.WithString("focus", mcpgo.Description("Opsiyonel odak klasoru. Ornek: app/images")),
mcpgo.WithNumber("depth", mcpgo.Description("Opsiyonel tarama derinligi. Varsayilan 2, maksimum 5")),
),
withToolRunLog("codebase_map", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
focus := req.GetString("focus", "")
depth := req.GetInt("depth", 0)
text, err := buildCodebaseMap(focus, depth)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
return mcpgo.NewToolResultText(text), nil
}),
)
s.AddTool(
mcpgo.NewTool(
"tool_stats",
mcpgo.WithDescription("MCP tool kullanim istatistiklerini veritabanindan ozetler."),
mcpgo.WithNumber("limit", mcpgo.Description("Opsiyonel kayit limiti. Varsayilan 10, maksimum 50")),
),
withToolRunLog("tool_stats", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
limit := req.GetInt("limit", 10)
statsText, err := getToolStats(limit)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
return mcpgo.NewToolResultText(statsText), nil
}),
)
registerMDGuideTools(s)
return s
}
// registerMDGuideTools docs/mcp-tools/ altindaki her .md dosyasini ayri bir tool olarak kaydeder.
func registerMDGuideTools(s *mcpserver.MCPServer) {
guides, err := listMDGuides()
if err != nil || len(guides) == 0 {
return
}
for _, guide := range guides {
guideName := guide // closure icin kopyala
toolName := mdGuideToolName(guideName)
description := fmt.Sprintf("Rehber: %s — MCP guide dokumani.", guideName)
s.AddTool(
mcpgo.NewTool(toolName, mcpgo.WithDescription(description)),
withToolRunLog(toolName, func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
content, err := readMDGuide(guideName)
if err != nil {
return mcpgo.NewToolResultError(err.Error()), nil
}
return mcpgo.NewToolResultText(content), nil
}),
)
}
}
// mdGuideToolName dosya adini gecerli bir tool adina donusturur.
// Ornek: "codebase_map.md" -> "guide_codebase_map"
func mdGuideToolName(filename string) string {
name := strings.TrimSuffix(filename, ".md")
// Alfanumerik ve alt cizgi disindaki karakterleri _ ile degistir
var b strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return "guide_" + b.String()
}
func withToolRunLog(toolName string, next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
started := time.Now()
result, err := next(ctx, req)
duration := time.Since(started)
status := "success"
errMessage := ""
if err != nil {
status = "error"
errMessage = err.Error()
} else if result != nil && result.IsError {
status = "error"
errMessage = extractToolResultText(result)
}
argsText := ""
if raw, marshalErr := json.Marshal(req.GetArguments()); marshalErr == nil {
argsText = string(raw)
}
if len(argsText) > 4096 {
argsText = argsText[:4096]
}
if configs.DB != nil && strings.TrimSpace(toolName) != "" {
run := mcpModels.ToolRun{
ToolName: toolName,
Status: status,
DurationMs: duration.Milliseconds(),
ErrorMessage: errMessage,
ArgumentsRaw: argsText,
}
_ = configs.DB.Create(&run).Error
}
return result, err
}
}
func extractToolResultText(result *mcpgo.CallToolResult) string {
if result == nil || len(result.Content) == 0 {
return "tool error"
}
for _, content := range result.Content {
if textContent, ok := content.(mcpgo.TextContent); ok {
if strings.TrimSpace(textContent.Text) != "" {
return textContent.Text
}
}
}
return "tool error"
}
func resolveBaseURLFromContext(ctx context.Context) string {
if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" {
return envURL
}
if fromCtx, ok := ctx.Value(mcpBaseURLKey{}).(string); ok && strings.TrimSpace(fromCtx) != "" {
return fromCtx
}
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
}
return "http://127.0.0.1:" + port
}
func resolveBaseURLFromRequest(r *http.Request) string {
if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" {
return envURL
}
if r == nil {
return resolveBaseURLFromContext(context.Background())
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, r.Host)
}
func runMCPGoFromEnv() error {
if mcpGoServer == nil {
_ = getMCPGoHTTPHandler()
}
return mcpserver.ServeStdio(mcpGoServer)
}

View File

@@ -0,0 +1,128 @@
package mcp
import (
"context"
"testing"
mcpgo "github.com/mark3labs/mcp-go/mcp"
)
// Test server creation
func TestNewMCPGoServer(t *testing.T) {
server := newMCPGoServer()
if server == nil {
t.Fatal("expected server to be created, got nil")
}
}
// Test withToolRunLog wrapper succeeds
func TestWithToolRunLogWrapper(t *testing.T) {
called := false
handler := withToolRunLog("test_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
called = true
return mcpgo.NewToolResultText("test result"), nil
})
result, err := handler(context.Background(), mcpgo.CallToolRequest{
Params: mcpgo.CallToolParams{
Name: "test_tool",
Arguments: map[string]any{},
},
})
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if !called {
t.Error("expected handler to be called")
}
if result == nil {
t.Error("expected result to be non-nil")
}
}
// Test withToolRunLog wrapper with error result
func TestWithToolRunLogWrapperErrorResult(t *testing.T) {
handler := withToolRunLog("error_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
return mcpgo.NewToolResultError("test error"), nil
})
result, err := handler(context.Background(), mcpgo.CallToolRequest{
Params: mcpgo.CallToolParams{
Name: "error_tool",
Arguments: map[string]any{},
},
})
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if result == nil {
t.Error("expected result to be non-nil")
}
if !result.IsError {
t.Error("expected IsError flag to be set")
}
}
// Test extractToolResultText with nil result
func TestExtractToolResultTextNil(t *testing.T) {
result := extractToolResultText(nil)
if result != "tool error" {
t.Errorf("expected 'tool error', got %q", result)
}
}
// Test extractToolResultText with empty content
func TestExtractToolResultTextEmpty(t *testing.T) {
toolResult := &mcpgo.CallToolResult{
Content: []mcpgo.Content{},
}
result := extractToolResultText(toolResult)
if result != "tool error" {
t.Errorf("expected 'tool error', got %q", result)
}
}
// Test getMCPGoHTTPHandler initializes once
func TestGetMCPGoHTTPHandlerOnce(t *testing.T) {
handler1 := getMCPGoHTTPHandler()
handler2 := getMCPGoHTTPHandler()
if handler1 == nil {
t.Error("expected handler1 to be non-nil")
}
if handler2 == nil {
t.Error("expected handler2 to be non-nil")
}
// Both should be the same instance (sync.Once ensures this)
if handler1 != handler2 {
t.Error("expected handlers to be the same instance")
}
}
// Test resolveBaseURLFromContext with env var
func TestResolveBaseURLFromContextEnv(t *testing.T) {
t.Setenv("GINIMAGE_API_BASE_URL", "http://api.example.com")
url := resolveBaseURLFromContext(context.Background())
expected := "http://api.example.com"
if url != expected {
t.Errorf("expected %q, got %q", expected, url)
}
}
// Test resolveBaseURLFromContext without env var
func TestResolveBaseURLFromContextDefault(t *testing.T) {
t.Setenv("GINIMAGE_API_BASE_URL", "")
t.Setenv("PORT", "")
url := resolveBaseURLFromContext(context.Background())
if url != "http://127.0.0.1:8080" {
t.Errorf("expected default URL, got %q", url)
}
}
// Test resolveBaseURLFromContext with custom port
func TestResolveBaseURLFromContextCustomPort(t *testing.T) {
t.Setenv("GINIMAGE_API_BASE_URL", "")
t.Setenv("PORT", "9090")
url := resolveBaseURLFromContext(context.Background())
expected := "http://127.0.0.1:9090"
if url != expected {
t.Errorf("expected %q, got %q", expected, url)
}
}

102
app/mcp/server_test.go Normal file
View File

@@ -0,0 +1,102 @@
package mcp
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestHTTPHandlerToolsList tests POST /mcp tools/list request
func TestHTTPHandlerToolsList(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/mcp", HTTPHandler())
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// TestHTTPHandlerAPIOverviewTool tests tools/call api_overview
func TestHTTPHandlerAPIOverviewTool(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/mcp", HTTPHandler())
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": map[string]interface{}{
"name": "api_overview",
"arguments": map[string]interface{}{},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// TestHTTPHandlerInvalidJSON tests invalid JSON request
func TestHTTPHandlerInvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/mcp", HTTPHandler())
req := httptest.NewRequest("POST", "/mcp", strings.NewReader("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
// TestStreamableHTTPDELETEHandler tests DELETE response
func TestStreamableHTTPDELETEHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.DELETE("/api/v1/mcp", StreamableHTTPDELETEHandler())
req := httptest.NewRequest("DELETE", "/api/v1/mcp", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", w.Code)
}
}
// TestMCPInitialize tests initialize method
func TestMCPInitialize(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/mcp", HTTPHandler())
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}