Skip to content

Client Basics

Learn the fundamentals of creating and managing MCP clients, including lifecycle management, initialization, and error handling.

Creating Clients

MCP-Go provides client constructors for each supported transport. The choice of transport determines how your client communicates with the server.

Client Constructor Patterns

// STDIO client - for command-line tools
client, err := client.NewStdioMCPClient("command", "arg1", "arg2")
 
// StreamableHTTP client - for web services
client := client.NewStreamableHttpClient("http://localhost:8080/mcp")
 
// SSE client - for real-time web applications
client := client.NewSSEMCPClient("http://localhost:8080/mcp/sse")
 
// In-process client - for testing and embedded scenarios
client := client.NewInProcessClient(server)

STDIO Client Creation

package main
 
import (
    "context"
    "errors"
    "fmt"
    "log"
    "math"
    "net/http"
    "sync"
    "time"
 
    "github.com/mark3labs/mcp-go/client"
    "github.com/mark3labs/mcp-go/mcp"
)
 
func createStdioClient() (client.Client, error) {
    // Create client that spawns a subprocess
    c, err := client.NewStdioMCPClient(
        "go", []string{}, "run", "/path/to/server/main.go",
    )
    if err != nil {
        return nil, fmt.Errorf("failed to create STDIO client: %w", err)
    }
 
    return c, nil
}
 
// With custom environment variables
func createStdioClientWithEnv() (client.Client, error) {
    env := []string{
        "LOG_LEVEL=debug",
        "DATABASE_URL=sqlite://test.db",
    }
    c, err := client.NewStdioMCPClient(
        "go", env, "run", "/path/to/server/main.go",
    )
    if err != nil {
        return nil, fmt.Errorf("failed to create STDIO client: %w", err)
    }
 
    return c, nil
}

StreamableHTTP Client Creation

func createStreamableHTTPClient() client.Client {
    // Basic StreamableHTTP client
	httpTransport, err := transport.NewStreamableHTTP(server.URL,
		// Set timeout
		transport.WithHTTPTimeout(30*time.Second),
		// Set custom headers
		transport.WithHTTPHeaders(map[string]string{
			"X-Custom-Header": "custom-value",
			"Y-Another-Header": "another-value",
		}),
		// With custom HTTP client
		transport.WithHTTPBasicClient(&http.Client{}),
	)
    if err != nil {
        log.Fatalf("Failed to create StreamableHTTP transport: %v", err)
    }
    c := client.NewClient(httpTransport)
    return c
}

SSE Client Creation

func createSSEClient() client.Client {
    // Basic SSE client
	c, err := NewSSEMCPClient(testServer.URL+"/sse",
		// Set custom headers
		WithHeaders(map[string]string{
			"X-Custom-Header": "custom-value",
			"Y-Another-Header": "another-value",
		}),
	)
    return c
}

Client Lifecycle

Understanding the client lifecycle is crucial for proper resource management and error handling.

Lifecycle Stages

  1. Creation - Instantiate the client
  2. Initialization - Establish connection and exchange capabilities
  3. Operation - Use tools, resources, and prompts
  4. Cleanup - Close connections and free resources

Complete Lifecycle Example

func demonstrateClientLifecycle() error {
    // 1. Creation
    c, err := client.NewSSEMCPClient("server-command")
    if err != nil {
        return fmt.Errorf("client creation failed: %w", err)
    }
 
    // Ensure cleanup happens
    defer func() {
        if closeErr := c.Close(); closeErr != nil {
            log.Printf("Error closing client: %v", closeErr)
        }
    }()
 
    ctx := context.Background()
 
    // 2. Initialization
    if err := c.Initialize(ctx); err != nil {
        return fmt.Errorf("client initialization failed: %w", err)
    }
 
    // 3. Operation
    if err := performClientOperations(ctx, c); err != nil {
        return fmt.Errorf("client operations failed: %w", err)
    }
 
    // 4. Cleanup (handled by defer)
    return nil
}
 
func performClientOperations(ctx context.Context, c client.Client) error {
    // List available tools
    tools, err := c.ListTools(ctx)
    if err != nil {
        return err
    }
 
    log.Printf("Found %d tools", len(tools.Tools))
 
    // Use the tools
    for _, tool := range tools.Tools {
        result, err := c.CallTool(ctx, mcp.CallToolRequest{
            Params: mcp.CallToolRequestParams{
                Name:      tool.Name,
                Arguments: map[string]interface{}{
                    "input": "example input",
                    "format": "json",
                },
            },
        })
        if err != nil {
            log.Printf("Tool %s failed: %v", tool.Name, err)
            continue
        }
 
        log.Printf("Tool %s result: %+v", tool.Name, result)
    }
 
    return nil
}

Initialization Process

The initialization process establishes the MCP connection and exchanges capabilities:

func initializeClientWithDetails(ctx context.Context, c client.Client) error {
    // Initialize with custom client info
    initReq := mcp.InitializeRequest{
        Params: mcp.InitializeRequestParams{
            ProtocolVersion: "2024-11-05",
            Capabilities: mcp.ClientCapabilities{
                Tools:     &mcp.ToolsCapability{},
                Resources: &mcp.ResourcesCapability{},
                Prompts:   &mcp.PromptsCapability{},
            },
            ClientInfo: mcp.ClientInfo{
                Name:    "My Application",
                Version: "1.0.0",
            },
        },
    }
 
    result, err := c.InitializeWithRequest(ctx, initReq)
    if err != nil {
        return fmt.Errorf("initialization failed: %w", err)
    }
 
    log.Printf("Connected to server: %s v%s", 
        result.ServerInfo.Name, 
        result.ServerInfo.Version)
    
    log.Printf("Server capabilities: %+v", result.Capabilities)
 
    return nil
}

Graceful Shutdown

type ManagedClient struct {
    client client.Client
    ctx    context.Context
    cancel context.CancelFunc
    done   chan struct{}
}
 
func NewManagedClient(clientType, address string) (*ManagedClient, error) {
    var c client.Client
    var err error
 
    switch clientType {
    case "stdio":
        c, err = client.NewSSEMCPClient("server-command")
    case "streamablehttp":
        c = client.NewStreamableHttpClient(address)
    case "sse":
        c = client.NewSSEMCPClient(address)
    default:
        return nil, fmt.Errorf("unknown client type: %s", clientType)
    }
 
    if err != nil {
        return nil, err
    }
 
    ctx, cancel := context.WithCancel(context.Background())
 
    mc := &ManagedClient{
        client: c,
        ctx:    ctx,
        cancel: cancel,
        done:   make(chan struct{}),
    }
 
    // Initialize in background
    go func() {
        defer close(mc.done)
        if err := c.Initialize(ctx); err != nil {
            log.Printf("Client initialization failed: %v", err)
        }
    }()
 
    return mc, nil
}
 
func (mc *ManagedClient) WaitForReady(timeout time.Duration) error {
    select {
    case <-mc.done:
        return nil
    case <-time.After(timeout):
        return fmt.Errorf("client initialization timeout")
    case <-mc.ctx.Done():
        return mc.ctx.Err()
    }
}
 
func (mc *ManagedClient) Close() error {
    mc.cancel()
    
    // Wait for initialization to complete or timeout
    select {
    case <-mc.done:
    case <-time.After(5 * time.Second):
        log.Println("Timeout waiting for client shutdown")
    }
 
    return mc.client.Close()
}

Error Handling

Proper error handling is essential for robust client applications.

Error Types

// Connection errors
var (
    ErrConnectionFailed = errors.New("connection failed")
    ErrConnectionLost   = errors.New("connection lost")
    ErrTimeout          = errors.New("operation timeout")
)
 
// Protocol errors
var (
    ErrInvalidResponse    = errors.New("invalid response")
    ErrProtocolViolation  = errors.New("protocol violation")
    ErrUnsupportedVersion = errors.New("unsupported protocol version")
)
 
// Operation errors
var (
    ErrToolNotFound       = errors.New("tool not found")
    ErrResourceNotFound   = errors.New("resource not found")
    ErrInvalidArguments   = errors.New("invalid arguments")
    ErrPermissionDenied   = errors.New("permission denied")
)

Comprehensive Error Handling

func handleClientErrors(ctx context.Context, c client.Client) {
    result, err := c.CallTool(ctx, mcp.CallToolRequest{
        Params: mcp.CallToolRequestParams{
            Name: "example_tool",
            Arguments: map[string]interface{}{
                "param": "value",
            },
        },
    })
 
    if err != nil {
        switch {
        // Connection errors - may be recoverable
        case errors.Is(err, client.ErrConnectionLost):
            log.Println("Connection lost, attempting reconnect...")
            if reconnectErr := reconnectClient(c); reconnectErr != nil {
                log.Printf("Reconnection failed: %v", reconnectErr)
                return
            }
            // Retry the operation
            return handleClientErrors(ctx, c)
 
        case errors.Is(err, client.ErrTimeout):
            log.Println("Operation timed out, retrying with longer timeout...")
            ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
            defer cancel()
            return handleClientErrors(ctx, c)
 
        // Protocol errors - usually not recoverable
        case errors.Is(err, client.ErrProtocolViolation):
            log.Printf("Protocol violation: %v", err)
            return
 
        case errors.Is(err, client.ErrUnsupportedVersion):
            log.Printf("Unsupported protocol version: %v", err)
            return
 
        // Operation errors - check and fix request
        case errors.Is(err, client.ErrToolNotFound):
            log.Printf("Tool not found: %v", err)
            // Maybe list available tools and suggest alternatives
            suggestAlternativeTools(ctx, c)
            return
 
        case errors.Is(err, client.ErrInvalidArguments):
            log.Printf("Invalid arguments: %v", err)
            // Maybe get tool schema and show required parameters
            showToolSchema(ctx, c, "example_tool")
            return
 
        case errors.Is(err, client.ErrPermissionDenied):
            log.Printf("Permission denied: %v", err)
            // Maybe prompt for authentication
            return
 
        // Unknown errors
        default:
            log.Printf("Unexpected error: %v", err)
            return
        }
    }
 
    // Process successful result
    log.Printf("Tool result: %+v", result)
}
 
func reconnectClient(c client.Client) error {
    // Close existing connection
    if err := c.Close(); err != nil {
        log.Printf("Error closing client: %v", err)
    }
 
    // Wait before reconnecting
    time.Sleep(1 * time.Second)
 
    // Reinitialize
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    return c.Initialize(ctx)
}
 
func suggestAlternativeTools(ctx context.Context, c client.Client) {
    tools, err := c.ListTools(ctx)
    if err != nil {
        log.Printf("Failed to list tools: %v", err)
        return
    }
 
    log.Println("Available tools:")
    for _, tool := range tools.Tools {
        log.Printf("- %s: %s", tool.Name, tool.Description)
    }
}
 
func showToolSchema(ctx context.Context, c client.Client, toolName string) {
    tools, err := c.ListTools(ctx)
    if err != nil {
        log.Printf("Failed to list tools: %v", err)
        return
    }
 
    for _, tool := range tools.Tools {
        if tool.Name == toolName {
            log.Printf("Tool schema for %s:", toolName)
            log.Printf("Description: %s", tool.Description)
            log.Printf("Input schema: %+v", tool.InputSchema)
            return
        }
    }
 
    log.Printf("Tool %s not found", toolName)
}

Retry Logic with Exponential Backoff

type RetryConfig struct {
    MaxRetries      int
    InitialDelay    time.Duration
    MaxDelay        time.Duration
    BackoffFactor   float64
    RetryableErrors []error
}
 
func DefaultRetryConfig() RetryConfig {
    return RetryConfig{
        MaxRetries:    3,
        InitialDelay:  1 * time.Second,
        MaxDelay:      30 * time.Second,
        BackoffFactor: 2.0,
        RetryableErrors: []error{
            client.ErrConnectionLost,
            client.ErrTimeout,
            client.ErrConnectionFailed,
        },
    }
}
 
func (rc RetryConfig) IsRetryable(err error) bool {
    for _, retryableErr := range rc.RetryableErrors {
        if errors.Is(err, retryableErr) {
            return true
        }
    }
    return false
}
 
func WithRetry[T any](ctx context.Context, config RetryConfig, operation func() (T, error)) (T, error) {
    var lastErr error
    var zero T
 
    for attempt := 0; attempt <= config.MaxRetries; attempt++ {
        result, err := operation()
        if err == nil {
            return result, nil
        }
 
        lastErr = err
 
        // Don't retry non-retryable errors
        if !config.IsRetryable(err) {
            break
        }
 
        // Don't retry on last attempt
        if attempt == config.MaxRetries {
            break
        }
 
        // Calculate delay with exponential backoff
        delay := time.Duration(float64(config.InitialDelay) * math.Pow(config.BackoffFactor, float64(attempt)))
        if delay > config.MaxDelay {
            delay = config.MaxDelay
        }
 
        log.Printf("Attempt %d failed, retrying in %v: %v", attempt+1, delay, err)
 
        // Wait with context cancellation support
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return zero, ctx.Err()
        }
    }
 
    return zero, fmt.Errorf("failed after %d attempts: %w", config.MaxRetries+1, lastErr)
}
 
// Usage example
func callToolWithRetry(ctx context.Context, c client.Client, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    config := DefaultRetryConfig()
    
    return WithRetry(ctx, config, func() (*mcp.CallToolResult, error) {
        return c.CallTool(ctx, req)
    })
}

Context and Timeout Management

func demonstrateContextUsage(c client.Client) {
    // Operation with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    result, err := c.CallTool(ctx, mcp.CallToolRequest{
        Params: mcp.CallToolRequestParams{
            Name: "long_running_tool",
            Arguments: map[string]interface{}{
                "duration": 60, // seconds
            },
        },
    })
 
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            log.Println("Tool call timed out")
        } else {
            log.Printf("Tool call failed: %v", err)
        }
        return
    }
 
    log.Printf("Tool completed: %+v", result)
}
 
func demonstrateCancellation(c client.Client) {
    ctx, cancel := context.WithCancel(context.Background())
 
    // Start operation in goroutine
    go func() {
        result, err := c.CallTool(ctx, mcp.CallToolRequest{
            Params: mcp.CallToolRequestParams{
                Name: "long_running_tool",
            },
        })
 
        if err != nil {
            if errors.Is(err, context.Canceled) {
                log.Println("Tool call was cancelled")
            } else {
                log.Printf("Tool call failed: %v", err)
            }
            return
        }
 
        log.Printf("Tool completed: %+v", result)
    }()
 
    // Cancel after 5 seconds
    time.Sleep(5 * time.Second)
    cancel()
    
    // Wait a bit to see the cancellation
    time.Sleep(1 * time.Second)
}

Connection Monitoring

Health Checks

type ClientHealthMonitor struct {
    client   client.Client
    interval time.Duration
    timeout  time.Duration
    healthy  bool
    mutex    sync.RWMutex
}
 
func NewClientHealthMonitor(c client.Client, interval, timeout time.Duration) *ClientHealthMonitor {
    return &ClientHealthMonitor{
        client:   c,
        interval: interval,
        timeout:  timeout,
        healthy:  false,
    }
}
 
func (chm *ClientHealthMonitor) Start(ctx context.Context) {
    ticker := time.NewTicker(chm.interval)
    defer ticker.Stop()
 
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            chm.checkHealth(ctx)
        }
    }
}
 
func (chm *ClientHealthMonitor) checkHealth(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, chm.timeout)
    defer cancel()
 
    // Try to list tools as a health check
    _, err := chm.client.ListTools(ctx)
    
    chm.mutex.Lock()
    chm.healthy = (err == nil)
    chm.mutex.Unlock()
 
    if err != nil {
        log.Printf("Health check failed: %v", err)
    }
}
 
func (chm *ClientHealthMonitor) IsHealthy() bool {
    chm.mutex.RLock()
    defer chm.mutex.RUnlock()
    return chm.healthy
}

Connection Recovery

type ResilientClient struct {
    factory    func() (client.Client, error)
    client     client.Client
    mutex      sync.RWMutex
    recovering bool
}
 
func NewResilientClient(factory func() (client.Client, error)) *ResilientClient {
    return &ResilientClient{
        factory: factory,
    }
}
 
func (rc *ResilientClient) ensureConnected(ctx context.Context) error {
    rc.mutex.RLock()
    if rc.client != nil && !rc.recovering {
        rc.mutex.RUnlock()
        return nil
    }
    rc.mutex.RUnlock()
 
    rc.mutex.Lock()
    defer rc.mutex.Unlock()
 
    // Double-check after acquiring write lock
    if rc.client != nil && !rc.recovering {
        return nil
    }
 
    rc.recovering = true
    defer func() { rc.recovering = false }()
 
    // Close existing client if any
    if rc.client != nil {
        rc.client.Close()
    }
 
    // Create new client
    newClient, err := rc.factory()
    if err != nil {
        return fmt.Errorf("failed to create client: %w", err)
    }
 
    // Initialize new client
    if err := newClient.Initialize(ctx); err != nil {
        newClient.Close()
        return fmt.Errorf("failed to initialize client: %w", err)
    }
 
    rc.client = newClient
    return nil
}
 
func (rc *ResilientClient) CallTool(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    if err := rc.ensureConnected(ctx); err != nil {
        return nil, err
    }
 
    rc.mutex.RLock()
    client := rc.client
    rc.mutex.RUnlock()
 
    result, err := client.CallTool(ctx, req)
    if err != nil && isConnectionError(err) {
        // Mark for recovery and retry once
        rc.mutex.Lock()
        rc.recovering = true
        rc.mutex.Unlock()
 
        if retryErr := rc.ensureConnected(ctx); retryErr != nil {
            return nil, fmt.Errorf("recovery failed: %w", retryErr)
        }
 
        rc.mutex.RLock()
        client = rc.client
        rc.mutex.RUnlock()
 
        return client.CallTool(ctx, req)
    }
 
    return result, err
}
 
func isConnectionError(err error) bool {
    return errors.Is(err, client.ErrConnectionLost) ||
           errors.Is(err, client.ErrConnectionFailed)
}

Next Steps