mcp: refuse a call on a legacy session before initialize (#1273)

Closes #1271.

`Server.handle` refuses a call made before `initialize` on a legacy
session, and that refusal lives in the `default` branch of its method
switch, so any method with a `case` of its own skips it. Three are
served on a session that never handshook: `logging/setLevel`,
`resources/subscribe` and `resources/unsubscribe`.

`resources/subscribe` is the one that reaches state rather than merely
answering: it starts a watcher and registers a subscriber, so a client
that subscribes before `initialize` is delivered
`notifications/resources/updated` for the lifetime of a session the
server never agreed to.

The check now runs after the switch, for every call carrying no
new-protocol `_meta`, with `initialize` and `ping` exempt. That is the
same condition the `default` branch already applied and the same error
it produced; the change is that the three methods above stop skipping
it.

`ping` stays allowed because the lifecycle page exempts it.
`server/discover` is deliberately left alone: a legacy request there
already receives method-not-found, which tells the client more than an
initialization error would. The two notification cases stay outside,
since the gate covers calls and a notification carries no refusal.

The SEP-2575 path is untouched. A session without an `initialize`
handshake is exactly what that proposal is for, so the exemption is the
request's own `_meta.protocolVersion` rather than anything about the
session, and a new-protocol client is served as it is today.

Tests: `TestServerHandle_LegacyCallBeforeInitialize` drives each
affected method on a fresh session and asserts the refusal, that
`resources/subscribe` runs no handler and leaves no subscriber, and that
`initialize` and `ping` are served.
`TestServerHandle_NewProtocolCallWithoutInitialize` asserts a
new-protocol session with no handshake still gets `tools/list`, and that
the three methods keep their existing method-not-found rather than the
initialization refusal. Without the change, the setLevel, subscribe and
unsubscribe rows fail.

`go test ./mcp/ -count=1` is green, `gofmt -l` is empty and `go vet
./mcp/` is clean. This does not depend on #1266 and rebases onto it
cleanly, verified with the suite green on the combination.

One decision worth your view: the refusal is a plain error, which on the
wire carries code 0, exactly as the `default` branch produced before
this change. The specification prescribes no code for it. If you would
rather it were `-32600` or `-32603`, say so and I will change it here.

---------

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
José M. Requena Plens 2026-09-17 10:36:23 +02:00 • committed by GitHub
commit 826e653c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1989,10 +1989,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
}
}
default:
if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() {
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
}
if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil {
ss.updateState(func(state *ServerSessionState) {
state.InitializeParams = validatedMeta.initializeParams
@ -2000,6 +1996,16 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
}
}
// In legacy protocol versions, a client cannot send requests other than
// pings before the server has responded to the initialize request. A
// new-protocol request is exempt: a SEP-2575 session has no 'initialize'
// to wait for, and the request itself says which protocol it speaks.
if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() &&
req.Method != methodInitialize && req.Method != methodPing {
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
}
// modelcontextprotocol/go-sdk#26: handle calls asynchronously, and
// notifications synchronously, except for 'initialize' which shouldn't be
// asynchronous to other