Summary
If an elicitation client replies {"action": "accept"} with the content field omitted (or null), and the requested schema has any property with a default, the server process panics with assignment to entry in nil map. The panic originates in jsonschema-go's ApplyDefaults but is reached through ServerSession.Elicit, so a peer's response shape can kill a Go MCP server outright.
Versions
github.com/modelcontextprotocol/go-sdk v1.6.1 (latest release)
github.com/google/jsonschema-go v0.4.3 (latest; indirect)
- Go 1.26
Reproduction (public API, in-memory transports)
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
ctx := context.Background()
server := mcp.NewServer(&mcp.Implementation{Name: "s", Version: "0.0.1"}, nil)
client := mcp.NewClient(&mcp.Implementation{Name: "c", Version: "0.0.1"}, &mcp.ClientOptions{
// A spec-loose client: accepts, but omits content entirely.
ElicitationHandler: func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{Action: "accept"}, nil
},
})
st, ct := mcp.NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
panic(err)
}
if _, err := client.Connect(ctx, ct, nil); err != nil {
panic(err)
}
res, err := ss.Elicit(ctx, &mcp.ElicitParams{
Message: "pick",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"x": {Type: "boolean", Default: json.RawMessage(`true`)},
},
},
})
fmt.Println("Elicit returned:", res, err)
}
Output (Go 1.26, darwin/arm64 — same on a stdio transport):
panic: assignment to entry in nil map
goroutine 1 [running]:
reflect.mapassign_faststr0(...)
reflect.Value.SetMapIndex(...)
github.com/google/jsonschema-go/jsonschema.(*state).applyDefaults(...)
.../jsonschema-go@v0.4.3/jsonschema/validate.go:741
Mechanism
In ServerSession.Elicit (mcp/server.go ~1298–1304, v1.6.1):
- The client's
{"action":"accept"} unmarshals with res.Content as a nil map[string]any.
resolved.Validate(res.Content) passes — a nil map still reflects as Kind() == Map, and with no required properties an empty object is valid.
resolved.ApplyDefaults(&res.Content) walks the schema, finds a property with a default that is missing from the instance, and assigns via instance.SetMapIndex(...) (jsonschema-go validate.go:741) — assignment into the nil map, panic.
The panic propagates out of whatever server code called Elicit (typically a tool handler); the SDK does not recover panics there, so the process dies and every session it serves dies with it.
Minimal jsonschema-go-only repro of steps 2+3, in case you'd rather route part of the fix there (happy to split this into a jsonschema-go issue if that's preferred):
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"x": {Type: "string", Default: json.RawMessage(`"hi"`)},
},
}
resolved, _ := schema.Resolve(nil)
var content map[string]any
_ = resolved.Validate(content) // passes
_ = resolved.ApplyDefaults(&content) // panics: assignment to entry in nil map
Why this matters
Whether accept-without-content is spec-conformant is debatable (the schema here has no required fields, so "accept with all defaults" is a reasonable client reading) — but either way, a response crossing a trust boundary shouldn't be able to terminate the receiving process. Any long-running server that elicits with defaults is one loose client away from a crash.
Suggested direction
Either (or both):
- In
ServerSession.Elicit (and the client twin at mcp/client.go:643): normalize a nil res.Content to an empty map[string]any{} before Validate/ApplyDefaults.
- In jsonschema-go
ApplyDefaults: when handed a pointer to a nil map, allocate through the pointer before assigning (it already has the settable lvalue).
Workaround we use
Wrap the Elicit call in a defer/recover and fold the panic into the same fallback path as "client can't elicit", so the consent surface survives a client that gets this wrong.
Summary
If an elicitation client replies
{"action": "accept"}with thecontentfield omitted (ornull), and the requested schema has any property with adefault, the server process panics withassignment to entry in nil map. The panic originates in jsonschema-go'sApplyDefaultsbut is reached throughServerSession.Elicit, so a peer's response shape can kill a Go MCP server outright.Versions
github.com/modelcontextprotocol/go-sdkv1.6.1 (latest release)github.com/google/jsonschema-gov0.4.3 (latest; indirect)Reproduction (public API, in-memory transports)
Output (Go 1.26, darwin/arm64 — same on a stdio transport):
Mechanism
In
ServerSession.Elicit(mcp/server.go ~1298–1304, v1.6.1):{"action":"accept"}unmarshals withres.Contentas a nilmap[string]any.resolved.Validate(res.Content)passes — a nil map still reflects asKind() == Map, and with norequiredproperties an empty object is valid.resolved.ApplyDefaults(&res.Content)walks the schema, finds a property with a default that is missing from the instance, and assigns viainstance.SetMapIndex(...)(jsonschema-go validate.go:741) — assignment into the nil map, panic.The panic propagates out of whatever server code called
Elicit(typically a tool handler); the SDK does not recover panics there, so the process dies and every session it serves dies with it.Minimal jsonschema-go-only repro of steps 2+3, in case you'd rather route part of the fix there (happy to split this into a jsonschema-go issue if that's preferred):
Why this matters
Whether accept-without-content is spec-conformant is debatable (the schema here has no required fields, so "accept with all defaults" is a reasonable client reading) — but either way, a response crossing a trust boundary shouldn't be able to terminate the receiving process. Any long-running server that elicits with defaults is one loose client away from a crash.
Suggested direction
Either (or both):
ServerSession.Elicit(and the client twin at mcp/client.go:643): normalize a nilres.Contentto an emptymap[string]any{}beforeValidate/ApplyDefaults.ApplyDefaults: when handed a pointer to a nil map, allocate through the pointer before assigning (it already has the settable lvalue).Workaround we use
Wrap the
Elicitcall in adefer/recoverand fold the panic into the same fallback path as "client can't elicit", so the consent surface survives a client that gets this wrong.