[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:
❌ 민감 정보 노출
❌ 무제한 리소스 사용
❌ 검증 없는 입력
❌ 하드코딩된 설정

참고 자료:

작성일: 2024-12-13