Architecture
Running MCP servers in Go in production
What the July 2026 MCP specification changes: a stateless core, OAuth instead of a shared token, and the tool design mistakes we see most often.
An MCP server following the specification of 28 July 2026 holds no session state. The revision removes the initialize handshake and the Mcp-Session-Id header: every request carries the protocol version and client identity itself, in _meta. For a production server in Go that means three things: round-robin behind an ordinary load balancer, state in server-issued handles, identity in an OAuth token per call.
The primary source is the changelog for revision 2026-07-28. All the code examples below are written against the official Go SDK v1.7.0, which went stable the same day.
What the specification of 28 July 2026 changes
Revision 2026-07-28 turns MCP from a bidirectional, stateful protocol into a request/response protocol. The changelog lists nine points as “major changes”, and six of them force work on an existing server.
| Before (2025-11-25) | After (2026-07-28) | What you have to change |
|---|---|---|
initialize handshake, Mcp-Session-Id header | every request self-describing through _meta; new mandatory RPC server/discover | turn off sticky sessions at the load balancer, remove process-local session storage |
server-initiated requests (roots/list, sampling/createMessage, elicitation/create) over a held stream | multi round-trip requests: the server answers with resultType: "input_required", the client repeats with inputResponses | encode intermediate state in requestState rather than holding it in the process |
| routing by inspecting the JSON body | mandatory Mcp-Method header on every streamable HTTP POST, Mcp-Name additionally on tools/call, resources/read and prompts/get | switch gateway rules to headers, otherwise HeaderMismatchError (-32020) |
| list results without a cache hint | ttlMs and cacheScope are mandatory fields on tools/list, prompts/list, resources/list, resources/read and resources/templates/list | set the values deliberately; for user-dependent tool lists, cacheScope: "private" |
| roots, sampling and logging active | all three deprecated (SEP-2577), still functional | log to stderr or through OpenTelemetry, replace sampling with a direct call to the provider API |
| Dynamic Client Registration (RFC 7591) as the standard route | deprecated in favour of Client ID Metadata Documents | support CIMD, keep DCR only as a fallback |
For planning purposes the governance part matters: the specification now has a feature lifecycle and deprecation policy with a minimum window of twelve months. Anyone using roots, sampling or logging does not have to rebuild this week. Anyone building new does not touch them at all.
A common misunderstanding concerns tasks: they have not moved into the core but out of the experimental core into the io.modelcontextprotocol/tasks extension. MCP Apps (io.modelcontextprotocol/ui) and enterprise-managed authorization also sit outside the core, but were never in it: both have been extensions from the start. Extensions are opt-in and negotiated through the extensions field.
Why an MCP server gets built in Go
Go is the language the agent infrastructure is already written in: Kubernetes controllers, inference gateways, agentgateway, kagent. Why we build critical backends in Go generally is in a separate article on Go for long-lived systems, and the rest of this text does not repeat it.
One point is new for MCP. A tool server is a short-lived process that restarts often, runs in many instances, and is occasionally deployed separately per tenant. A static binary in a FROM scratch image is a different starting position for that than an interpreter with a package tree.
The Go SDK requires at least Go 1.25.0 in its go.mod. As a base, Go 1.26 at patch level 1.26.6 of 13 August 2026 is a sensible choice.
Stateless means the state gets a name
A production MCP server holds no session state in the process. State belongs in external storage and is addressed through a server-issued handle that travels back and forth as an ordinary tool argument. That is how SEP-2567 describes it.
The gain: the second call may land on a different instance, a deployment in the middle of an agent session destroys nothing, and the load balancer needs no stickiness. In the Go SDK it is one field, StreamableHTTPOptions.Stateless.
The four examples in this article are complete programs: copy them, go get github.com/modelcontextprotocol/go-sdk@v1.7.0, go run .
// Complete program. go get github.com/modelcontextprotocol/go-sdk@v1.7.0
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Store is the external state store. In production: Redis, Valkey, Postgres.
// Just not the process, because the next call lands on a different instance.
type Store interface {
Put(ctx context.Context, handle string, v any, ttl time.Duration) error
Get(ctx context.Context, handle string, v any) error
}
// memStore is the throwaway variant, so this example runs without Redis.
type memStore struct{ m sync.Map }
func (s *memStore) Put(_ context.Context, h string, v any, _ time.Duration) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
s.m.Store(h, b)
return nil
}
func (s *memStore) Get(_ context.Context, h string, v any) error {
b, ok := s.m.Load(h)
if !ok {
return fmt.Errorf("handle %q unknown or expired", h)
}
return json.Unmarshal(b.([]byte), v)
}
type exportState struct {
UserID string `json:"user_id"`
Rows []string `json:"rows"`
}
type startIn struct {
Tenant string `json:"tenant" jsonschema:"Tenant code, three uppercase letters"`
}
type startOut struct {
Handle string `json:"handle" jsonschema:"Opaque handle, pass back unchanged to export_page"`
Total int `json:"total" jsonschema:"Number of rows in the export"`
}
type pageIn struct {
Handle string `json:"handle" jsonschema:"Handle from export_start"`
}
func addExport(s *mcp.Server, store Store) {
mcp.AddTool(s, &mcp.Tool{
Name: "export_start",
Description: "Starts a bookings export and returns a handle. Fetches no data itself.",
}, func(ctx context.Context, req *mcp.CallToolRequest, in startIn) (*mcp.CallToolResult, startOut, error) {
rows := []string{"B-1", "B-2", "B-3"}
handle := fmt.Sprintf("exp_%s_%d", in.Tenant, time.Now().UnixNano())
st := exportState{UserID: userID(req), Rows: rows}
if err := store.Put(ctx, handle, st, 15*time.Minute); err != nil {
return nil, startOut{}, fmt.Errorf("could not store the handle: %w", err)
}
return nil, startOut{Handle: handle, Total: len(rows)}, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "export_page",
Description: "Returns the rows of an export begun with export_start.",
}, func(ctx context.Context, req *mcp.CallToolRequest, in pageIn) (*mcp.CallToolResult, any, error) {
var st exportState
if err := store.Get(ctx, in.Handle, &st); err != nil {
return nil, nil, fmt.Errorf("%w, please call export_start again", err)
}
// The handle belongs to the caller, or it is not served.
if st.UserID != userID(req) {
return nil, nil, fmt.Errorf("handle %q belongs to a different identity", in.Handle)
}
b, _ := json.Marshal(st.Rows)
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil, nil
})
}
// userID returns the identity from the bearer token, see the authentication section.
func userID(req *mcp.CallToolRequest) string {
if req.Extra != nil && req.Extra.TokenInfo != nil {
return req.Extra.TokenInfo.UserID
}
return ""
}
func main() {
store := &memStore{} // one instance, outside the request path
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)
addExport(s, store)
return s
}, &mcp.StreamableHTTPOptions{
// No Mcp-Session-Id, no held connection, no process memory.
Stateless: true,
JSONResponse: true,
})
log.Fatal((&http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}).ListenAndServe())
}
The handle gets a TTL and an owner. Without a TTL the store grows without bound; without the ownership check in export_page, a second user can redeem somebody else’s handle. Both are two lines, and both are missing from most example projects.
Authentication: how a tool call is bound to a user identity
A tool call is bound to an identity through an OAuth 2.1 access token whose aud claim contains the canonical URI of exactly this MCP server. The specification is unusually clear here: MCP servers must verify that a token was issued with them as the audience, and they must not accept or pass on any other tokens.
This is the section the example projects leave out. A prototype runs with a shared bearer token in an environment variable, and every tool call reaches the database under the same service account. At that point the log can no longer establish which human triggered what.
There is also an obligation that is not new but regularly missing: the server must serve OAuth 2.0 Protected Resource Metadata under RFC 9728, otherwise no client finds the responsible authorization server. That has been in the spec since revision 2025-06-18.
// Complete program. go get github.com/modelcontextprotocol/go-sdk@v1.7.0 github.com/golang-jwt/jwt/v5
package main
import (
"context"
"fmt"
"log"
"net/http"
"slices"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/oauthex"
)
const (
// canonicalURI is the resource indicator under RFC 8707. Exactly this value
// must appear in the aud claim, otherwise the token was for another service.
canonicalURI = "https://mcp.example.ch/mcp"
issuer = "https://login.example.ch/realms/erp"
)
// claims extends the standard claims with scope, as Keycloak and Entra ID deliver it.
type claims struct {
Scope string `json:"scope"`
jwt.RegisteredClaims
}
func verifyToken(parser *jwt.Parser, keyFunc jwt.Keyfunc) auth.TokenVerifier {
return func(ctx context.Context, raw string, _ *http.Request) (*auth.TokenInfo, error) {
var c claims
if _, err := parser.ParseWithClaims(raw, &c, keyFunc); err != nil {
return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
}
if c.Issuer != issuer {
return nil, fmt.Errorf("%w: unexpected issuer %q", auth.ErrInvalidToken, c.Issuer)
}
if !slices.Contains(c.Audience, canonicalURI) {
return nil, fmt.Errorf("%w: token not issued for %s", auth.ErrInvalidToken, canonicalURI)
}
if c.Subject == "" {
return nil, fmt.Errorf("%w: no sub claim, no identity", auth.ErrInvalidToken)
}
return &auth.TokenInfo{
UserID: c.Subject,
Scopes: strings.Fields(c.Scope),
Expiration: c.ExpiresAt.Time,
}, nil
}
}
type bookingIn struct {
Account string `json:"account" jsonschema:"Account number under the Swiss SME chart of accounts"`
Rappen int `json:"amount_rappen" jsonschema:"Amount in rappen, integer"`
IdemKey string `json:"idempotency_key" jsonschema:"UUID generated by the caller. The same key never books twice."`
}
func book(ctx context.Context, req *mcp.CallToolRequest, in bookingIn) (*mcp.CallToolResult, any, error) {
tok := req.Extra.TokenInfo
if tok == nil {
return nil, nil, fmt.Errorf("call without identity")
}
if !slices.Contains(tok.Scopes, "accounting:write") {
return nil, nil, fmt.Errorf("scope accounting:write missing")
}
// From here every query runs under tok.UserID, not under a service account.
log.Printf("booking user=%s account=%s rappen=%d idem=%s", tok.UserID, in.Account, in.Rappen, in.IdemKey)
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "booked"}}}, nil, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "booking_create",
Description: "Records a booking. Writes. The same idempotency key never creates a second booking.",
Annotations: &mcp.ToolAnnotations{IdempotentHint: true},
}, book)
mcpHandler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return server },
&mcp.StreamableHTTPOptions{Stateless: true},
)
var keyFunc jwt.Keyfunc // from the issuer's JWKS, cached
requireToken := auth.RequireBearerToken(
verifyToken(jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})), keyFunc),
&auth.RequireBearerTokenOptions{
Scopes: []string{"accounting:read"},
ResourceMetadataURL: "https://mcp.example.ch/.well-known/oauth-protected-resource",
},
)
mux := http.NewServeMux()
mux.Handle("/mcp", requireToken(mcpHandler))
// RFC 9728. The specification makes this document mandatory: without it
// no client finds the responsible authorization server.
mux.Handle("/.well-known/oauth-protected-resource", auth.ProtectedResourceMetadataHandler(
&oauthex.ProtectedResourceMetadata{
Resource: canonicalURI,
AuthorizationServers: []string{issuer},
ScopesSupported: []string{"accounting:read", "accounting:write"},
}))
log.Fatal(http.ListenAndServe(":8080", mux))
}
The decisive point is in a single line: tok.UserID comes from the sub claim and is available to every tool handler from then on. The agent thereby inherits exactly the rights of the signed-in human, not those of a service account.
In Switzerland that is not merely good style. Article 4 of the Data Protection Ordinance (DPO, SR 235.11), the implementing ordinance to the revised DPA, obliges private controllers to log as soon as they process sensitive personal data automatically on a large scale and preventive measures cannot ensure data protection. Paragraph 4 says what has to be in it: information about “the identity of the person who carried out the processing, the type, the date and the time of the processing”.
Paragraph 5 sets retention at a minimum of one year, separated from the processing system. A shared service account does not satisfy that technically. What the revised DPA demands of an agent system beyond that, provision by provision, is in A revDSG-compliant AI architecture.
This is exactly where most prototypes stop, and usually only when the security review arrives. If that is where you are: we will look at your chain from identity provider to tool handler together once and say where it breaks. Book a slot.
Tool design: the mistakes we see most often
The three most common tool design mistakes are too many tools, vague descriptions, and missing idempotency. All three only show up in operation, as wrongly chosen tools and duplicate bookings.
A description is the only context the model has when choosing. It belongs written with boundaries: what the tool does, what it does not do, and which other tool covers the adjacent case.
// Complete program, speaks streamable HTTP on :8080.
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type searchIn struct {
CustomerNo string `json:"customer_no" jsonschema:"Customer number in the format K-00000, exactly as held in Abacus"`
Year int `json:"year" jsonschema:"Financial year, four digits, between 2015 and 2030"`
}
type searchOut struct {
Hits []string `json:"hits" jsonschema:"Invoice numbers, newest first"`
Total int `json:"total" jsonschema:"Total number of hits, even where the list is truncated"`
Truncated bool `json:"truncated" jsonschema:"true when truncated to 50 entries"`
}
func search(ctx context.Context, req *mcp.CallToolRequest, in searchIn) (*mcp.CallToolResult, searchOut, error) {
if in.Year < 2015 || in.Year > 2030 {
// Error text as an instruction, not a status code. The model reads it.
return nil, searchOut{}, fmt.Errorf(
"year %d is outside the maintained range 2015 to 2030, please call again with a year in that range", in.Year)
}
return nil, searchOut{Hits: []string{"RE-2026-0184"}, Total: 1}, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "invoices_search",
Description: "Searches a customer's accounts receivable invoices in a financial year. " +
"Returns at most 50 invoice numbers and changes nothing. " +
"Do not use to load a known invoice number; invoice_read is for that.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, Title: "Search invoices"},
}, search)
handler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return server },
&mcp.StreamableHTTPOptions{Stateless: true},
)
log.Fatal(http.ListenAndServe(":8080", handler))
}
You can call it with curl. Since 2026-07-28 a streamable HTTP POST needs three headers and two mandatory fields in _meta, otherwise the server answers with -32020 or -32602:
curl -sN -X POST http://localhost:8080/ \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: invoices_search' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"invoices_search",
"arguments":{"customer_no":"K-00042","year":1999},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{},
"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1"}}}}'
# event: message
# data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text",
# "text":"year 1999 is outside the maintained range 2015 to 2030,
# please call again with a year in that range"}],
# "isError":true,"resultType":"complete"}}
The error case comes back as isError: true with readable text, not as a JSON-RPC error. That is deliberate: a model can read a sentence and correct the call; a status code it can only guess at.
One detail that stands out when rebuilding this: the Go SDK sets cacheScope: "public" on tools/list by default. Anyone serving a user-dependent tool list has to set private themselves, otherwise a gateway may show one user’s list to the next.
For write tools the same logic applies as for a payments API: the caller sends a self-generated key, the server reserves it before processing, and a repeat returns the same answer without a second effect. How we build that is in our article on idempotency in payments APIs.
With agents, repetition is more frequent than with humans, because after a timeout a model simply calls again.
On quantity: twenty sharply drawn tools beat sixty overlapping ones. And since this revision tools/list is supposed to be deterministically sorted, which improves clients’ prompt cache hit rate.
Operations: timeouts, rate limits, and what belongs in the logs
A failed tool call is reconstructable when a single log line contains the tool name, identity, protocol version, duration and correlation ID. Revision 2026-07-28 documents the OpenTelemetry conventions for _meta for the first time: traceparent, tracestate and baggage travel inside the protocol.
// Complete program. Every tool call produces exactly one JSON log line.
package main
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// logToolCalls writes one line per tool call, from which a failure can be
// reconstructed without asking anyone.
func logToolCalls(logger *slog.Logger) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
call, ok := req.(*mcp.CallToolRequest)
if !ok || method != "tools/call" {
return next(ctx, method, req)
}
start := time.Now()
res, err := next(ctx, method, req)
attrs := []any{
slog.String("tool", call.Params.Name),
slog.String("protocol_version", call.ProtocolVersion()),
slog.Duration("duration", time.Since(start)),
}
// traceparent travels in _meta under the 2026-07-28 specification.
if tp, ok := call.Params.Meta["traceparent"].(string); ok {
attrs = append(attrs, slog.String("traceparent", tp))
}
if extra := call.GetExtra(); extra != nil && extra.TokenInfo != nil {
attrs = append(attrs, slog.String("user_id", extra.TokenInfo.UserID))
}
if err != nil {
logger.Error("tool_call failed", append(attrs, slog.String("error", err.Error()))...)
} else {
logger.Info("tool_call", attrs...)
}
return res, err
}
}
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
server := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)
server.AddReceivingMiddleware(logToolCalls(logger))
mcp.AddTool(server, &mcp.Tool{
Name: "ping",
Description: "Answers with pong. Serves only to check the logging chain.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "pong"}}}, nil, nil
})
handler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return server },
&mcp.StreamableHTTPOptions{Stateless: true},
)
log.Fatal(http.ListenAndServe(":8080", handler))
}
Two operational values are better set explicitly before the first incident sets them for you. First, a timeout per tool rather than per server: a full-text search may take ten seconds, an account balance may not.
Second, a rate limit per identity rather than per IP. Behind an IP sits an entire company; behind an identity sits an agent that loops when something fails. What belongs on the dashboards and what does not is in three dashboards instead of thirty.
Is a pod the right unit for an agent?
One pod per agent is the obvious answer but not obviously the right one. Lin Sun of Solo.io asked the question on 14 July 2026 in the CNCF blog under exactly that title: Is a Pod the right deployment unit for an AI agent?
A pod of its own brings process isolation and, through the ServiceAccount, a Kubernetes identity, but it costs baseline load: an agent is active for seconds and then idle for a long time.
For an MCP server doing pure tool execution that is good news: it is an ordinary stateless service and does not need this discussion. It only becomes relevant once the server executes foreign code. Then the pod is the wrong boundary, and a real sandbox is required.
What we advise against
Do not build an MCP server when an ordinary HTTP API will do. MCP solves a discovery problem, not an integration problem: it describes tools so that a model can find and choose them at runtime without anyone hard-wiring the integration beforehand. If your application knows exactly which endpoint it wants to call, a POST /invoices/search is cheaper and simpler to operate.
The trade-off is real. MCP costs you an authorization server, an RFC 9728 document, maintained tool descriptions, and a protocol in motion. In return you get tools any MCP-capable client can discover without you building anything for it.
Our line: an MCP server earns its keep when more than one client needs the same tools, or when choosing the tools is itself part of the task. For the AI agent platform AIgent that applies.
For a single internal automation with three known calls it does not. We say so even when a project was budgeted differently.
And: do not start the deprecation migration while authentication is missing. Roots, sampling and logging have twelve months. A server without identity binding does not have a deadline, it has a problem.
Frequently asked
What is the Model Context Protocol?
The Model Context Protocol is an open protocol through which AI applications discover and call a server’s tools, resources and prompts at runtime. It builds on JSON-RPC and knows two transports: STDIO for local processes and streamable HTTP for network services. Since revision 2026-07-28 the protocol core is stateless, and every request describes itself.
How do I build an MCP server in Go?
With the official github.com/modelcontextprotocol/go-sdk, currently v1.7.0. A server is created with mcp.NewServer, tools are registered through the generic function mcp.AddTool, which derives the input and output schema from your Go types by reflection. For network operation add mcp.NewStreamableHTTPHandler; for local processes server.Run with mcp.StdioTransport.
How do I authenticate an AI agent’s tool calls?
Through OAuth 2.1 with the MCP server as resource server. The client obtains an access token whose aud claim names the server’s canonical URI and sends it as a bearer token in every HTTP request. The server verifies signature, issuer and audience and translates the sub claim into an identity under which the tool handlers work. A shared static token does not satisfy this.
Do I have to migrate my existing MCP server now?
Not immediately, but the direction is clear. Roots, sampling, logging, the HTTP+SSE transport and dynamic client registration are deprecated and continue to work for at least twelve months. The move to stateless, by contrast, is not a deprecation matter but a protocol change: sessions and Mcp-Session-Id are removed in 2026-07-28. Anyone building new builds stateless.
Let’s talk about your agent infrastructure. Thirty minutes with one of our Go engineers: which tools you actually want to expose, how identity is passed from login through to the tool handler, and whether MCP is the right answer for your case. No sales pitch, no preparation on your side.
Book a slot · Get in touch · More on AI and agent systems and backend engineering