fix(mcp): survive an inbound message that carries no params (#629) - #640
Open
css521 wants to merge 1 commit into
Open
fix(mcp): survive an inbound message that carries no params (#629)#640css521 wants to merge 1 commit into
css521 wants to merge 1 commit into
Conversation
) traceMiddleware pulled trace context out of every inbound message's _meta behind an `if params := req.GetParams(); params != nil` guard. That guard does not hold: GetParams returns the Params interface, and a message sent without a "params" object arrives as a typed-nil pointer inside it. An interface holding a nil pointer is not itself nil, so the guard passed, and the SDK's GetMeta — promoted from an embedded Meta, so the generated method dereferences its receiver — segfaulted. Claude Code sends notifications/initialized with no params, which the spec allows, so this was every connection: the server died immediately after initialize and tools/list never got a response. paramsMeta now decides nil the way the value actually is. The SDK declares Params.isNil for exactly this and keeps it unexported, so the check is by reflection. Callers index the result directly; a nil map reads as empty. The reproduction is a ServerRequest with Params left at its zero value, which is what the wire message without "params" decodes to. Note the precondition is asserted as a Go interface comparison rather than with require.NotNil: NotNil reflects, and calls a typed-nil pointer nil, which would have made the test pass against the unfixed code.
roborev: Combined Review (
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #629.
Why the existing guard did not hold
traceMiddlewarealready checked for absent params:GetParamsreturns theParamsinterface, and a message sent without a"params"object decodes into a typed-nil*InitializedParams, which(*ServerRequest[P]).GetParamsthen boxes into that interface. An interface holding a nil pointer is not itself nil, soparams != nilwas true and execution continued intoGetMeta.GetMetais not hand-written for these types.InitializedParamsembedsMeta, andMeta.GetMetahas a value receiver, so Go generates the promoted method on the pointer type — and it dereferences the receiver to reach the embedded field. That is the<autogenerated>:1frame in the reported stack, and on a nil receiver it segfaults.Claude Code sends
notifications/initializedwith no params, which the spec allows, so this was not an edge case for that client — it was every connection, right afterinitialize, beforetools/listcould be answered. @nitay's bisect to #566 and the reproduction were exactly right; the only part I'd restate is the suggested fix, since "guard the params against a nil pointer" was already there and is the thing that doesn't work.The fix
paramsMetadecides nil by what the value is rather than by what the interface looks like:Reflection is not my first choice here, and it is worth saying why it is the option left. The SDK declares this exact predicate on the interface:
isNilis unexported and implemented per type (func (x *InitializedParams) isNil() bool { return x == nil }), so a caller outsidemcpcannot reach it. The alternatives were a type switch over every params type, which goes stale with each protocol revision, or arecover, which turns a bug into a silent one. If you would rather see this pushed upstream as an exportedParams.IsNil()— or as a nil-receiver guard in the generatedGetMeta, per the second half of the issue — I'm happy to open that and let this carry the local fix in the meantime.Callers now index the returned map directly. A nil map reads as empty in Go, so the
traceparent/tracestate/baggagelookups need no branch of their own.Review boundaries
traceMiddlewareis the only site in the tree that callsGetMetaon inbound params, soparamsMetahas one caller today; it is a named function rather than an inline check so the next middleware that needs_metahas the guarded path to reach for.errorIsolationMiddlewaredoes not recover panics — it inspects returned errors only, which is why this took the process down rather than failing one call. Arecoverthere would have contained the blast radius of this and anything like it, but that is a policy decision about how the server should treat programmer error, and it belongs in its own change rather than bundled into a bug fix. Flagging it as the reason a single nil deref was fatal.require.NotNilreflects and reports a typed-nil pointer as nil, so writing it that way would have asserted the opposite of what the production code sees and let the test pass against the unfixed code.testifylintsuggestsmust.NotEqualon that line; the comparison is bound to a named variable so the check is satisfied without changing its meaning. Worth a look in case you would prefer a different shape.