stateless

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 29, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package stateless implements the SEP-2575 stateless wire — server/discover, per-request _meta envelope dispatch, subscriptions/listen, and the new HTTP-status error mapping. Designed so the legacy session wire and this wire can be served from one URL via the parent server package's Dual mode, and so removing the legacy wire is a single-package deletion that leaves this code untouched.

Spec: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575

Index

Constants

View Source
const ModeEnvVar = "MCPKIT_STATELESS_MODE"

ModeEnvVar names the override-via-environment knob. Public so callers can read or mutate it in tests (e.g., t.Setenv(stateless.ModeEnvVar, "legacy")).

Variables

View Source
var DefaultMode = ModeDual

DefaultMode is the wire mode used when no WithStatelessMode option is passed and no MCPKIT_STATELESS_MODE env var is set.

Default: ModeDual — a fresh server.New() gains SEP-2575 wire support automatically. Integrators that need to pin the old behavior without per-construction plumbing can flip this in init():

func init() { stateless.DefaultMode = stateless.ModeLegacyOnly }

Tests can flip with t.Cleanup-style restore. Reads serialize via defaultModeMu so concurrent test flips don't race with constructors.

Functions

func HTTPStatusForCode

func HTTPStatusForCode(code int) int

HTTPStatusForCode maps a SEP-2575 JSON-RPC error code to the HTTP status the transport must return. Codes the spec does not pin map to 200 (the legacy behavior of "JSON-RPC error in a 200 body").

Transport-aware error mapping is required by SEP-2575: a stateless client MAY use the HTTP status as a fast-path signal before parsing the JSON body. Specifically:

-32601 Method not found              → 404 Not Found
-32020 HeaderMismatch                → 400 Bad Request
-32021 MissingRequiredClientCap      → 400 Bad Request
-32004 UnsupportedProtocolVersion    → 400 Bad Request
-32602 Invalid params                → 400 Bad Request (missing _meta etc.)
-32700 Parse / -32600 InvalidRequest → 400 Bad Request
-32010 SubscriptionLimitExceeded     → 429 Too Many Requests
everything else                      → 200 OK (body carries the error)

Notification frames (no id, no error) are out of scope for this mapper; the transport applies its own status for those.

func RequestMetaFromContext

func RequestMetaFromContext(ctx context.Context) *core.RequestMeta

RequestMetaFromContext returns the validated SEP-2575 _meta envelope attached to ctx by the stateless dispatcher, or nil if the call did not arrive over the stateless wire. Thin re-export of core.RequestMetaFromContext so packages already importing server/stateless don't need to add a core import for this one accessor.

Handlers that just want to know whether a capability is declared should prefer the typed ctx.ClientCaps() accessor on ToolContext or PromptContext; this raw view is for the rare path that needs protocolVersion or clientInfo directly.

func SetDefaultMode

func SetDefaultMode(m Mode)

SetDefaultMode mutates DefaultMode under write-lock. Test helpers should prefer this over direct assignment so the read-lock in ResolveMode sees a consistent value.

Types

type AcknowledgedFrame

type AcknowledgedFrame struct {
	JSONRPC string                  `json:"jsonrpc"`
	Method  string                  `json:"method"`
	Params  AcknowledgedFrameParams `json:"params"`
}

AcknowledgedFrame is the very first frame the server emits on every subscriptions/listen stream. The frame is a notification (no id), method = `notifications/subscriptions/acknowledged`, with the freshly- minted subscriptionId on params._meta.

Used by the streaming handler to construct the ack body; not part of the dispatcher's synchronous path.

func NewAcknowledgedFrame

func NewAcknowledgedFrame(subscriptionID string) AcknowledgedFrame

NewAcknowledgedFrame builds the ack frame for the given subscriptionId. Single allocation per stream; the streaming handler emits it before looping over filter-matching notifications.

type AcknowledgedFrameParams

type AcknowledgedFrameParams struct {
	Meta map[string]string `json:"_meta"`
}

AcknowledgedFrameParams carries the subscriptionId under _meta where the SEP-2575 conformance suite (and any other listener) expects it.

type Backend

type Backend interface {
	// ServerInfo returns the implementation name/version pair advertised
	// in server/discover.
	ServerInfo() core.ServerInfo

	// Capabilities returns the capability shape advertised by this server
	// in server/discover. The dispatcher does not mutate it; the backend
	// builds it from registered tools/resources/prompts/extensions.
	Capabilities() core.ServerCapabilities

	// SupportedVersions returns the protocol versions this server speaks
	// on the stateless wire. Always includes core.DraftProtocolVersion2026V1.
	SupportedVersions() []string

	// Tools returns a snapshot of registered tool definitions, in
	// registration order. Mutations made after the call are not visible.
	Tools() []core.ToolDef

	// Tool returns the definition + handler for a registered tool, or
	// ok=false if no such tool. Used by tools/call dispatch.
	Tool(name string) (core.ToolDef, core.ToolHandler, bool)

	// Resources returns a snapshot of registered resource definitions.
	Resources() []core.ResourceDef

	// Resource returns def + handler for a concrete resource URI.
	Resource(uri string) (core.ResourceDef, core.ResourceHandler, bool)

	// ResourceTemplates returns a snapshot of registered templates.
	ResourceTemplates() []core.ResourceTemplate

	// ResourceTemplate returns def + handler for a template URI.
	ResourceTemplate(uriTemplate string) (core.ResourceTemplate, core.TemplateHandler, bool)

	// Prompts returns a snapshot of registered prompt definitions.
	Prompts() []core.PromptDef

	// Prompt returns def + handler for a prompt name.
	Prompt(name string) (core.PromptDef, core.PromptHandler, bool)

	// Completion returns a registered completion handler for the
	// (refType, name) pair, e.g. ("ref/prompt", "summarize").
	Completion(refType, name string) (core.CompletionHandler, bool)

	// ListTTLMs / ListCacheScope are the SEP-2549 cache hints applied
	// to every list response. A nil *int / empty string omits the field.
	ListTTLMs() *int
	ListCacheScope() string

	// InvokeWithMiddleware runs the server's middleware chain around
	// invoking the given request, returning the JSON-RPC response.
	//
	// Used by the stateless dispatcher for methods that must traverse
	// server-level middleware on the stateless wire — SEP-2663 tools/call
	// (so taskV2Middleware fires) and tasks/get|update|cancel (registered
	// via Server.HandleMethod). Without this seam, extensions installed
	// via Server.UseMiddleware / HandleMethod would be invisible to the
	// stateless dispatcher.
	//
	// Returns (response, err, true) when the backend handled the request.
	// A non-nil err is a middleware short-circuit (typically *core.AuthError)
	// that the transport surfaces as an HTTP-level response via writeAuthError
	// — the dispatcher forwards it verbatim rather than folding it into a
	// generic -32603 JSON-RPC body, so the legacy and stateless wires share
	// the same 403 + WWW-Authenticate signaling (issue 815). When err is nil
	// the response is used verbatim. Returns (nil, nil, false) to let the
	// dispatcher fall back to its built-in per-method handler — used by
	// minimal test fakes that don't carry middleware or custom-method
	// registrations.
	InvokeWithMiddleware(ctx context.Context, req *core.Request) (*core.Response, error, bool)
}

Backend is the read-only registry + server-metadata surface the dispatcher needs. The parent server package provides an adapter wrapping its *Registry and Server fields; tests provide fakes.

Defining the surface here (not in server) keeps the import direction one-way: server depends on stateless, never the reverse. When legacy goes away, this Backend interface stays — the future "stateless-only" server will still satisfy it.

type DiscoverResult

type DiscoverResult struct {
	SupportedVersions []string                `json:"supportedVersions"`
	Capabilities      core.ServerCapabilities `json:"capabilities"`
	ServerInfo        core.ServerInfo         `json:"serverInfo"`
}

DiscoverResult is the SEP-2575 server/discover response payload.

Wire shape:

{
  "supportedVersions": ["2026-07-28"],
  "capabilities":      { ...ServerCapabilities... },
  "serverInfo":        { "name": "...", "version": "..." }
}

Per the spec, clients MAY call server/discover up front to negotiate version + capability shape, OR call any other RPC inline and handle UnsupportedProtocolVersionError as a probe. Both paths are supported.

type Dispatcher

type Dispatcher struct {
	Backend Backend
}

Dispatcher routes SEP-2575 stateless-wire requests.

Process-shared (one per Server) — no per-request state lives on it. Every call carries its own _meta envelope which the dispatcher validates up front and threads through context so handlers can read per-request capabilities via core.ClientSupportsExtensionForRequest.

Method coverage on this commit:

server/discover                tools/list   tools/call
resources/list                 resources/read
resources/templates/list       prompts/list   prompts/get
completion/complete

Removed legacy methods (initialize, ping, logging/setLevel, resources/(un)subscribe, etc.) short-circuit to -32601; the transport layer maps that to HTTP 404. subscriptions/listen and the MRTR-routed sample/elicit path land in follow-up commits.

func New

func New(b Backend) *Dispatcher

New constructs a Dispatcher bound to the given backend. The Server constructs one of these in transport-setup time and routes requests through it whenever the per-request shape signals the stateless wire.

func (*Dispatcher) Dispatch

func (d *Dispatcher) Dispatch(ctx context.Context, req *core.Request) (*core.Response, error)

Dispatch routes one stateless-wire request, returning (*core.Response, error).

On the happy path err is nil and the *core.Response carries a JSON-RPC payload; transport-layer error → HTTP status mapping happens in errors.go (HTTPStatusForCode) at the transport boundary.

A non-nil error is a middleware short-circuit (typically *core.AuthError) raised by the backend's middleware chain on tools/call, prompts/get, or a custom method. The transport surfaces it via writeAuthError — emitting the correct HTTP status (e.g. 403) + WWW-Authenticate header — so the stateless wire matches the legacy wire's auth signaling instead of folding it into a generic -32603 body (issue 815). When err is non-nil the *core.Response is nil.

type Mode

type Mode int

Mode selects which protocol wire(s) the Streamable HTTP transport will serve on a single URL. Distinct from the parent server package's WithStateless option, which controls process-architecture (no in-process session storage) — the two are orthogonal and may be combined freely.

Example: pure SEP-2575 wire + no session storage (the common serverless/Lambda deployment shape):

server.New(impl,
    server.WithStatelessMode(stateless.ModeStateless),
    server.WithStateless(true),
)

Default Dual mode + session-backed legacy clients allowed:

server.New(impl) // implicit stateless.ModeDual + WithStateless(false)
const (
	// ModeLegacyOnly accepts only the legacy session wire
	// (initialize/notifications/initialized handshake; Mcp-Session-Id
	// header). server/discover is rejected with -32601 + HTTP 404.
	// Zero-value-compatible with the previous default behavior, so a
	// build that explicitly opts out of SEP-2575 keeps the old shape.
	ModeLegacyOnly Mode = iota

	// ModeDual accepts both wires on the same URL, branching per-request
	// on the shape of the incoming payload (method == initialize /
	// presence of Mcp-Session-Id → legacy; MCP-Protocol-Version header
	// or _meta protocolVersion field → stateless). Default.
	ModeDual

	// ModeStateless accepts only the SEP-2575 stateless wire. initialize,
	// notifications/initialized, ping, logging/setLevel, and
	// resources/(un)subscribe are all rejected with -32601 + HTTP 404.
	ModeStateless
)

func ParseMode

func ParseMode(s string) (Mode, bool)

ParseMode decodes a mode token (the inverse of String). Accepts "legacy", "dual", "stateless"; case-insensitive; empty string maps to the caller's fallback via the returned ok=false.

func ResolveMode

func ResolveMode() Mode

ResolveMode seeds the runtime mode from the env var or, failing that, DefaultMode. Server constructors call this to seed transportConfig; the WithStatelessMode option clobbers the seed when present.

Precedence (high → low):

  1. WithStatelessMode (applied post-seed)
  2. MCPKIT_STATELESS_MODE environment variable
  3. DefaultMode (under read-lock)

func (Mode) String

func (m Mode) String() string

String returns the lowercase token used in env vars and structured logs.

type SubscribeFilter

type SubscribeFilter struct {
	ToolsListChanged     bool `json:"toolsListChanged,omitempty"`
	PromptsListChanged   bool `json:"promptsListChanged,omitempty"`
	ResourcesListChanged bool `json:"resourcesListChanged,omitempty"`
}

SubscribeFilter is the structured per-notification opt-in. Each boolean gates a class of notification methods. Resource-update subscriptions (per-URI granularity) are deferred — defaultable to a future ResourceUpdates []string field without breaking wire compat.

func (SubscribeFilter) Matches

func (f SubscribeFilter) Matches(method string) bool

Matches returns true if the given notification method falls within this filter. The transport-level fanout checks Matches before pushing a frame onto any open subscription stream.

type SubscribeParams

type SubscribeParams struct {
	Notifications SubscribeFilter `json:"notifications"`
}

SubscribeParams is the params shape clients post to subscriptions/listen.

_meta carries the standard SEP-2575 envelope (validated upstream by the dispatcher) plus optional client identifiers. Notifications carries the subscription filter. Fields default to false → the server MUST NOT emit that notification type for this subscription.

type TaggedFrame

type TaggedFrame struct {
	JSONRPC string         `json:"jsonrpc"`
	Method  string         `json:"method"`
	Params  map[string]any `json:"params"`
}

TagFrameWithSubscriptionID wraps any outbound notification in the SEP-2575-required envelope: jsonrpc, method, and a params._meta block carrying the subscriptionId. The original params are preserved verbatim under the same key. The transport calls this for every notification it fans out to an open stream.

origParams may be nil (notification with no params); the result still carries _meta.subscriptionId so clients can route the frame.

func NewTaggedFrame

func NewTaggedFrame(method string, origParams any, subscriptionID string) TaggedFrame

NewTaggedFrame builds a notification frame stamped with the given subscriptionId under params._meta[io.modelcontextprotocol/subscriptionId].

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL