Documentation
¶
Overview ¶
Package mcp is the platform's MCP client: a thin wrapper over the official github.com/modelcontextprotocol/go-sdk exposing what the platform needs and nothing else.
Connections are per-work-item. A caller connects, does its work, and closes, so a crashed executor loses no state a fresh one cannot rebuild. What is per-work-item is the MCP session, not the socket: DefaultClient is package-level and keeps an ordinary net/http connection pool, so two work items reaching the same origin may well share a TCP connection, or multiplex over one HTTP/2 connection. That is deliberate — the state worth not sharing is protocol state, and there is none to share. The 2026-07-28 revision of MCP is what makes that affordable: it removed protocol-level sessions, so nothing accumulates on the server that a new connection has to re-establish, and there is no affinity between discovering a server's tools and later calling one. Not free, though — a connection still negotiates, and what that costs depends on the server it reaches: against a 2026-07-28 one a single `server/discover` round trip (two, where the SDK retries it after an unsupported-version answer), and against an older one that discover plus the legacy `initialize` and `notifications/initialized`, three requests, before Connect returns. What per-work-item connections cost is that handshake; what they buy is a client with no state to lose.
The SDK is a dependency of this package alone. Its types do not appear in the wrapper's surface — the platform's domain model is Anthropic-native (CLAUDE.md design principle 1), and an MCP tool reaches the rest of the system as the platform's own Tool, not as an SDK struct.
Index ¶
Constants ¶
const CallTimeout = 2 * time.Minute
CallTimeout bounds a whole call rather than any one request inside it, the way ListTimeout bounds a whole listing. A call is one request only for as long as the server answers it with the tool's output: under the multi round-trip pattern below, the SDK re-sends the call up to ten times, each attempt bounded only on its own, so without an aggregate budget a server that keeps asking holds the caller — and the queue lease behind it — for ten times a single request's cap.
Two minutes because that is what a tool call gets on this platform already (toolset.DefaultTimeout), and an MCP tool is a tool: a remote one should not outlive a local one by default.
It is a ceiling on the aggregate and not a promise about how long a call may run, and which client carries the call decides whether it binds at all. DefaultClient sets Timeout to DialTimeout, a whole-request cap net/http applies to the body read as well — and a `tools/call` response is not complete until the tool is, so a call through that client is bounded at thirty seconds and an MCP tool that takes longer fails every time, whatever this constant says. That is why CallClient exists and why the executor calls through it: its request cap is this same budget, leaving the relationship ListTimeout has with a client's cap, where the aggregate bounds the round trips and the client bounds each one.
const DialTimeout = 30 * time.Second
DialTimeout bounds a single connection attempt, and is the fallback deadline for any request that reaches the transport without one. MCP servers are third-party endpoints reached from a work item that holds a queue lease, so an unbounded wait would hold the lease rather than fail the item.
The fallback catches the request that reaches the transport with no deadline at all — see withResponseLimit for why the SDK makes one of those, and why the caller's http.Client.Timeout cannot be relied on to bound it. A request that already carries a deadline keeps it; the fallback never shortens one.
That does not make DialTimeout a floor overall, and it would be easy to read it that way: DefaultClient also sets it as its own Timeout, which is a whole-request cap that beats a longer context deadline. So every request through DefaultClient is bounded at 30s, and ListTimeout's two minutes bound the listing across its pages rather than any single one of them. Which client carries a connection therefore decides this too: on CallClient the same cap is CallTimeout, so *every* request that connection makes gets a tool's budget rather than a dial's — the handshake, and the session-ending DELETE the SDK sends on a context nothing here can cancel, included. That is the price of running tools through one client, and it is bounded rather than open: the driver's own pass budget is what keeps a server that accepts and never answers from holding a work item indefinitely. A caller supplying its own client sets that policy itself, and may set none — which is the case the fallback exists for.
None of the deadlines here is exact, and the overshoot is the SDK's: when a caller's context ends a request that is still outstanding, the SDK tells the server so with a `notifications/cancelled`, and it sends that on a context deliberately detached from the one that just ended (context.WithoutCancel) with a 5-second timeout of its own. Against a server that has stopped answering, that notification is itself unanswerable, so every cancelled call costs up to five seconds after its deadline — twice over for a connection, which may have both a `server/discover` and a legacy `initialize` in flight. Callers that bound a whole pass should treat these as budgets that stop the work rather than ceilings on the wall clock.
const ListTimeout = 2 * time.Minute
ListTimeout bounds a whole listing rather than one request in it. Pagination is server-driven, so without an aggregate budget a server that answers each page just inside DialTimeout holds the work item — and the queue lease behind it — for maxToolPages requests in a row.
const MaxResponseBytes = 8 << 20
MaxResponseBytes bounds what a connection accounts for across every response rather than one response at a time — bodies in full, header blocks only as far as they survive parsing to be counted, which is a distinction this comment draws out below rather than one to take on trust from this line.
The bound is not optional politeness: go-sdk v1.7.0 reads a response with io.ReadAll before it decodes anything (mcp/streamable.go, handleJSON), so a server that answers a tools/list with a chunked, multi-gigabyte description grows the executor's heap until the process dies — and it takes every other session on that host with it. Neither the request timeout nor the page bound stops it, because both count requests rather than bytes, and no recover catches an out-of-memory. The only place to refuse is before the SDK sees the body.
It is cumulative because a per-response cap does not actually bound a listing. maxToolPages responses of MaxResponseBytes each is 800 MiB, and both ends retain it. Under a 2026-07-28 negotiation the SDK puts every tools/list result into a per-cursor cache unconditionally (mcp/client.go ListTools → toolsCache.put, gated only on usesNewProtocol), and an entry stays there until that cursor is asked for again or the server sends notifications/tools/list_changed, which clears the cache outright (mcp/cache.go invalidate). So a hundred unique cursors hold a hundred pages live. This package retains its own copy alongside: names and descriptions are shared string backings rather than second copies, but every accepted schema exists twice, once as the SDK's decoded map and once as the bytes re-marshaled here. Cumulative makes the bound mean what it says.
(Whether a *read* of that cache avoids the wire is a separate question with a different answer, and worth not conflating: mcp/cache.go get treats an entry as a miss and deletes it unless the server sent a positive, unexpired `ttlMs` hint, which nothing defaults — so a modern server that omits the field caches nothing usefully and every repeat goes back on the wire. Retention does not depend on that; serving does.)
What it covers is bodies in full and header blocks only as far as they can be counted after parsing — see headerBytes. Header fields that never reach the parsed map are outside it, held per block by maxHeaderBytesPerResponse instead, and *per block* is where the arithmetic stops rather than continues.
No cumulative bound on delivered header bytes is worth publishing. Several revisions of this comment published one anyway and every one was falsified: by whitespace padding, by mistaking a block for a response, by claiming a socket total that no header arithmetic can produce, and by multiplying a per-block cap by a response count. That last is the one that matters, because it is what all of them needed — a bound on how many responses one connection answers, which go-sdk v1.7.0 does not have. Counting the handshake, a hundred pages and the session-ending DELETE reaches 104, and two paths walk past it. A server that answers the first server/discover with CodeUnsupportedProtocolVersion and a supported-version list is probed a second time (mcp/client.go, `for range 2`). And any response delivered as text/event-stream may end carrying a fresh `id:` and no call response, which sends handleSSE round its reconnect loop again — the no-progress retry cap it grew for exactly this resets on every id that advances, the server picks the delay through the SSE `retry:` field, and the SDK's own TODO beside it records that a limit on total attempts for one logical request is still missing (mcp/streamable.go, handleSSE/connectSSE). TestAConnectionAnswersMoreResponsesThanItHasPages drives it: around two thousand responses to a single listing in three seconds, in three of the 120 seconds ListTimeout allows, against the 104 that arithmetic multiplied by. It is written to go red if that upstream limit ever lands, which is how this comment would learn it may tighten again. It counts responses and says nothing about their bytes, deliberately — see the note there on the figure that came of multiplying that count by a per-response size instead of measuring one.
One thing here is bounded and worth saying: headerBytes charges every response at least the twenty-odd bytes of its status line and terminator, so this budget caps the response count too, at a few hundred thousand. Its product with a maximal block is left unstated on purpose, under the rule this comment kept failing: do not multiply by a quantity nothing bounds. Deriving a figure is fine where both terms are enforced somewhere — 800 MiB above is maxToolPages times MaxResponseBytes, and the few hundred thousand just above is this budget over a floor headerBytes guarantees — and the withdrawn product was not that. It multiplied an enforced per-block cap by a response count with no bound at all, which is why every attempt to publish it has been wrong, including one that named the rule and broke it in the same sentence.
What is bounded usefully is the peak and not the sum: one header block at a time, plus this cumulative figure for everything that can be accounted. The unaccounted blocks are parsed and dropped per response rather than retained, so they cost bandwidth rather than memory, and the loop that produces them ends on whichever of this budget or ListTimeout arrives first. Nothing here bounds socket traffic either, which a hostile server inflates at will — DATA frames may carry one byte each, and PING or WINDOW_UPDATE floods are outside every count in this package. ListTimeout is what bounds a hostile connection's cost in that direction. These bounds are about memory, which is what an io.ReadAll of a multi-gigabyte response actually threatens.
8 MiB is far above any real catalog — the whole point of a tool definition is to fit in a model request, and a catalog that cannot be sent to a model is not a catalog — and small enough that a host running many sessions is not one hostile server away from an out-of-memory.
Variables ¶
var CallClient = guardedClient(CallTimeout)
CallClient is DefaultClient's twin for running tools, and it exists because http.Client.Timeout bounds the whole request including the body read: a `tools/call` response is not complete until the tool is, so a client whose cap is the dial budget cannot run a tool that takes longer than a dial. That is the right cap for a handshake and a listing, where the server is answering from what it already knows, and the wrong one for a tool that goes and does something — a query, a build, a fetch of its own.
The cap here is CallTimeout, which is what a tool call gets on this platform already: a remote tool should not outlive a local one by default. Everything else is DefaultClient's, the address guard included — the URL is customer-supplied on this path exactly as it is on that one.
var DefaultClient = guardedClient(DialTimeout)
DefaultClient is the guarded HTTP client used when a Config supplies none.
Two protections, both of which the platform needs because the URL is customer-supplied. The dial-time address guard (internal/dialguard) refuses loopback, link-local, the unspecified address and multicast on the resolved IP of every dial, so neither a hostile MCP server URL nor a DNS rebind reaches the platform's own surfaces or a cloud metadata endpoint. And redirects are never followed: following one would replay the request — with its Authorization header — to a target the per-hop guard vets but never approved as a destination.
The transport is spelled out rather than cloned from http.DefaultTransport, so three of its settings are decisions rather than omissions.
No Proxy. http.DefaultTransport reads HTTP_PROXY/HTTPS_PROXY from the environment; this deliberately does not, because a proxy moves the dial off the target and onto the proxy, and the address guard would then be vetting the proxy's address while the proxy fetched whatever the URL named. That is the guard removed rather than satisfied. A deployment whose egress genuinely requires a proxy supplies its own client and owns the consequence.
ForceAttemptHTTP2, because setting DialContext at all turns HTTP/2 off unless it is set — an MCP server reached over https would otherwise be spoken to in HTTP/1.1 only, which is a downgrade nobody chose.
Idle-connection settings, because this Transport is package-level: it outlives every connection made through it, and with the zero values an idle connection to a server never reached again is held until the process ends.
And MaxResponseHeaderBytes, which is the *only* bound on a response's header bytes and not a second line behind the cumulative budget below. That budget charges what it can reconstruct from resp.Header, and net/http normalizes the block before handing it over: a value's padding whitespace is trimmed and a folded continuation is joined, so the bytes are read, allocated, and then unaccountable. Measured against a raw listener answering Content-Length and an X-Pad value of one character followed by 200,000 spaces: 200,048 header bytes on the wire, reconstructed to 49 — a factor of 4,083, which is a distortion no arithmetic on the parsed map can correct. So the raw block is bounded here instead — 64 KiB rather than net/http's 10 MiB default, per header block, which is a bound on the peak one block reaches and deliberately not a total: how many blocks a connection answers is not this package's to know, and MaxResponseBytes says why.
var ErrServerAnswered = errors.New("the server answered, refusing the call")
ErrServerAnswered marks every error this package returns from a call the server *did* answer: a JSON-RPC error rather than a result, a request for input this platform cannot supply, and an answer that arrived but could not be read — one whose blocks are all of types a tool result cannot carry, or whose structured value would not re-marshal. All of them leave a caller with nothing to hand a model, which is why they are errors rather than results; none of them is a connection that failed, and the difference is one a caller cannot recover from the message. A caller that reports connection failures separately (the MCP work driver does, on the wire, as mcp_connection_failed_error) tests for this before doing so. The rule is the boundary rather than the list: an error raised after `callTool` returned is the server's answer, not the transport's failure.
ErrUnauthorized marks an error raised on an exchange the server answered 401 or 403 to — the credential was refused, or the server required one and this dial carried none.
It exists because the two failures the wire distinguishes cannot otherwise be told apart here. The reference splits them by cause: `mcp_connection_failed_error` is "the MCP server could not be reached (network error, timeout, or non-authentication HTTP failure)", while `mcp_authentication_failed_error` is "the server rejected the credential from the attached vault, required authentication when no matching credential was configured, or an OAuth token refresh failed". A refused credential is therefore not a connection that failed — the connection worked well enough to be refused.
The status is observed here rather than read off the SDK's error, which does not carry it: go-sdk v1.7.0 renders a non-2xx as `http.StatusText(code)` inside a formatted message and wraps no sentinel (mcp/streamable.go, checkResponse), and 401/403 are not among the statuses it treats as transient. Matching that message would be matching prose that a version bump may reword; watching the response is exact, and this package already owns the whole transport chain.
Distinct from ErrServerAnswered, which marks a call the server answered *and refused*: that is a working server reporting a working failure, and the model is told to stop calling the tool. An authentication failure is the operator's to fix, so a caller checks this one first.
Functions ¶
This section is empty.
Types ¶
type CallResult ¶
type CallResult struct {
// Content is the answer's blocks in the order the server sent them.
Content []Content
// IsError reports a tool that ran and failed on its own terms.
IsError bool
}
CallResult is one tool call's answer.
IsError is the tool's verdict, never the transport's. MCP asks a server to report a tool that ran and failed as an ordinary result with IsError set — "otherwise, the LLM would not be able to see that an error occurred and self-correct" — and to reserve JSON-RPC errors for the call not happening at all. CallTool keeps the two apart on the same line: a failed tool comes back as a CallResult with a nil error, and a nil CallResult means the platform, not the model, has something to fix.
type Config ¶
type Config struct {
// URL is the server's MCP endpoint, from the agent's mcp_servers entry.
URL string
// BearerToken, when set, is sent as `Authorization: Bearer`. It comes from
// a session vault's matching credential; an empty token connects
// anonymously, which is the reference's documented no-match behavior.
BearerToken string
// HTTPClient replaces the guarded client rather than adding to it. Nil
// selects [DefaultClient], which is what production uses; a test supplies
// its own to reach an httptest server on loopback. A supplied client gives
// up everything [DefaultClient] carries — the dial-time address guard, the
// refusal to follow redirects, and the per-block cap on raw response header
// bytes (maxHeaderBytesPerResponse, in place of net/http's 10 MiB default) —
// so a non-test caller that wants its own transport policy installs those
// itself.
//
// The header cap belongs on that list rather than under the bounds below,
// and the distinction is easy to get backwards: the two bounds this package
// owns — the cumulative response budget and the fallback request deadline —
// are wrapped around whatever client is supplied and are not optional, but
// the response budget can only charge header bytes that survive parsing.
// The bytes it cannot see are held by the transport setting, which lives on
// the client, so a supplied client is bounded on bodies and unbounded on
// padded, informational and trailing header blocks.
HTTPClient *http.Client
}
Config describes one MCP server connection.
type Conn ¶
type Conn struct {
// contains filtered or unexported fields
}
Conn is one open connection to an MCP server. It is not safe for concurrent use and is not meant to be: one work item, one connection, one goroutine.
func Connect ¶
Connect opens a connection to one MCP server over Streamable HTTP.
Only Streamable HTTP is spoken. The SDK negotiates every protocol version from 2024-11-05 up over it, so an older server is reached as long as it hosts the modern endpoint; the separate HTTP+SSE transport of 2024-11-05 is deprecated ("New implementations SHOULD NOT adopt it" — spec 2026-07-28, transports/streamable-http) and a client-side fallback to it is deliberately not wired here.
The standalone SSE stream is disabled. It is how a server pushed unsolicited notifications in revisions 2025-03-26 through 2025-11-25; 2026-07-28 removed the GET endpoint it used, and a per-work-item connection has no use for server-initiated messages in either era — it asks one question and closes.
The setting therefore does nothing against a modern server: go-sdk v1.7.0 returns before opening the GET whenever the negotiated version is 2026-07-28 or later (streamable.go, sessionUpdated), so it only takes effect on an older negotiation, where the spec makes the GET optional and a tools/list answer comes back on the POST regardless.
That older negotiation is what the contract suite actually runs against, which is easy to get backwards. go-sdk serves 2026-07-28 only from a stateless handler, so a default sdk.NewStreamableHTTPHandler does not answer server/discover at all and every fixture here falls back to the legacy initialize and negotiates 2025-11-25 — precisely the era where this line is load-bearing. It is asserted rather than assumed: TestConnectOpensNoStandaloneStream counts the GETs.
func (*Conn) CallTool ¶
func (c *Conn) CallTool(ctx context.Context, name string, arguments json.RawMessage) (*CallResult, error)
CallTool runs one tool on the connected server and returns its answer.
arguments is sent verbatim: json.RawMessage marshals as itself, so the bytes the model produced reach the server unaltered rather than through a map[string]any round trip that would reorder keys and re-render numbers. Empty arguments become the empty object the SDK sends in place of a nil.
One thing a fresh connection does not do, which matters when a server uses it: the SDK lifts a tool parameter annotated `x-mcp-header` (SEP-2243) out of the arguments and onto an HTTP header only for a tool it already has cached from a `tools/list` on this same session (ClientSession.lookupTool). Connections here are per-work-item and a caller that only calls never lists, so such a parameter travels in the JSON body instead.
One request is also not always one round trip. A server may answer with `resultType: "input_required"` instead of the tool's output — the multi round-trip pattern that replaced roots, sampling and elicitation in MCP 2026-07-28 (SEP-2322). This platform offers no interactive surface to fulfil such a request with, so every shape of it ends the call, and the three shapes end it differently because the SDK's client middleware handles them differently:
- `inputRequests` with entries: the middleware answers them itself and re-sends the call, up to ten attempts, then fails. The result never reaches here still carrying them.
- `inputRequests` present and empty — which the spec gives servers as a load-shedding signal: the middleware retries a few times and fails.
- `inputRequests` absent altogether: the middleware's retry loop turns on a non-nil map, so this one is handed straight back, and it is the shape that would otherwise pass for success. The tool did not run and the answer carries no output, so a caller reading only Content would show the model an empty result from a tool that was never executed. It is refused here instead.
Both of the shapes that end a call this way are the server's own answer, and so are marked ErrServerAnswered.
func (*Conn) Close ¶
Close ends the connection. It is safe to call on a Conn whose work failed.
It does not guard the zero value, where ListTools does, and the asymmetry is the point rather than an oversight: ListTools sits in front of a recover that would catch a nil dereference and report it as a server crashing the client library, which is a lie about whose fault it is. Nothing catches a panic here, so calling Close on a Conn that was never opened panics with a stack pointing at the caller — already an accurate account of the misuse, and not worth a branch to restate.
func (*Conn) ListTools ¶
ListTools returns every tool the server reports, following pagination to the end. A server that reports none is not an error, and neither is an entry this refuses. The nearest documented case is adjacent rather than identical: Anthropic's *Messages API* MCP connector states that a `configs` entry naming a tool the server does not offer logs a backend warning and returns no error, because MCP servers may have dynamic tool availability. The managed-agents pages say nothing about either an empty listing or a malformed entry, so treating both as facts to record rather than failures is consistent with that posture by analogy — it is not the reference stating it.
Pagination is driven here rather than through the SDK's Tools iterator because the iterator hides the cursor, and the cursor is the only thing that says whether a server is paginating or looping: the iterator follows `nextCursor` until a server omits it, which a server that keeps returning the same cursor never does. Everything a server sends is treated as hostile until checked — this endpoint is customer-supplied, and the SDK decodes rather than validates it.
type Content ¶
type Content struct {
// Type is the MCP content type: "text", "image", "audio", "resource" or
// "resource_link". Those five are exactly what the protocol admits inside a
// tool result, and a block of any other type is dropped rather than
// guessed at — the SDK's decoder also accepts the two sampling-only types
// (tool_use, tool_result) here, which a tool result has no business
// carrying and this platform has nowhere to put.
Type string
// Text is a text block's body, or an embedded text resource's.
Text string
// Data is an image's or audio's bytes, or an embedded blob resource's,
// already base64-decoded.
Data []byte
// MIMEType describes Data, or the resource behind URI. Servers are not
// required to send one.
MIMEType string
// URI names the resource, for "resource" and "resource_link" alone.
URI string
}
Content is one block of a tool call's answer, in a shape this package owns rather than the SDK's content interface (CLAUDE.md design principle 1).
One flat struct covers all five block types a tool result may carry, and no field is nonsense for the type that leaves it empty: text fills Text, image and audio fill Data and MIMEType, an embedded resource fills URI, MIMEType and whichever of Text (a text resource) or Data (a blob) it carries, and a resource link fills URI and MIMEType. Rendering these into the block types an Anthropic tool result admits is the caller's, not this package's: what a client owes its caller is the server's answer intact.
type Tool ¶
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema"`
}
Tool is one tool an MCP server reports, in the shape the platform stores and later hands the model. The JSON tags are the Anthropic tool-definition field names rather than MCP's, so a catalog row needs no second translation at request-assembly time; the name is the bare tool name as the server reports it, which is also what a `configs[]` entry addresses.