Skip to content

Commit cd51cfd

Browse files
committed
mcp: add tests for UnmarshalJSON handling of nil Content pointers
- Introduces a new test file `content_nil_test.go` which verifies that `UnmarshalJSON` methods for various `Content` types do not panic when unmarshaling onto `nil` pointers. - Adds a `nil` check in `contentFromWire` function to guard against a `nil` `wire.Content` parameter. - Tests cover different scenarios, including valid and invalid content types, as well as cases with empty or missing content fields. For [#205](#205)
1 parent 4e413da commit cd51cfd

File tree

2 files changed

+254
-0
lines changed

2 files changed

+254
-0
lines changed

mcp/content.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, e
252252
}
253253

254254
func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) {
255+
if wire == nil {
256+
return nil, fmt.Errorf("content wire is nil")
257+
}
255258
if allow != nil && !allow[wire.Type] {
256259
return nil, fmt.Errorf("invalid content type %q", wire.Type)
257260
}

mcp/content_nil_test.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
// This file contains tests to verify that UnmarshalJSON methods for Content types
6+
// don't panic when unmarshaling onto nil pointers, as requested in GitHub issue #205.
7+
//
8+
// NOTE: The contentFromWire function has been fixed to handle nil wire.Content
9+
// gracefully by returning an error instead of panicking.
10+
11+
package mcp_test
12+
13+
import (
14+
"encoding/json"
15+
"testing"
16+
17+
"github.com/modelcontextprotocol/go-sdk/mcp"
18+
)
19+
20+
func TestContentUnmarshalNil(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
json string
24+
content interface{}
25+
}{
26+
{
27+
name: "CallToolResult nil Content",
28+
json: `{"content":[{"type":"text","text":"hello"}]}`,
29+
content: &mcp.CallToolResult{},
30+
},
31+
{
32+
name: "CreateMessageResult nil Content",
33+
json: `{"content":{"type":"text","text":"hello"},"model":"test","role":"user"}`,
34+
content: &mcp.CreateMessageResult{},
35+
},
36+
{
37+
name: "PromptMessage nil Content",
38+
json: `{"content":{"type":"text","text":"hello"},"role":"user"}`,
39+
content: &mcp.PromptMessage{},
40+
},
41+
{
42+
name: "SamplingMessage nil Content",
43+
json: `{"content":{"type":"text","text":"hello"},"role":"user"}`,
44+
content: &mcp.SamplingMessage{},
45+
},
46+
{
47+
name: "CallToolResultFor nil Content",
48+
json: `{"content":[{"type":"text","text":"hello"}]}`,
49+
content: &mcp.CallToolResultFor[string]{},
50+
},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
// Test that unmarshaling doesn't panic on nil Content fields
56+
defer func() {
57+
if r := recover(); r != nil {
58+
t.Errorf("UnmarshalJSON panicked: %v", r)
59+
}
60+
}()
61+
62+
err := json.Unmarshal([]byte(tt.json), tt.content)
63+
if err != nil {
64+
t.Errorf("UnmarshalJSON failed: %v", err)
65+
}
66+
67+
// Verify that the Content field was properly populated
68+
switch v := tt.content.(type) {
69+
case *mcp.CallToolResult:
70+
if len(v.Content) == 0 {
71+
t.Error("CallToolResult.Content was not populated")
72+
}
73+
if _, ok := v.Content[0].(*mcp.TextContent); !ok {
74+
t.Error("CallToolResult.Content[0] is not TextContent")
75+
}
76+
case *mcp.CallToolResultFor[string]:
77+
if len(v.Content) == 0 {
78+
t.Error("CallToolResultFor.Content was not populated")
79+
}
80+
if _, ok := v.Content[0].(*mcp.TextContent); !ok {
81+
t.Error("CallToolResultFor.Content[0] is not TextContent")
82+
}
83+
case *mcp.CreateMessageResult:
84+
if v.Content == nil {
85+
t.Error("CreateMessageResult.Content was not populated")
86+
}
87+
if _, ok := v.Content.(*mcp.TextContent); !ok {
88+
t.Error("CreateMessageResult.Content is not TextContent")
89+
}
90+
case *mcp.PromptMessage:
91+
if v.Content == nil {
92+
t.Error("PromptMessage.Content was not populated")
93+
}
94+
if _, ok := v.Content.(*mcp.TextContent); !ok {
95+
t.Error("PromptMessage.Content is not TextContent")
96+
}
97+
case *mcp.SamplingMessage:
98+
if v.Content == nil {
99+
t.Error("SamplingMessage.Content was not populated")
100+
}
101+
if _, ok := v.Content.(*mcp.TextContent); !ok {
102+
t.Error("SamplingMessage.Content is not TextContent")
103+
}
104+
}
105+
})
106+
}
107+
}
108+
109+
func TestContentUnmarshalNilWithDifferentTypes(t *testing.T) {
110+
tests := []struct {
111+
name string
112+
json string
113+
content interface{}
114+
expectError bool
115+
}{
116+
{
117+
name: "ImageContent",
118+
json: `{"content":{"type":"image","mimeType":"image/png","data":"YTFiMmMz"}}`,
119+
content: &mcp.CreateMessageResult{},
120+
expectError: false,
121+
},
122+
{
123+
name: "AudioContent",
124+
json: `{"content":{"type":"audio","mimeType":"audio/wav","data":"YTFiMmMz"}}`,
125+
content: &mcp.CreateMessageResult{},
126+
expectError: false,
127+
},
128+
{
129+
name: "ResourceLink",
130+
json: `{"content":{"type":"resource_link","uri":"file:///test","name":"test"}}`,
131+
content: &mcp.CreateMessageResult{},
132+
expectError: true, // CreateMessageResult only allows text, image, audio
133+
},
134+
{
135+
name: "EmbeddedResource",
136+
json: `{"content":{"type":"resource","resource":{"uri":"file://test","text":"test"}}}`,
137+
content: &mcp.CreateMessageResult{},
138+
expectError: true, // CreateMessageResult only allows text, image, audio
139+
},
140+
}
141+
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
// Test that unmarshaling doesn't panic on nil Content fields
145+
defer func() {
146+
if r := recover(); r != nil {
147+
t.Errorf("UnmarshalJSON panicked: %v", r)
148+
}
149+
}()
150+
151+
err := json.Unmarshal([]byte(tt.json), tt.content)
152+
if tt.expectError && err == nil {
153+
t.Error("Expected error but got none")
154+
}
155+
if !tt.expectError && err != nil {
156+
t.Errorf("Unexpected error: %v", err)
157+
}
158+
159+
// Verify that the Content field was properly populated for successful cases
160+
if !tt.expectError {
161+
if result, ok := tt.content.(*mcp.CreateMessageResult); ok {
162+
if result.Content == nil {
163+
t.Error("CreateMessageResult.Content was not populated")
164+
}
165+
}
166+
}
167+
})
168+
}
169+
}
170+
171+
func TestContentUnmarshalNilWithEmptyContent(t *testing.T) {
172+
tests := []struct {
173+
name string
174+
json string
175+
content interface{}
176+
expectError bool
177+
}{
178+
{
179+
name: "Empty Content array",
180+
json: `{"content":[]}`,
181+
content: &mcp.CallToolResult{},
182+
expectError: false,
183+
},
184+
{
185+
name: "Missing Content field",
186+
json: `{"model":"test","role":"user"}`,
187+
content: &mcp.CreateMessageResult{},
188+
expectError: true, // Content field is required for CreateMessageResult
189+
},
190+
}
191+
192+
for _, tt := range tests {
193+
t.Run(tt.name, func(t *testing.T) {
194+
// Test that unmarshaling doesn't panic on nil Content fields
195+
defer func() {
196+
if r := recover(); r != nil {
197+
t.Errorf("UnmarshalJSON panicked: %v", r)
198+
}
199+
}()
200+
201+
err := json.Unmarshal([]byte(tt.json), tt.content)
202+
if tt.expectError && err == nil {
203+
t.Error("Expected error but got none")
204+
}
205+
if !tt.expectError && err != nil {
206+
t.Errorf("Unexpected error: %v", err)
207+
}
208+
})
209+
}
210+
}
211+
212+
func TestContentUnmarshalNilWithInvalidContent(t *testing.T) {
213+
tests := []struct {
214+
name string
215+
json string
216+
content interface{}
217+
expectError bool
218+
}{
219+
{
220+
name: "Invalid content type",
221+
json: `{"content":{"type":"invalid","text":"hello"}}`,
222+
content: &mcp.CreateMessageResult{},
223+
expectError: true,
224+
},
225+
{
226+
name: "Missing type field",
227+
json: `{"content":{"text":"hello"}}`,
228+
content: &mcp.CreateMessageResult{},
229+
expectError: true,
230+
},
231+
}
232+
233+
for _, tt := range tests {
234+
t.Run(tt.name, func(t *testing.T) {
235+
// Test that unmarshaling doesn't panic on nil Content fields
236+
defer func() {
237+
if r := recover(); r != nil {
238+
t.Errorf("UnmarshalJSON panicked: %v", r)
239+
}
240+
}()
241+
242+
err := json.Unmarshal([]byte(tt.json), tt.content)
243+
if tt.expectError && err == nil {
244+
t.Error("Expected error but got none")
245+
}
246+
if !tt.expectError && err != nil {
247+
t.Errorf("Unexpected error: %v", err)
248+
}
249+
})
250+
}
251+
}

0 commit comments

Comments
 (0)