[MCP&A2A] 10. 도구 개발
[MCP&A2A] 10. 도구 개발
MCP Tool이란?
MCP Tool은 AI 에이전트가 실행할 수 있는 구조화된 함수입니다. JSON Schema로 입력을 정의하고, 실행 결과를 표준화된 형식으로 반환합니다.
Tool의 구성 요소
1
2
3
4
5
6
7
8
9
MCP Tool
├── Definition (정의)
│ ├── Name: 도구 이름 (고유 식별자)
│ ├── Description: 도구 기능 설명
│ └── InputSchema: JSON Schema 입력 정의
└── Execute (실행)
├── Input: 검증된 파라미터
├── Logic: 비즈니스 로직
└── Output: 표준화된 결과
Tool vs Function Call
| 특성 | MCP Tool | Function Call (OpenAI) |
|---|---|---|
| 표준화 | ✅ MCP 표준 | 각 모델마다 다름 |
| 스키마 | JSON Schema | JSON Schema |
| 실행 | 서버 사이드 | 클라이언트 결정 |
| 재사용 | ✅ 어떤 AI든 | OpenAI만 |
| 보안 | ✅ 서버 통제 | 클라이언트 신뢰 필요 |
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
// 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)
}
// 선택적: 입력 검증 인터페이스
type ValidatableTool interface {
Tool
Validate(args map[string]interface{}) error
}
// 선택적: 권한 체크 인터페이스
type AuthorizableTool interface {
Tool
RequiredPermissions() []string
CheckPermission(ctx context.Context, permission string) bool
}
Tool Registry
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
// internal/tools/registry.go
package tools
import (
"fmt"
"sync"
"mcp-server/internal/protocol"
)
type Registry struct {
tools map[string]Tool
mu sync.RWMutex
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
}
}
// 도구 등록
func (r *Registry) Register(tool Tool) error {
r.mu.Lock()
defer r.mu.Unlock()
def := tool.Definition()
// 중복 체크
if _, exists := r.tools[def.Name]; exists {
return fmt.Errorf("tool already registered: %s", def.Name)
}
r.tools[def.Name] = tool
return nil
}
// 도구 조회
func (r *Registry) Get(name string) (Tool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
tool, ok := r.tools[name]
if !ok {
return nil, fmt.Errorf("tool not found: %s", name)
}
return tool, nil
}
// 모든 도구 정의 목록
func (r *Registry) List() []protocol.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
var definitions []protocol.ToolDefinition
for _, tool := range r.tools {
definitions = append(definitions, tool.Definition())
}
return definitions
}
// 도구 개수
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.tools)
}
JSON Schema 작성
Schema 기본 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"type": "object",
"properties": {
"property_name": {
"type": "string",
"description": "설명",
"default": "기본값",
"enum": ["옵션1", "옵션2"],
"pattern": "정규식",
"minLength": 1,
"maxLength": 100
}
},
"required": ["필수_필드"]
}
주요 타입과 검증
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
// 문자열
map[string]interface{}{
"type": "string",
"description": "사용자 이름",
"minLength": 1,
"maxLength": 50,
"pattern": "^[a-zA-Z0-9_]+$",
}
// 정수
map[string]interface{}{
"type": "integer",
"description": "페이지 크기",
"default": 10,
"minimum": 1,
"maximum": 100,
}
// 숫자 (실수)
map[string]interface{}{
"type": "number",
"description": "가중치",
"default": 0.5,
"minimum": 0.0,
"maximum": 1.0,
}
// 불리언
map[string]interface{}{
"type": "boolean",
"description": "활성화 여부",
"default": true,
}
// 배열
map[string]interface{}{
"type": "array",
"description": "태그 목록",
"items": map[string]interface{}{
"type": "string",
},
"minItems": 1,
"maxItems": 10,
}
// 객체
map[string]interface{}{
"type": "object",
"description": "필터 조건",
"properties": map[string]interface{}{
"field": map[string]interface{}{
"type": "string",
},
"operator": map[string]interface{}{
"type": "string",
"enum": []string{"eq", "ne", "gt", "lt"},
},
},
"required": []string{"field", "operator"},
}
// Enum (선택지)
map[string]interface{}{
"type": "string",
"description": "정렬 순서",
"enum": []string{"asc", "desc"},
"default": "desc",
}
// OneOf (여러 타입 중 하나)
map[string]interface{}{
"oneOf": []map[string]interface{}{
{"type": "string"},
{"type": "integer"},
},
}
실전 도구 개발
1. 계산기 도구
간단한 수식 계산 도구:
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
// internal/tools/calculator.go
package tools
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"mcp-server/internal/protocol"
)
type CalculatorTool struct{}
func NewCalculatorTool() *CalculatorTool {
return &CalculatorTool{}
}
func (t *CalculatorTool) Definition() protocol.ToolDefinition {
return protocol.ToolDefinition{
Name: "calculator",
Description: "간단한 수학 계산기 (덧셈, 뺄셈, 곱셈, 나눗셈, 제곱)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"expression": map[string]interface{}{
"type": "string",
"description": "계산할 수식 (예: 2 + 3 * 4)",
},
},
"required": []string{"expression"},
},
}
}
func (t *CalculatorTool) Validate(args map[string]interface{}) error {
expr, ok := args["expression"].(string)
if !ok || expr == "" {
return fmt.Errorf("expression은 비어있지 않은 문자열이어야 합니다")
}
// 허용된 문자만 포함하는지 체크
allowed := "0123456789+-*/(). "
for _, char := range expr {
if !strings.ContainsRune(allowed, char) {
return fmt.Errorf("허용되지 않은 문자: %c", char)
}
}
return nil
}
func (t *CalculatorTool) 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
}
expr := args["expression"].(string)
// 수식 계산 (간단한 구현)
result, err := t.evaluate(expr)
if err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("계산 실패: %v", err)},
},
}, nil
}
return protocol.ToolCallResult{
Content: []protocol.ContentBlock{
{
Type: "text",
Text: fmt.Sprintf("%s = %v", expr, result),
Metadata: map[string]interface{}{
"expression": expr,
"result": result,
},
},
},
}, nil
}
func (t *CalculatorTool) evaluate(expr string) (float64, error) {
// 간단한 수식 평가기
// 실무에서는 github.com/Knetic/govaluate 같은 라이브러리 사용
expr = strings.TrimSpace(expr)
// 숫자만 있는 경우
if num, err := strconv.ParseFloat(expr, 64); err == nil {
return num, nil
}
// 간단한 연산 처리 (예시)
// 실제로는 파싱 트리 구성 필요
return 0, fmt.Errorf("복잡한 수식은 지원하지 않습니다")
}
2. 날씨 조회 도구
외부 API 호출 예제:
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
// internal/tools/weather.go
package tools
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"mcp-server/internal/protocol"
)
type WeatherTool struct {
apiKey string
httpClient *http.Client
}
func NewWeatherTool(apiKey string) *WeatherTool {
return &WeatherTool{
apiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (t *WeatherTool) Definition() protocol.ToolDefinition {
return protocol.ToolDefinition{
Name: "get_weather",
Description: "특정 도시의 현재 날씨 정보 조회",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"city": map[string]interface{}{
"type": "string",
"description": "도시 이름 (예: Seoul, Tokyo, New York)",
},
"units": map[string]interface{}{
"type": "string",
"description": "온도 단위",
"enum": []string{"metric", "imperial"},
"default": "metric",
},
},
"required": []string{"city"},
},
}
}
func (t *WeatherTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
city := args["city"].(string)
units := "metric"
if u, ok := args["units"].(string); ok {
units = u
}
// OpenWeatherMap API 호출
weatherData, err := t.fetchWeather(ctx, city, units)
if err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("날씨 조회 실패: %v", err)},
},
}, nil
}
// 결과 포맷팅
temp := weatherData.Main.Temp
tempUnit := "°C"
if units == "imperial" {
tempUnit = "°F"
}
text := fmt.Sprintf(
"🌍 %s 날씨\n"+
"🌡️ 온도: %.1f%s\n"+
"💧 습도: %d%%\n"+
"☁️ 상태: %s\n"+
"💨 풍속: %.1f m/s",
city,
temp,
tempUnit,
weatherData.Main.Humidity,
weatherData.Weather[0].Description,
weatherData.Wind.Speed,
)
return protocol.ToolCallResult{
Content: []protocol.ContentBlock{
{
Type: "text",
Text: text,
Metadata: map[string]interface{}{
"city": city,
"temperature": temp,
"units": units,
},
},
},
}, nil
}
type WeatherResponse struct {
Main struct {
Temp float64 `json:"temp"`
Humidity int `json:"humidity"`
} `json:"main"`
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
Wind struct {
Speed float64 `json:"speed"`
} `json:"wind"`
}
func (t *WeatherTool) fetchWeather(ctx context.Context, city, units string) (*WeatherResponse, error) {
baseURL := "https://api.openweathermap.org/data/2.5/weather"
params := url.Values{}
params.Set("q", city)
params.Set("appid", t.apiKey)
params.Set("units", units)
req, err := http.NewRequestWithContext(
ctx,
"GET",
baseURL+"?"+params.Encode(),
nil,
)
if err != nil {
return nil, err
}
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error: status %d", resp.StatusCode)
}
var weatherData WeatherResponse
if err := json.NewDecoder(resp.Body).Decode(&weatherData); err != nil {
return nil, err
}
return &weatherData, nil
}
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
185
186
187
188
189
190
// internal/tools/database_query.go
package tools
import (
"context"
"fmt"
"strings"
"mcp-server/internal/database"
"mcp-server/internal/protocol"
)
type DatabaseQueryTool struct {
db *database.DB
}
func NewDatabaseQueryTool(db *database.DB) *DatabaseQueryTool {
return &DatabaseQueryTool{db: db}
}
func (t *DatabaseQueryTool) Definition() protocol.ToolDefinition {
return protocol.ToolDefinition{
Name: "query_documents",
Description: "고급 필터링으로 문서 검색",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"filters": map[string]interface{}{
"type": "array",
"description": "필터 조건 목록",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"field": map[string]interface{}{
"type": "string",
"enum": []string{"title", "source", "created_at"},
},
"operator": map[string]interface{}{
"type": "string",
"enum": []string{"eq", "ne", "like", "gt", "lt"},
},
"value": map[string]interface{}{
"description": "비교 값",
},
},
"required": []string{"field", "operator", "value"},
},
},
"sort_by": map[string]interface{}{
"type": "string",
"enum": []string{"created_at", "updated_at", "title"},
"default": "created_at",
},
"sort_order": map[string]interface{}{
"type": "string",
"enum": []string{"asc", "desc"},
"default": "desc",
},
"limit": map[string]interface{}{
"type": "integer",
"default": 10,
"minimum": 1,
"maximum": 100,
},
},
},
}
}
func (t *DatabaseQueryTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
tenantID := ctx.Value("tenant_id").(string)
// 필터 파싱
var filters []Filter
if f, ok := args["filters"].([]interface{}); ok {
for _, filter := range f {
fm := filter.(map[string]interface{})
filters = append(filters, Filter{
Field: fm["field"].(string),
Operator: fm["operator"].(string),
Value: fm["value"],
})
}
}
// 정렬 파라미터
sortBy := "created_at"
if s, ok := args["sort_by"].(string); ok {
sortBy = s
}
sortOrder := "desc"
if s, ok := args["sort_order"].(string); ok {
sortOrder = s
}
limit := 10
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
// 쿼리 생성
query, queryArgs := t.buildQuery(tenantID, filters, sortBy, sortOrder, limit)
// 쿼리 실행
results, err := t.db.ExecuteQuery(ctx, query, queryArgs...)
if err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("쿼리 실패: %v", err)},
},
}, nil
}
// 결과 포맷팅
var text strings.Builder
text.WriteString(fmt.Sprintf("검색 결과 (%d개):\n\n", len(results)))
for i, result := range results {
text.WriteString(fmt.Sprintf("%d. %s\n", i+1, result.Title))
text.WriteString(fmt.Sprintf(" 생성: %s\n", result.CreatedAt.Format("2006-01-02")))
if result.Source != "" {
text.WriteString(fmt.Sprintf(" 출처: %s\n", result.Source))
}
text.WriteString("\n")
}
return protocol.ToolCallResult{
Content: []protocol.ContentBlock{
{
Type: "text",
Text: text.String(),
Metadata: map[string]interface{}{
"count": len(results),
"filters": filters,
},
},
},
}, nil
}
type Filter struct {
Field string
Operator string
Value interface{}
}
func (t *DatabaseQueryTool) buildQuery(
tenantID string,
filters []Filter,
sortBy, sortOrder string,
limit int,
) (string, []interface{}) {
query := "SELECT id, title, content, source, created_at FROM documents WHERE tenant_id = $1"
args := []interface{}{tenantID}
argIndex := 2
// 필터 추가
for _, filter := range filters {
var condition string
switch filter.Operator {
case "eq":
condition = fmt.Sprintf("%s = $%d", filter.Field, argIndex)
case "ne":
condition = fmt.Sprintf("%s != $%d", filter.Field, argIndex)
case "like":
condition = fmt.Sprintf("%s LIKE $%d", filter.Field, argIndex)
filter.Value = "%" + filter.Value.(string) + "%"
case "gt":
condition = fmt.Sprintf("%s > $%d", filter.Field, argIndex)
case "lt":
condition = fmt.Sprintf("%s < $%d", filter.Field, argIndex)
}
query += " AND " + condition
args = append(args, filter.Value)
argIndex++
}
// 정렬
query += fmt.Sprintf(" ORDER BY %s %s", sortBy, strings.ToUpper(sortOrder))
// 제한
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, limit)
return query, args
}
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
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
// internal/tools/file_upload.go
package tools
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"mcp-server/internal/database"
"mcp-server/internal/protocol"
)
type FileUploadTool struct {
db *database.DB
uploadDir string
maxFileSize int64 // bytes
}
func NewFileUploadTool(db *database.DB, uploadDir string, maxFileSizeMB int) *FileUploadTool {
return &FileUploadTool{
db: db,
uploadDir: uploadDir,
maxFileSize: int64(maxFileSizeMB) * 1024 * 1024,
}
}
func (t *FileUploadTool) Definition() protocol.ToolDefinition {
return protocol.ToolDefinition{
Name: "upload_document",
Description: "텍스트 파일을 업로드하고 문서로 저장",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"filename": map[string]interface{}{
"type": "string",
"description": "파일 이름",
},
"content": map[string]interface{}{
"type": "string",
"description": "파일 내용 (base64 인코딩)",
},
"title": map[string]interface{}{
"type": "string",
"description": "문서 제목",
},
},
"required": []string{"filename", "content", "title"},
},
}
}
func (t *FileUploadTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
filename := args["filename"].(string)
content := args["content"].(string)
title := args["title"].(string)
tenantID := ctx.Value("tenant_id").(string)
// 파일 크기 체크
if int64(len(content)) > t.maxFileSize {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("파일 크기가 제한(%dMB)을 초과합니다", t.maxFileSize/1024/1024)},
},
}, nil
}
// 파일 저장
fileID := uuid.New().String()
filePath := filepath.Join(t.uploadDir, tenantID, fileID+"_"+filename)
// 디렉토리 생성
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("디렉토리 생성 실패: %v", err)},
},
}, nil
}
// 파일 쓰기
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("파일 저장 실패: %v", err)},
},
}, nil
}
// DB에 문서 생성
doc := &database.Document{
TenantID: tenantID,
Title: title,
Content: content,
Source: filePath,
}
if err := t.db.InsertDocument(ctx, doc); err != nil {
// 파일 삭제
os.Remove(filePath)
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("문서 저장 실패: %v", err)},
},
}, nil
}
return protocol.ToolCallResult{
Content: []protocol.ContentBlock{
{
Type: "text",
Text: fmt.Sprintf("✅ 파일 업로드 완료\n문서 ID: %s\n파일: %s", doc.ID, filename),
Metadata: map[string]interface{}{
"document_id": doc.ID,
"filename": filename,
"size": len(content),
},
},
},
}, nil
}
5. 이메일 전송 도구
SMTP 통합:
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
// internal/tools/email.go
package tools
import (
"context"
"fmt"
"net/smtp"
"strings"
"mcp-server/internal/protocol"
)
type EmailTool struct {
smtpHost string
smtpPort string
username string
password string
fromAddr string
}
func NewEmailTool(smtpHost, smtpPort, username, password, fromAddr string) *EmailTool {
return &EmailTool{
smtpHost: smtpHost,
smtpPort: smtpPort,
username: username,
password: password,
fromAddr: fromAddr,
}
}
func (t *EmailTool) Definition() protocol.ToolDefinition {
return protocol.ToolDefinition{
Name: "send_email",
Description: "이메일 전송",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"to": map[string]interface{}{
"type": "string",
"description": "수신자 이메일",
"format": "email",
},
"subject": map[string]interface{}{
"type": "string",
"description": "제목",
},
"body": map[string]interface{}{
"type": "string",
"description": "본문",
},
"cc": map[string]interface{}{
"type": "array",
"description": "참조 수신자",
"items": map[string]interface{}{
"type": "string",
"format": "email",
},
},
},
"required": []string{"to", "subject", "body"},
},
}
}
func (t *EmailTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
to := args["to"].(string)
subject := args["subject"].(string)
body := args["body"].(string)
var cc []string
if ccList, ok := args["cc"].([]interface{}); ok {
for _, addr := range ccList {
cc = append(cc, addr.(string))
}
}
// 이메일 메시지 구성
msg := t.buildMessage(to, cc, subject, body)
// SMTP 인증
auth := smtp.PlainAuth("", t.username, t.password, t.smtpHost)
// 수신자 목록
recipients := append([]string{to}, cc...)
// 이메일 전송
addr := t.smtpHost + ":" + t.smtpPort
err := smtp.SendMail(addr, auth, t.fromAddr, recipients, []byte(msg))
if err != nil {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: fmt.Sprintf("이메일 전송 실패: %v", err)},
},
}, nil
}
return protocol.ToolCallResult{
Content: []protocol.ContentBlock{
{
Type: "text",
Text: fmt.Sprintf("✅ 이메일 전송 완료\n수신: %s\n제목: %s", to, subject),
Metadata: map[string]interface{}{
"to": to,
"cc": cc,
"subject": subject,
},
},
},
}, nil
}
func (t *EmailTool) buildMessage(to string, cc []string, subject, body string) string {
var msg strings.Builder
msg.WriteString("From: " + t.fromAddr + "\r\n")
msg.WriteString("To: " + to + "\r\n")
if len(cc) > 0 {
msg.WriteString("Cc: " + strings.Join(cc, ",") + "\r\n")
}
msg.WriteString("Subject: " + subject + "\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
return msg.String()
}
도구 테스트
단위 테스트
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
// internal/tools/calculator_test.go
package tools
import (
"context"
"testing"
)
func TestCalculatorTool_Definition(t *testing.T) {
tool := NewCalculatorTool()
def := tool.Definition()
if def.Name != "calculator" {
t.Errorf("Expected name 'calculator', got '%s'", def.Name)
}
if def.InputSchema == nil {
t.Error("InputSchema should not be nil")
}
}
func TestCalculatorTool_Validate(t *testing.T) {
tool := NewCalculatorTool()
tests := []struct {
name string
args map[string]interface{}
wantErr bool
}{
{
name: "valid expression",
args: map[string]interface{}{
"expression": "2 + 3",
},
wantErr: false,
},
{
name: "empty expression",
args: map[string]interface{}{
"expression": "",
},
wantErr: true,
},
{
name: "invalid character",
args: map[string]interface{}{
"expression": "2 + 3; DROP TABLE users",
},
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)
}
})
}
}
func TestCalculatorTool_Execute(t *testing.T) {
tool := NewCalculatorTool()
ctx := context.Background()
args := map[string]interface{}{
"expression": "5",
}
result, err := tool.Execute(ctx, args)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result.IsError {
t.Error("Expected successful result")
}
if len(result.Content) == 0 {
t.Error("Expected non-empty content")
}
}
통합 테스트
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
// internal/tools/integration_test.go
package tools
import (
"context"
"testing"
"mcp-server/internal/database"
)
func TestHybridSearchTool_Integration(t *testing.T) {
// 테스트 DB 설정
db := setupTestDB(t)
defer db.Close()
// 테스트 데이터 삽입
tenantID := "test-tenant-123"
doc := &database.Document{
TenantID: tenantID,
Title: "Test Document",
Content: "Machine learning is awesome",
}
err := db.InsertDocument(context.Background(), doc)
if err != nil {
t.Fatalf("Failed to insert document: %v", err)
}
// 도구 생성
tool := NewHybridSearchTool(db)
// 컨텍스트 설정
ctx := context.WithValue(context.Background(), "tenant_id", tenantID)
// 검색 실행
args := map[string]interface{}{
"query": "machine learning",
"limit": float64(10),
}
result, err := tool.Execute(ctx, args)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result.IsError {
t.Errorf("Expected successful result, got error: %v", result.Content[0].Text)
}
// 결과 검증
if len(result.Content) == 0 {
t.Error("Expected non-empty results")
}
text := result.Content[0].Text
if !strings.Contains(text, "Test Document") {
t.Errorf("Expected to find 'Test Document' in results, got: %s", text)
}
}
도구 등록 및 사용
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
// cmd/server/main.go
func main() {
// ... DB 초기화 ...
// 도구 레지스트리 생성
toolRegistry := tools.NewRegistry()
// 기본 도구 등록
toolRegistry.Register(tools.NewHybridSearchTool(db))
toolRegistry.Register(tools.NewGetDocumentTool(db))
toolRegistry.Register(tools.NewListDocumentsTool(db))
// 추가 도구 등록
toolRegistry.Register(tools.NewCalculatorTool())
// 조건부 등록 (API 키 있을 때만)
if weatherAPIKey := os.Getenv("WEATHER_API_KEY"); weatherAPIKey != "" {
toolRegistry.Register(tools.NewWeatherTool(weatherAPIKey))
log.Println("✅ Weather tool registered")
}
// 이메일 도구 (SMTP 설정 있을 때만)
if smtpHost := os.Getenv("SMTP_HOST"); smtpHost != "" {
emailTool := tools.NewEmailTool(
smtpHost,
os.Getenv("SMTP_PORT"),
os.Getenv("SMTP_USERNAME"),
os.Getenv("SMTP_PASSWORD"),
os.Getenv("FROM_EMAIL"),
)
toolRegistry.Register(emailTool)
log.Println("✅ Email tool registered")
}
log.Printf("✅ Registered %d tools", toolRegistry.Count())
// 핸들러 생성
mcpHandler := handlers.NewMCPHandler(toolRegistry)
// ... 서버 시작 ...
}
보안 고려사항
1. 입력 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 항상 입력을 검증하세요
func (t *Tool) Validate(args map[string]interface{}) error {
// SQL 인젝션 방지
if strings.ContainsAny(input, "';\"") {
return fmt.Errorf("invalid characters")
}
// 경로 탐색 방지
if strings.Contains(filepath, "..") {
return fmt.Errorf("path traversal not allowed")
}
// 최대 크기 제한
if len(data) > MAX_SIZE {
return fmt.Errorf("data too large")
}
return nil
}
2. 권한 체크
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type AuthorizableTool struct {
requiredRole string
}
func (t *AuthorizableTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
// 사용자 역할 확인
userRole := ctx.Value("user_role").(string)
if userRole != t.requiredRole {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: "권한이 없습니다"},
},
}, nil
}
// 실제 로직 실행
// ...
}
3. Rate Limiting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type RateLimitedTool struct {
limiter *rate.Limiter
}
func (t *RateLimitedTool) Execute(ctx context.Context, args map[string]interface{}) (protocol.ToolCallResult, error) {
// Rate limit 체크
if !t.limiter.Allow() {
return protocol.ToolCallResult{
IsError: true,
Content: []protocol.ContentBlock{
{Type: "text", Text: "요청 한도 초과"},
},
}, nil
}
// 실행
// ...
}
핵심 요약
Tool 개발 체크리스트
- ✅ Definition: 명확한 이름, 설명, JSON Schema
- ✅ Validate: 모든 입력 검증
- ✅ Execute: 에러 처리, 결과 포맷팅
- ✅ Test: 단위 + 통합 테스트
- ✅ Security: 권한, Rate limit, 입력 검증
- ✅ Documentation: 사용 예제
JSON Schema 필수 요소
- ✅ type: 데이터 타입
- ✅ description: 명확한 설명
- ✅ required: 필수 필드
- ✅ default: 기본값 (선택사항)
- ✅ enum: 허용 값 목록 (선택사항)
- ✅ validation: min/max, pattern 등
실무 팁
1
2
3
4
5
6
7
8
9
10
11
DO:
✅ 명확한 에러 메시지
✅ 메타데이터 포함
✅ 타임아웃 설정
✅ 구조화된 로깅
DON'T:
❌ 민감 정보 노출
❌ 무제한 리소스 사용
❌ 검증 없는 입력
❌ 하드코딩된 설정
참고 자료:
- JSON Schema: https://json-schema.org/
- MCP Spec: https://spec.modelcontextprotocol.io/
- Go Testing: https://go.dev/doc/tutorial/add-a-test
작성일: 2024-12-13
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.