[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, ¶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. 인증 미들웨어
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 라이센스를 따릅니다.