Blog

Architektur

MCP-Server in Go produktiv betreiben

Was die MCP-Spezifikation vom Juli 2026 ändert: Stateless Core, OAuth statt geteiltem Token, und die Tool-Design-Fehler, die wir am häufigsten sehen.

Ein MCP-Server nach der Spezifikation vom 28. Juli 2026 hält keinen Sitzungszustand. Die Revision entfernt den initialize-Handshake und den Mcp-Session-Id-Header: Jede Anfrage trägt Protokollversion und Client-Identität selbst, in _meta. Produktiv heisst das drei Dinge: Round-Robin hinter einem gewöhnlichen Load Balancer, Zustand in server-vergebenen Handles, Identität in einem OAuth-Token pro Aufruf.

Primärquelle ist die Änderungsliste der Revision 2026-07-28. Alle Codebeispiele unten sind gegen das offizielle Go-SDK v1.7.0 übersetzt, das am selben Tag stabil erschienen ist.

Was die Spezifikation vom 28. Juli 2026 ändert

Die Revision 2026-07-28 macht MCP von einem bidirektionalen, zustandsbehafteten Protokoll zu einem Request/Response-Protokoll. Neun Punkte führt die Änderungsliste als „Major changes“, und sechs davon zwingen zu Arbeit an einem bestehenden Server.

Vorher (2025-11-25) Nachher (2026-07-28) Was ihr anpassen müsst
initialize-Handshake, Mcp-Session-Id-Header Jede Anfrage selbstbeschreibend über _meta; neu Pflicht-RPC server/discover Sticky Sessions am Load Balancer abschalten, prozesslokalen Sitzungsspeicher entfernen
Server-initiierte Requests (roots/list, sampling/createMessage, elicitation/create) über gehaltenen Stream Multi Round-Trip Requests: Server antwortet mit resultType: "input_required", Client wiederholt mit inputResponses Zwischenzustand in requestState kodieren statt im Prozess halten
Routing durch Inspektion des JSON-Bodys Pflicht-Header Mcp-Method auf jedem Streamable-HTTP-POST, Mcp-Name zusätzlich bei tools/call, resources/read und prompts/get Gateway-Regeln auf Header umstellen, sonst HeaderMismatchError (-32020)
Listenergebnisse ohne Cache-Hinweis ttlMs und cacheScope sind auf tools/list, prompts/list, resources/list, resources/read und resources/templates/list Pflichtfelder Werte bewusst setzen; bei nutzerabhängigen Toollisten cacheScope: "private"
Roots, Sampling und Logging aktiv Alle drei deprecated (SEP-2577), weiterhin funktionsfähig Nach stderr oder über OpenTelemetry loggen, Sampling durch direkten Aufruf der Provider-API ersetzen
Dynamic Client Registration (RFC 7591) als Standardweg Deprecated zugunsten von Client ID Metadata Documents CIMD unterstützen, DCR nur noch als Rückfallebene betreiben

Für die Planung zählt der Governance-Teil: Die Spezifikation hat erstmals eine Feature-Lifecycle- und Deprecation-Policy mit einem Mindestfenster von zwölf Monaten. Wer Roots, Sampling oder Logging im Einsatz hat, muss nicht diese Woche umbauen. Wer neu baut, fasst sie gar nicht erst an.

Ein verbreitetes Missverständnis betrifft Tasks: Sie sind nicht in den Kern gewandert, sondern aus dem experimentellen Kern heraus in die Erweiterung io.modelcontextprotocol/tasks. MCP Apps (io.modelcontextprotocol/ui) und Enterprise-Managed Authorization stehen ebenfalls ausserhalb des Kerns, waren dort aber nie: Beide sind von Beginn weg Erweiterungen. Erweiterungen sind opt-in und werden über das Feld extensions ausgehandelt.

Warum ein MCP-Server in Go gebaut wird

Go ist die Sprache, in der die Agenten-Infrastruktur ohnehin schon geschrieben ist: Kubernetes-Controller, Inference-Gateways, agentgateway, kagent. Warum wir kritische Backends generell in Go bauen, steht in einem eigenen Beitrag über Go für langlebige Systeme, und der Rest dieses Texts wiederholt das nicht.

Ein Punkt kommt für MCP neu dazu. Ein Tool-Server ist ein kurzlebiger Prozess, der oft neu startet, in vielen Instanzen läuft und gelegentlich pro Mandant separat deployt wird. Ein statisches Binary in einem FROM scratch-Image ist dafür eine andere Ausgangslage als ein Interpreter mit Paketbaum.

Das Go-SDK verlangt in seiner go.mod mindestens Go 1.25.0. Als Unterbau empfiehlt sich Go 1.26 mit dem Patch-Stand 1.26.6 vom 13. August 2026.

Zustandslos heisst, dass der Zustand einen Namen bekommt

Ein produktiver MCP-Server hält keinen Sitzungszustand im Prozess. Zustand gehört in einen externen Speicher und wird über ein server-vergebenes Handle adressiert, das als gewöhnliches Tool-Argument hin und zurück läuft. So beschreibt es SEP-2567.

Der Gewinn: Der zweite Aufruf darf auf einer anderen Instanz landen, ein Deployment mitten in einer Agenten-Sitzung zerstört nichts, und der Load Balancer braucht keine Klebrigkeit. Im Go-SDK ist das ein Feld, StreamableHTTPOptions.Stateless. Die vier Beispiele in diesem Beitrag sind vollständige Programme: kopieren, go get github.com/modelcontextprotocol/go-sdk@v1.7.0, go run .

// Vollständiges Programm. 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 ist der externe Zustandsspeicher. Produktiv: Redis, Valkey, Postgres.
// Nur nicht der Prozess, denn der nächste Aufruf landet auf einer anderen Instanz.
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 ist die Wegwerf-Variante, damit dieses Beispiel ohne Redis läuft.
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 unbekannt oder abgelaufen", h)
	}
	return json.Unmarshal(b.([]byte), v)
}

type exportState struct {
	UserID string   `json:"user_id"`
	Zeilen []string `json:"zeilen"`
}

type startIn struct {
	Mandant string `json:"mandant" jsonschema:"Mandantenkürzel, drei Grossbuchstaben"`
}

type startOut struct {
	Handle string `json:"handle" jsonschema:"Undurchsichtiges Handle, unverändert an export_seite zurückgeben"`
	Gesamt int    `json:"gesamt" jsonschema:"Anzahl Zeilen im Export"`
}

type seiteIn struct {
	Handle string `json:"handle" jsonschema:"Handle aus export_starten"`
}

func addExport(s *mcp.Server, store Store) {
	mcp.AddTool(s, &mcp.Tool{
		Name:        "export_starten",
		Description: "Startet einen Buchungsexport und liefert ein Handle. Ruft selbst keine Daten ab.",
	}, func(ctx context.Context, req *mcp.CallToolRequest, in startIn) (*mcp.CallToolResult, startOut, error) {
		zeilen := []string{"B-1", "B-2", "B-3"}
		handle := fmt.Sprintf("exp_%s_%d", in.Mandant, time.Now().UnixNano())
		st := exportState{UserID: userID(req), Zeilen: zeilen}
		if err := store.Put(ctx, handle, st, 15*time.Minute); err != nil {
			return nil, startOut{}, fmt.Errorf("Handle konnte nicht abgelegt werden: %w", err)
		}
		return nil, startOut{Handle: handle, Gesamt: len(zeilen)}, nil
	})

	mcp.AddTool(s, &mcp.Tool{
		Name:        "export_seite",
		Description: "Liefert die Zeilen eines mit export_starten begonnenen Exports.",
	}, func(ctx context.Context, req *mcp.CallToolRequest, in seiteIn) (*mcp.CallToolResult, any, error) {
		var st exportState
		if err := store.Get(ctx, in.Handle, &st); err != nil {
			return nil, nil, fmt.Errorf("%w, bitte export_starten erneut aufrufen", err)
		}
		// Das Handle gehört dem Aufrufer, oder es wird nicht bedient.
		if st.UserID != userID(req) {
			return nil, nil, fmt.Errorf("Handle %q gehört zu einer anderen Identität", in.Handle)
		}
		b, _ := json.Marshal(st.Zeilen)
		return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil, nil
	})
}

// userID liefert die Identität aus dem Bearer Token, siehe Abschnitt Authentifizierung.
func userID(req *mcp.CallToolRequest) string {
	if req.Extra != nil && req.Extra.TokenInfo != nil {
		return req.Extra.TokenInfo.UserID
	}
	return ""
}

func main() {
	store := &memStore{} // eine Instanz, ausserhalb des Request-Pfads

	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{
		// Kein Mcp-Session-Id, keine gehaltene Verbindung, kein Prozessgedächtnis.
		Stateless:    true,
		JSONResponse: true,
	})

	log.Fatal((&http.Server{
		Addr:              ":8080",
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
	}).ListenAndServe())
}

Das Handle bekommt eine TTL und einen Eigentümer. Ohne TTL wächst der Speicher unbegrenzt, ohne die Eigentümerprüfung in export_seite kann ein zweiter Nutzer ein fremdes Handle einlösen. Beides sind zwei Zeilen, und beide fehlen in den meisten Beispielprojekten.

Authentifizierung: wie ein Tool-Aufruf an eine Benutzeridentität gebunden wird

Ein Tool-Aufruf wird über ein OAuth-2.1-Access-Token an eine Identität gebunden, dessen aud-Claim die kanonische URI genau dieses MCP-Servers enthält. Die Spezifikation ist hier ungewöhnlich deutlich: MCP-Server müssen prüfen, dass ein Token für sie als Zielgruppe ausgestellt wurde, und sie dürfen keine anderen Tokens akzeptieren oder weiterreichen.

Das ist der Abschnitt, den die Beispielprojekte auslassen. Ein Prototyp läuft mit einem geteilten Bearer-Token in einer Umgebungsvariable, und alle Tool-Aufrufe erreichen die Datenbank unter demselben Dienstkonto. Im Log ist dann nicht mehr feststellbar, welcher Mensch was ausgelöst hat.

Dazu kommt eine Pflicht, die nicht neu ist, aber regelmässig fehlt: Der Server muss OAuth 2.0 Protected Resource Metadata nach RFC 9728 ausliefern, sonst findet kein Client den zuständigen Authorization Server. Das steht so schon in der Revision 2025-06-18.

// Vollständiges Programm. 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 ist der Resource Indicator nach RFC 8707. Genau dieser Wert
	// muss in der aud-Claim stehen, sonst war das Token für einen anderen Dienst.
	canonicalURI = "https://mcp.example.ch/mcp"
	issuer       = "https://login.example.ch/realms/erp"
)

// claims ergänzt die Standard-Claims um scope, wie es Keycloak und Entra ID liefern.
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: unerwarteter Aussteller %q", auth.ErrInvalidToken, c.Issuer)
		}
		if !slices.Contains(c.Audience, canonicalURI) {
			return nil, fmt.Errorf("%w: Token nicht für %s ausgestellt", auth.ErrInvalidToken, canonicalURI)
		}
		if c.Subject == "" {
			return nil, fmt.Errorf("%w: kein sub-Claim, keine Identität", auth.ErrInvalidToken)
		}
		return &auth.TokenInfo{
			UserID:     c.Subject,
			Scopes:     strings.Fields(c.Scope),
			Expiration: c.ExpiresAt.Time,
		}, nil
	}
}

type buchungIn struct {
	Konto   string `json:"konto" jsonschema:"Kontonummer nach Schweizer KMU-Kontenrahmen"`
	Rappen  int    `json:"betrag_rappen" jsonschema:"Betrag in Rappen, ganzzahlig"`
	IdemKey string `json:"idempotenz_schluessel" jsonschema:"Vom Aufrufer erzeugte UUID. Derselbe Schlüssel bucht nie zweimal."`
}

func buchen(ctx context.Context, req *mcp.CallToolRequest, in buchungIn) (*mcp.CallToolResult, any, error) {
	tok := req.Extra.TokenInfo
	if tok == nil {
		return nil, nil, fmt.Errorf("Aufruf ohne Identität")
	}
	if !slices.Contains(tok.Scopes, "buchhaltung:schreiben") {
		return nil, nil, fmt.Errorf("Scope buchhaltung:schreiben fehlt")
	}
	// Ab hier läuft jede Abfrage unter tok.UserID, nicht unter einem Dienstkonto.
	log.Printf("buchung user=%s konto=%s rappen=%d idem=%s", tok.UserID, in.Konto, in.Rappen, in.IdemKey)
	return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "gebucht"}}}, nil, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "buchung_erfassen",
		Description: "Erfasst eine Buchung. Schreibend. Derselbe Idempotenz-Schlüssel erzeugt nie eine zweite Buchung.",
		Annotations: &mcp.ToolAnnotations{IdempotentHint: true},
	}, buchen)

	mcpHandler := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return server },
		&mcp.StreamableHTTPOptions{Stateless: true},
	)

	var keyFunc jwt.Keyfunc // aus dem JWKS des Ausstellers, gecached
	requireToken := auth.RequireBearerToken(
		verifyToken(jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})), keyFunc),
		&auth.RequireBearerTokenOptions{
			Scopes:              []string{"buchhaltung:lesen"},
			ResourceMetadataURL: "https://mcp.example.ch/.well-known/oauth-protected-resource",
		},
	)

	mux := http.NewServeMux()
	mux.Handle("/mcp", requireToken(mcpHandler))
	// RFC 9728. Die Spezifikation macht dieses Dokument zur Pflicht: ohne es
	// findet kein Client den zuständigen Authorization Server.
	mux.Handle("/.well-known/oauth-protected-resource", auth.ProtectedResourceMetadataHandler(
		&oauthex.ProtectedResourceMetadata{
			Resource:             canonicalURI,
			AuthorizationServers: []string{issuer},
			ScopesSupported:      []string{"buchhaltung:lesen", "buchhaltung:schreiben"},
		}))

	log.Fatal(http.ListenAndServe(":8080", mux))
}

Der entscheidende Punkt liegt in einer einzigen Zeile: tok.UserID stammt aus dem sub-Claim und steht ab da jedem Tool-Handler zur Verfügung. Der Agent erbt damit exakt die Rechte des angemeldeten Menschen, nicht die eines Dienstkontos.

In der Schweiz ist das nicht bloss guter Stil. Artikel 4 der Datenschutzverordnung (DSV, SR 235.11), der Ausführungsverordnung zum revDSG, verpflichtet private Verantwortliche zur Protokollierung, sobald sie besonders schützenswerte Personendaten in grossem Umfang automatisiert bearbeiten und die präventiven Massnahmen den Datenschutz nicht gewährleisten können. Absatz 4 sagt, was drinstehen muss: Aufschluss über „die Identität der Person, die die Bearbeitung vorgenommen hat, die Art, das Datum und die Uhrzeit der Bearbeitung“.

Absatz 5 setzt die Aufbewahrung auf mindestens ein Jahr, getrennt vom bearbeitenden System. Ein geteiltes Dienstkonto erfüllt das technisch nicht.

Genau hier bleiben die meisten Prototypen stehen, und meistens erst dann, wenn das Sicherheitsreview kommt. Wenn ihr an dieser Stelle steht: Wir schauen uns eure Kette vom Identity Provider bis zum Tool-Handler einmal gemeinsam an und sagen, wo sie reisst. Termin buchen.

Tool-Design: die Fehler, die wir am häufigsten sehen

Die drei häufigsten Fehler im Tool-Design sind zu viele Werkzeuge, unscharfe Beschreibungen und fehlende Idempotenz. Alle drei zeigen sich erst im Betrieb, als falsch gewählte Werkzeuge und doppelte Buchungen.

Eine Beschreibung ist der einzige Kontext, den das Modell bei der Auswahl hat. Sie gehört mit Abgrenzung geschrieben: was das Werkzeug tut, was es nicht tut, und welches andere Werkzeug für den Nachbarfall zuständig ist.

// Vollständiges Programm, spricht Streamable HTTP auf :8080.
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type sucheIn struct {
	KundenNr string `json:"kunden_nr" jsonschema:"Kundennummer im Format K-00000, exakt wie in Abacus hinterlegt"`
	Jahr     int    `json:"jahr" jsonschema:"Geschäftsjahr, vierstellig, zwischen 2015 und 2030"`
}

type sucheOut struct {
	Treffer  []string `json:"treffer" jsonschema:"Rechnungsnummern, absteigend nach Datum"`
	Gesamt   int      `json:"gesamt" jsonschema:"Anzahl Treffer insgesamt, auch wenn die Liste begrenzt ist"`
	Begrenzt bool     `json:"begrenzt" jsonschema:"true, wenn auf 50 Einträge gekürzt wurde"`
}

func suchen(ctx context.Context, req *mcp.CallToolRequest, in sucheIn) (*mcp.CallToolResult, sucheOut, error) {
	if in.Jahr < 2015 || in.Jahr > 2030 {
		// Fehlertext als Anweisung, nicht als Statuscode. Das Modell liest ihn.
		return nil, sucheOut{}, fmt.Errorf(
			"Jahr %d liegt ausserhalb des gepflegten Bereichs 2015 bis 2030, bitte mit einem Jahr in diesem Bereich erneut aufrufen", in.Jahr)
	}
	return nil, sucheOut{Treffer: []string{"RE-2026-0184"}, Gesamt: 1}, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "erp-bridge", Version: "1.4.0"}, nil)

	mcp.AddTool(server, &mcp.Tool{
		Name: "rechnungen_suchen",
		Description: "Sucht Debitorenrechnungen eines Kunden in einem Geschäftsjahr. " +
			"Liefert höchstens 50 Rechnungsnummern und ändert nichts. " +
			"Nicht verwenden, um eine bekannte Rechnungsnummer zu laden, dafür ist rechnung_lesen da.",
		Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, Title: "Rechnungen suchen"},
	}, suchen)

	handler := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return server },
		&mcp.StreamableHTTPOptions{Stateless: true},
	)
	log.Fatal(http.ListenAndServe(":8080", handler))
}

Aufrufen lässt sich das mit curl. Ein Streamable-HTTP-POST braucht seit 2026-07-28 drei Header und zwei Pflichtfelder in _meta, sonst antwortet der Server mit -32020 oder -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: rechnungen_suchen' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
        "name":"rechnungen_suchen",
        "arguments":{"kunden_nr":"K-00042","jahr":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":"Jahr 1999 liegt ausserhalb des gepflegten Bereichs 2015 bis 2030,
#           bitte mit einem Jahr in diesem Bereich erneut aufrufen"}],
#   "isError":true,"resultType":"complete"}}

Der Fehlerfall kommt als isError: true mit lesbarem Text zurück, nicht als JSON-RPC-Fehler. Das ist Absicht: Ein Modell kann einen Satz lesen und den Aufruf korrigieren, einen Statuscode kann es nur raten.

Ein Detail, das beim Nachbauen auffällt: Das Go-SDK setzt auf tools/list standardmässig cacheScope: "public". Wer eine nutzerabhängige Werkzeugliste ausliefert, muss private selbst setzen, sonst darf ein Gateway die Liste des einen Nutzers dem nächsten zeigen.

Für schreibende Werkzeuge gilt dieselbe Logik wie für eine Zahlungs-API: Der Aufrufer schickt einen selbst erzeugten Schlüssel mit, der Server reserviert ihn vor der Verarbeitung, und eine Wiederholung liefert dieselbe Antwort ohne zweite Wirkung. Wie wir das bauen, steht in unserem Beitrag über Idempotenz in Zahlungs-APIs.

Bei Agenten ist die Wiederholung häufiger als bei Menschen, weil ein Modell nach einem Timeout einfach nochmals aufruft.

Zur Menge: Zwanzig scharf geschnittene Werkzeuge schlagen sechzig überlappende. Und tools/list soll seit dieser Revision deterministisch sortiert sein, was die Prompt-Cache-Trefferquote der Clients verbessert.

Betrieb: Timeouts, Rate Limits und was in die Logs gehört

Ein fehlgeschlagener Tool-Aufruf ist rekonstruierbar, wenn eine einzige Logzeile Werkzeugname, Identität, Protokollversion, Dauer und Korrelations-ID enthält. Die Revision 2026-07-28 dokumentiert dafür erstmals die OpenTelemetry-Konventionen für _meta: traceparent, tracestate und baggage reisen im Protokoll mit.

// Vollständiges Programm. Jeder Tool-Aufruf erzeugt genau eine JSON-Logzeile.
package main

import (
	"context"
	"log"
	"log/slog"
	"net/http"
	"os"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// logToolCalls schreibt pro Tool-Aufruf eine Zeile, aus der sich ein
// Fehlschlag ohne Rückfrage rekonstruieren lässt.
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("dauer", time.Since(start)),
			}
			// traceparent reist nach der Spezifikation 2026-07-28 in _meta mit.
			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 fehlgeschlagen", append(attrs, slog.String("fehler", 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: "Antwortet mit pong. Dient nur der Prüfung der Logkette.",
		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))
}

Zwei Betriebsgrössen setzt ihr besser explizit, bevor der erste Vorfall sie für euch setzt. Erstens ein Timeout pro Werkzeug, nicht pro Server: Eine Volltextsuche darf zehn Sekunden brauchen, ein Kontostand nicht.

Zweitens ein Rate Limit pro Identität statt pro IP. Hinter einer IP sitzt ein ganzes Unternehmen, hinter einer Identität ein Agent, der im Fehlerfall in einer Schleife läuft. Was auf die Dashboards gehört und was nicht, steht in Drei Dashboards statt dreissig.

Ist ein Pod die richtige Einheit für einen Agenten?

Ein Pod pro Agent ist die naheliegende, aber nicht offensichtlich richtige Antwort. Lin Sun von Solo.io hat die Frage am 14. Juli 2026 im CNCF-Blog unter genau diesem Titel gestellt: Is a Pod the right deployment unit for an AI agent?

Ein eigener Pod bringt Prozessisolation und über den ServiceAccount eine Kubernetes-Identität, kostet aber Grundlast: Ein Agent ist Sekunden aktiv und danach lange leer.

Für einen MCP-Server, der reine Tool-Ausführung macht, ist das eine gute Nachricht: Er ist ein gewöhnlicher zustandsloser Dienst und braucht diese Diskussion nicht. Sie wird erst relevant, sobald der Server fremden Code ausführt. Dann ist der Pod die falsche Grenze, und es braucht eine echte Sandbox.

Wovon wir abraten

Baut keinen MCP-Server, wenn eine gewöhnliche HTTP-API reicht. MCP löst ein Entdeckungsproblem, kein Integrationsproblem: Es beschreibt Werkzeuge so, dass ein Modell sie zur Laufzeit findet und auswählt, ohne dass jemand die Integration vorher fest verdrahtet. Wenn eure Anwendung genau weiss, welchen Endpunkt sie aufrufen will, ist ein POST /rechnungen/suchen billiger und einfacher zu betreiben.

Der Trade-off ist real. MCP kostet einen Authorization Server, ein RFC-9728-Dokument, gepflegte Tool-Beschreibungen und ein Protokoll in Bewegung. Dafür bekommt ihr Werkzeuge, die jeder MCP-fähige Client entdecken kann, ohne dass ihr für ihn etwas baut.

Unsere Linie: Ein MCP-Server lohnt sich, wenn mehr als ein Client dieselben Werkzeuge braucht, oder wenn die Auswahl der Werkzeuge selbst Teil der Aufgabe ist. Für die AI-Agenten-Plattform AIgent trifft das zu.

Für eine einzelne interne Automatisierung mit drei bekannten Aufrufen trifft es nicht zu. Das sagen wir auch dann, wenn ein Projekt anders budgetiert war.

Und: Fangt nicht mit der Deprecation-Migration an, solange die Authentifizierung fehlt. Roots, Sampling und Logging haben zwölf Monate. Ein Server ohne Identitätsbindung hat keine Frist, sondern ein Problem.

Häufige Fragen

Was ist das Model Context Protocol?

Das Model Context Protocol ist ein offenes Protokoll, über das KI-Anwendungen Werkzeuge, Ressourcen und Prompts eines Servers zur Laufzeit entdecken und aufrufen. Es setzt auf JSON-RPC auf und kennt zwei Transporte: STDIO für lokale Prozesse und Streamable HTTP für Netzwerkdienste. Seit der Revision 2026-07-28 ist der Protokollkern zustandslos, jede Anfrage beschreibt sich selbst.

Wie baue ich einen MCP-Server in Go?

Mit dem offiziellen github.com/modelcontextprotocol/go-sdk, aktuell v1.7.0. Ein Server entsteht mit mcp.NewServer, Werkzeuge werden über die generische Funktion mcp.AddTool registriert, die Ein- und Ausgabeschema per Reflection aus euren Go-Typen ableitet. Für den Netzwerkbetrieb kommt mcp.NewStreamableHTTPHandler dazu, für lokale Prozesse server.Run mit mcp.StdioTransport.

Wie authentifiziere ich Tool-Aufrufe eines KI-Agenten?

Über OAuth 2.1 mit dem MCP-Server als Resource Server. Der Client holt ein Access Token, dessen aud-Claim die kanonische URI des Servers nennt, und schickt es in jedem HTTP-Request als Bearer Token. Der Server prüft Signatur, Aussteller und Zielgruppe und übersetzt den sub-Claim in eine Identität, unter der die Tool-Handler arbeiten. Ein geteiltes statisches Token erfüllt das nicht.

Muss ich meinen bestehenden MCP-Server jetzt migrieren?

Nicht sofort, aber die Richtung ist klar. Roots, Sampling, Logging, der HTTP+SSE-Transport und Dynamic Client Registration sind deprecated und laufen mindestens zwölf Monate weiter. Die zustandslose Umstellung ist dagegen kein Deprecation-Thema, sondern eine Protokolländerung: Sitzungen und Mcp-Session-Id sind in 2026-07-28 entfernt. Wer neu baut, baut zustandslos.


Über eure Agenten-Infrastruktur reden. 30 Minuten mit einem unserer Go-Engineers: welche Werkzeuge ihr überhaupt exponieren wollt, wie die Identität vom Login bis zum Tool-Handler durchgereicht wird, und ob MCP für euren Fall die richtige Antwort ist. Ohne Verkaufsgespräch, ohne Vorbereitung auf eurer Seite.

Termin buchen · Kontakt aufnehmen · Mehr zu KI- und Agenten-Systemen und Backend-Entwicklung

Gespräch statt Newsletter

Lassen Sie uns über Ihr System sprechen

Wenn Sie sich in diesem Artikel wiedererkennen, ist ein Gespräch der kürzeste Weg zu einer Antwort.

Jetzt sprechen