Skip to content

fix(mcp): survive an inbound message that carries no params (#629) - #640

Open
css521 wants to merge 1 commit into
kenn-io:mainfrom
css521:fix/629-trace-middleware-nil-params
Open

fix(mcp): survive an inbound message that carries no params (#629)#640
css521 wants to merge 1 commit into
kenn-io:mainfrom
css521:fix/629-trace-middleware-nil-params

Conversation

@css521

@css521 css521 commented Aug 18, 2026

Copy link
Copy Markdown

Fixes #629.

Why the existing guard did not hold

traceMiddleware already checked for absent params:

if params := req.GetParams(); params != nil {
    meta := params.GetMeta()

GetParams returns the Params interface, and a message sent without a "params" object decodes into a typed-nil *InitializedParams, which (*ServerRequest[P]).GetParams then boxes into that interface. An interface holding a nil pointer is not itself nil, so params != nil was true and execution continued into GetMeta.

GetMeta is not hand-written for these types. InitializedParams embeds Meta, and Meta.GetMeta has 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>:1 frame in the reported stack, and on a nil receiver it segfaults.

Claude Code sends notifications/initialized with no params, which the spec allows, so this was not an edge case for that client — it was every connection, right after initialize, before tools/list could 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

paramsMeta decides nil by what the value is rather than by what the interface looks like:

func paramsMeta(params sdkmcp.Params) map[string]any {
	if params == nil {
		return nil
	}
	if value := reflect.ValueOf(params); value.Kind() == reflect.Pointer && value.IsNil() {
		return nil
	}
	return params.GetMeta()
}

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:

type Params interface {
	GetMeta() map[string]any
	SetMeta(map[string]any)
	isParams()
	// isNil returns true if the underlying value is nil.
	isNil() bool
}

isNil is unexported and implemented per type (func (x *InitializedParams) isNil() bool { return x == nil }), so a caller outside mcp cannot reach it. The alternatives were a type switch over every params type, which goes stale with each protocol revision, or a recover, which turns a bug into a silent one. If you would rather see this pushed upstream as an exported Params.IsNil() — or as a nil-receiver guard in the generated GetMeta, 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/baggage lookups need no branch of their own.

Review boundaries

  • Scoped to the reported failure. traceMiddleware is the only site in the tree that calls GetMeta on inbound params, so paramsMeta has one caller today; it is a named function rather than an inline check so the next middleware that needs _meta has the guarded path to reach for.
  • errorIsolationMiddleware does not recover panics — it inspects returned errors only, which is why this took the process down rather than failing one call. A recover there 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.
  • The precondition in the test is a Go interface comparison, deliberately. require.NotNil reflects 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. testifylint suggests must.NotEqual on 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.

)

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-ci

roborev-ci Bot commented Aug 18, 2026

Copy link
Copy Markdown

roborev: Combined Review (64d474c)

No issues found.


Reviewers: 2 done | Synthesis: codex | Total: 50s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

mcp: nil pointer panic in traceMiddleware on notifications/initialized from Claude Code (regression since v0.19.2, PR #566)

1 participant