modelcontextprotocol / modelcontextprotocol/go-sdk

mcp: the declared protocol version, not the negotiated one, decides what a session is served

Open
#1,258 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
5.1k
Forks
543
Avg merge
1d 17h
Merged PRs (30d)
37

Description

When a client sends initialize asking for protocol version 2026-07-28, the server negotiates it down to 2025-11-25 and then goes on serving it as if it spoke 2026-07-28. The version the client declared stays in InitializeParams.ProtocolVersion and is what the capability decisions read; the version the two sides actually agreed on is returned as InitializeResult.ProtocolVersion and stored as ServerSessionState.NegotiatedProtocolVersion, and nothing reads it.

The negotiation itself is deliberate. negotiatedVersion in mcp/shared.go caps the initialize handshake below 2026-07-28 because initialize is deprecated in that version, and the comment there says so. The problem is that the rest of the server does not follow the answer it just gave.

What I see

Three places read the declared version, and each gives the same session a different symptom.

clientSupportsMultiRoundTrip (mcp/mrtr.go, line 65) decides whether a result is marked input_required. Reading the declared version, it answers a handler's InputRequests with a multi round-trip result (SEP-2322) on a session that negotiated 2025-11-25, where that result type does not exist.

ServerSession.assertServerInitiatedRequestAllowed (mcp/server.go, line 1619) decides whether the server may send elicitation/create, sampling/createMessage or roots/list. Reading the declared version, it makes ServerSession.Elicit, CreateMessage, CreateMessageWithTools and ListRoots fail on that same session with "elicitation/create" cannot be sent while serving a request on protocol version 2026-07-28, naming a version the session is not speaking. Its own doc comment describes it as a check on a session "negotiated at protocol version >= 2026-07-28", which is what it does not do.

Server.notifySessions (mcp/server.go, line 782) and Server.ResourceUpdated (line 1190) decide whether a session is a legacy subscriber. Reading the declared version, they leave that session out of the legacy set, so it gets no tools/list_changed, prompts/list_changed, resources/list_changed or resources/updated on the session channel, and it has not opened the subscriptions/listen stream the other branch delivers on, since that method does not exist in the version it negotiated.

What the documentation says

docs/server.md describes the server-side compatibility shim as running "for clients on a protocol version earlier than 2026-07-28", and the multi round-trip example says the round trip is handled on one side of the wire or the other "depending on the negotiated protocol version". The same section says ServerSession.Elicit, CreateMessage(WithTools) and ListRoots "remain available and work for both new and legacy clients". None of those three statements holds for a session that asked for 2026-07-28 in initialize.

The specification's lifecycle rule is the other half of it: a server that does not support the requested version answers with one it does support, and the client either continues on that version or disconnects. Once the server has answered 2025-11-25, that is the version of the session. The value the client asked for is a request that was not granted.

Reproduction

Any client that sends initialize declaring the newest version it knows reaches this. The SDK's own client does not, because Client.Connect tries server/discover first and falls back to an initialize declaring 2025-11-25, so for it the declared and the negotiated value agree. A hand-written client is enough.

The tool is a handler that asks for input:

srv.AddTool(&mcp.Tool{Name: "act", InputSchema: &jsonschema.Schema{Type: "object"}},
	func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		return &mcp.CallToolResult{
			InputRequests: mcp.InputRequestMap{"confirm": &mcp.ElicitParams{Message: "OK?"}},
			RequestState:  "state-1",
		}, nil
	})

and the exchange is:

C -> S {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"elicitation":{}},"clientInfo":{"name":"demo","version":"v1"},"protocolVersion":"2026-07-28"}}
S -> C {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"tools":{"listChanged":true}},"protocolVersion":"2025-11-25","serverInfo":{"name":"test","version":"v1.0.0"}}}
C -> S {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
C -> S {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"act"}}
S -> C {"jsonrpc":"2.0","id":2,"result":{"content":null,"requestState":"state-1","resultType":"input_required","inputRequests":{"confirm":{"method":"elicitation/create","params":{"mode":"form","message":"OK?"}}}}}

The server answered 2025-11-25 and then sent a 2026-07-28 result.

For the notification symptom, run the same handshake, add a tool to the server afterwards, and wait: nothing arrives. The identical session declaring 2025-11-25 in initialize is sent notifications/tools/list_changed immediately.

What it costs a server

A server built on this SDK cannot ask such a session for anything, on either path. The multi round-trip result goes to a client that negotiated a version where inputRequests is not defined, and a client that ignores result fields it does not know, which is the conforming thing for it to do, sees a tool call that returned no content and no error. The legacy path that same client does implement is refused inside the SDK before it reaches the wire, with an error naming a protocol version the session never got. Every elicitation, sampling request and roots listing on that session is therefore lost, silently in one direction and with a misleading message in the other.

It costs the deployment its list-changed and resource-updated notifications in the same silent way: the server believes it has delivered them on a stream the client never opened.

Version

I observed this in a deployed server on v1.7.0. The code is unchanged on main at 5bc078a, which is where the line numbers above come from, and at v1.8.0-pre.2. ServerSessionState.NegotiatedProtocolVersion, which records the value the handshake settled on, was added in #1199 and ships from v1.8.0-pre.1.

I have a fix for the first two and will open a pull request against main.

Part of #1257.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with ServerSessionState.NegotiatedProtocolVersion and the version checks in mcp/mrtr.go and mcp/server.go, then review negotiatedVersion in mcp/shared.go and the compatibility notes in docs/server.md. Reproduce the initialize exchange using the declared 2026-07-28 and negotiated 2025-11-25 versions; done means multi-round-trip, server-initiated requests, and notification routing follow the negotiated version and the documentation matches.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api, backend-api-design
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.