Answer initialize in the revision the client asked for

initialize always answered 2026-07-28. The official TypeScript client
(SDK 1.30.0) and Python client (SDK 2.2.0) ask for 2025-11-25 and refuse
an answer in a revision they did not request and do not know, so neither
could connect to /mcp.

The MCP lifecycle's version negotiation answers in the requested revision
when the server supports it. This door serves 2025-06-18 and 2025-11-25 as
written (the same Streamable HTTP transport and tool methods, and no
batches), so those are echoed. Anything else is answered in 2026-07-28, as
before. With the change both clients connect, list the tools and call one,
the Python client in its default auto mode as well.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-11 10:36:14 -07:00
co-authored by Hanzo Dev
parent 46a0a196f6
commit 60001fc32b
2 changed files with 50 additions and 1 deletions
+20 -1
View File
@@ -145,6 +145,25 @@ const mimeJSON = "application/json"
// the HTTP+SSE transport are absent here, so nothing needs a migration window.
const mcpProtocolVersion = "2026-07-28"
// mcpRevisions are the revisions this door answers in. The two before
// mcpProtocolVersion define the same Streamable HTTP transport and the same tool
// methods, and neither has JSON-RPC batches, so a client that asks for one is
// served it as written.
var mcpRevisions = map[string]bool{"2025-06-18": true, "2025-11-25": true, mcpProtocolVersion: true}
// negotiate is the revision an initialize is answered in, by the MCP lifecycle's
// version negotiation: the one the client asked for when this door speaks it,
// else the newest.
func negotiate(params []byte) string {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && mcpRevisions[p.ProtocolVersion] {
return p.ProtocolVersion
}
return mcpProtocolVersion
}
// defaultMCPPath is where an app serves its own MCP door unless MCPConfig moves
// it, and therefore where a host forwards a composed tools/call.
const defaultMCPPath = "/mcp"
@@ -409,7 +428,7 @@ func (a *App) MCP(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
// own migration path assumes.
case "initialize", "server/discover":
return f.Answer(mcpJSON(map[string]any{
"protocolVersion": mcpProtocolVersion,
"protocolVersion": negotiate(f.Params),
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": a.mcpName(), "version": a.cfg.OpenAPI.Version},
}))
+30
View File
@@ -42,3 +42,33 @@ func TestDiscoverAnswersLikeInitialize(t *testing.T) {
t.Fatalf("server/discover %v != initialize %v", disc["protocolVersion"], in["protocolVersion"])
}
}
// A client that asks for a revision this door speaks is answered in it; one that
// asks for anything else is answered in the newest. The official TypeScript and
// Python clients both refuse an initialize answered in a revision they did not
// ask for and do not know.
func TestInitializeAnswersTheRevisionTheClientAsked(t *testing.T) {
a := New(Config{})
for asked, want := range map[string]string{
"2025-11-25": "2025-11-25",
"2025-06-18": "2025-06-18",
"2026-07-28": "2026-07-28",
"2024-11-05": mcpProtocolVersion,
"": mcpProtocolVersion,
} {
params, err := json.Marshal(map[string]any{"protocolVersion": asked})
if err != nil {
t.Fatal(err)
}
f := a.MCP(context.Background(), &zapmcp.Frame{Method: "initialize", Kind: zapmcp.Request, ID: "1", Params: params})
var got struct {
ProtocolVersion string `json:"protocolVersion"`
}
if err := json.Unmarshal(f.Result, &got); err != nil {
t.Fatalf("asked %q: %v", asked, err)
}
if got.ProtocolVersion != want {
t.Errorf("asked %q: answered %q, want %q", asked, got.ProtocolVersion, want)
}
}
}