To build an MCP server in Go, use the official SDK, github.com/modelcontextprotocol/go-sdk. Define each tool as a Go function with typed input and output structs, and register it with mcp.AddTool. The SDK generates the JSON schema the model sees. Serve it over stdio for local tools, or over Streamable HTTP for a remote SaaS server. Before any agent touches real data, put it behind bearer-token auth, derive the tenant from the token, and add rate limits, input validation, and an audit log. The protocol part is small. Designing safe, useful tools is the real work.
We build Go backends and AI automation, and "can you give our product an MCP server?" is now a regular request from SaaS teams whose customers use AI assistants. This is how we build one.
The Model Context Protocol is an open standard for connecting AI applications to tools and data. A server exposes tools (actions the model can call), resources (data it can read), and prompts (reusable templates). Any MCP-compatible client can discover and use them, so you integrate once instead of once per AI vendor.
The protocol has matured quickly. The 2026 roadmap moves remote servers toward a stateless core that scales on ordinary HTTP infrastructure, which makes a Go service behind a normal load balancer a natural fit.
The most common mistake is to wrap every REST endpoint as a tool. Models choose tools from their names and descriptions, so a long list of low-level endpoints produces confused, expensive agents. Design for the task instead:
One tool per user intent.
For example
search_orders
and
refund_order
, not
GET /orders
,
GET /orders/{id}
, and
POST /refunds
separately.
Small, typed inputs
with enums where possible, and descriptions that say what each field means.
Descriptions that state limits and side effects:
read-only or not, maximum results, what happens on error.
Compact outputs.
Return the fields the model needs, not your whole database row. Every byte ends up in the model's context and on your token bill (see
).
Separate read tools from write tools
, and make destructive actions require explicit confirmation.
The SDK's typed tools are the nicest part. Input and output are plain structs, and jsonschema tags become the descriptions the model sees:
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type GreetInput struct {
Name string `json:"name" jsonschema:"the name of the person to greet"`
}
type GreetOutput struct {
Greeting string `json:"greeting" jsonschema:"the greeting to tell the user"`
}
func greet(ctx context.Context, req *mcp.CallToolRequest, in GreetInput) (*mcp.CallToolResult, GreetOutput, error) {
return nil, GreetOutput{Greeting: "Hi " + in.Name}, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "Say hi to a person by name."}, greet)
// stdio: the client launches this binary and talks over stdin/stdout.
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}That's enough for a local developer tool. A SaaS product needs a remote server.
Here's the shape we use for a product integration: one read-only tool, stateless Streamable HTTP, and bearer-token auth where the tenant comes from the verified token, never from tool arguments.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type SearchOrdersInput struct {
Status string `json:"status" jsonschema:"order status: pending, shipped or cancelled"`
Limit int `json:"limit,omitempty" jsonschema:"max orders to return, 1-50 (default 20)"`
}
type OrderSummary struct {
ID string `json:"id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
CreatedAt time.Time `json:"created_at"`
}
type SearchOrdersOutput struct {
Orders []OrderSummary `json:"orders"`
}
// OrderStore is your data layer; every query is scoped by tenant.
type OrderStore interface {
Search(ctx context.Context, tenantID, status string, limit int) ([]OrderSummary, error)
}
func searchOrders(store OrderStore) func(context.Context, *mcp.CallToolRequest, SearchOrdersInput) (*mcp.CallToolResult, SearchOrdersOutput, error) {
return func(ctx context.Context, req *mcp.CallToolRequest, in SearchOrdersInput) (*mcp.CallToolResult, SearchOrdersOutput, error) {
tenantID, err := tenantFrom(req)
if err != nil {
return nil, SearchOrdersOutput{}, err
}
switch in.Status {
case "pending", "shipped", "cancelled":
default:
return nil, SearchOrdersOutput{}, fmt.Errorf("unknown status %q", in.Status)
}
if in.Limit <= 0 || in.Limit > 50 {
in.Limit = 20
}
orders, err := store.Search(ctx, tenantID, in.Status, in.Limit)
if err != nil {
return nil, SearchOrdersOutput{}, err
}
return nil, SearchOrdersOutput{Orders: orders}, nil
}
}
// The tenant comes from the verified token, never from model-supplied input.
func tenantFrom(req *mcp.CallToolRequest) (string, error) {
// Extra is nil when the call didn't arrive over HTTP (e.g. stdio or tests).
if req.Extra == nil || req.Extra.TokenInfo == nil {
return "", errors.New("unauthenticated")
}
tenant, _ := req.Extra.TokenInfo.Extra["tenant_id"].(string)
if tenant == "" {
return "", errors.New("token has no tenant")
}
return tenant, nil
}
func main() {
var store OrderStore = newPostgresOrderStore() // your implementation
server := mcp.NewServer(&mcp.Implementation{Name: "acme-orders", Version: "v1.0.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "search_orders",
Description: "Search the signed-in customer's orders by status. Read-only. Returns at most 50 orders.",
}, searchOrders(store))
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{Stateless: true})
// verifyToken validates signature, issuer, audience and expiry with your
// identity provider and maps the claims (scopes, tenant) into TokenInfo.
protected := auth.RequireBearerToken(verifyToken, &auth.RequireBearerTokenOptions{
Scopes: []string{"orders:read"},
})(handler)
mux := http.NewServeMux()
mux.Handle("/mcp", protected)
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}A few notes:
Stateless mode
means no sticky sessions or shared session store, so the server scales horizontally behind a plain load balancer like any other Go service.
auth.RequireBearerToken
rejects unauthenticated requests before they reach MCP handling, and the verified
TokenInfo
is available to every tool as
req.Extra.TokenInfo
. The SDK evolves quickly, so check the verifier signature and
TokenInfo
fields against the version you pin.
Returning an error
from a tool handler reports a tool error to the model, which can then correct its input. Validate strictly and return messages the model can act on.
Want an MCP server for your product without opening a hole in your data layer? We design and build production MCP servers in Go, with auth, tenancy, and audit built in. Book a technical discovery call.
The SDK ships in-memory transports, so you can drive your server with a real MCP client inside go test, speaking the actual protocol, with no network involved. Start with the test that matters most: a call without a verified tenant must fail before it reaches your data layer.
package main
import (
"context"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type fakeStore struct{ called bool }
func (f *fakeStore) Search(ctx context.Context, tenantID, status string, limit int) ([]OrderSummary, error) {
f.called = true
return nil, nil
}
// A call with no verified token must fail before touching the data layer.
func TestSearchOrdersRequiresTenant(t *testing.T) {
ctx := context.Background()
store := &fakeStore{}
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "search_orders", Description: "test"}, searchOrders(store))
serverTransport, clientTransport := mcp.NewInMemoryTransports()
if _, err := server.Connect(ctx, serverTransport, nil); err != nil {
t.Fatal(err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
res, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_orders",
Arguments: map[string]any{"status": "pending"},
})
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatal("expected a tool error for an unauthenticated call")
}
if store.called {
t.Fatal("data layer was queried without a tenant")
}
}From there, add table-driven tests per tool: valid input, each invalid enum, out-of-range limits, and an ID owned by a different tenant. The last one is the test that stops a data leak. Tool descriptions are part of your contract too, so snapshot tools/list output in a test and review the diff whenever it changes, since a reworded description changes how models use the tool.
Before an AI agent touches real customer data through your server:
Authentication:
short-lived tokens from your existing identity provider, checked on every request. No shared API keys pasted into prompts.
Tenant isolation:
tenant and user derived from the token, and enforced in every query (ideally also with row-level security in Postgres).
Least privilege:
scopes per tool, with read tools and write tools behind different scopes.
Input validation:
enums, bounds, and ID ownership checks. Treat model input like untrusted user input, because it can be steered by prompt injection.
Confirmation for side effects:
destructive or financial tools return a preview and require an explicit confirm step. See the human-in-the-loop pattern in
.
Rate limits and quotas
per tenant and per tool, so a looping agent can't hammer your database.
Audit logging:
who (user, tenant, client), which tool, what input, what result. This is also your debugging trail.
Timeouts and output limits:
cap execution time and response size per tool.
Observability:
trace each tool call with OpenTelemetry, alongside your normal service metrics.
Versioning:
add new tools rather than silently changing existing ones, and keep descriptions accurate.
Load testing:
agents generate bursty traffic. Test the server like any other public API. Our
includes a load-test harness you can reuse.
A Model Context Protocol server exposes tools, resources, and prompts that AI agents and assistants can discover and call through a standard protocol. Instead of building a custom integration per AI client, you expose your product's capabilities once and any MCP-compatible client can use them.
Yes. The Model Context Protocol project maintains github.com/modelcontextprotocol/go-sdk, which supports typed tools with generated JSON schemas, stdio and Streamable HTTP transports, and bearer-token auth middleware.
Local (stdio) servers suit developer tools that act on a user's machine. For a SaaS product, run a remote server over Streamable HTTP, behind your normal auth, so every call is authenticated, scoped to a tenant, rate-limited, and logged.
Authenticate every request with short-lived tokens from your identity provider, derive the tenant and user from the token (never from tool arguments), expose narrowly scoped tools, validate every input, rate-limit per tenant, require confirmation for destructive actions, and keep an audit log.
An endpoint is designed for programs that already know your API. A tool is designed for a model deciding what to call: it needs a clear name, a precise description, a small typed input, and compact output. Good tools usually wrap several endpoints into one task.
An MCP server is a small Go service with a big blast radius if it's done carelessly. We build them the way we build any high-performance Go backend: tested, load-tested, OWASP-checked, and yours to own. Book a technical discovery call and we'll scope the tools your customers' agents actually need.

CEO & Senior Backend Engineer at CapregSoft. Building high-performance backends and AI automation systems for funded startups and mid-market teams.
The 12 checks we run before a system hits 10k req/sec — what to fix before it breaks in production. Free, no fluff.