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:
- The MCP Server launches as a child process and communicates via stdin/stdout
- Pros: Simple, naturally isolated, no network configuration needed, auto-cleanup when process exits
- Use cases: Local dev tools, IDE plugins, command-line utilities
HTTP+SSE (Server-Sent Events) transport:
- MCP Server runs as an independent HTTP service
- Pros: Can be deployed on remote servers, supports concurrent connections from multiple clients, can be scaled behind a load balancer
- Use cases: Cloud-hosted shared tools, enterprise internal services, APIs requiring authentication
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:
- Automatically handles protocol handshakes and version negotiation
- Provides type-safe parameter extraction
- Has built-in stdio and SSE transport support
- Manages concurrency and message queuing automatically
Summary
Building a production-grade MCP Server requires mastery of:
- JSON-RPC 2.0 fundamentals: Distinguishing requests, responses, and notifications; understanding error codes
- MCP lifecycle: Initialize handshake โ capability negotiation โ tool calls โ shutdown
- Tool design: Clear descriptions, precise schemas, robust error handling
- Security: Path restrictions, SQL injection prevention, authentication
- 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.