A Go framework for building Model Context Protocol (MCP) servers — like Gin, but for MCP.
mcp-go is an opinionated, production-ready framework for building MCP servers in Go with typed handlers, automatic schema generation, middleware, and multiple transports.
If the MCP SDK gives you protocol correctness, and mark3labs/mcp-go gives you SDK convenience, mcp-go gives you application structure and production defaults.
Building an MCP server directly on top of SDKs means repeatedly solving the same problems:
- input decoding & validation
- schema generation
- error handling
- middleware (auth, timeouts, logging)
- transport wiring (stdio vs HTTP)
mcp-go solves these once — idiomatically, safely, and with great DX.
package main
import (
"context"
"log"
"go.klarlabs.de/mcp"
)
func main() {
srv := mcp.NewServer(mcp.ServerInfo{
Name: "example",
Version: "0.1.0",
})
srv.Tool("hello").
Description("Say hello").
Handler(func(ctx context.Context, in struct{ Name string `json:"name"` }) (string, error) {
return "Hello " + in.Name, nil
})
log.Fatal(mcp.ServeStdio(context.Background(), srv))
}That's it:
- Typed input
- Automatic JSON Schema
- MCP-compliant responses
- No manual routing or validation
Define tools using strongly-typed Go structs. Invalid input never reaches your business logic.
type SearchInput struct {
Query string `json:"query" jsonschema:"required"`
Limit int `json:"limit"`
}
srv.Tool("search").
Description("Search for items").
Handler(func(ctx context.Context, input SearchInput) ([]string, error) {
// Your search logic here
return []string{"result1", "result2"}, nil
})Schemas are derived from Go structs and:
- validated automatically
- exposed via MCP introspection
- kept in sync with code
No manual schema maintenance.
Apply cross-cutting concerns consistently:
srv.Use(
middleware.Recover(),
middleware.Timeout(5*time.Second),
middleware.RequestID(),
middleware.Logging(logger),
)Use cases:
- caller-supplied authz (mcp-go ships no auth — terminate it in your own middleware/transport)
- rate limiting
- tracing
- metrics
- panic recovery
Run the same server over:
- stdio (CLI / agent use)
- HTTP + SSE (service deployments)
- WebSocket (bidirectional communication)
- gRPC (high-performance service-to-service)
// Stdio for CLI tools
mcp.ServeStdio(ctx, srv)
// HTTP + SSE for web services
mcp.ServeHTTP(ctx, srv, ":8080")
// WebSocket for bidirectional communication
mcp.ServeWebSocket(ctx, srv, ":8081")
// gRPC for high-performance service-to-service
mcp.ServeGRPC(ctx, srv, ":9090")Build tools with interactive UIs using the MCP Apps extension. Tools declare a ui:// resource URI via UIResource(), and the linked HTML resource is rendered as a sandboxed iframe in supported hosts like Claude Desktop.
srv.Tool("visualize").
Description("Visualize data interactively").
UIResource("ui://my-app/visualizer").
Handler(func(input VisualizeInput) (any, error) {
return getData(input.ID), nil
})
// Serve the UI as a resource with the MCP Apps MIME type
srv.Resource("ui://my-app/visualizer").
Name("Visualizer").
MimeType("text/html;profile=mcp-app").
Handler(func(ctx context.Context, uri string, params map[string]string) (*mcp.ResourceContent, error) {
return &mcp.ResourceContent{
URI: uri,
MimeType: "text/html;profile=mcp-app",
Text: visualizerHTML,
}, nil
})UIResource() sets _meta.ui.resourceUri on both tools/list and tools/call responses, telling MCP hosts to render the linked resource as an interactive app alongside tool results.
The HTML resource must be a single self-contained file (inline CSS, JS, no external requests). The recommended stack:
@modelcontextprotocol/ext-apps— Client SDK for the MCP Apps postMessage protocol (handlesui/initializehandshake, receives tool results)- Vite +
vite-plugin-singlefile— Bundles everything into one HTML file - Go
embed.FS— Embeds the built HTML files into the Go binary
Vue/React string templates and the runtime compiler. If you use Vue's defineComponent with a template string (instead of .vue SFCs), Vite's default Vue build is runtime-only and cannot compile templates at runtime. The iframe will render empty with no error. Fix this by aliasing Vue to the full build in vite.config.ts:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
vue: "vue/dist/vue.esm-bundler.js",
},
},
});No TypeScript in runtime-compiled templates. Vue's runtime template compiler only understands JavaScript. TypeScript syntax like as any or : string in template expressions will throw SyntaxError: Unexpected identifier. Move type assertions to setup() or use computed properties.
Resource MIME type. Use text/html;profile=mcp-app (not plain text/html) so hosts recognize the resource as an MCP App.
Roady ships 14 MCP Apps (D3.js charts, task boards, interactive dashboards) built with mcp-go. See app/ for the Vue + Vite + singlefile setup and internal/infrastructure/mcp/ for the Go resource registration.
- strict JSON decoding
- safe error mapping
- graceful shutdown
- context propagation everywhere
You can opt out — but safety is the default.
| Project | What it is |
|---|---|
| MCP Go SDK | Low-level protocol implementation |
| mark3labs/mcp-go | Community SDK / helpers |
| mcp-go | Full Go framework for MCP servers |
Think:
- MCP SDK →
net/http - mcp-go → Gin
We build on top of the MCP spec — not instead of it.
Use mcp-go if you:
- are building real MCP services, not just experiments
- want typed APIs and validation
- need auth, limits, observability
- deploy MCP servers in production
If you want raw protocol access only, an SDK may be a better fit.
- Roady — Planning-first system of record with 14 interactive MCP Apps (D3.js visualizations, task boards, dashboards)
- Obvia — Incident automation & AIOps tooling
- Internal MCP services and experiments
Want to add your project? Open a PR!
go get go.klarlabs.de/mcpRequires Go 1.25 or later.
- API Reference
- Getting Started
- Migration from mark3labs/mcp-go
- Comparison: MCP SDK vs mark3labs vs mcp-go
- Examples
- MCP Specification
Tools are functions that can be called by the AI model:
type CalculateInput struct {
Operation string `json:"operation" jsonschema:"required"`
A float64 `json:"a" jsonschema:"required"`
B float64 `json:"b" jsonschema:"required"`
}
srv.Tool("calculate").
Description("Perform arithmetic operations").
Handler(func(input CalculateInput) (float64, error) {
switch input.Operation {
case "add":
return input.A + input.B, nil
case "multiply":
return input.A * input.B, nil
default:
return 0, fmt.Errorf("unknown operation: %s", input.Operation)
}
})Tool handlers can return any JSON-serializable type. The framework automatically handles serialization:
// String returns are used as-is
srv.Tool("greet").
Handler(func(input GreetInput) (string, error) {
return "Hello, " + input.Name, nil
})
// Structs are automatically JSON-serialized
type StatusResult struct {
Status string `json:"status"`
Count int `json:"count"`
Healthy bool `json:"healthy"`
}
srv.Tool("status").
Handler(func(input StatusInput) (StatusResult, error) {
return StatusResult{
Status: "ok",
Count: 42,
Healthy: true,
}, nil
})
// Response text: {"status":"ok","count":42,"healthy":true}
// Maps and slices work too
srv.Tool("list").
Handler(func(input ListInput) (map[string]any, error) {
return map[string]any{
"items": []string{"a", "b", "c"},
"total": 3,
}, nil
})This ensures compliance with the MCP specification which requires the text field to always be a string.
Tools can return typed structured data alongside text content blocks using OutputSchema and StructuredResult:
type TableOutput struct {
Headers []string `json:"headers"`
Rows [][]string `json:"rows"`
}
srv.Tool("extract_table").
Description("Extract table data from a page").
OutputSchema(TableOutput{}).
Handler(func(ctx context.Context, input ExtractInput) (mcp.StructuredResult, error) {
return mcp.StructuredResult{
Content: []mcp.Content{mcp.NewTextContent("Found 3 rows")},
StructuredContent: map[string]any{"headers": []string{"name", "age"}, "rows": [][]string{{"Alice", "30"}}},
}, nil
})The response includes both content (text blocks for display) and structuredContent (typed data matching the output schema). Clients that understand structuredContent can render it natively (tables, trees, etc.).
Add and remove tools at runtime, then notify connected clients:
// Add a tool dynamically
srv.Tool("fill_form").Handler(fillFormHandler)
// Notify clients that the tool list changed
session := mcp.SessionFromContext(ctx)
session.NotifyToolListChanged()
// Remove a tool when it's no longer relevant
srv.RemoveTool("fill_form")
session.NotifyToolListChanged()
// Same pattern works for resources and prompts
srv.RemoveResource("config://temp")
session.NotifyResourceListChanged()
srv.RemovePrompt("onboarding")
session.NotifyPromptListChanged()Resources expose data via URI templates:
srv.Resource("file://{path}").
Name("File").
Description("Read file content").
MimeType("text/plain").
Handler(func(ctx context.Context, uri string, params map[string]string) (*mcp.ResourceContent, error) {
content, err := os.ReadFile(params["path"])
if err != nil {
return nil, err
}
return &mcp.ResourceContent{
URI: uri,
MimeType: "text/plain",
Text: string(content),
}, nil
})Prompts are parameterized message templates:
srv.Prompt("code-review").
Description("Generate a code review prompt").
Argument("language", "Programming language", true).
Handler(func(ctx context.Context, args map[string]string) (*mcp.PromptResult, error) {
return &mcp.PromptResult{
Messages: []mcp.PromptMessage{
{
Role: "user",
Content: mcp.TextContent{Type: "text", Text: fmt.Sprintf("Review this %s code:", args["language"])},
},
},
}, nil
})Attach arbitrary metadata to tools via _meta, or use the UIResource shorthand for MCP Apps:
// Arbitrary metadata
srv.Tool("my-tool").
Meta(map[string]any{"custom": "data"}).
Handler(myHandler)
// MCP Apps shorthand — sets _meta.ui.resourceUri
srv.Tool("visualize").
UIResource("ui://my-app/dashboard").
Handler(vizHandler)The _meta field is included in both tools/list and tools/call JSON-RPC responses.
Add cross-cutting concerns with middleware:
// Use default production middleware stack
middleware := mcp.DefaultMiddlewareWithTimeout(logger, 30*time.Second)
mcp.ServeStdio(ctx, srv, mcp.WithMiddleware(middleware...))Built-in middleware:
Recover()- Catch panics and convert to errorsRequestID()- Inject unique request IDsTimeout(d)- Enforce request deadlinesLogging(logger)- Structured request loggingRateLimit()- Request throttlingSizeLimit()- Request size limitsOTel()- OpenTelemetry tracing and metricsAudit()- Request/response audit loggingTracing()- Correlation and trace ID propagation
Authentication is out of scope. mcp-go never handles tokens, OAuth flows, or credentials. On the client, inject auth via the supplied
http.Clienttransport (mcp.WithHTTPClient). On the server, terminate auth at the transport/proxy layer or in your own middleware.
Persist sessions across server restarts for load-balanced deployments:
// Redis-backed session store
store := redis.NewSessionStore(redisClient, 24*time.Hour)
mcp.ServeHTTP(ctx, srv, ":8080",
mcp.WithSessionStore(store),
)
// In-memory store for single-instance deployments
mcp.ServeHTTP(ctx, srv, ":8080",
mcp.WithSessionStore(mcp.NewInMemorySessionStore()),
)Clients can discover MCP servers via /.well-known/mcp:
mcp.ServeHTTP(ctx, srv, ":8080",
mcp.WithDiscovery(mcp.ServerDiscovery{
Name: "my-server",
Description: "My MCP server",
}),
)Register async tasks that can be created, monitored, and canceled:
srv.RegisterTask("long-task", "A long running task", func(ctx context.Context, input map[string]any) (*mcp.TaskResult, error) {
// Task runs asynchronously
return &mcp.TaskResult{Data: "completed"}, nil
})
// Create a task
task, _ := srv.Tasks().CreateTask(ctx, mcp.CreateTaskRequest{
Name: "long-task",
Params: map[string]any{"key": "value"},
})
// List and cancel tasks
tasks, _ := srv.Tasks().ListTasks(10, "")
srv.Tasks().CancelTask(task.ID)Mark tools with behavioral hints for clients:
srv.Tool("read-config").
Description("Read configuration").
ReadOnly().
Handler(readHandler)
srv.Tool("delete-user").
Description("Delete a user account").
Destructive().
Handler(deleteHandler)
srv.Tool("update-setting").
Description("Update a setting").
Idempotent().
Handler(updateHandler)Provide autocomplete suggestions for prompt arguments and resource URIs:
srv.PromptCompletion("code-review").
Argument("language", func(ctx context.Context, value string) (*mcp.CompletionResult, error) {
return &mcp.CompletionResult{
Values: []string{"go", "python", "typescript"},
}, nil
})Servers can make requests back to clients via sessions:
// Request LLM completion from client (sampling)
session := mcp.SessionFromContext(ctx)
result, _ := session.CreateMessage(ctx, mcp.CreateMessageRequest{
Messages: []mcp.SamplingMessage{{Role: mcp.User, Content: mcp.Content{Type: "text", Text: "Summarize this"}}},
})
// Query client workspace roots
roots, _ := session.ListRoots(ctx)
// Send log messages to client
session.LogMessage(ctx, mcp.LoggingMessage{Level: mcp.Info, Data: "Processing complete"})
// Notify clients of resource changes
session.NotifyResourceUpdated(ctx, "config://app")
// Elicitation — ask the user for structured input mid-task
elicitor := mcp.ElicitFromContext(ctx)
if elicitor != nil {
result, _ := elicitor.Elicit(ctx, &mcp.ElicitRequest{
Message: "Multiple fields match 'Name'. Which one?",
RequestedSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"field": map[string]any{"type": "string", "enum": []string{"First Name", "Last Name"}},
},
},
})
if result.Action == "accept" {
selectedField := result.Content["field"]
// Use selectedField...
}
}
// Channels — push messages proactively into the AI session
channel := mcp.ChannelFromContext(ctx)
if channel != nil {
channel.SendText("navigation", "Page navigated to /dashboard")
channel.Send(&mcp.ChannelMessage{
Channel: "network",
Content: mcp.NewTextContent("API response received"),
Data: map[string]any{"status": 200, "url": "/api/users"},
Priority: "high",
})
}Serve over HTTP with Server-Sent Events:
mcp.ServeHTTP(ctx, srv, ":8080",
mcp.WithReadTimeout(30*time.Second),
mcp.WithWriteTimeout(30*time.Second),
)Serve over WebSocket for full-duplex communication:
mcp.ServeWebSocket(ctx, srv, ":8081",
mcp.WithWebSocketReadTimeout(30*time.Second),
mcp.WithWebSocketWriteTimeout(30*time.Second),
)Serve over gRPC with Protocol Buffers for high-performance service-to-service communication:
mcp.ServeGRPC(ctx, srv, ":9090",
mcp.WithGRPCShutdownTimeout(10*time.Second),
)Consume MCP servers programmatically:
transport, _ := client.NewStdioTransport("./my-server")
c := client.New(transport, client.WithTimeout(10*time.Second))
info, _ := c.Initialize(ctx)
tools, _ := c.ListTools(ctx)
result, _ := c.CallTool(ctx, "search", SearchInput{Query: "hello"})Test MCP servers without transport overhead:
func TestMyServer(t *testing.T) {
srv := mcp.NewServer(mcp.ServerInfo{Name: "test", Version: "1.0.0"})
srv.Tool("greet").Handler(greetHandler)
tc := testutil.NewTestClient(t, srv)
defer tc.Close()
result, err := tc.CallTool("greet", map[string]any{"name": "World"})
if err != nil {
t.Fatal(err)
}
// result == "Hello, World!"
}Use struct tags to define JSON Schema for tool inputs:
type SearchInput struct {
Query string `json:"query" jsonschema:"required,description=Search query"`
Limit int `json:"limit" jsonschema:"description=Max results,default=10"`
Tags []string `json:"tags" jsonschema:"description=Filter by tags"`
MinScore float64 `json:"minScore" jsonschema:"minimum=0,maximum=1"`
}Supported tags:
required- Field is requireddescription=...- Field descriptiondefault=...- Default valueminimum=N/maximum=N- Numeric boundsminLength=N/maxLength=N- String length boundsenum=a|b|c- Allowed values
- Typed > dynamic
- Safe defaults > flexibility
- Frameworks create ecosystems
mcp-go aims to be the default Go framework for MCP — boring, predictable, and a joy to use.
Contributions are welcome! Please read our Contributing Guide.
MIT License - see LICENSE for details.