[MCP&A2A] 08. MCP 서버 개발 - k82022603/k82022603.github.io GitHub Wiki

[MCP&A2A] 08. MCP 서버 개발 #

Go 기반 MCP 서버 구현

MCP(Model Context Protocol) 서버는 AI 모델이 도구(Tools)와 데이터(Resources)에 접근할 수 있도록 하는 핵심 인프라입니다. 이 장에서는 프로덕션급 Go MCP 서버를 처음부터 구축하는 방법을 다룹니다.

왜 Go인가?

Go 선택 이유:
├── 성능: 5,000+ req/sec 처리 가능
├── 동시성: Goroutines으로 간단한 병렬 처리
├── 타입 안정성: 컴파일 타임 에러 검출
├── 단일 바이너리: 배포 간소화
├── 풍부한 생태계: HTTP, DB, JSON 라이브러리
└── 메모리 효율: 낮은 메모리 풋프린트

대안과 비교

언어 성능 개발 속도 생태계 타입 안전 적합성
Go ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ✅ 최적
Python ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⚠️ AI 로직용
TypeScript ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⚠️ Node.js
Rust ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⚠️ 러닝커브

프로젝트 구조

디렉토리 레이아웃

mcp-server/
├── cmd/
│   └── server/
│       └── main.go                 # 진입점
├── internal/
│   ├── auth/
│   │   ├── jwt.go                  # JWT 검증
│   │   └── jwt_test.go
│   ├── config/
│   │   └── config.go               # 설정 관리
│   ├── database/
│   │   ├── postgres.go             # DB 연결 풀
│   │   ├── documents.go            # 문서 CRUD
│   │   └── search.go               # 하이브리드 검색
│   ├── handlers/
│   │   ├── mcp.go                  # MCP 핸들러
│   │   ├── initialize.go           # 초기화
│   │   ├── tools.go                # 도구 목록/호출
│   │   └── resources.go            # 리소스 처리
│   ├── middleware/
│   │   ├── auth.go                 # 인증 미들웨어
│   │   ├── ratelimit.go            # Rate limiting
│   │   ├── logger.go               # 로깅
│   │   └── recovery.go             # Panic 복구
│   ├── protocol/
│   │   ├── types.go                # MCP 타입 정의
│   │   ├── request.go              # 요청 구조
│   │   ├── response.go             # 응답 구조
│   │   └── errors.go               # 에러 코드
│   └── tools/
│       ├── tool.go                 # Tool 인터페이스
│       ├── hybrid_search.go        # 하이브리드 검색 도구
│       ├── get_document.go         # 문서 조회 도구
│       └── list_documents.go       # 문서 목록 도구
├── pkg/
│   └── logging/
│       └── logger.go               # 구조화된 로거
├── configs/
│   ├── config.yaml                 # 기본 설정
│   └── config.prod.yaml            # 프로덕션 설정
├── scripts/
│   ├── migrate.sh                  # DB 마이그레이션
│   └── build.sh                    # 빌드 스크립트
├── go.mod
├── go.sum
├── Dockerfile
└── README.md

핵심 컴포넌트

1. MCP 프로토콜 타입 정의

// internal/protocol/types.go
package protocol

import "encoding/json"

// JSON-RPC 2.0 요청
type Request struct {
    JSONRPC string          `json:"jsonrpc"`
    ID      interface{}     `json:"id"`      // string 또는 number
    Method  string          `json:"method"`
    Params  json.RawMessage `json:"params,omitempty"`
}

// JSON-RPC 2.0 응답
type Response struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id"`
    Result  interface{} `json:"result,omitempty"`
    Error   *Error      `json:"error,omitempty"`
}

// JSON-RPC 2.0 에러
type Error struct {
    Code    int         `json:"code"`
    Message string      `json:"message"`
    Data    interface{} `json:"data,omitempty"`
}

// 표준 에러 코드
const (
    ParseError     = -32700  // JSON 파싱 실패
    InvalidRequest = -32600  // 잘못된 요청
    MethodNotFound = -32601  // 메서드 없음
    InvalidParams  = -32602  // 잘못된 파라미터
    InternalError  = -32603  // 내부 에러
)

// MCP 커스텀 에러 코드
const (
    AuthRequired      = -32001  // 인증 필요
    AuthorizationFail = -32002  // 권한 없음
    RateLimitExceeded = -32003  // Rate limit 초과
    ResourceNotFound  = -32004  // 리소스 없음
    ValidationError   = -32005  // 검증 실패
)

// 초기화 요청
type InitializeParams struct {
    ProtocolVersion string                 `json:"protocolVersion"`
    Capabilities    ClientCapabilities     `json:"capabilities"`
    ClientInfo      ClientInfo             `json:"clientInfo"`
}

type ClientCapabilities struct {
    Experimental map[string]interface{} `json:"experimental,omitempty"`
    Sampling     map[string]interface{} `json:"sampling,omitempty"`
}

type ClientInfo struct {
    Name    string `json:"name"`
    Version string `json:"version"`
}

// 초기화 응답
type InitializeResult struct {
    ProtocolVersion string             `json:"protocolVersion"`
    Capabilities    ServerCapabilities `json:"capabilities"`
    ServerInfo      ServerInfo         `json:"serverInfo"`
}

type ServerCapabilities struct {
    Tools     *ToolsCapability     `json:"tools,omitempty"`
    Resources *ResourcesCapability `json:"resources,omitempty"`
    Prompts   *PromptsCapability   `json:"prompts,omitempty"`
    Logging   map[string]interface{} `json:"logging,omitempty"`
}

type ToolsCapability struct {
    ListChanged bool `json:"listChanged,omitempty"`
}

type ResourcesCapability struct {
    Subscribe   bool `json:"subscribe,omitempty"`
    ListChanged bool `json:"listChanged,omitempty"`
}

type PromptsCapability struct {
    ListChanged bool `json:"listChanged,omitempty"`
}

type ServerInfo struct {
    Name    string `json:"name"`
    Version string `json:"version"`
}

// 도구 정의
type ToolDefinition struct {
    Name        string                 `json:"name"`
    Description string                 `json:"description"`
    InputSchema map[string]interface{} `json:"inputSchema"`
}

// 도구 호출 파라미터
type ToolCallParams struct {
    Name      string                 `json:"name"`
    Arguments map[string]interface{} `json:"arguments,omitempty"`
}

// 도구 호출 결과
type ToolCallResult struct {
    Content []ContentBlock `json:"content"`
    IsError bool           `json:"isError,omitempty"`
}

// 콘텐츠 블록
type ContentBlock struct {
    Type     string                 `json:"type"`  // "text", "image", "resource"
    Text     string                 `json:"text,omitempty"`
    Data     string                 `json:"data,omitempty"`
    MimeType string                 `json:"mimeType,omitempty"`
    URI      string                 `json:"uri,omitempty"`
    Metadata map[string]interface{} `json:"metadata,omitempty"`
}

// 리소스 정의
type Resource struct {
    URI         string                 `json:"uri"`
    Name        string                 `json:"name"`
    Description string                 `json:"description,omitempty"`
    MimeType    string                 `json:"mimeType,omitempty"`
    Metadata    map[string]interface{} `json:"metadata,omitempty"`
}

// 리소스 내용
type ResourceContents struct {
    URI      string         `json:"uri"`
    MimeType string         `json:"mimeType,omitempty"`
    Contents []ContentBlock `json:"contents"`
}

2. Tool 인터페이스

// internal/tools/tool.go
package tools

import (
    "context"
    "mcp-server/internal/protocol"
)

// Tool 인터페이스
type Tool interface {
    // 도구 정의 반환
    Definition() protocol.ToolDefinition
    
    // 도구 실행
    Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error)
    
    // 입력 검증 (선택사항)
    Validate(args map[string]interface{}) error
}

// 도구 레지스트리
type Registry struct {
    tools map[string]Tool
}

func NewRegistry() *Registry {
    return &Registry{
        tools: make(map[string]Tool),
    }
}

func (r *Registry) Register(tool Tool) {
    def := tool.Definition()
    r.tools[def.Name] = tool
}

func (r *Registry) Get(name string) (Tool, bool) {
    tool, ok := r.tools[name]
    return tool, ok
}

func (r *Registry) List() []protocol.ToolDefinition {
    var definitions []protocol.ToolDefinition
    for _, tool := range r.tools {
        definitions = append(definitions, tool.Definition())
    }
    return definitions
}

3. 하이브리드 검색 도구 구현

// internal/tools/hybrid_search.go
package tools

import (
    "context"
    "fmt"
    "mcp-server/internal/database"
    "mcp-server/internal/protocol"
)

type HybridSearchTool struct {
    db *database.DB
}

func NewHybridSearchTool(db *database.DB) *HybridSearchTool {
    return &HybridSearchTool{db: db}
}

func (t *HybridSearchTool) Definition() protocol.ToolDefinition {
    return protocol.ToolDefinition{
        Name:        "hybrid_search",
        Description: "BM25와 벡터 유사도를 결합한 하이브리드 검색",
        InputSchema: map[string]interface{}{
            "type": "object",
            "properties": map[string]interface{}{
                "query": map[string]interface{}{
                    "type":        "string",
                    "description": "검색 쿼리",
                },
                "limit": map[string]interface{}{
                    "type":        "integer",
                    "description": "결과 개수 (기본값: 10)",
                    "default":     10,
                    "minimum":     1,
                    "maximum":     100,
                },
                "bm25_weight": map[string]interface{}{
                    "type":        "number",
                    "description": "BM25 가중치 (기본값: 0.5)",
                    "default":     0.5,
                    "minimum":     0.0,
                    "maximum":     1.0,
                },
                "vector_weight": map[string]interface{}{
                    "type":        "number",
                    "description": "벡터 가중치 (기본값: 0.5)",
                    "default":     0.5,
                    "minimum":     0.0,
                    "maximum":     1.0,
                },
            },
            "required": []string{"query"},
        },
    }
}

func (t *HybridSearchTool) Validate(args map[string]interface{}) error {
    // query 필수
    query, ok := args["query"].(string)
    if !ok || query == "" {
        return fmt.Errorf("query는 비어있지 않은 문자열이어야 합니다")
    }
    
    // limit 범위 체크
    if limit, ok := args["limit"].(float64); ok {
        if limit < 1 || limit > 100 {
            return fmt.Errorf("limit은 1-100 사이여야 합니다")
        }
    }
    
    // 가중치 합 체크
    bm25Weight := 0.5
    vectorWeight := 0.5
    
    if w, ok := args["bm25_weight"].(float64); ok {
        bm25Weight = w
    }
    if w, ok := args["vector_weight"].(float64); ok {
        vectorWeight = w
    }
    
    if bm25Weight+vectorWeight <= 0 {
        return fmt.Errorf("가중치 합은 0보다 커야 합니다")
    }
    
    return nil
}

func (t *HybridSearchTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
    // 입력 검증
    if err := t.Validate(args); err != nil {
        return protocol.ToolCallResult{
            IsError: true,
            Content: []protocol.ContentBlock{
                {Type: "text", Text: fmt.Sprintf("검증 실패: %v", err)},
            },
        }, nil
    }
    
    // 파라미터 추출
    query := args["query"].(string)
    
    limit := 10
    if l, ok := args["limit"].(float64); ok {
        limit = int(l)
    }
    
    bm25Weight := 0.5
    if w, ok := args["bm25_weight"].(float64); ok {
        bm25Weight = w
    }
    
    vectorWeight := 0.5
    if w, ok := args["vector_weight"].(float64); ok {
        vectorWeight = w
    }
    
    // 컨텍스트에서 tenant_id 추출
    tenantID, ok := ctx.Value("tenant_id").(string)
    if !ok {
        return protocol.ToolCallResult{
            IsError: true,
            Content: []protocol.ContentBlock{
                {Type: "text", Text: "tenant_id를 찾을 수 없습니다"},
            },
        }, nil
    }
    
    // 하이브리드 검색 실행
    results, err := t.db.HybridSearch(ctx, database.SearchParams{
        TenantID:     tenantID,
        Query:        query,
        Limit:        limit,
        BM25Weight:   bm25Weight,
        VectorWeight: vectorWeight,
    })
    
    if err != nil {
        return protocol.ToolCallResult{
            IsError: true,
            Content: []protocol.ContentBlock{
                {Type: "text", Text: fmt.Sprintf("검색 실패: %v", err)},
            },
        }, nil
    }
    
    // 결과 포맷팅
    if len(results) == 0 {
        return protocol.ToolCallResult{
            Content: []protocol.ContentBlock{
                {Type: "text", Text: "검색 결과가 없습니다."},
            },
        }, nil
    }
    
    // 결과를 텍스트로 변환
    var text string
    text += fmt.Sprintf("검색 결과 (%d개):\n\n", len(results))
    
    for i, result := range results {
        text += fmt.Sprintf("%d. %s (점수: %.4f)\n", i+1, result.Title, result.Score)
        text += fmt.Sprintf("   내용: %s\n\n", truncate(result.Content, 200))
    }
    
    return protocol.ToolCallResult{
        Content: []protocol.ContentBlock{
            {
                Type: "text",
                Text: text,
                Metadata: map[string]interface{}{
                    "results_count": len(results),
                    "query":         query,
                },
            },
        },
    }, nil
}

func truncate(s string, maxLen int) string {
    if len(s) <= maxLen {
        return s
    }
    return s[:maxLen] + "..."
}

4. 문서 조회 도구

// internal/tools/get_document.go
package tools

import (
    "context"
    "fmt"
    "mcp-server/internal/database"
    "mcp-server/internal/protocol"
)

type GetDocumentTool struct {
    db *database.DB
}

func NewGetDocumentTool(db *database.DB) *GetDocumentTool {
    return &GetDocumentTool{db: db}
}

func (t *GetDocumentTool) Definition() protocol.ToolDefinition {
    return protocol.ToolDefinition{
        Name:        "get_document",
        Description: "ID로 특정 문서 조회",
        InputSchema: map[string]interface{}{
            "type": "object",
            "properties": map[string]interface{}{
                "document_id": map[string]interface{}{
                    "type":        "string",
                    "description": "문서 UUID",
                    "format":      "uuid",
                },
            },
            "required": []string{"document_id"},
        },
    }
}

func (t *GetDocumentTool) Validate(args map[string]interface{}) error {
    docID, ok := args["document_id"].(string)
    if !ok || docID == "" {
        return fmt.Errorf("document_id는 비어있지 않은 문자열이어야 합니다")
    }
    return nil
}

func (t *GetDocumentTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
    if err := t.Validate(args); err != nil {
        return protocol.ToolCallResult{
            IsError: true,
            Content: []protocol.ContentBlock{
                {Type: "text", Text: fmt.Sprintf("검증 실패: %v", err)},
            },
        }, nil
    }
    
    docID := args["document_id"].(string)
    tenantID := ctx.Value("tenant_id").(string)
    
    // 문서 조회
    doc, err := t.db.GetDocument(ctx, tenantID, docID)
    if err != nil {
        return protocol.ToolCallResult{
            IsError: true,
            Content: []protocol.ContentBlock{
                {Type: "text", Text: fmt.Sprintf("문서 조회 실패: %v", err)},
            },
        }, nil
    }
    
    // 결과 반환
    text := fmt.Sprintf("제목: %s\n\n%s", doc.Title, doc.Content)
    
    return protocol.ToolCallResult{
        Content: []protocol.ContentBlock{
            {
                Type: "text",
                Text: text,
                Metadata: map[string]interface{}{
                    "document_id": doc.ID,
                    "source":      doc.Source,
                    "created_at":  doc.CreatedAt,
                },
            },
        },
    }, nil
}

5. MCP 핸들러

// internal/handlers/mcp.go
package handlers

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    
    "mcp-server/internal/protocol"
    "mcp-server/internal/tools"
)

type MCPHandler struct {
    toolRegistry *tools.Registry
    serverInfo   protocol.ServerInfo
}

func NewMCPHandler(toolRegistry *tools.Registry) *MCPHandler {
    return &MCPHandler{
        toolRegistry: toolRegistry,
        serverInfo: protocol.ServerInfo{
            Name:    "MCP Server",
            Version: "1.0.0",
        },
    }
}

func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Content-Type 체크
    if r.Header.Get("Content-Type") != "application/json" {
        h.respondError(w, nil, protocol.InvalidRequest, "Content-Type must be application/json")
        return
    }
    
    // 요청 파싱
    var req protocol.Request
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.respondError(w, nil, protocol.ParseError, "Invalid JSON")
        return
    }
    
    // JSON-RPC 버전 체크
    if req.JSONRPC != "2.0" {
        h.respondError(w, req.ID, protocol.InvalidRequest, "jsonrpc must be 2.0")
        return
    }
    
    // 메서드 라우팅
    switch req.Method {
    case "initialize":
        h.handleInitialize(w, &req)
    case "tools/list":
        h.handleToolsList(w, &req)
    case "tools/call":
        h.handleToolsCall(w, r, &req)
    case "resources/list":
        h.handleResourcesList(w, &req)
    case "resources/read":
        h.handleResourcesRead(w, &req)
    default:
        h.respondError(w, req.ID, protocol.MethodNotFound, 
            fmt.Sprintf("Method not found: %s", req.Method))
    }
}

func (h *MCPHandler) handleInitialize(w http.ResponseWriter, req *protocol.Request) {
    var params protocol.InitializeParams
    if err := json.Unmarshal(req.Params, &params); err != nil {
        h.respondError(w, req.ID, protocol.InvalidParams, "Invalid params")
        return
    }
    
    result := protocol.InitializeResult{
        ProtocolVersion: "2024-11-05",
        Capabilities: protocol.ServerCapabilities{
            Tools: &protocol.ToolsCapability{
                ListChanged: false,
            },
            Resources: &protocol.ResourcesCapability{
                Subscribe:   false,
                ListChanged: false,
            },
        },
        ServerInfo: h.serverInfo,
    }
    
    h.respondSuccess(w, req.ID, result)
}

func (h *MCPHandler) handleToolsList(w http.ResponseWriter, req *protocol.Request) {
    tools := h.toolRegistry.List()
    
    result := map[string]interface{}{
        "tools": tools,
    }
    
    h.respondSuccess(w, req.ID, result)
}

func (h *MCPHandler) handleToolsCall(w http.ResponseWriter, r *http.Request, req *protocol.Request) {
    var params protocol.ToolCallParams
    if err := json.Unmarshal(req.Params, &params); err != nil {
        h.respondError(w, req.ID, protocol.InvalidParams, "Invalid params")
        return
    }
    
    // 도구 찾기
    tool, ok := h.toolRegistry.Get(params.Name)
    if !ok {
        h.respondError(w, req.ID, protocol.ResourceNotFound, 
            fmt.Sprintf("Tool not found: %s", params.Name))
        return
    }
    
    // 도구 실행
    result, err := tool.Execute(r.Context(), params.Arguments)
    if err != nil {
        h.respondError(w, req.ID, protocol.InternalError, err.Error())
        return
    }
    
    h.respondSuccess(w, req.ID, result)
}

func (h *MCPHandler) handleResourcesList(w http.ResponseWriter, req *protocol.Request) {
    // 리소스 목록 반환 (구현 예정)
    result := map[string]interface{}{
        "resources": []protocol.Resource{},
    }
    
    h.respondSuccess(w, req.ID, result)
}

func (h *MCPHandler) handleResourcesRead(w http.ResponseWriter, req *protocol.Request) {
    // 리소스 읽기 (구현 예정)
    h.respondError(w, req.ID, protocol.MethodNotFound, "Not implemented")
}

func (h *MCPHandler) respondSuccess(w http.ResponseWriter, id interface{}, result interface{}) {
    resp := protocol.Response{
        JSONRPC: "2.0",
        ID:      id,
        Result:  result,
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(resp)
}

func (h *MCPHandler) respondError(w http.ResponseWriter, id interface{}, code int, message string) {
    resp := protocol.Response{
        JSONRPC: "2.0",
        ID:      id,
        Error: &protocol.Error{
            Code:    code,
            Message: message,
        },
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)  // JSON-RPC는 항상 200 OK
    json.NewEncoder(w).Encode(resp)
}

6. 인증 미들웨어

// internal/middleware/auth.go
package middleware

import (
    "context"
    "net/http"
    "strings"
    
    "mcp-server/internal/auth"
)

func AuthMiddleware(validator *auth.JWTValidator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Authorization 헤더 추출
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, `{"error": "Missing authorization"}`, http.StatusUnauthorized)
                return
            }
            
            // Bearer 토큰 파싱
            parts := strings.Split(authHeader, " ")
            if len(parts) != 2 || parts[0] != "Bearer" {
                http.Error(w, `{"error": "Invalid authorization format"}`, http.StatusUnauthorized)
                return
            }
            
            tokenString := parts[1]
            
            // 토큰 검증
            claims, err := validator.ValidateToken(tokenString)
            if err != nil {
                http.Error(w, `{"error": "Invalid token"}`, http.StatusUnauthorized)
                return
            }
            
            // 컨텍스트에 클레임 추가
            ctx := r.Context()
            ctx = context.WithValue(ctx, "tenant_id", claims.TenantID)
            ctx = context.WithValue(ctx, "user_id", claims.UserID)
            ctx = context.WithValue(ctx, "roles", claims.Roles)
            
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

7. Rate Limiting 미들웨어

// internal/middleware/ratelimit.go
package middleware

import (
    "net/http"
    "sync"
    "time"
    
    "golang.org/x/time/rate"
)

type RateLimiter struct {
    limiters map[string]*rate.Limiter
    mu       sync.RWMutex
    rate     rate.Limit
    burst    int
}

func NewRateLimiter(reqPerSec int, burst int) *RateLimiter {
    return &RateLimiter{
        limiters: make(map[string]*rate.Limiter),
        rate:     rate.Limit(reqPerSec),
        burst:    burst,
    }
}

func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
    rl.mu.RLock()
    limiter, exists := rl.limiters[key]
    rl.mu.RUnlock()
    
    if !exists {
        rl.mu.Lock()
        limiter = rate.NewLimiter(rl.rate, rl.burst)
        rl.limiters[key] = limiter
        rl.mu.Unlock()
    }
    
    return limiter
}

func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // 테넌트별 rate limiting
            tenantID, ok := r.Context().Value("tenant_id").(string)
            if !ok {
                tenantID = "anonymous"
            }
            
            if !limiter.getLimiter(tenantID).Allow() {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusTooManyRequests)
                w.Write([]byte(`{
                    "jsonrpc": "2.0",
                    "error": {
                        "code": -32003,
                        "message": "Rate limit exceeded"
                    }
                }`))
                return
            }
            
            next.ServeHTTP(w, r)
        })
    }
}

8. 로깅 미들웨어

// internal/middleware/logger.go
package middleware

import (
    "log"
    "net/http"
    "time"
)

type responseWriter struct {
    http.ResponseWriter
    statusCode int
    bytes      int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func (rw *responseWriter) Write(b []byte) (int, error) {
    n, err := rw.ResponseWriter.Write(b)
    rw.bytes += n
    return n, err
}

func LoggerMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        rw := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK,
        }
        
        next.ServeHTTP(rw, r)
        
        duration := time.Since(start)
        
        log.Printf(
            "%s %s %d %d bytes %v",
            r.Method,
            r.URL.Path,
            rw.statusCode,
            rw.bytes,
            duration,
        )
    })
}

9. main.go - 전체 조합

// cmd/server/main.go
package main

import (
    "log"
    "net/http"
    "os"
    
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    
    "mcp-server/internal/auth"
    "mcp-server/internal/database"
    "mcp-server/internal/handlers"
    custommw "mcp-server/internal/middleware"
    "mcp-server/internal/tools"
)

func main() {
    // 환경 변수 로드
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        log.Fatal("DATABASE_URL is required")
    }
    
    jwtPublicKeyPath := os.Getenv("JWT_PUBLIC_KEY_PATH")
    if jwtPublicKeyPath == "" {
        log.Fatal("JWT_PUBLIC_KEY_PATH is required")
    }
    
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    // 데이터베이스 연결
    db, err := database.New(dbURL)
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }
    defer db.Close()
    
    log.Println("✅ Database connected")
    
    // JWT 검증기 초기화
    jwtValidator, err := auth.NewJWTValidator(jwtPublicKeyPath)
    if err != nil {
        log.Fatalf("Failed to create JWT validator: %v", err)
    }
    
    log.Println("✅ JWT validator initialized")
    
    // 도구 레지스트리 생성
    toolRegistry := tools.NewRegistry()
    
    // 도구 등록
    toolRegistry.Register(tools.NewHybridSearchTool(db))
    toolRegistry.Register(tools.NewGetDocumentTool(db))
    toolRegistry.Register(tools.NewListDocumentsTool(db))
    
    log.Printf("✅ Registered %d tools", len(toolRegistry.List()))
    
    // Rate limiter 생성 (100 req/sec, burst 200)
    rateLimiter := custommw.NewRateLimiter(100, 200)
    
    // 라우터 설정
    r := chi.NewRouter()
    
    // 미들웨어 체인
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(custommw.LoggerMiddleware)
    r.Use(middleware.Recoverer)
    r.Use(custommw.AuthMiddleware(jwtValidator))
    r.Use(custommw.RateLimitMiddleware(rateLimiter))
    
    // 핸들러 등록
    mcpHandler := handlers.NewMCPHandler(toolRegistry)
    r.Post("/mcp", mcpHandler.ServeHTTP)
    
    // 헬스 체크 (인증 불필요)
    r.Group(func(r chi.Router) {
        r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
            w.WriteHeader(http.StatusOK)
            w.Write([]byte(`{"status":"ok"}`))
        })
    })
    
    // 서버 시작
    addr := ":" + port
    log.Printf("🚀 MCP Server listening on %s", addr)
    
    if err := http.ListenAndServe(addr, r); err != nil {
        log.Fatalf("Server failed: %v", err)
    }
}

빌드 및 실행

go.mod

module mcp-server

go 1.22

require (
    github.com/go-chi/chi/v5 v5.0.11
    github.com/golang-jwt/jwt/v5 v5.2.0
    github.com/google/uuid v1.5.0
    github.com/jackc/pgx/v5 v5.5.1
    github.com/pgvector/pgvector-go v0.1.1
    golang.org/x/time v0.5.0
)

빌드

# 개발 빌드
go build -o bin/mcp-server cmd/server/main.go

# 프로덕션 빌드 (최적화)
CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o bin/mcp-server \
    cmd/server/main.go

# 실행
./bin/mcp-server

Docker

# Dockerfile
FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o mcp-server \
    cmd/server/main.go

FROM alpine:3.19

RUN apk --no-cache add ca-certificates

WORKDIR /app

COPY --from=builder /app/mcp-server .
COPY configs/ ./configs/

EXPOSE 8080

CMD ["./mcp-server"]
# 빌드
docker build -t mcp-server:latest .

# 실행
docker run -d \
    -p 8080:8080 \
    -e DATABASE_URL="postgres://..." \
    -e JWT_PUBLIC_KEY_PATH="/app/certs/public_key.pem" \
    -v $(pwd)/certs:/app/certs \
    mcp-server:latest

테스트

단위 테스트

// internal/tools/hybrid_search_test.go
package tools

import (
    "context"
    "testing"
)

func TestHybridSearchTool_Validate(t *testing.T) {
    tool := &HybridSearchTool{}
    
    tests := []struct {
        name    string
        args    map[string]interface{}
        wantErr bool
    }{
        {
            name: "valid args",
            args: map[string]interface{}{
                "query": "test query",
                "limit": float64(10),
            },
            wantErr: false,
        },
        {
            name: "missing query",
            args: map[string]interface{}{
                "limit": float64(10),
            },
            wantErr: true,
        },
        {
            name: "invalid limit",
            args: map[string]interface{}{
                "query": "test",
                "limit": float64(200),
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tool.Validate(tt.args)
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

통합 테스트

// cmd/server/integration_test.go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestMCPServer_Initialize(t *testing.T) {
    // 테스트 서버 설정
    // ...
    
    reqBody := map[string]interface{}{
        "jsonrpc": "2.0",
        "id":      1,
        "method":  "initialize",
        "params": map[string]interface{}{
            "protocolVersion": "2024-11-05",
            "capabilities":    map[string]interface{}{},
            "clientInfo": map[string]interface{}{
                "name":    "test-client",
                "version": "1.0.0",
            },
        },
    }
    
    body, _ := json.Marshal(reqBody)
    req := httptest.NewRequest("POST", "/mcp", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    
    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)
    
    if w.Code != http.StatusOK {
        t.Errorf("Expected status 200, got %d", w.Code)
    }
    
    var resp map[string]interface{}
    json.NewDecoder(w.Body).Decode(&resp)
    
    if resp["jsonrpc"] != "2.0" {
        t.Error("Invalid JSON-RPC version")
    }
}

핵심 요약

구조

  • ✅ 계층화: protocol → tools → handlers → main
  • ✅ 의존성 주입: 테스트 용이
  • ✅ 인터페이스: Tool 확장 가능

성능

  • ✅ 동시성: Goroutines으로 병렬 처리
  • ✅ 연결 풀: 데이터베이스 최적화
  • ✅ Rate limiting: 테넌트별 제한

보안

  • ✅ JWT 검증: RS256 비대칭 암호화
  • ✅ 컨텍스트 전파: tenant_id 격리
  • ✅ 입력 검증: 모든 파라미터 체크

작성일: 2024-12-13