TL;DR
Go is a useful implementation language for a streaming transport, but a goroutine and an http.Flusher do not constitute an MCP server. A transport adapter must respect the selected MCP revision and client profile, keep identity and session state separate, correlate JSON-RPC messages, bound memory, propagate cancellation, and fail safely.
The endpoint pair commonly shown as GET /sse plus POST /message describes a legacy compatibility pattern. It should not be presented as the universal or current MCP remote transport. New deployments should evaluate the current Streamable HTTP profile first and keep any legacy route isolated, versioned, and tested.
Choose the Protocol Profile First
Before writing a handler, record:
| Decision | Questions |
|---|---|
| MCP revision | Which specification and SDK version are pinned? |
| Transport | Is this stdio, Streamable HTTP, or a legacy compatibility profile? |
| Session | Is state connection-local, server-issued, or externally registered? |
| Identity | Where is the bearer token validated, and how is tenant context derived? |
| Delivery | How are ordering, cancellation, reconnects, and duplicate requests handled? |
| Limits | What are the request, result, queue, concurrency, time, and cost budgets? |
Do not copy a legacy SSE example into a current deployment without checking the transport and authorization specifications. The names of endpoints and events are not a substitute for the selected protocol contract.
What the Legacy SSE Pattern Contains
When compatibility requires the older pattern, its shape is roughly:
client -> GET /sse
server -> server-issued endpoint information
client -> POST message endpoint with JSON-RPC request
server -> event stream carrying a correlated response
This pattern creates two separate HTTP surfaces. The server must ensure that:
- the endpoint information does not expose credentials or an authority claim;
- the session is generated and owned by the server;
- the POST request is authenticated and authorized independently;
- the response is correlated by JSON-RPC ID, not by arrival order alone;
- queue overflow, disconnects, cancellation, and duplicate requests have explicit behavior.
The event stream is not a permission channel. An SSE connection that was once authenticated must not silently retain access after token expiry or policy revocation.
Session State and Identity
Use an opaque, server-generated session identifier and store only the state needed by the selected profile:
package transport
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
type Principal struct {
Subject string
Tenant string
}
type Session struct {
ID string
Principal Principal
CreatedAt time.Time
ExpiresAt time.Time
Events chan []byte
Context context.Context
Cancel context.CancelFunc
}
type Registry struct {
mu sync.RWMutex
sessions map[string]*Session
}
func NewRegistry() *Registry {
return &Registry{sessions: make(map[string]*Session)}
}
func newSessionID() (string, error) {
var raw [24]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
func (r *Registry) Create(parent context.Context, principal Principal, ttl time.Duration) (*Session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(parent)
session := &Session{
ID: id, Principal: principal, CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
// Illustrative capacity: derive this from event size and memory budgets.
Events: make(chan []byte, 64),
Context: ctx, Cancel: cancel,
}
r.mu.Lock()
r.sessions[id] = session
r.mu.Unlock()
return session, nil
}
func (r *Registry) Get(id string, principal Principal) (*Session, error) {
r.mu.RLock()
session := r.sessions[id]
r.mu.RUnlock()
if session == nil || time.Now().After(session.ExpiresAt) {
return nil, errors.New("session_not_found")
}
if session.Principal != principal {
return nil, errors.New("session_principal_mismatch")
}
return session, nil
}
This is a structural excerpt. A real implementation also needs deletion, expiry sweeps, safe cancellation, distributed ownership if it scales horizontally, and a policy for token revocation. A session ID is an index into server state, not a substitute for token validation.
Streaming Handler Responsibilities
An SSE-compatible handler should:
- authenticate the initial request;
- create a server-owned session;
- set streaming headers before writing;
- send only the endpoint information required by the pinned profile;
- select between queued events, heartbeats, request cancellation, and session expiry;
- remove the session and close resources exactly once.
func writeEvent(w http.ResponseWriter, flush func(), event string, data []byte) error {
if _, err := w.Write([]byte("event: " + event + "\n")); err != nil {
return err
}
if _, err := w.Write([]byte("data: " + string(data) + "\n\n")); err != nil {
return err
}
flush()
return nil
}
func serveEvents(
w http.ResponseWriter,
session *Session,
heartbeat time.Duration,
) {
flusher, ok := w.(interface{ Flush() })
if !ok {
httpError(w, "streaming_not_supported", 500)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
ticker := time.NewTicker(heartbeat)
defer ticker.Stop()
defer session.Cancel()
for {
select {
case payload := <-session.Events:
if err := writeEvent(w, "message", payload, flusher.Flush); err != nil {
return
}
case <-ticker.C:
if err := writeEvent(w, "heartbeat", []byte(""), flusher.Flush); err != nil {
return
}
case <-session.Context.Done():
return
}
}
}
The helper names httpError and the registry deletion path are intentionally omitted. They must be implemented by the application and tested. Do not copy the fragment as a complete server.
Heartbeat intervals are deployment parameters. They must be shorter than the relevant proxy idle timeout, but not so short that they create avoidable traffic. Test through the actual load balancer and CDN rather than relying on a local curl session.
Message Endpoint and JSON-RPC
The message endpoint must validate more than JSON syntax:
- HTTP method, content type, and request size;
- JSON-RPC version, ID shape, method, and parameter schema;
- session existence, expiry, principal, tenant, and transport profile;
- request cancellation and duplicate/idempotency behavior;
- tool authorization and resource ownership;
- queue capacity and downstream timeout.
Use a bounded decoder and return protocol-appropriate errors. Do not place arbitrary client input into a tool dispatcher or derive ownership from a model-generated argument.
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
func decodeRequest(body io.Reader, maxBytes int64) (Request, error) {
limited := io.LimitReader(body, maxBytes+1)
var request Request
decoder := json.NewDecoder(limited)
if err := decoder.Decode(&request); err != nil {
return Request{}, errors.New("invalid_json")
}
if request.JSONRPC != "2.0" || request.Method == "" {
return Request{}, errors.New("invalid_jsonrpc_request")
}
return request, nil
}
The snippet requires the standard encoding/json, errors, and io imports and deliberately leaves method-specific validation to the application. JSON-RPC framing does not authorize a Tool call.
Backpressure and Ordering
An event channel is a memory budget. Decide what happens when it is full:
| Policy | Use when | Risk |
|---|---|---|
| block producer | every event is required | can exhaust worker capacity |
| reject new work | caller can retry safely | requires a clear retry response |
| disconnect session | stale clients are expendable | client must reconnect and recover |
| spill to durable queue | replay is required | adds ordering and deletion complexity |
Never grow an unbounded channel. Preserve JSON-RPC correlation and define whether responses for one session are ordered. If a tool is not idempotent, a network timeout must not trigger an automatic duplicate execution without an idempotency key or reconciliation step.
Authentication and Authorization
Authentication belongs before session creation and on every message request. The implementation should validate the token issuer, audience/resource, signature algorithm, expiry, key rotation, and scopes using a trusted provider configuration.
Authorization remains an application decision:
- the principal and tenant come from trusted context;
- object ownership comes from an authoritative resource service;
- Tool annotations are hints, not permissions;
- destructive or external actions require a separate confirmation or workflow;
- token expiry and revocation can invalidate an existing session;
- a downstream Tool result is untrusted input and cannot change policy.
Do not expose a session endpoint without TLS, request limits, rate limits, structured audit events, and cross-tenant tests.
Proxy and Shutdown Behavior
Streaming behavior is affected by every intermediary:
- disable buffering only where the selected proxy requires it;
- configure read and idle timeouts from measured heartbeat behavior;
- preserve trace context and authorization headers safely;
- limit concurrent connections and response bytes;
- close sessions during deployment with a drain deadline;
- propagate context cancellation to tool and downstream calls.
WriteTimeout: 0 is not a universal safe setting. It may be necessary for some streaming servers, but it can also allow stuck handlers to live indefinitely. Use a bounded lifecycle with heartbeats, cancellation, connection limits, and a tested drain strategy.
Testing Matrix
Before exposing a compatibility transport, test:
| Case | Expected result |
|---|---|
| malformed JSON or oversized body | bounded protocol error, no handler execution |
| unknown or expired session | reject without revealing another tenant |
| valid session with another principal | reject |
| token expires while stream remains open | policy-defined reauthentication or closure |
| full event queue | bounded rejection, disconnect, or durable spill |
| client disconnects during tool execution | cancellation reaches downstream work |
| duplicate request ID or idempotency key | deterministic reconciliation |
| reconnect after node failure | no unauthorized session resurrection |
| proxy buffers or closes idle stream | detected in integration tests |
| tool result contains instructions | treated as untrusted data |
Run protocol conformance tests against the pinned SDK, then run load tests with idle sessions, active calls, reconnect storms, queue pressure, and dependency failures. A local happy-path curl test is not a capacity or security result.
Go Library or From Scratch?
Prefer a maintained SDK when it supports the required MCP revision, transport, cancellation, authorization hooks, and lifecycle. A standard-library implementation can be useful for learning or a tightly controlled compatibility adapter, but it assumes responsibility for:
- protocol conformance and version changes;
- JSON-RPC edge cases and cancellation;
- authentication and object authorization;
- session expiry and distributed ownership;
- backpressure and duplicate delivery;
- observability, security updates, and incident response.
“No third-party dependency” is not the same as “lower operational risk.”
Production Checklist
- [ ] Protocol revision and transport profile are pinned.
- [ ] Legacy SSE support is isolated and labeled as compatibility behavior.
- [ ] Sessions are server-generated and bound to principal, tenant, and profile.
- [ ] Every message request revalidates authentication and authorization.
- [ ] Request, result, queue, concurrency, timeout, and cost limits are enforced.
- [ ] Cancellation, reconnect, duplicate delivery, and shutdown are tested.
- [ ] Tool results are treated as untrusted data.
- [ ] Raw tokens, secrets, and unbounded payloads are absent from telemetry.
- [ ] The SDK or custom transport has conformance and load-test evidence.
Frequently Asked Questions
Should a new remote MCP server use legacy SSE?
Not by default. Pin the current supported transport profile first. Use legacy SSE only for a documented compatibility requirement and test its two-channel session behavior separately.
What does Go provide?
It provides HTTP streaming, cancellation contexts, and concurrency primitives. It does not provide MCP authorization, session policy, JSON-RPC validation, backpressure, or safe shutdown automatically.
Can a client choose a session ID?
No. The server creates and binds the opaque ID. The client presents it as a lookup key, while the bearer token and resource policy remain authoritative.
How many connections can one process support?
There is no portable number. Measure the selected transport, event size, heartbeat, proxy, file descriptors, memory, downstream work, and reconnect behavior on the target environment.
What must be added before internet exposure?
Trusted authentication, per-request authorization, tenant isolation, limits, cancellation, rate controls, telemetry redaction, and failure/conformance tests.
Conclusion
Implementing a Go streaming adapter can teach the mechanics of HTTP flushing, channels, cancellation, and JSON-RPC correlation. Production MCP transport is broader: the selected protocol profile, server-issued sessions, authorization, bounded memory, reconnect semantics, and lifecycle behavior all matter. Treat legacy SSE as a compatibility boundary, measure the real deployment, and let a maintained SDK carry protocol details whenever its coverage and governance justify the dependency.