Go Implementation Guide¶
Building MCP Servers in Go¶
This guide covers implementation patterns, best practices, and examples for building robust MCP servers using Go.
Core Implementation Patterns¶
Server Implementation¶
// internal/server/handlers.go
package server
import (
"context"
"fmt"
"log"
"github.com/modelcontextprotocol/go-sdk/server"
"github.com/your-org/mcp-server-go/internal/tools"
)
func (s *Server) handleListTools(ctx context.Context) (*server.ListToolsResponse, error) {
toolList := s.toolRegistry.ListTools()
return &server.ListToolsResponse{
Tools: toolList,
}, nil
}
func (s *Server) handleCallTool(ctx context.Context, req *server.CallToolRequest) (*server.CallToolResponse, error) {
log.Printf("Calling tool: %s with arguments: %v", req.Params.Name, req.Params.Arguments)
content, err := s.toolRegistry.CallTool(ctx, req.Params.Name, req.Params.Arguments)
if err != nil {
return nil, fmt.Errorf("tool execution failed: %w", err)
}
return &server.CallToolResponse{
Content: content,
}, nil
}
func (s *Server) handleListResources(ctx context.Context) (*server.ListResourcesResponse, error) {
resources := s.resourceRegistry.ListResources()
return &server.ListResourcesResponse{
Resources: resources,
}, nil
}
func (s *Server) handleReadResource(ctx context.Context, req *server.ReadResourceRequest) (*server.ReadResourceResponse, error) {
content, err := s.resourceRegistry.ReadResource(ctx, req.Params.URI)
if err != nil {
return nil, fmt.Errorf("resource read failed: %w", err)
}
return &server.ReadResourceResponse{
Contents: content,
}, nil
}
Advanced Tool Implementation¶
// internal/tools/database.go
package tools
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/server"
_ "github.com/lib/pq" // PostgreSQL driver
)
type DatabaseTool struct {
db *sql.DB
}
func NewDatabaseTool(databaseURL string) (*DatabaseTool, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DatabaseTool{db: db}, nil
}
func (t *DatabaseTool) Name() string {
return "query_database"
}
func (t *DatabaseTool) Description() string {
return "Execute SQL queries against the database. Only SELECT statements are allowed."
}
func (t *DatabaseTool) InputSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "SQL query to execute (SELECT only)",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "Maximum number of rows to return",
"default": 100,
"minimum": 1,
"maximum": 1000,
},
},
"required": []string{"query"},
}
}
func (t *DatabaseTool) Execute(ctx context.Context, arguments map[string]interface{}) ([]server.Content, error) {
query, ok := arguments["query"].(string)
if !ok {
return nil, fmt.Errorf("query argument must be a string")
}
// Security: Only allow SELECT statements
if err := t.validateQuery(query); err != nil {
return nil, err
}
limit := 100
if l, ok := arguments["limit"].(float64); ok {
limit = int(l)
}
// Add LIMIT clause if not present
if !strings.Contains(strings.ToUpper(query), "LIMIT") {
query = fmt.Sprintf("%s LIMIT %d", query, limit)
}
rows, err := t.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
defer rows.Close()
results, err := t.scanRows(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan results: %w", err)
}
resultJSON, err := json.MarshalIndent(results, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal results: %w", err)
}
return []server.Content{
{
Type: "text",
Text: string(resultJSON),
},
}, nil
}
func (t *DatabaseTool) validateQuery(query string) error {
upperQuery := strings.ToUpper(strings.TrimSpace(query))
// Only allow SELECT statements
if !strings.HasPrefix(upperQuery, "SELECT") {
return fmt.Errorf("only SELECT statements are allowed")
}
// Block dangerous keywords
dangerousKeywords := []string{
"DROP", "DELETE", "INSERT", "UPDATE", "ALTER",
"CREATE", "TRUNCATE", "REPLACE", "MERGE",
}
for _, keyword := range dangerousKeywords {
if strings.Contains(upperQuery, keyword) {
return fmt.Errorf("query contains forbidden keyword: %s", keyword)
}
}
return nil
}
func (t *DatabaseTool) scanRows(rows *sql.Rows) ([]map[string]interface{}, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range columns {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
row := make(map[string]interface{})
for i, col := range columns {
row[col] = t.convertValue(values[i])
}
results = append(results, row)
}
return results, rows.Err()
}
func (t *DatabaseTool) convertValue(value interface{}) interface{} {
if value == nil {
return nil
}
switch v := value.(type) {
case []byte:
return string(v)
default:
return v
}
}
func (t *DatabaseTool) Close() error {
return t.db.Close()
}
HTTP Client Tool¶
// internal/tools/http.go
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/modelcontextprotocol/go-sdk/server"
)
type HTTPTool struct {
client *http.Client
}
func NewHTTPTool() *HTTPTool {
return &HTTPTool{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (t *HTTPTool) Name() string {
return "http_request"
}
func (t *HTTPTool) Description() string {
return "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE methods."
}
func (t *HTTPTool) InputSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"method": map[string]interface{}{
"type": "string",
"enum": []string{"GET", "POST", "PUT", "DELETE"},
"description": "HTTP method to use",
"default": "GET",
},
"url": map[string]interface{}{
"type": "string",
"description": "URL to make the request to",
"format": "uri",
},
"headers": map[string]interface{}{
"type": "object",
"description": "HTTP headers to include",
},
"body": map[string]interface{}{
"type": "string",
"description": "Request body (for POST/PUT requests)",
},
"timeout": map[string]interface{}{
"type": "integer",
"description": "Request timeout in seconds",
"default": 30,
"minimum": 1,
"maximum": 300,
},
},
"required": []string{"url"},
}
}
func (t *HTTPTool) Execute(ctx context.Context, arguments map[string]interface{}) ([]server.Content, error) {
reqURL, ok := arguments["url"].(string)
if !ok {
return nil, fmt.Errorf("url argument must be a string")
}
// Validate URL
if _, err := url.Parse(reqURL); err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
method := "GET"
if m, ok := arguments["method"].(string); ok {
method = m
}
// Set timeout
timeout := 30 * time.Second
if t, ok := arguments["timeout"].(float64); ok {
timeout = time.Duration(t) * time.Second
}
client := &http.Client{Timeout: timeout}
// Prepare request body
var body io.Reader
if b, ok := arguments["body"].(string); ok && b != "" {
body = bytes.NewReader([]byte(b))
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add headers
if headers, ok := arguments["headers"].(map[string]interface{}); ok {
for key, value := range headers {
if strValue, ok := value.(string); ok {
req.Header.Set(key, strValue)
}
}
}
// Set default content type for POST/PUT
if (method == "POST" || method == "PUT") && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
result := map[string]interface{}{
"status_code": resp.StatusCode,
"status": resp.Status,
"headers": resp.Header,
"body": string(responseBody),
}
resultJSON, err := json.MarshalIndent(result, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return []server.Content{
{
Type: "text",
Text: string(resultJSON),
},
}, nil
}
File System Tool¶
// internal/tools/filesystem.go
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/modelcontextprotocol/go-sdk/server"
)
type FileSystemTool struct {
allowedPaths []string
}
func NewFileSystemTool(allowedPaths []string) *FileSystemTool {
return &FileSystemTool{
allowedPaths: allowedPaths,
}
}
func (t *FileSystemTool) Name() string {
return "read_file"
}
func (t *FileSystemTool) Description() string {
return "Read the contents of a text file from the filesystem."
}
func (t *FileSystemTool) InputSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Path to the file to read",
},
},
"required": []string{"path"},
}
}
func (t *FileSystemTool) Execute(ctx context.Context, arguments map[string]interface{}) ([]server.Content, error) {
filePath, ok := arguments["path"].(string)
if !ok {
return nil, fmt.Errorf("path argument must be a string")
}
// Security: Validate file path
if err := t.validatePath(filePath); err != nil {
return nil, err
}
content, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", filePath)
}
return nil, fmt.Errorf("failed to read file: %w", err)
}
return []server.Content{
{
Type: "text",
Text: string(content),
},
}, nil
}
func (t *FileSystemTool) validatePath(filePath string) error {
// Prevent path traversal attacks
if strings.Contains(filePath, "..") {
return fmt.Errorf("path traversal not allowed")
}
// Check if path is within allowed directories
if len(t.allowedPaths) > 0 {
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
allowed := false
for _, allowedPath := range t.allowedPaths {
absAllowed, err := filepath.Abs(allowedPath)
if err != nil {
continue
}
if strings.HasPrefix(absPath, absAllowed) {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("path not in allowed directories: %s", filePath)
}
}
return nil
}
Resource Implementation¶
// internal/resources/config.go
package resources
import (
"context"
"encoding/json"
"fmt"
"github.com/modelcontextprotocol/go-sdk/server"
"github.com/your-org/mcp-server-go/internal/config"
)
type ConfigResource struct {
config *config.Config
}
func NewConfigResource(cfg *config.Config) *ConfigResource {
return &ConfigResource{config: cfg}
}
func (r *ConfigResource) ListResources() []server.Resource {
return []server.Resource{
{
URI: "config://server",
Name: "Server Configuration",
Description: "Current server configuration settings",
MimeType: "application/json",
},
{
URI: "config://database",
Name: "Database Configuration",
Description: "Database connection settings",
MimeType: "application/json",
},
}
}
func (r *ConfigResource) ReadResource(ctx context.Context, uri string) ([]server.ResourceContent, error) {
switch uri {
case "config://server":
return r.getServerConfig()
case "config://database":
return r.getDatabaseConfig()
default:
return nil, fmt.Errorf("unknown resource URI: %s", uri)
}
}
func (r *ConfigResource) getServerConfig() ([]server.ResourceContent, error) {
configData := map[string]interface{}{
"server_name": r.config.ServerName,
"version": r.config.Version,
"transport": r.config.Transport,
"http_addr": r.config.HTTPAddr,
"log_level": r.config.LogLevel,
"log_format": r.config.LogFormat,
}
jsonData, err := json.MarshalIndent(configData, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal server config: %w", err)
}
return []server.ResourceContent{
{
URI: "config://server",
MimeType: "application/json",
Text: string(jsonData),
},
}, nil
}
func (r *ConfigResource) getDatabaseConfig() ([]server.ResourceContent, error) {
// Don't expose sensitive information like passwords
configData := map[string]interface{}{
"max_conns": r.config.Database.MaxConns,
"max_idle": r.config.Database.MaxIdle,
"conn_timeout": r.config.Database.ConnTimeout,
"url_scheme": "postgresql", // Only show scheme, not full URL
}
jsonData, err := json.MarshalIndent(configData, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal database config: %w", err)
}
return []server.ResourceContent{
{
URI: "config://database",
MimeType: "application/json",
Text: string(jsonData),
},
}, nil
}
Error Handling Patterns¶
Custom Error Types¶
// internal/errors/errors.go
package errors
import "fmt"
type MCPError struct {
Code string
Message string
Err error
}
func (e *MCPError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *MCPError) Unwrap() error {
return e.Err
}
// Common error types
func NewValidationError(message string, err error) *MCPError {
return &MCPError{
Code: "VALIDATION_ERROR",
Message: message,
Err: err,
}
}
func NewExecutionError(message string, err error) *MCPError {
return &MCPError{
Code: "EXECUTION_ERROR",
Message: message,
Err: err,
}
}
func NewPermissionError(message string) *MCPError {
return &MCPError{
Code: "PERMISSION_ERROR",
Message: message,
}
}
Logging Implementation¶
// internal/logging/logger.go
package logging
import (
"context"
"log/slog"
"os"
)
type Logger struct {
*slog.Logger
}
func New(level string, format string) *Logger {
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: parseLevel(level),
}
switch format {
case "json":
handler = slog.NewJSONHandler(os.Stdout, opts)
default:
handler = slog.NewTextHandler(os.Stdout, opts)
}
return &Logger{
Logger: slog.New(handler),
}
}
func parseLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func (l *Logger) WithRequest(ctx context.Context, requestID string) *slog.Logger {
return l.With("request_id", requestID)
}
Testing Helpers¶
// internal/testutil/testutil.go
package testutil
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/your-org/mcp-server-go/internal/config"
"github.com/your-org/mcp-server-go/internal/server"
)
func NewTestServer(t *testing.T) *server.Server {
cfg := &config.Config{
ServerName: "test-server",
Version: "test",
Transport: "stdio",
LogLevel: "debug",
}
srv, err := server.New(cfg)
assert.NoError(t, err)
return srv
}
func NewTestContext() context.Context {
return context.Background()
}
type MockTool struct {
NameValue string
DescriptionValue string
SchemaValue map[string]interface{}
ExecuteFunc func(context.Context, map[string]interface{}) ([]server.Content, error)
}
func (m *MockTool) Name() string {
return m.NameValue
}
func (m *MockTool) Description() string {
return m.DescriptionValue
}
func (m *MockTool) InputSchema() map[string]interface{} {
return m.SchemaValue
}
func (m *MockTool) Execute(ctx context.Context, args map[string]interface{}) ([]server.Content, error) {
if m.ExecuteFunc != nil {
return m.ExecuteFunc(ctx, args)
}
return nil, nil
}
This implementation guide provides robust patterns for building production-ready MCP servers in Go with proper error handling, logging, and testing support.