304 lines
8.6 KiB
Go
304 lines
8.6 KiB
Go
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)
|
||
}
|