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:

text
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:

go
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:

  1. authenticate the initial request;
  2. create a server-owned session;
  3. set streaming headers before writing;
  4. send only the endpoint information required by the pinned profile;
  5. select between queued events, heartbeats, request cancellation, and session expiry;
  6. remove the session and close resources exactly once.
go
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.

go
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.

Primary Sources