포스트

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

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

Go 기반 MCP 서버 구현

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

왜 Go인가?

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

대안과 비교

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

프로젝트 구조

디렉토리 레이아웃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 프로토콜 타입 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// 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 인터페이스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 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. 하이브리드 검색 도구 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// 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. 문서 조회 도구

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 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 핸들러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// 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. 인증 미들웨어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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 미들웨어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 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. 로깅 미들웨어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 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 - 전체 조합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// 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

1
2
3
4
5
6
7
8
9
10
11
12
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
)

빌드

1
2
3
4
5
6
7
8
9
10
11
# 개발 빌드
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 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"]
1
2
3
4
5
6
7
8
9
10
# 빌드
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

테스트

단위 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 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)
            }
        })
    }
}

통합 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.