Model Context Protocol (MCP) is an open protocol from Anthropic that lets AI models like Claude interact with external tools and data sources in a standardized way. Instead of hardcoding logic into prompts or building custom integrations for each AI platform, you expose your APIs as MCP tools that AI can automatically discover and use.
This guide walks through building a complete MCP server in Go that connects to an existing Books API, handling everything from tool registration to production deployment.
What is MCP and Why Build a Server?
Imagine you have a Books API with these endpoints:
GET /books- List books with paginationGET /books/:id- Get book detailsGET /books/search?q=...- Search booksGET /books/stats- Get statistics
The traditional approach: Copy-paste data into prompts or write custom integrations for each AI platform.
With MCP: Build once, and AI models automatically:
- Discover available tools (
list_books,get_book,search_books,get_book_stats) - Understand input schemas (parameters, types, validation)
- Call tools and process JSON responses
- Chain multiple tool calls to solve complex queries
Real-world use cases:
- User: “Find all architecture books published after 2020” → AI calls
search_bookswith filters - User: “Compare these two books” → AI calls
get_booktwice and analyzes - User: “How many books are in the database?” → AI calls
get_book_stats
The AI handles the logic; you just expose the tools.
Architecture Overview
┌─────────────────────────────────────────────┐
│ Claude Desktop / MCP Inspector │
│ (MCP Client) │
└────────────────┬────────────────────────────┘
│
│ MCP Protocol (stdio/SSE/HTTP)
│ JSON-RPC 2.0 messages
▼
┌────────────────────┐
│ MCP Server │
│ (Go) │
│ │
│ Tools: │
│ • list_books │
│ • get_book │
│ • search_books │
│ • get_book_stats │
│ │
│ Auth: Bearer │
└────────┬───────────┘
│
│ gRPC / REST / GraphQL
│ (Your existing API)
▼
┌────────────────┐
│ Books API │
│ (Backend) │
└────────────────┘
How it works:
- Tool Discovery: Claude calls
tools/list→ MCP server returns available tools + schemas - Tool Execution: Claude calls
tools/callwith tool name + arguments → MCP server validates, calls backend API, returns result - AI Processing: Claude receives JSON response and synthesizes answer for user
Part 1: Project Setup
Initialize Go Module
mkdir book-mcp-server
cd book-mcp-server
go mod init github.com/yourusername/book-mcp-server
# Install dependencies
go get github.com/mark3labs/mcp-go@latest
go get github.com/urfave/cli/v2
go get connectrpc.com/connect # If using gRPC backend
Dependencies explained:
mark3labs/mcp-go: Official Go SDK for MCPurfave/cli/v2: CLI framework for parsing flagsconnectrpc.com/connect: Modern gRPC client (if backend uses gRPC)
Project Structure
book-mcp-server/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── internal/
│ ├── client/
│ │ └── book_client.go # API client wrapper
│ ├── tools/
│ │ └── handler.go # MCP tools registration
│ └── auth/
│ └── validator.go # Token authentication
├── go.mod
└── go.sum
Part 2: Connect to Backend API
Assuming you have an existing Books API (gRPC or REST), create a client wrapper.
Option A: gRPC Backend (with Connect)
If your backend uses gRPC with Connect protocol:
// internal/client/book_client.go
package client
import (
"context"
"fmt"
"net/http"
"connectrpc.com/connect"
booksv1 "github.com/yourusername/book-mcp-server/gen/books/v1"
"github.com/yourusername/book-mcp-server/gen/books/v1/booksv1connect"
)
type BookClient struct {
client booksv1connect.BookServiceClient
}
func NewBookClient(baseURL string) *BookClient {
httpClient := &http.Client{}
client := booksv1connect.NewBookServiceClient(httpClient, baseURL)
return &BookClient{client: client}
}
func (c *BookClient) ListBooks(ctx context.Context, page, limit int32, query string) ([]Book, int64, error) {
req := connect.NewRequest(&booksv1.ListBooksRequest{
Page: page,
Limit: limit,
Query: &query,
})
res, err := c.client.ListBooks(ctx, req)
if err != nil {
return nil, 0, fmt.Errorf("failed to list books: %w", err)
}
books := make([]Book, len(res.Msg.Books))
for i, b := range res.Msg.Books {
books[i] = Book{
ID: b.Id,
Title: b.Title,
Author: b.Author,
Year: b.Year,
Category: b.Category,
}
}
return books, res.Msg.Total, nil
}
func (c *BookClient) GetBook(ctx context.Context, id int32) (*Book, error) {
req := connect.NewRequest(&booksv1.GetBookRequest{Id: id})
res, err := c.client.GetBook(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get book: %w", err)
}
book := res.Msg.GetBook()
return &Book{
ID: book.Id,
Title: book.Title,
Author: book.Author,
Year: book.Year,
Category: book.Category,
}, nil
}
Option B: REST API Backend
If your backend is a REST API:
// internal/client/book_client.go
package client
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type Book struct {
ID int32 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year *int32 `json:"year,omitempty"`
Category string `json:"category,omitempty"`
}
type ListBooksResponse struct {
Books []Book `json:"books"`
Total int64 `json:"total"`
Page int32 `json:"page"`
TotalPages int32 `json:"total_pages"`
}
type BookClient struct {
baseURL string
httpClient *http.Client
}
func NewBookClient(baseURL string) *BookClient {
return &BookClient{
baseURL: baseURL,
httpClient: &http.Client{},
}
}
func (c *BookClient) ListBooks(ctx context.Context, page, limit int32, query string) ([]Book, int64, error) {
u, err := url.Parse(fmt.Sprintf("%s/api/v1/books", c.baseURL))
if err != nil {
return nil, 0, err
}
q := u.Query()
q.Set("page", fmt.Sprintf("%d", page))
q.Set("limit", fmt.Sprintf("%d", limit))
if query != "" {
q.Set("q", query)
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, 0, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var result ListBooksResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, 0, err
}
return result.Books, result.Total, nil
}
func (c *BookClient) GetBook(ctx context.Context, id int32) (*Book, error) {
url := fmt.Sprintf("%s/api/v1/books/%d", c.baseURL, id)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var book Book
if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
return nil, err
}
return &book, nil
}
Part 3: Build the MCP Server
Initialize MCP Server
// cmd/server/main.go
package main
import (
"log"
"os"
"github.com/mark3labs/mcp-go/server"
"github.com/yourusername/book-mcp-server/internal/client"
"github.com/yourusername/book-mcp-server/internal/tools"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "book-mcp-server",
Usage: "MCP Server for Books API",
Version: "1.0.0",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "api-url",
Value: "http://localhost:8080",
Usage: "Backend API URL",
EnvVars: []string{"BOOKS_API_URL"},
},
&cli.StringFlag{
Name: "port",
Value: "4000",
Usage: "Port to listen on",
EnvVars: []string{"MCP_PORT"},
},
},
Action: runServer,
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func runServer(ctx *cli.Context) error {
apiURL := ctx.String("api-url")
port := ctx.String("port")
log.Printf("Starting MCP server on port %s", port)
log.Printf("Backend API: %s", apiURL)
// Create API client
bookClient := client.NewBookClient(apiURL)
// Create MCP server
mcpServer := server.NewMCPServer(
"Books MCP Server",
"1.0.0",
)
// Register tools
toolsHandler := tools.NewHandler(bookClient)
toolsHandler.RegisterTools(mcpServer)
// Start HTTP server (Streamable SSE endpoint)
httpServer := server.NewStreamableHTTPServer(mcpServer)
log.Printf("MCP endpoint: http://localhost:%s/mcp", port)
return httpServer.Start(":" + port)
}
Key points:
server.NewMCPServer(): Creates MCP server with name and versionserver.NewStreamableHTTPServer(): Creates HTTP server with SSE (Server-Sent Events) transport- The
/mcpendpoint handles MCP protocol messages (JSON-RPC over SSE)
Part 4: Register MCP Tools
This is the core part - defining tools that AI can use.
Tool Handler Structure
// internal/tools/handler.go
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/yourusername/book-mcp-server/internal/client"
)
type Handler struct {
client *client.BookClient
}
func NewHandler(client *client.BookClient) *Handler {
return &Handler{client: client}
}
func (h *Handler) RegisterTools(s *server.MCPServer) {
h.registerListBooks(s)
h.registerGetBook(s)
h.registerSearchBooks(s)
}
Tool 1: List Books
func (h *Handler) registerListBooks(s *server.MCPServer) {
// Define tool schema
tool := mcp.NewTool("list_books",
mcp.WithDescription("List books with pagination. Returns a list of books with total count and page info."),
mcp.WithNumber("page",
mcp.Required(),
mcp.Description("Page number (starting from 1)"),
),
mcp.WithNumber("limit",
mcp.Required(),
mcp.Description("Number of books per page (1-100)"),
),
)
// Define handler function
handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
// Parse arguments (JSON-RPC sends numbers as float64)
page := int32(arguments["page"].(float64))
limit := int32(arguments["limit"].(float64))
// Validate inputs
if page < 1 {
return mcp.NewToolResultError("page must be >= 1"), nil
}
if limit < 1 || limit > 100 {
return mcp.NewToolResultError("limit must be between 1 and 100"), nil
}
// Call backend API
books, total, err := h.client.ListBooks(ctx, page, limit, "")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to list books: %v", err)), nil
}
// Build response
response := map[string]interface{}{
"books": books,
"total": total,
"page": page,
"limit": limit,
}
// Format as JSON
jsonData, err := json.MarshalIndent(response, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
}
// Return to AI
return mcp.NewToolResultText(string(jsonData)), nil
}
// Register tool + handler
s.AddTool(tool, handler)
}
Understanding the code:
-
Tool Definition (
mcp.NewTool):- First arg: tool name (used by AI to call it)
WithDescription(): Explains what the tool does (AI reads this to decide when to use it)WithNumber(),WithString(): Define input parameters with typesmcp.Required(): Mark parameter as required
-
Handler Function:
- Receives
context.Contextandarguments map[string]interface{} - Parse arguments (JSON-RPC sends numbers as
float64, so cast them) - Validate inputs before calling backend
- Call backend API with context
- Return JSON response with
mcp.NewToolResultText()
- Receives
-
Error Handling:
- Use
mcp.NewToolResultError()for user-facing errors - AI will read the error message and might retry with different arguments
- Return
(result, nil)not(nil, err)- framework errors are rare
- Use
Tool 2: Get Book by ID
func (h *Handler) registerGetBook(s *server.MCPServer) {
tool := mcp.NewTool("get_book",
mcp.WithDescription("Get detailed information about a specific book by ID"),
mcp.WithNumber("id",
mcp.Required(),
mcp.Description("Book ID"),
),
)
handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
id := int32(arguments["id"].(float64))
if id < 1 {
return mcp.NewToolResultError("id must be >= 1"), nil
}
book, err := h.client.GetBook(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to get book: %v", err)), nil
}
jsonData, err := json.MarshalIndent(book, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
}
return mcp.NewToolResultText(string(jsonData)), nil
}
s.AddTool(tool, handler)
}
Tool 3: Search Books
func (h *Handler) registerSearchBooks(s *server.MCPServer) {
tool := mcp.NewTool("search_books",
mcp.WithDescription("Search books by query (searches in title and author fields). Supports pagination."),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query (e.g., 'architecture', 'Martin Fowler')"),
),
mcp.WithNumber("page",
mcp.Description("Page number (default: 1)"),
),
mcp.WithNumber("limit",
mcp.Description("Results per page (default: 10, max: 100)"),
),
)
handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
query := arguments["query"].(string)
// Optional parameters with defaults
page := int32(1)
limit := int32(10)
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
page = int32(pageVal.(float64))
}
if limitVal, ok := arguments["limit"]; ok && limitVal != nil {
limit = int32(limitVal.(float64))
}
// Validate
if query == "" {
return mcp.NewToolResultError("query cannot be empty"), nil
}
if limit > 100 {
return mcp.NewToolResultError("limit cannot exceed 100"), nil
}
books, total, err := h.client.ListBooks(ctx, page, limit, query)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil
}
response := map[string]interface{}{
"books": books,
"total": total,
"page": page,
"limit": limit,
"query": query,
}
jsonData, err := json.MarshalIndent(response, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
}
return mcp.NewToolResultText(string(jsonData)), nil
}
s.AddTool(tool, handler)
}
Handling optional parameters:
// Default value
page := int32(1)
// Check if parameter exists and is not nil
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
page = int32(pageVal.(float64))
}
Part 5: Authentication (Optional but Recommended)
Token-based Auth Middleware
// internal/auth/validator.go
package auth
import (
"context"
"fmt"
"os"
"github.com/mark3labs/mcp-go/mcp"
)
type TokenValidator struct {
validToken string
}
func NewTokenValidator() *TokenValidator {
token := os.Getenv("MCP_TOKEN")
if token == "" {
token = "default-secret-token" // Dev only!
}
return &TokenValidator{validToken: token}
}
func (v *TokenValidator) Validate(token string) error {
if token != v.validToken {
return fmt.Errorf("invalid token")
}
return nil
}
// Middleware wrapper for tool handlers
func WithAuth(validator *TokenValidator, handler func(context.Context, map[string]interface{}) (*mcp.CallToolResult, error)) func(context.Context, map[string]interface{}) (*mcp.CallToolResult, error) {
return func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
// Check for auth token (could be passed via arguments or context)
token := os.Getenv("MCP_TOKEN")
if t, ok := arguments["_auth_token"].(string); ok && t != "" {
token = t
}
if err := validator.Validate(token); err != nil {
return mcp.NewToolResultError("Authentication failed"), nil
}
// Remove auth token from arguments before passing to handler
delete(arguments, "_auth_token")
return handler(ctx, arguments)
}
}
Usage in tool registration:
func (h *Handler) registerGetBook(s *server.MCPServer) {
tool := mcp.NewTool("get_book", ...)
// Wrap handler with auth
handler := auth.WithAuth(h.validator, func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
id := int32(arguments["id"].(float64))
book, err := h.client.GetBook(ctx, id)
// ...
})
s.AddTool(tool, handler)
}
Part 6: Testing and Deployment
Local Testing with MCP Inspector
The MCP Inspector is an official tool for testing MCP servers:
# Terminal 1: Start your MCP server
go run cmd/server/main.go --api-url http://localhost:8080 --port 4000
# Terminal 2: Run inspector
npx @modelcontextprotocol/inspector@latest http://localhost:4000/mcp
Inspector UI opens at http://localhost:5173:
- Tools tab: View all available tools
- Test tool: Select tool, fill arguments, click “Run”
- View response: JSON output from backend
Test cases:
// list_books
{
"page": 1,
"limit": 10
}
// get_book
{
"id": 1
}
// search_books
{
"query": "architecture",
"page": 1,
"limit": 5
}
Testing with Claude Desktop
Add to Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"books": {
"command": "/path/to/book-mcp-server",
"args": [
"--api-url", "http://localhost:8080",
"--port", "4000"
],
"env": {
"MCP_TOKEN": "your-secret-token"
}
}
}
}
Restart Claude Desktop. You’ll see a tools icon. Test with:
- “List 5 books from the database”
- “Search for books about ‘distributed systems’”
- “Tell me about book ID 3”
- “Find books by Martin Fowler”
Docker Deployment
# Dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o mcp-server ./cmd/server
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/mcp-server .
EXPOSE 4000
ENTRYPOINT ["./mcp-server"]
# docker-compose.yml
version: '3.8'
services:
mcp-server:
build: .
ports:
- "4000:4000"
environment:
- BOOKS_API_URL=http://backend:8080
- MCP_TOKEN=${MCP_TOKEN}
- MCP_PORT=4000
depends_on:
- backend
backend:
image: your-books-api:latest
ports:
- "8080:8080"
Deploy:
export MCP_TOKEN="production-secret-token"
docker-compose up -d
# Test
curl http://localhost:4000/mcp
Best Practices
1. Descriptive Tool Names and Descriptions
Good:
mcp.NewTool("search_books",
mcp.WithDescription("Search books by query (searches in title and author). Returns paginated results with total count."),
)
Bad:
mcp.NewTool("search", // Too generic
mcp.WithDescription("Search"), // Not informative
)
AI needs clear descriptions to decide when to use each tool.
2. Input Validation
Always validate before calling backend:
if page < 1 {
return mcp.NewToolResultError("page must be >= 1"), nil
}
if limit < 1 || limit > 100 {
return mcp.NewToolResultError("limit must be between 1 and 100"), nil
}
3. Clear Error Messages
Return descriptive errors for AI:
// ❌ Bad
return mcp.NewToolResultError("error"), nil
// ✅ Good
return mcp.NewToolResultError("Failed to fetch book: book ID 999 not found"), nil
4. Structured JSON Responses
AI processes JSON best. Always return well-formatted JSON:
response := map[string]interface{}{
"books": books,
"total": total,
"page": page,
}
jsonData, err := json.MarshalIndent(response, "", " ")
return mcp.NewToolResultText(string(jsonData)), nil
5. Context Propagation
Pass context to support timeouts and cancellation:
handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
// ctx has timeout from MCP framework
result, err := h.client.GetBook(ctx, id) // ✅ Pass ctx
// ...
}
6. Pagination Defaults
Provide sensible defaults:
page := int32(1) // Default page
limit := int32(10) // Default limit
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
page = int32(pageVal.(float64))
}
7. Logging for Debugging
Log tool calls:
import "log"
handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
log.Printf("[MCP] get_book called with id=%v", arguments["id"])
book, err := h.client.GetBook(ctx, id)
if err != nil {
log.Printf("[MCP] get_book failed: %v", err)
return mcp.NewToolResultError(fmt.Sprintf("Failed: %v", err)), nil
}
log.Printf("[MCP] get_book success: %s", book.Title)
return mcp.NewToolResultText(string(jsonData)), nil
}
Common Pitfalls
1. Type Assertions
JSON-RPC sends numbers as float64:
// ❌ Wrong - will panic!
id := arguments["id"].(int32)
// ✅ Correct
id := int32(arguments["id"].(float64))
2. Optional Parameters
Check existence before accessing:
// ❌ Wrong - panics if not provided
page := arguments["page"].(float64)
// ✅ Correct
page := int32(1) // Default
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
page = int32(pageVal.(float64))
}
3. Error Returns
Understand the difference:
// ❌ Wrong - framework error (rare, for connection issues)
return nil, fmt.Errorf("book not found")
// ✅ Correct - tool execution error (user-facing)
return mcp.NewToolResultError("Book not found"), nil
Return nil, err only for internal framework errors.
4. Large Responses
MCP has no built-in response pagination. Limit at tool level:
if limit > 100 {
return mcp.NewToolResultError("Maximum limit is 100 to prevent large responses"), nil
}
5. Context Cancellation
Respect context cancellation for long-running operations:
select {
case <-ctx.Done():
return mcp.NewToolResultError("Request cancelled or timed out"), nil
default:
result, err := h.client.LongRunningOperation(ctx)
// ...
}
Conclusion
You now have a production-ready MCP server that:
- Exposes your API to AI: No more prompt engineering with hardcoded data
- Type-safe tool definitions: Schema validation with
mcp.NewTool() - Proper error handling: Clear messages for AI to understand
- Authentication: Token-based auth middleware
- Testing tools: Inspector UI and Claude Desktop integration
- Production deployment: Docker with health checks
Key takeaways:
- MCP standardizes how AI models interact with external tools
mark3labs/mcp-goSDK handles protocol complexity- Tool descriptions must be clear - AI uses them to decide when to call
- Validate inputs at MCP layer before hitting backend
- JSON responses work best with AI models
- Test with Inspector before deploying to Claude Desktop
Next steps:
- Add write operations (create_book, update_book, delete_book)
- Implement rate limiting per API key
- Add caching layer for frequently-accessed data
- Monitor tool usage with metrics (call count, latency, errors)
- Build multi-API MCP server (books + users + orders)
Resources: