Skip to content

mcp: preserve required text field when marshaling empty ResourceContents - #1149

Open
shoemoney wants to merge 1 commit into
modelcontextprotocol:mainfrom
shoemoney:fix/resource-contents-required-text
Open

mcp: preserve required text field when marshaling empty ResourceContents#1149
shoemoney wants to merge 1 commit into
modelcontextprotocol:mainfrom
shoemoney:fix/resource-contents-required-text

Conversation

@shoemoney

Copy link
Copy Markdown

The bug

ResourceContents (mcp/content.go) carries Text string \json:"text,omitempty"`. One struct backs both branches of the spec's anyOf[TextResourceContents, BlobResourceContents], and textis **required** onTextResourceContents. A text resource with empty content (e.g. &ResourceContents{URI: "u"}) therefore marshals to {"uri":"u"}— notext` key at all — which
matches neither branch of the union. Strict clients validating against the schema reject the
response outright.

This doesn't show up in Go↔Go round-trips because the Go unmarshaler is lenient about the
missing field, so nothing caught it until now.

The fix

Added MarshalJSON on ResourceContents, mirroring the existing TextContent.MarshalJSON
(same mechanism, no new pattern introduced). It branches on Blob:

  • Blob == nil → text resource: text is always emitted, even when empty; blob is omitted.
  • Blob != nil (including []byte{}) → blob resource: blob is emitted; text is omitted
    entirely.

_meta is carried through on both branches — flagging this explicitly because #96 previously
had to go back and repair a dropped Meta field after #95, so it's an easy thing to miss when
splitting a marshal method into two wire structs.

Struct tags on the fields are left untouched, since they're still what UnmarshalJSON relies on
(there's no custom unmarshaler for ResourceContents).

This finishes the series the TODO at the top of content.go asks for
(// TODO(findleyr): update JSON marshalling of all content types to preserve required fields.):
TextContent landed in #91, ImageContent/AudioContent in #95, and ResourceContents was the
one left.

Test change — please look closely here

mcp/content_test.go's TestEmbeddedResource pinned {"uri":"u"} as the expected output for
&ResourceContents{URI: "u"} (empty text, nil blob) — that expectation was the bug. Updated it
to {"uri":"u","text":""} with a comment explaining why. Calling this out explicitly since
changing a test to make your own fix pass is exactly the kind of diff that deserves a second set
of eyes.

The existing &ResourceContents{URI: "u", Blob: []byte{}}{"uri":"u","blob":""} case is
unchanged and still passes, confirming the blob branch wasn't touched.

Verification

  • gofmt -l ./ — clean
  • go build ./... — passes
  • go vet ./... — passes
  • go test ./... — all packages pass

Discrimination check: reverted only the source change (kept the new test expectation) and ran
go test ./mcp/ -run TestEmbeddedResource -v. It failed exactly as expected:

got  {"uri":"u"}
want {"uri":"u","text":""}

Restored the fix and reran the full suite — passes again.

Dedupe check

Searched open/closed issues and PRs for ResourceContents/MarshalJSON/text. #781 and #782
moved Blob to json:"blob,omitzero" (Go 1.24) — already landed, which is why Blob has that
tag today — but neither touched Text. No open PR currently touches content.go. This isn't a
duplicate of prior work; it's the same series continued.

ResourceContents.Text carries json:"text,omitempty", so a text resource
with empty content marshals without a text key and matches neither branch
of the spec's anyOf[TextResourceContents, BlobResourceContents] union.
Strict clients reject the response even though it round-trips fine between
two Go peers, since the Go unmarshaler is lenient about the missing field.

Add MarshalJSON on ResourceContents, branching on Blob so nil Blob always
emits text (even empty) and omits blob, while a non-nil Blob (including
an empty one) emits blob and omits text entirely. _meta is carried on
both branches. This mirrors TextContent.MarshalJSON and finishes the
series from the TODO at the top of content.go: TextContent (modelcontextprotocol#91) and
ImageContent/AudioContent (modelcontextprotocol#95) already got this treatment; ResourceContents
was the one left.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants