CVE-2026-27896
Description
The Go MCP SDK used Go's standard encoding/json.Unmarshal for JSON-RPC and MCP protocol message parsing in versions prior to 1.3.1. Go's standard library performs case-insensitive matching of JSON keys to struct field tags — a field tagged json:"method" would also match "Method", "METHOD", etc. This violated the JSON-RPC 2.0 specification, which defines exact field names. A malicious MCP peer may have been able to send protocol messages with non-standard field casing that the SDK would silently accept. This had the potential for bypassing intermediary inspection and coss-implementation inconsistency. Go's standard JSON unmarshaling was replaced with a case-sensitive decoder in commit 7b8d81c. Users are advised to update to v1.3.1 to resolve this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/modelcontextprotocol/go-sdkGo | < 1.3.1 | 1.3.1 |
Affected products
1Patches
17b8d81c26407all: use case-sensitive JSON unmarshaling (#807)
16 files changed · +134 −25
go.mod+6 −0 modified@@ -6,7 +6,13 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 + github.com/segmentio/encoding v0.5.3 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.34.0 golang.org/x/tools v0.41.0 ) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + golang.org/x/sys v0.40.0 // indirect +)
go.sum+6 −0 modified@@ -4,9 +4,15 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
internal/json/json.go+19 −0 added@@ -0,0 +1,19 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package json provides internal JSON utilities. + +package json + +import ( + "bytes" + + "github.com/segmentio/encoding/json" +) + +func Unmarshal(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DontMatchCaseInsensitiveStructFields() + return dec.Decode(v) +}
internal/json/json_test.go+63 −0 added@@ -0,0 +1,63 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package json + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestUnmarshalCaseSensitivity(t *testing.T) { + type Nested struct { + Field string `json:"field"` + } + type Target struct { + Field string + TaggedField string `json:"custom_tag"` + Nested *Nested + } + + tests := []struct { + name string + json string + want Target + }{ + { + name: "exact match", + json: `{"Field": "value", "custom_tag": "tagged", "Nested": {"field": "nested"}}`, + want: Target{ + Field: "value", + TaggedField: "tagged", + Nested: &Nested{ + Field: "nested", + }, + }, + }, + { + name: "case mismatch", + json: `{"field": "value", "Custom_tag": "tagged", "Nested": {"Field": "nested"}}`, + want: Target{ + Field: "", + TaggedField: "", + Nested: &Nested{ + Field: "", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Target + if err := Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Unmarshal mismatch (-want +got):\n%s", diff) + } + }) + } +}
internal/jsonrpc2/conn.go+2 −1 modified@@ -6,13 +6,14 @@ package jsonrpc2 import ( "context" - "encoding/json" "errors" "fmt" "io" "sync" "sync/atomic" "time" + + "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Binder builds a connection configuration.
internal/jsonrpc2/messages.go+3 −1 modified@@ -10,6 +10,8 @@ import ( "errors" "fmt" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) @@ -173,7 +175,7 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { func DecodeMessage(data []byte) (Message, error) { msg := wireCombined{} - if err := json.Unmarshal(data, &msg); err != nil { + if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } if msg.VersionTag != wireVersion {
mcp/client.go+1 −2 modified@@ -6,7 +6,6 @@ package mcp import ( "context" - "encoding/json" "errors" "fmt" "iter" @@ -18,7 +17,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" - + "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" )
mcp/content.go+5 −3 modified@@ -10,6 +10,8 @@ package mcp import ( "encoding/json" "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // A Content is a [TextContent], [ImageContent], [AudioContent], @@ -248,7 +250,7 @@ func (c *ToolResultContent) MarshalJSON() ([]byte, error) { return nil, err } var w wireContent - if err := json.Unmarshal(data, &w); err != nil { + if err := internaljson.Unmarshal(data, &w); err != nil { return nil, err } contentWire = append(contentWire, &w) @@ -328,11 +330,11 @@ func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, er } // Try array first, then fall back to single object. var wires []*wireContent - if err := json.Unmarshal(raw, &wires); err == nil { + if err := internaljson.Unmarshal(raw, &wires); err == nil { return contentsFromWire(wires, allow) } var wire wireContent - if err := json.Unmarshal(raw, &wire); err != nil { + if err := internaljson.Unmarshal(raw, &wire); err != nil { return nil, err } c, err := contentFromWire(&wire, allow)
mcp/protocol.go+9 −7 modified@@ -14,6 +14,8 @@ import ( "encoding/json" "fmt" "maps" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Optional annotations for the client. The client can use annotations to inform @@ -141,7 +143,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { res Content []*wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -315,7 +317,7 @@ type CompleteReference struct { func (r *CompleteReference) UnmarshalJSON(data []byte) error { type wireCompleteReference CompleteReference // for naive unmarshaling var r2 wireCompleteReference - if err := json.Unmarshal(data, &r2); err != nil { + if err := internaljson.Unmarshal(data, &r2); err != nil { return err } switch r2.Type { @@ -502,7 +504,7 @@ func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { msg Content json.RawMessage `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -542,7 +544,7 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { result Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -605,7 +607,7 @@ func (r *CreateMessageWithToolsResult) UnmarshalJSON(data []byte) error { result Content json.RawMessage `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -1056,7 +1058,7 @@ func (m *PromptMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -1253,7 +1255,7 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } // Allow text, image, audio, tool_use, and tool_result in sampling messages
mcp/server.go+3 −2 modified@@ -25,6 +25,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -327,7 +328,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // Unmarshal and validate args. var in In if input != nil { - if err := json.Unmarshal(input, &in); err != nil { + if err := internaljson.Unmarshal(input, &in); err != nil { return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) } } @@ -1371,7 +1372,7 @@ func initializeMethodInfo() methodInfo { info.unmarshalParams = func(m json.RawMessage) (Params, error) { var params *initializeParamsV2 if m != nil { - if err := json.Unmarshal(m, ¶ms); err != nil { + if err := internaljson.Unmarshal(m, ¶ms); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) } }
mcp/shared.go+2 −1 modified@@ -23,6 +23,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -283,7 +284,7 @@ func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInf unmarshalParams: func(m json.RawMessage) (Params, error) { var p P if m != nil { - if err := json.Unmarshal(m, &p); err != nil { + if err := internaljson.Unmarshal(m, &p); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) } }
mcp/streamable.go+2 −1 modified@@ -30,6 +30,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" @@ -1105,7 +1106,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques isInitialize = true // Extract the protocol version from InitializeParams. var params InitializeParams - if err := json.Unmarshal(jreq.Params, ¶ms); err == nil { + if err := internaljson.Unmarshal(jreq.Params, ¶ms); err == nil { initializeProtocolVersion = params.ProtocolVersion } }
mcp/tool.go+2 −1 modified@@ -11,6 +11,7 @@ import ( "strings" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // A ToolHandler handles a call to tools/call. @@ -83,7 +84,7 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM if resolved != nil { v := make(map[string]any) if len(data) > 0 { - if err := json.Unmarshal(data, &v); err != nil { + if err := internaljson.Unmarshal(data, &v); err != nil { return nil, fmt.Errorf("unmarshaling arguments: %w", err) } }
mcp/transport.go+3 −2 modified@@ -15,6 +15,7 @@ import ( "os" "sync" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -193,7 +194,7 @@ type canceller struct { func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) { if req.Method == notificationCancelled { var params CancelledParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { + if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil { return nil, err } id, err := jsonrpc2.MakeID(params.RequestID) @@ -569,7 +570,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) { // Try to read an array of messages first. var rawBatch []json.RawMessage - if err := json.Unmarshal(data, &rawBatch); err == nil { + if err := internaljson.Unmarshal(data, &rawBatch); err == nil { if len(rawBatch) == 0 { return nil, true, fmt.Errorf("empty batch") }
mcp/util.go+3 −1 modified@@ -6,6 +6,8 @@ package mcp import ( "encoding/json" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) func assert(cond bool, msg string) { @@ -21,7 +23,7 @@ func remarshal(from, to any) error { if err != nil { return err } - if err := json.Unmarshal(data, to); err != nil { + if err := internaljson.Unmarshal(data, to); err != nil { return err } return nil
oauthex/dcr.go+5 −3 modified@@ -17,6 +17,8 @@ import ( "io" "net/http" "time" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). @@ -144,7 +146,7 @@ func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { }{ alias: (*alias)(r), } - if err := json.Unmarshal(data, &aux); err != nil { + if err := internaljson.Unmarshal(data, &aux); err != nil { return err } if aux.ClientIDIssuedAt != 0 { @@ -206,7 +208,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusCreated { var regResponse ClientRegistrationResponse - if err := json.Unmarshal(body, ®Response); err != nil { + if err := internaljson.Unmarshal(body, ®Response); err != nil { return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) } if regResponse.ClientID == "" { @@ -221,7 +223,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusBadRequest { var regError ClientRegistrationError - if err := json.Unmarshal(body, ®Error); err != nil { + if err := internaljson.Unmarshal(body, ®Error); err != nil { return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) } return nil, ®Error
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/modelcontextprotocol/go-sdk/commit/7b8d81c264074404abdf5aa16e2cf0c2d9c64cc0nvdPatchWEB
- github.com/modelcontextprotocol/go-sdk/security/advisories/GHSA-wvj2-96wp-fq3fnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-wvj2-96wp-fq3fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27896ghsaADVISORY
News mentions
0No linked articles in our index yet.