Skip to content

ServerSession.Elicit: accept response without content panics the server ("assignment to entry in nil map") #1062

Description

@Yuncun

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):

  1. The client's {"action":"accept"} unmarshals with res.Content as a nil map[string]any.
  2. resolved.Validate(res.Content) passes — a nil map still reflects as Kind() == Map, and with no required properties an empty object is valid.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions