[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, ¶ms); 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, ¶ms); 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