Documentation
¶
Overview ¶
This file is the fixture's mechanism for pinning a server session to a protocol revision older than 2026-07-28.
SDK v1.7.0 has no public API to request an older protocol version from a test peer: mcp.ClientSessionOptions carries a protocolVersion field for exactly this, but it is unexported. And the SDK's own client always tries the SEP-2575 "server/discover" RPC first when it wants 2026-07-28 (which is always, by default) — succeeding at discover is what negotiates 2026-07-28, so there is no session to pin an older version onto after the fact. That is why every one of this module's stdio and in-memory tests began negotiating 2026-07-28 the moment the SDK bumped its own default, and why ServerSession's assertServerInitiatedRequestAllowed (vendor mcp/server.go) then refuses every ad hoc ServerSession.Elicit / ListRoots / CreateMessage call a test makes against it.
PinLegacyProtocol closes that gap by making discover fail the way a real pre-SEP-2575 server would: "method not found". It does this at the connection layer, intercepting the request before it ever reaches mcp.Server's own discover handler — that handler unconditionally records the session as initialized as a side effect of merely being called (see its doc in vendor mcp/server.go), which would make the legacy "initialize" that follows fail as a duplicate if discover were allowed to run and only *afterward* rejected (e.g. by restricting mcp.ProtocolVersionSupporter, which was tried and hits exactly this). A discover that never reaches the server touches no state, so the client's documented fallback — the legacy initialize handshake, which the SDK negotiates at exactly LegacyProtocolVersion — runs clean.
Package mcptest provides a real MCP server for driving this module's transports in tests.
It is a fixture, not a mock: the server here is built on the go-sdk's server API and speaks the real protocol over a real transport. A hand-rolled JSON-RPC stub would test our idea of MCP; this tests MCP.
The package is a test fixture that happens to be compiled as ordinary code, because cmd/fixture must build it into a binary that tests exec. Nothing in production imports it, and nothing here is part of any consumer contract — so unlike the rest of the module below internal/protocol, it may name SDK types freely (see internal/protocol's leak guard allowlist).
Configuration ¶
Everything is driven by Config, which cmd/fixture parses from flags. Each server feature is off unless a flag turns it on, so a test opts into exactly the surface it means to exercise and nothing else can surprise it.
Mutation and the list-changed notification ¶
The server can add and remove a tool at runtime, which makes the SDK emit notifications/tools/list_changed. The trigger is the "mutate" tool: the client that wants the notification asks for it, in-band, over the same connection it is already testing. The alternatives are worse — stdin is the transport and cannot carry a control channel, a signal needs the test to know the pid and races the delivery, and a polled control file adds a filesystem dependency and a latency floor for no gain.
Streams ¶
stdout belongs to the protocol. Everything this package writes for a human — the stderr noise, any diagnostic — goes to stderr, and the SDK's own logger defaults to discarding. A single stray byte on stdout corrupts the framing, which is exactly the failure the stderr-noise mode exists to test for.
Shutdown: closing stdin is a stop, not a flush ¶
The MCP spec has the client shut a stdio server down by closing stdin. In this SDK that is a hard stop, not a drain: the resulting read EOF puts the connection into shutdown, and any reply not already written is dropped on the floor. A client must therefore be quiescent — every reply it wants already in hand — before it closes stdin. Otherwise it will observe a server that "never answered", and the cause will look like the transport rather than the shutdown.
What decides the outcome is whether anything was still owed when the EOF landed — not how the EOF was produced. Two cases, verified against this fixture:
- EOF with replies outstanding (a pipe that ends right after its requests, a redirect from a file of requests, or an explicit close mid-request): the unwritten replies are dropped, the process exits 1, and stderr carries "fixture: serving: server is closing: EOF". The drop reaches all the way back: send initialize and close, and even *its* reply is lost — stdout ends up completely empty, which reads as a server that never spoke.
- EOF with nothing outstanding (all replies written, or nothing ever asked — e.g. a redirect from /dev/null): the process exits 0 with empty stderr.
The second case is what keeps a non-zero exit meaningful: a crash is distinguishable from an ordinary shutdown only because an ordinary shutdown is silent and zero.
The exit code and the error text above are this fixture's, from the command in cmd/fixture; the dropped-reply behavior underneath is the SDK's, and applies to any stdio MCP server built on it.
Index ¶
- Constants
- func BuildFixture(t TB) string
- func NewHTTPHandler(cfg Config) (http.Handler, error)
- func NewSSEHandler(cfg Config) (http.Handler, error)
- func NewServer(cfg Config) (*mcp.Server, error)
- func PinLegacyProtocol(t mcp.Transport) mcp.Transport
- func WriteNoise(w io.Writer, n int) error
- type BigInput
- type Config
- type EchoInput
- type ElicitInput
- type FailInput
- type LogInput
- type MutateInput
- type MutatedAltInput
- type ProgressInput
- type SlowInput
- type TB
Constants ¶
const ( ServerName = "looprig-mcp-fixture" ServerVersion = "0.0.1" ServerTitle = "looprig MCP fixture server" )
Identity is what the fixture calls itself at initialize. Tests assert on these, so they are constants rather than configuration.
const ( ToolEcho = "echo" ToolSlow = "slow" ToolFail = "fail" ToolBig = "big" ToolMutate = "mutate" ToolCrash = "crash" ToolMutated = "echo2" ToolProgress = "progress" ToolLog = "log" ToolElicit = "elicit" // ToolElicitMRTR is ToolElicit's counterpart: it asks the same question // through the multi round-trip mechanism (SEP-2322) — returning // InputRequests and reading the answer back from a retry's // InputResponses — instead of an ad hoc ServerSession.Elicit call. See // addElicitMRTRTool for why it is a second tool rather than a rewrite of // ToolElicit's handler. ToolElicitMRTR = "elicit_mrtr" )
Tool names the fixture may expose. MutatedToolName is the tool the "mutate" tool adds and removes; it is never present at startup.
const ( PromptGreet = "greet" // ResourceStaticURI is a fixed resource with a known body. ResourceStaticURI = "fixture://static/hello" // ResourceStaticBody is that body. ResourceStaticBody = "hello from the fixture" // ResourceEchoTemplate is an RFC 6570 template: reading // fixture://echo/{word} returns {word}. ResourceEchoTemplate = "fixture://echo/{word}" )
Prompt and resource identifiers the fixture exposes when enabled.
const ( // MaxSlowMS caps the "slow" tool's sleep. MaxSlowMS = 5 * 60 * 1000 // MaxBigBytes caps the "big" tool's result. MaxBigBytes = 32 << 20 // MaxProgressCount caps how many notifications the "progress" tool emits. MaxProgressCount = 1000 // MaxLogBytes caps the "log" tool's message size. MaxLogBytes = 1 << 20 // MaxNoiseBytes caps the stderr noise. MaxNoiseBytes = 32 << 20 // MaxInstructionsBytes caps the configured instructions string. MaxInstructionsBytes = 1 << 20 )
Bounds on tool arguments. The arguments come from a test, not an attacker, but a fixture that hangs for an hour or allocates a gigabyte because a test typo'd a zero is a bad fixture: it fails as a timeout somewhere else, long after the cause.
const DefaultCrashExitCode = 7
DefaultCrashExitCode is the "crash" tool's exit status unless configured otherwise.
const DefaultFailMessage = "fixture: deliberate tool failure"
DefaultFailMessage is what "fail" reports when the client sends no message.
const ElicitAnswerPrefix = "elicited: "
ElicitAnswerPrefix prefixes the "elicit" tool's result. What follows is the action the client reported, so a test can assert that the human's answer made it all the way back to the server that asked.
const ElicitMessage = "fixture: confirm startup"
ElicitMessage is the prompt the initialize-time elicitation sends. Tests match on it.
const ExtraToolPrefix = "extra_"
ExtraToolPrefix names the tools Config.ExtraTools adds.
const GreetArg = "name"
GreetArg is the name of the "greet" prompt's required argument.
const LegacyProtocolVersion = "2025-11-25"
LegacyProtocolVersion is the protocol revision a server connected through PinLegacyProtocol negotiates. It is the newest revision before 2026-07-28 (SEP-2322 / SEP-2575) — the last one under which a server may still call ServerSession.Elicit, ServerSession.ListRoots, or ServerSession.CreateMessage ad hoc, rather than returning InputRequests from a multi round-trip handler. It is also the version the SDK's client hardcodes for its legacy-initialize fallback (see client.go's Connect), which is what makes it the outcome here rather than merely the intent.
const LogFill = "L"
LogFill is the byte the "log" tool repeats to reach the requested size.
const MaxExtraTools = 4096
MaxExtraTools caps Config.ExtraTools.
const ProgressMessage = "working"
ProgressMessage is the text the "progress" tool puts on each notification. Tests match on it.
const SlowCancelledMarker = "MCPTEST-SLOW-CANCELLED"
SlowCancelledMarker is written to stderr when the "slow" tool observes its context being cancelled.
It exists because server-side cancellation is otherwise invisible: the reply to a cancelled request is discarded, so from the client there is nothing to see, and a test that only measures its own deadline passes just as happily against a handler that ignores ctx entirely. This marker is what makes "slow honors cancellation" a falsifiable claim — which matters, because the transport's cancellation and timeout tests are built on it.
Variables ¶
This section is empty.
Functions ¶
func BuildFixture ¶
BuildFixture builds the fixture command and returns the path to the binary. It fails the test, with the compiler's output, if the build fails.
The binary goes in t.TempDir(), so it is removed when the test ends and no two tests can race over it. Each call is a fresh `go build`; after the first, Go's build cache makes that cheap, which is why there is no caching here to get wrong.
func NewHTTPHandler ¶
NewHTTPHandler builds a configured fixture server and returns it as an http.Handler speaking the Streamable HTTP transport. Mount it on an httptest.Server and point a client at that server's URL.
Every session the handler serves gets the same *mcp.Server, which is what the SDK's getServer hook is for and what a real deployment does. It matters for Config.Mutate: mutation changes the server's tool list, so two sessions on one handler see each other's mutations — as they would against a real server.
The Config fields that describe a process rather than a server have no meaning here, and NewHTTPHandler refuses them rather than ignoring them:
- Crash exits the process, which over HTTP would take the test binary down with it, not a child.
- NoiseBytes writes to stderr, which is this process's stderr; there is no child stream for it to pollute and no bounded capture reading it.
A test that wants those wants the stdio fixture, which has a process to crash.
func NewSSEHandler ¶
NewSSEHandler builds a configured fixture server and returns it as an http.Handler speaking the *legacy* HTTP+SSE transport (the 2024-11-05 spec's). Mount it on an httptest.Server and point pkg/transport/sse at that server's URL.
It exists because a compatibility transport tested against a fixture that cannot do the thing proves nothing. The legacy protocol's whole shape — the hanging GET, the "endpoint" event that tells the client where to POST, the POSTs going somewhere other than where the stream came from — is the part worth testing, and only a real legacy server has it. This is the SDK's own SSE server, so what the transport is tested against is the protocol rather than this module's idea of it.
It shares NewHTTPHandler's restrictions, for the same reasons.
func NewServer ¶
NewServer builds a configured MCP server. It does not connect it: the caller chooses the transport (see cmd/fixture, which runs it over stdio).
The returned server holds no process-global state except through the "crash" tool, so a test may build several.
func PinLegacyProtocol ¶
PinLegacyProtocol wraps t so a server connected over it refuses "server/discover" with "method not found", which is indistinguishable to the SDK's client from a genuine pre-2026-07-28 server and sends it down the legacy initialize path. See the package doc comment above for the reasoning and what it does to a client that connects through it.
func WriteNoise ¶
WriteNoise writes n bytes to w. It exists so the command can put a configurable amount of chatter on stderr — the thing real servers do, and the thing a client's bounded stderr capture has to survive.
The output is exactly n bytes, so a test can assert on the count.
Types ¶
type BigInput ¶
type BigInput struct {
Bytes int `json:"bytes" jsonschema:"how many bytes of text to return"`
}
BigInput is the "big" tool's argument.
type Config ¶
type Config struct {
// Instructions is the server's instructions string, returned at
// initialize. Empty means the server sends none.
Instructions string
// Prompts adds the "greet" prompt (which takes arguments), and with it the
// prompts capability.
Prompts bool
// Resources adds the static resource and the echo resource template, and
// with them the resources capability.
Resources bool
// Mutate adds the "mutate" tool, which adds or removes ToolMutated at
// runtime and so makes the server emit notifications/tools/list_changed.
Mutate bool
// Crash adds the "crash" tool, which exits the process immediately with
// CrashExitCode: no reply, no shutdown, no flush.
Crash bool
// CrashExitCode is the status the "crash" tool exits with. It must be in
// [1, 125] — a crash that exits 0 is not a crash, and the shell reserves
// the values above 125.
CrashExitCode int
// NoiseBytes is how much the fixture writes to stderr at startup. It is
// written by the command, not by NewServer; see WriteNoise.
NoiseBytes int
// ElicitOnInitialize makes the server send an elicitation request as soon
// as the client's "initialized" notification arrives. See NewServer for
// what "as soon as" can and cannot mean here.
ElicitOnInitialize bool
// Elicit adds the "elicit" tool, which asks the client a question while
// answering a tool call and reports what it was told.
//
// It is a tool rather than another unprompted send because that is what
// makes an elicitation *observable to the caller*: the client drives it,
// and the answer the human gave comes back in the tool's own result. A
// test asserts on a reply instead of waiting for a notification that may
// or may not have happened yet. ElicitOnInitialize keeps its own,
// different job: the first moment a server is allowed to speak unprompted.
Elicit bool
// PageSize caps how many items the server returns per list page. Zero uses
// the SDK's default (1000), which no fixture catalog comes close to — so a
// test that needs to exercise a client's pagination sets this to 1 or 2 and
// gets a genuinely multi-page server rather than a simulated one.
PageSize int
// ExtraTools adds this many trivial echo-shaped tools, named
// ExtraToolPrefix + index. It exists so a test can build a catalog big
// enough to paginate, or big enough to exceed a client's item bound.
ExtraTools int
// Stateless serves the Streamable HTTP handler in stateless mode
// (SEP-2567), which is what lets a client negotiate spec revision
// 2026-07-28: no Mcp-Session-Id, no server-initiated requests, GET and
// DELETE answered 405. Meaningless for the stdio and SSE fixtures, whose
// constructors refuse it.
Stateless bool
// LegacyProtocol pins the stdio fixture (cmd/fixture) to protocol
// revisions at or before mcptest.LegacyProtocolVersion, via
// PinLegacyProtocol. It exists for tests that need a real server to make
// an ad hoc ServerSession.Elicit / ListRoots / CreateMessage call: SDK
// v1.7.0 forbids all three once the negotiated version reaches
// 2026-07-28 (SEP-2322), and offers no other way to request an older
// version from a test peer. Meaningless over HTTP, where
// StreamableServerTransport already implements
// mcp.ProtocolVersionSupporter on its own; only cmd/fixture consults it.
LegacyProtocol bool
}
Config is the fixture's complete configuration. The zero value is a valid, minimal server: the four tools, no prompts, no resources, no instructions, no mutation, no crash tool, no noise.
type EchoInput ¶
type EchoInput struct {
Text string `json:"text" jsonschema:"the text to echo back verbatim"`
}
EchoInput is the "echo" tool's argument.
type ElicitInput ¶
type ElicitInput struct {
// Mode is the elicitation mode to send, verbatim. It is a free string, not
// an enum, precisely so a test can send a mode MCP does not define and see
// what a client does with it — which is the interesting case.
Mode string `json:"mode,omitempty" jsonschema:"the elicitation mode to send (form, url, or anything else)"`
// Message overrides the prompt. Empty uses ElicitMessage.
Message string `json:"message,omitempty" jsonschema:"the prompt to send; defaults to the fixture's own"`
// Schema, when true, requests a one-string-field form, so a test can
// exercise a client that has to carry a schema to a human and an answer
// back through it.
Schema bool `json:"schema,omitempty" jsonschema:"request a {name: string} form schema"`
// URL is the action URL to send, for url mode.
URL string `json:"url,omitempty" jsonschema:"the action URL, for url mode"`
}
ElicitInput is the "elicit" tool's argument.
type FailInput ¶
type FailInput struct {
Message string `json:"message,omitempty" jsonschema:"the message to report in the error result"`
}
FailInput is the "fail" tool's argument.
type LogInput ¶
type LogInput struct {
Bytes int `json:"bytes" jsonschema:"how many bytes of log message to emit"`
Level string `json:"level,omitempty" jsonschema:"the level to log at; defaults to info"`
}
LogInput is the "log" tool's argument.
type MutateInput ¶
type MutateInput struct {
Add bool `json:"add" jsonschema:"true to add the echo2 tool, false to remove it"`
// Schema re-adds echo2 under a DIFFERENT input schema.
//
// It exists because "the tool kept its name and changed its contract" is
// otherwise unreachable: add/remove can only make a tool appear or vanish,
// and a client's schema-drift handling is invisible against a server that
// never drifts. With it, one call turns generation N's echo2(text) into
// generation N+1's echo2(word) — same name, same server, incompatible
// interface.
Schema bool `json:"schema,omitempty" jsonschema:"with add, register echo2 under a different input schema"`
}
MutateInput is the "mutate" tool's argument.
type MutatedAltInput ¶
type MutatedAltInput struct {
Word string `json:"word" jsonschema:"the word to echo back verbatim"`
}
MutatedAltInput is ToolMutated's argument when MutateInput.Schema asked for the alternative schema. The field name differs from EchoInput's, so the tool's input-schema digest differs too — which is the whole point.
type ProgressInput ¶
type ProgressInput struct {
Count int `json:"count" jsonschema:"how many progress notifications to send"`
MS int `json:"ms,omitempty" jsonschema:"how long to pause between notifications, in milliseconds"`
Hang bool `json:"hang,omitempty" jsonschema:"true to keep reporting progress forever and never reply"`
}
ProgressInput is the "progress" tool's argument.