[MCP&A2A] 10. 도구 개발 - k82022603/k82022603.github.io GitHub Wiki
[MCP&A2A] 10. 도구 개발 #
MCP Tool이란?
MCP Tool은 AI 에이전트가 실행할 수 있는 구조화된 함수입니다. JSON Schema로 입력을 정의하고, 실행 결과를 표준화된 형식으로 반환합니다.
Tool의 구성 요소
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 인터페이스 설계
기본 인터페이스
// 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
// 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 기본 구조
{
"type": "object",
"properties": {
"property_name": {
"type": "string",
"description": "설명",
"default": "기본값",
"enum": ["옵션1", "옵션2"],
"pattern": "정규식",
"minLength": 1,
"maxLength": 100
}
},
"required": ["필수_필드"]
}
주요 타입과 검증
// 문자열
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. 계산기 도구
간단한 수식 계산 도구:
// 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 호출 예제:
// 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. 데이터베이스 쿼리 도구
동적 쿼리 생성:
// 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. 파일 업로드 도구
멀티파트 업로드 처리:
// 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 통합:
// 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()
}
도구 테스트
단위 테스트
// 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")
}
}
통합 테스트
// 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에서 등록
// 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. 입력 검증
// 항상 입력을 검증하세요
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. 권한 체크
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
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 등
실무 팁
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