first commit
This commit is contained in:
337
app/mcp/README.md
Normal file
337
app/mcp/README.md
Normal 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
61
app/mcp/http_helpers.go
Normal 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)
|
||||
}
|
||||
17
app/mcp/models/tool_run.go
Normal file
17
app/mcp/models/tool_run.go
Normal 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
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()
|
||||
}
|
||||
303
app/mcp/server_mcpgo.go
Normal file
303
app/mcp/server_mcpgo.go
Normal 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)
|
||||
}
|
||||
128
app/mcp/server_mcpgo_test.go
Normal file
128
app/mcp/server_mcpgo_test.go
Normal 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
102
app/mcp/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user