Chapter 51

Building an MCP Server

Building an MCP Server

L1: Concept โ€” What MCP Is and Why It Matters

The Fragmentation Problem in AI Tool Integration

Before MCP (Model Context Protocol) existed, AI tool integration was a chaotic battlefield. Every AI platform, every assistant application, had its own unique way of integrating tools. Want to connect a database query tool to Claude? You implement one specific API. The same tool in GPT? Yet another approach. Gemini? Another one entirely.

This fragmentation created massive duplicated effort. Developers wrote three different integration versions of the same functionality, and every time an AI platform updated its API, all integrations needed synchronous updates. Not only does this waste development resources, it severely hinders the flourishing of the AI tool ecosystem.

The arrival of MCP is to AI tool integration what the HTTP protocol was to the Web: a universal, open standard that allows any tool to connect to any AI system in a unified way.

MCP's Core Value Proposition

MCP (Model Context Protocol) is an open standard protocol published by Anthropic in November 2024. It defines the communication specification between AI models (clients) and external tool providers (servers).

Why does MCP matter?

Unified interface: A single MCP Server can be used simultaneously by Claude Desktop, any MCP-supporting IDE plugin, and any custom AI application โ€” no separate implementation needed per client.

Separation of concerns: Tool implementors only need to focus on the tool's business logic, without knowing the internals of the AI application above. AI application developers only need to interface with the MCP protocol, without caring about each tool's internal implementation.

Security: MCP's design naturally supports running tools in a separate process, isolated from the AI application. Sensitive data (like database passwords) lives only within the MCP Server process, unexposed to the AI application.

Composability: An MCP Proxy can aggregate multiple MCP Servers into one, providing a unified entry point for AI applications.

Choosing Between stdio and HTTP Transport

MCP supports two transport layers:

stdio (standard input/output) transport:

HTTP+SSE (Server-Sent Events) transport:

This chapter focuses primarily on stdio transport (the more common local deployment scenario), with HTTP+SSE transport covered in L4.


L2: Principles โ€” The MCP Protocol in Detail

JSON-RPC 2.0 Foundations

MCP is built on top of the JSON-RPC 2.0 protocol. JSON-RPC is a lightweight remote procedure call protocol, well-suited as MCP's foundation:

// Request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "query_database",
    "arguments": {
      "sql": "SELECT * FROM users LIMIT 10"
    }
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[{\"id\": 1, \"name\": \"Alice\"}, ...]"
      }
    ]
  }
}

// Error Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid SQL syntax"
  }
}

// Notification (no id, no response expected)
{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed"
}

The Initialize Handshake

An MCP session begins with an initialization handshake:

// 1. Client sends initialize request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": {"listChanged": true},
      "sampling": {}
    },
    "clientInfo": {
      "name": "Claude Desktop",
      "version": "0.7.0"
    }
  }
}

// 2. Server responds, announcing its capabilities
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {"listChanged": true},
      "resources": {"subscribe": true, "listChanged": true},
      "prompts": {"listChanged": true}
    },
    "serverInfo": {
      "name": "my-mcp-server",
      "version": "1.0.0"
    }
  }
}

// 3. Client sends initialized notification (confirms completion)
{
  "jsonrpc": "2.0",
  "method": "initialized"
}

tools/list and tools/call

List tools:

// Request
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}

// Response
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "query_database",
        "description": "Execute a read-only SQL query against the database",
        "inputSchema": {
          "type": "object",
          "properties": {
            "sql": {
              "type": "string",
              "description": "The SQL query to execute (SELECT only)"
            }
          },
          "required": ["sql"]
        }
      }
    ]
  }
}

Call a tool:

// Request
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "query_database",
    "arguments": {"sql": "SELECT count(*) FROM orders WHERE status = 'pending'"}
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Result: 42 pending orders"
      }
    ],
    "isError": false
  }
}

Resources (Data Access)

Resources allow an MCP Server to expose files, URLs, or other data resources for the AI to read:

// List resources
{"jsonrpc": "2.0", "id": 4, "method": "resources/list"}
// Response includes resource list:
// {"resources": [{"uri": "file:///logs/app.log", "name": "App Log", "mimeType": "text/plain"}]}

// Read a resource
{"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "file:///logs/app.log"}}

Prompts (Reusable Prompt Templates)

Prompts are server-defined reusable prompt templates that allow users to quickly invoke common AI workflows:

// List prompts
{"jsonrpc": "2.0", "id": 6, "method": "prompts/list"}

// Get a specific prompt
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "prompts/get",
  "params": {
    "name": "analyze_slow_queries",
    "arguments": {"threshold_ms": "1000"}
  }
}
// Response: a message list with arguments filled in, ready to send to the LLM

Sampling (Asking the Client LLM to Generate)

Sampling is an advanced MCP feature: an MCP Server can ask the client to use its built-in LLM to generate text. This gives the MCP Server itself AI capabilities without needing to maintain its own API key:

// Server sends a sampling request to the Client
{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "sampling/createMessage",
  "params": {
    "messages": [
      {"role": "user", "content": {"type": "text", "text": "Summarize this log: ..."}}
    ],
    "maxTokens": 1000
  }
}

L3: Code Practice โ€” Building a Complete MCP Server in Go

Core Framework: JSON-RPC Dispatcher

// mcp/server.go
package mcp

import (
    "bufio"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log/slog"
    "os"
    "sync"
    "sync/atomic"
)

// JSONRPCRequest represents a JSON-RPC request
type JSONRPCRequest struct {
    JSONRPC string           `json:"jsonrpc"`
    ID      *json.RawMessage `json:"id,omitempty"` // nil means notification
    Method  string           `json:"method"`
    Params  json.RawMessage  `json:"params,omitempty"`
}

// JSONRPCResponse represents a JSON-RPC response
type JSONRPCResponse struct {
    JSONRPC string           `json:"jsonrpc"`
    ID      *json.RawMessage `json:"id,omitempty"`
    Result  interface{}      `json:"result,omitempty"`
    Error   *JSONRPCError    `json:"error,omitempty"`
}

// JSONRPCError represents a JSON-RPC error
type JSONRPCError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

// Standard JSON-RPC error codes
const (
    ErrParseError     = -32700
    ErrInvalidRequest = -32600
    ErrMethodNotFound = -32601
    ErrInvalidParams  = -32602
    ErrInternalError  = -32603
)

// Handler is the type for method handlers
type Handler func(ctx context.Context, params json.RawMessage) (interface{}, error)

// Server is the core of the MCP server
type Server struct {
    name     string
    version  string
    handlers map[string]Handler
    tools    *ToolRegistry
    mu       sync.RWMutex

    reader *bufio.Reader
    writer io.Writer
    wrMu   sync.Mutex

    nextID atomic.Int64
    logger *slog.Logger
}

func NewServer(name, version string) *Server {
    s := &Server{
        name:     name,
        version:  version,
        handlers: make(map[string]Handler),
        tools:    NewToolRegistry(),
        reader:   bufio.NewReader(os.Stdin),
        writer:   os.Stdout,
        logger:   slog.New(slog.NewTextHandler(os.Stderr, nil)),
    }

    // Register built-in methods
    s.handlers["initialize"] = s.handleInitialize
    s.handlers["initialized"] = s.handleInitialized
    s.handlers["tools/list"] = s.handleToolsList
    s.handlers["tools/call"] = s.handleToolsCall
    s.handlers["resources/list"] = s.handleResourcesList
    s.handlers["resources/read"] = s.handleResourcesRead
    s.handlers["prompts/list"] = s.handlePromptsList
    s.handlers["prompts/get"] = s.handlePromptsGet

    return s
}

// Run starts the MCP server, reading from stdin and writing to stdout
func (s *Server) Run(ctx context.Context) error {
    s.logger.Info("MCP server started", "name", s.name, "version", s.version)

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }

        line, err := s.reader.ReadBytes('\n')
        if err != nil {
            if err == io.EOF {
                s.logger.Info("Client disconnected")
                return nil
            }
            return fmt.Errorf("read error: %w", err)
        }

        if len(line) == 0 {
            continue
        }

        go s.handleRequest(ctx, line)
    }
}

func (s *Server) handleRequest(ctx context.Context, data []byte) {
    var req JSONRPCRequest
    if err := json.Unmarshal(data, &req); err != nil {
        s.sendError(nil, ErrParseError, "Parse error: "+err.Error())
        return
    }

    if req.JSONRPC != "2.0" {
        s.sendError(req.ID, ErrInvalidRequest, "Invalid JSON-RPC version")
        return
    }

    s.logger.Debug("Handling request", "method", req.Method)

    handler, ok := s.handlers[req.Method]
    if !ok {
        if req.ID == nil {
            return // Notifications need no response
        }
        s.sendError(req.ID, ErrMethodNotFound, fmt.Sprintf("Method not found: %s", req.Method))
        return
    }

    result, err := handler(ctx, req.Params)
    if err != nil {
        s.sendError(req.ID, ErrInternalError, err.Error())
        return
    }

    if req.ID == nil {
        return // No response for notifications
    }

    s.sendResult(req.ID, result)
}

func (s *Server) sendResult(id *json.RawMessage, result interface{}) {
    resp := JSONRPCResponse{JSONRPC: "2.0", ID: id, Result: result}
    s.writeResponse(resp)
}

func (s *Server) sendError(id *json.RawMessage, code int, message string) {
    resp := JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      id,
        Error:   &JSONRPCError{Code: code, Message: message},
    }
    s.writeResponse(resp)
}

func (s *Server) writeResponse(resp JSONRPCResponse) {
    data, err := json.Marshal(resp)
    if err != nil {
        s.logger.Error("Failed to marshal response", "error", err)
        return
    }

    s.wrMu.Lock()
    defer s.wrMu.Unlock()

    if _, err = fmt.Fprintf(s.writer, "%s\n", data); err != nil {
        s.logger.Error("Failed to write response", "error", err)
    }
}

// RegisterTool adds a tool to the server
func (s *Server) RegisterTool(def ToolDefinition, handler MCPToolHandler) {
    s.tools.Register(def, handler)
}

Three Practical Tools

// mcp/tools.go
package mcp

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
    "time"

    _ "github.com/lib/pq"
)

// ToolDefinition is an MCP tool definition
type ToolDefinition struct {
    Name        string          `json:"name"`
    Description string          `json:"description"`
    InputSchema json.RawMessage `json:"inputSchema"`
}

// MCPToolHandler is the tool handler type; returns result, isError, and error
type MCPToolHandler func(ctx context.Context, args json.RawMessage) (string, bool, error)

// ToolRegistry manages MCP tools
type ToolRegistry struct {
    defs     []ToolDefinition
    handlers map[string]MCPToolHandler
}

func NewToolRegistry() *ToolRegistry {
    return &ToolRegistry{handlers: make(map[string]MCPToolHandler)}
}

func (r *ToolRegistry) Register(def ToolDefinition, handler MCPToolHandler) {
    r.defs = append(r.defs, def)
    r.handlers[def.Name] = handler
}

func (r *ToolRegistry) List() []ToolDefinition { return r.defs }

func (r *ToolRegistry) Call(ctx context.Context, name string, args json.RawMessage) (string, bool, error) {
    handler, ok := r.handlers[name]
    if !ok {
        return "", true, fmt.Errorf("tool not found: %s", name)
    }
    return handler(ctx, args)
}

// --- Tool 1: Database Query ---

type DatabaseTool struct {
    db *sql.DB
}

func NewDatabaseTool(dsn string) (*DatabaseTool, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("open database: %w", err)
    }
    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("ping database: %w", err)
    }
    db.SetMaxOpenConns(10)
    db.SetMaxIdleConns(5)
    return &DatabaseTool{db: db}, nil
}

func (t *DatabaseTool) Definition() ToolDefinition {
    return ToolDefinition{
        Name:        "query_database",
        Description: "Execute a read-only SQL query against the PostgreSQL database. Only SELECT statements are permitted for security.",
        InputSchema: json.RawMessage(`{
            "type": "object",
            "properties": {
                "sql": {"type": "string", "description": "The SQL SELECT query to execute"},
                "max_rows": {"type": "integer", "description": "Maximum rows to return (default: 100)", "default": 100}
            },
            "required": ["sql"]
        }`),
    }
}

func (t *DatabaseTool) Handle(ctx context.Context, args json.RawMessage) (string, bool, error) {
    var req struct {
        SQL     string `json:"sql"`
        MaxRows int    `json:"max_rows"`
    }
    if err := json.Unmarshal(args, &req); err != nil {
        return "", true, fmt.Errorf("invalid arguments: %w", err)
    }
    if req.MaxRows == 0 {
        req.MaxRows = 100
    }

    // Security: allow only SELECT statements
    sqlUpper := strings.ToUpper(strings.TrimSpace(req.SQL))
    if !strings.HasPrefix(sqlUpper, "SELECT") {
        return "", true, fmt.Errorf("only SELECT statements are allowed")
    }

    limitedSQL := fmt.Sprintf("SELECT * FROM (%s) AS q LIMIT %d", req.SQL, req.MaxRows)
    rows, err := t.db.QueryContext(ctx, limitedSQL)
    if err != nil {
        return "", true, fmt.Errorf("query error: %w", err)
    }
    defer rows.Close()

    columns, err := rows.Columns()
    if err != nil {
        return "", true, err
    }

    var result strings.Builder
    result.WriteString(strings.Join(columns, "\t") + "\n")
    result.WriteString(strings.Repeat("-", 40) + "\n")

    values := make([]interface{}, len(columns))
    valuePtrs := make([]interface{}, len(columns))
    for i := range values {
        valuePtrs[i] = &values[i]
    }

    rowCount := 0
    for rows.Next() {
        if err := rows.Scan(valuePtrs...); err != nil {
            return "", true, err
        }
        var parts []string
        for _, v := range values {
            parts = append(parts, fmt.Sprintf("%v", v))
        }
        result.WriteString(strings.Join(parts, "\t") + "\n")
        rowCount++
    }
    result.WriteString(fmt.Sprintf("\n%d row(s) returned", rowCount))
    return result.String(), false, nil
}

// --- Tool 2: File Reader ---

type FileReaderTool struct {
    allowedDirs []string
}

func NewFileReaderTool(allowedDirs []string) *FileReaderTool {
    return &FileReaderTool{allowedDirs: allowedDirs}
}

func (t *FileReaderTool) Definition() ToolDefinition {
    return ToolDefinition{
        Name:        "read_file",
        Description: "Read the contents of a file from the allowed directories.",
        InputSchema: json.RawMessage(`{
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Absolute path to the file"}
            },
            "required": ["path"]
        }`),
    }
}

func (t *FileReaderTool) Handle(ctx context.Context, args json.RawMessage) (string, bool, error) {
    var req struct {
        Path string `json:"path"`
    }
    if err := json.Unmarshal(args, &req); err != nil {
        return "", true, err
    }

    allowed := false
    for _, dir := range t.allowedDirs {
        if strings.HasPrefix(req.Path, dir) {
            allowed = true
            break
        }
    }
    if !allowed || strings.Contains(req.Path, "..") {
        return "", true, fmt.Errorf("access denied: path not in allowed directories")
    }

    f, err := os.Open(req.Path)
    if err != nil {
        return "", true, fmt.Errorf("open file: %w", err)
    }
    defer f.Close()

    content, err := io.ReadAll(io.LimitReader(f, 512*1024)) // max 512KB
    if err != nil {
        return "", true, err
    }
    return string(content), false, nil
}

// --- Tool 3: HTTP Fetcher ---

type HTTPFetcherTool struct {
    client *http.Client
}

func NewHTTPFetcherTool() *HTTPFetcherTool {
    return &HTTPFetcherTool{
        client: &http.Client{Timeout: 30 * time.Second},
    }
}

func (t *HTTPFetcherTool) Definition() ToolDefinition {
    return ToolDefinition{
        Name:        "fetch_url",
        Description: "Fetch the content of a URL via HTTP GET. Returns the response body as text.",
        InputSchema: json.RawMessage(`{
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The URL to fetch"},
                "headers": {
                    "type": "object",
                    "description": "Optional HTTP headers",
                    "additionalProperties": {"type": "string"}
                }
            },
            "required": ["url"]
        }`),
    }
}

func (t *HTTPFetcherTool) Handle(ctx context.Context, args json.RawMessage) (string, bool, error) {
    var req struct {
        URL     string            `json:"url"`
        Headers map[string]string `json:"headers"`
    }
    if err := json.Unmarshal(args, &req); err != nil {
        return "", true, err
    }

    httpReq, err := http.NewRequestWithContext(ctx, "GET", req.URL, nil)
    if err != nil {
        return "", true, fmt.Errorf("create request: %w", err)
    }
    for k, v := range req.Headers {
        httpReq.Header.Set(k, v)
    }

    resp, err := t.client.Do(httpReq)
    if err != nil {
        return "", true, fmt.Errorf("fetch failed: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
    if err != nil {
        return "", true, err
    }

    return fmt.Sprintf("HTTP %d\n\n%s", resp.StatusCode, string(body)), resp.StatusCode >= 400, nil
}

Packaging as a Standalone Binary

// cmd/my-mcp-server/main.go
package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/yourorg/mcp"
)

func main() {
    dbDSN := os.Getenv("DATABASE_URL")
    allowedDirs := []string{"/var/logs", "/etc/myapp"}

    server := mcp.NewServer("my-tools-server", "1.0.0")

    if dbDSN != "" {
        dbTool, err := mcp.NewDatabaseTool(dbDSN)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Database connection failed: %v\n", err)
        } else {
            server.RegisterTool(dbTool.Definition(), dbTool.Handle)
        }
    }

    fileTool := mcp.NewFileReaderTool(allowedDirs)
    server.RegisterTool(fileTool.Definition(), fileTool.Handle)

    httpTool := mcp.NewHTTPFetcherTool()
    server.RegisterTool(httpTool.Definition(), httpTool.Handle)

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    go func() {
        <-sigCh
        cancel()
    }()

    if err := server.Run(ctx); err != nil && err != context.Canceled {
        fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
        os.Exit(1)
    }
}

Integrating with Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "my-tools": {
      "command": "/usr/local/bin/my-mcp-server",
      "env": {
        "DATABASE_URL": "postgres://user:pass@localhost/mydb"
      }
    }
  }
}

After restarting Claude Desktop, your tools become available in conversation.


L4: Advanced โ€” HTTP+SSE, Authentication, MCP Proxy, and Dynamic Discovery

MCP over HTTP+SSE Transport

HTTP+SSE transport allows an MCP Server to run as a persistent network service:

// mcp/http_server.go
package mcp

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "sync"
)

type HTTPTransport struct {
    server  *Server
    clients sync.Map // sessionID -> chan []byte
}

func NewHTTPTransport(server *Server) *HTTPTransport {
    return &HTTPTransport{server: server}
}

func (t *HTTPTransport) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/sse":
        t.handleSSE(w, r)
    case "/message":
        t.handleMessage(w, r)
    default:
        http.NotFound(w, r)
    }
}

// handleSSE establishes the SSE connection; the server pushes responses through it
func (t *HTTPTransport) handleSSE(w http.ResponseWriter, r *http.Request) {
    sessionID := generateSessionID()

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*")

    ch := make(chan []byte, 100)
    t.clients.Store(sessionID, ch)
    defer t.clients.Delete(sessionID)

    // Send endpoint information
    fmt.Fprintf(w, "event: endpoint\ndata: /message?sessionId=%s\n\n", sessionID)
    w.(http.Flusher).Flush()

    for {
        select {
        case <-r.Context().Done():
            return
        case data := <-ch:
            fmt.Fprintf(w, "data: %s\n\n", data)
            w.(http.Flusher).Flush()
        }
    }
}

// handleMessage receives client JSON-RPC requests
func (t *HTTPTransport) handleMessage(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    sessionID := r.URL.Query().Get("sessionId")
    chVal, ok := t.clients.Load(sessionID)
    if !ok {
        http.Error(w, "Session not found", http.StatusNotFound)
        return
    }
    ch := chVal.(chan []byte)

    var req JSONRPCRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    go func() {
        result, handlerErr := t.server.handleRequestInternal(r.Context(), &req)
        if req.ID == nil {
            return
        }

        var resp JSONRPCResponse
        if handlerErr != nil {
            resp = JSONRPCResponse{
                JSONRPC: "2.0",
                ID:      req.ID,
                Error:   &JSONRPCError{Code: ErrInternalError, Message: handlerErr.Error()},
            }
        } else {
            resp = JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result}
        }

        data, _ := json.Marshal(resp)
        ch <- data
    }()

    w.WriteHeader(http.StatusAccepted)
}

Authenticating MCP Servers

For production deployments, authentication is mandatory:

// API Key authentication middleware
func AuthMiddleware(validKeys map[string]string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        apiKey := r.Header.Get("X-API-Key")
        if apiKey == "" {
            auth := r.Header.Get("Authorization")
            if strings.HasPrefix(auth, "Bearer ") {
                apiKey = strings.TrimPrefix(auth, "Bearer ")
            }
        }

        clientName, ok := validKeys[apiKey]
        if !ok {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), clientKey{}, clientName)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

MCP Proxy: Aggregating Multiple MCP Servers

An MCP Proxy aggregates multiple upstream MCP Servers into one, transparent to the client:

// mcp/proxy.go
type MCPProxy struct {
    upstreams map[string]*UpstreamServer
    mu        sync.RWMutex
}

type UpstreamServer struct {
    name   string
    prefix string // tool name prefix, e.g., "db_", "file_"
    stdin  io.WriteCloser
    stdout *bufio.Reader
    mu     sync.Mutex
}

// AggregateTools aggregates tool lists from all upstreams
func (p *MCPProxy) AggregateTools(ctx context.Context) ([]ToolDefinition, error) {
    p.mu.RLock()
    defer p.mu.RUnlock()

    var allTools []ToolDefinition
    for _, upstream := range p.upstreams {
        tools, err := upstream.ListTools(ctx)
        if err != nil {
            continue // degrade: ignore failing upstreams
        }
        for _, t := range tools {
            t.Name = upstream.prefix + t.Name // add prefix to avoid name collisions
            allTools = append(allTools, t)
        }
    }
    return allTools, nil
}

// RouteToolCall routes a tool call to the appropriate upstream server
func (p *MCPProxy) RouteToolCall(ctx context.Context, name string, args json.RawMessage) (string, error) {
    p.mu.RLock()
    defer p.mu.RUnlock()

    for _, upstream := range p.upstreams {
        if strings.HasPrefix(name, upstream.prefix) {
            originalName := strings.TrimPrefix(name, upstream.prefix)
            return upstream.CallTool(ctx, originalName, args)
        }
    }
    return "", fmt.Errorf("no upstream found for tool: %s", name)
}

mark3labs/mcp-go Library Deep Dive

mark3labs/mcp-go is the most mature Go MCP library, abstracting away boilerplate:

import (
    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)

func buildServerWithLibrary() *server.MCPServer {
    s := server.NewMCPServer(
        "my-tools-server",
        "1.0.0",
        server.WithToolCapabilities(false),
    )

    // Add tools using a clean DSL
    s.AddTool(mcp.NewTool("query_database",
        mcp.WithDescription("Execute a read-only SQL query"),
        mcp.WithString("sql",
            mcp.Required(),
            mcp.Description("The SQL SELECT query"),
        ),
        mcp.WithNumber("max_rows",
            mcp.Description("Maximum rows to return"),
        ),
    ), handleDatabaseQuery)

    return s
}

func handleDatabaseQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    sql, _ := req.Params.Arguments["sql"].(string)
    // ... execute the query ...
    return mcp.NewToolResultText(result), nil
}

Compared to hand-written JSON-RPC handlers, mcp-go:

Summary

Building a production-grade MCP Server requires mastery of:

  1. JSON-RPC 2.0 fundamentals: Distinguishing requests, responses, and notifications; understanding error codes
  2. MCP lifecycle: Initialize handshake โ†’ capability negotiation โ†’ tool calls โ†’ shutdown
  3. Tool design: Clear descriptions, precise schemas, robust error handling
  4. Security: Path restrictions, SQL injection prevention, authentication
  5. Scalability: HTTP+SSE transport, Proxy pattern, dynamic tool registration

MCP's true value lies in its ecosystem effects: as more tools implement the MCP protocol, the capability frontier of AI assistants will continuously expand โ€” and each tool only needs to be implemented once.

Rate this chapter
4.8  / 5  (3 ratings)

๐Ÿ’ฌ Comments