Skip to content

Commit 6268548

Browse files
jbayardoCopilot
andcommitted
Stop mirroring file bodies in get_file_contents structured output
get_file_contents returned each file twice on protocol 2026-07-28: once in the embedded resource and again as text/blob in structuredContent. Clients that forward both to the model paid for every file twice. Structured output now carries only the resource uri and mimeType; the body stays in content. Modern JSON text for all-text results is also encoded without HTML escaping, so it matches JSON.stringify(structuredContent) and clients can dedupe it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c59e8c1 commit 6268548

6 files changed

Lines changed: 139 additions & 74 deletions

File tree

‎docs/typed-tool-schemas.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,12 @@ request-dependent output such as CSV. This is for intentional non-DTO responses
7070
such as resources, errors, or CSV; ordinary declared structured success must
7171
retain shared DTO serialization, with modern JSON text equal to
7272
`structuredContent`, even when legacy handler text was a plain mutation message.
73+
That text is encoded without HTML escaping (`<`, `>` and `&` stay literal), so it
74+
matches a standard re-serialization such as `JSON.stringify(structuredContent)`
75+
and clients can recognize it as a duplicate.
76+
77+
Content blocks are kept as-is whenever any block is not text. Structured output
78+
for such results should describe the blocks rather than repeat their payloads:
79+
`get_file_contents` keeps file bodies in the embedded resource and reports only
80+
its `uri` and `mimeType` in `structuredContent`, so clients that forward both
81+
fields do not send each file to the model twice.

‎pkg/github/__toolsnaps__/get_file_contents.snap‎

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -181,61 +181,11 @@
181181
"properties": {
182182
"resource": {
183183
"additionalProperties": false,
184-
"oneOf": [
185-
{
186-
"not": {
187-
"required": [
188-
"blob"
189-
]
190-
},
191-
"required": [
192-
"text"
193-
]
194-
},
195-
{
196-
"not": {
197-
"required": [
198-
"text"
199-
]
200-
},
201-
"required": [
202-
"blob"
203-
]
204-
},
205-
{
206-
"not": {
207-
"anyOf": [
208-
{
209-
"required": [
210-
"text"
211-
]
212-
},
213-
{
214-
"required": [
215-
"blob"
216-
]
217-
}
218-
]
219-
}
220-
}
221-
],
184+
"description": "Metadata for the embedded resource. The file body is returned only in the result content, not repeated here.",
222185
"properties": {
223-
"blob": {
224-
"description": "Base64-encoded file bytes.",
225-
"type": [
226-
"null",
227-
"string"
228-
]
229-
},
230186
"mimeType": {
231187
"type": "string"
232188
},
233-
"text": {
234-
"type": [
235-
"null",
236-
"string"
237-
]
238-
},
239189
"uri": {
240190
"type": "string"
241191
}

‎pkg/github/repository_tool_types.go‎

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package github
22

33
import (
4-
"encoding/base64"
54
"encoding/json"
65
"fmt"
76
"maps"
@@ -212,8 +211,9 @@ func (out ForkRepositoryOutput) MarshalJSON() ([]byte, error) {
212211
}
213212

214213
// Repository contents can be a projected directory listing or a heterogeneous
215-
// MCP content response. The latter is represented losslessly as typed content
216-
// blocks, including the human-readable status and embedded text/blob or link.
214+
// MCP content response. The latter is represented as typed content blocks,
215+
// including the human-readable status and resource or link metadata. File
216+
// bodies stay in the result content and are not repeated in structured output.
217217
type RepositoryContentsOutput struct {
218218
Directory []*RepositoryDirectoryEntryOutput
219219
Content *RepositoryContentOutput
@@ -324,14 +324,15 @@ type RepositoryTextBlock struct {
324324

325325
type RepositoryResourceBlock struct {
326326
Type string `json:"type"`
327-
Resource RepositoryResourceOutput `json:"resource"`
327+
Resource RepositoryResourceOutput `json:"resource" jsonschema:"Metadata for the embedded resource. The file body is returned only in the result content, not repeated here."`
328328
}
329329

330+
// RepositoryResourceOutput describes an embedded file resource without its
331+
// body, so clients that forward both content and structured output to a model
332+
// do not receive every file twice.
330333
type RepositoryResourceOutput struct {
331-
URI string `json:"uri"`
332-
MIMEType string `json:"mimeType,omitempty"`
333-
Text *string `json:"text,omitempty"`
334-
Blob *string `json:"blob,omitempty" jsonschema:"Base64-encoded file bytes."`
334+
URI string `json:"uri"`
335+
MIMEType string `json:"mimeType,omitempty"`
335336
}
336337

337338
type RepositoryLinkBlock struct {
@@ -353,12 +354,6 @@ func repositoryContentOutput(result *mcp.CallToolResult) (*RepositoryContentsOut
353354
out.Content = append(out.Content, RepositoryContentBlock{Text: &RepositoryTextBlock{Type: "text", Text: block.Text}})
354355
case *mcp.EmbeddedResource:
355356
resource := RepositoryResourceOutput{URI: block.Resource.URI, MIMEType: block.Resource.MIMEType}
356-
if block.Resource.Blob != nil {
357-
blob := base64.StdEncoding.EncodeToString(block.Resource.Blob)
358-
resource.Blob = &blob
359-
} else if block.Resource.Text != "" {
360-
resource.Text = &block.Resource.Text
361-
}
362357
out.Content = append(out.Content, RepositoryContentBlock{Resource: &RepositoryResourceBlock{Type: "resource", Resource: resource}})
363358
case *mcp.ResourceLink:
364359
out.Content = append(out.Content, RepositoryContentBlock{Link: &RepositoryLinkBlock{
@@ -521,11 +516,6 @@ func repositoryContentsOutputSchema() *jsonschema.Schema {
521516
text.Properties["type"].Const = new(any("text"))
522517
resource := repositoryOutputSchema[RepositoryResourceBlock]()
523518
resource.Properties["type"].Const = new(any("resource"))
524-
resource.Properties["resource"].OneOf = []*jsonschema.Schema{
525-
{Required: []string{"text"}, Not: &jsonschema.Schema{Required: []string{"blob"}}},
526-
{Required: []string{"blob"}, Not: &jsonschema.Schema{Required: []string{"text"}}},
527-
{Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Required: []string{"text"}}, {Required: []string{"blob"}}}}},
528-
}
529519
link := repositoryOutputSchema[RepositoryLinkBlock]()
530520
link.Properties["type"].Const = new(any("resource_link"))
531521
content := &jsonschema.Schema{

‎pkg/github/typed_repository_outputs_test.go‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,11 @@ func TestTypedRepositoryOutputs(t *testing.T) {
373373
first := result.Content[0].(*mcp.TextContent).Text
374374
switch {
375375
case call.name == "get_file_contents" && !strings.HasPrefix(first, "["):
376-
assert.JSONEq(t, `{"content":`+mustMarshalJSON(t, legacy.Content)+`}`, mustMarshalJSON(t, result.StructuredContent))
376+
structured := mustMarshalJSON(t, result.StructuredContent)
377+
assert.JSONEq(t, `{"content":`+legacyContentWithoutResourceBodies(t, legacy.Content)+`}`, structured,
378+
"structured output repeats content metadata but not file bodies")
379+
assert.NotContains(t, structured, `"text":"hello\n"`)
380+
assert.NotContains(t, structured, `"blob"`)
377381
case call.name == "get_file_contents":
378382
var entries []*RepositoryDirectoryEntryOutput
379383
require.NoError(t, json.Unmarshal([]byte(first), &entries))
@@ -439,6 +443,19 @@ func hasOnlyTextContent(content []mcp.Content) bool {
439443
return true
440444
}
441445

446+
func legacyContentWithoutResourceBodies(t *testing.T, content []mcp.Content) string {
447+
t.Helper()
448+
var blocks []map[string]any
449+
require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, content)), &blocks))
450+
for _, block := range blocks {
451+
if resource, ok := block["resource"].(map[string]any); ok {
452+
delete(resource, "text")
453+
delete(resource, "blob")
454+
}
455+
}
456+
return mustMarshalJSON(t, blocks)
457+
}
458+
442459
func assertLegacyRepositoryText(t *testing.T, name string, args map[string]any, result *mcp.CallToolResult) {
443460
t.Helper()
444461
switch name {
@@ -546,9 +563,10 @@ func TestRepositoryOutputUnions(t *testing.T) {
546563
"contents", repositoryContentsOutputSchema(),
547564
[]string{`null`, `[]`, `[{"name":"file"}]`, `{"content":[]}`, `{"content":[{"type":"text","text":"status"}]}`,
548565
`{"content":[{"type":"resource","resource":{"uri":"repo://file","mimeType":"text/plain"}}]}`,
549-
`{"content":[{"type":"resource","resource":{"uri":"repo://file","blob":"AAEC"}}]}`,
550566
`{"content":[{"type":"resource_link","uri":"repo://file","name":"file","size":1048576}]}`},
551-
[]string{`{}`, `{"content":[{"type":"text"}]}`, `{"content":[{"type":"resource","resource":{"uri":"repo://file","text":"x","blob":"AA=="}}]}`,
567+
[]string{`{}`, `{"content":[{"type":"text"}]}`,
568+
`{"content":[{"type":"resource","resource":{"uri":"repo://file","text":"x"}}]}`,
569+
`{"content":[{"type":"resource","resource":{"uri":"repo://file","blob":"AAEC"}}]}`,
552570
`{"content":[{"type":"opaque","data":{}}]}`},
553571
},
554572
} {

‎pkg/inventory/typed_output.go‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ func applyModernStructuredText(result *mcp.CallToolResult) error {
361361
if result == nil || result.IsError || result.InputRequests != nil || result.StructuredContent == nil {
362362
return nil
363363
}
364-
encoded, err := json.Marshal(result.StructuredContent)
364+
encoded, err := marshalStructuredText(result.StructuredContent)
365365
if err != nil {
366366
return fmt.Errorf("marshal structured tool output for text content: %w", err)
367367
}
@@ -376,6 +376,58 @@ func applyModernStructuredText(result *mcp.CallToolResult) error {
376376
return nil
377377
}
378378

379+
// marshalStructuredText encodes structured output for its JSON text block
380+
// without HTML escaping, so the text matches a standard re-serialization of
381+
// structuredContent (such as JSON.stringify) and clients can detect it as a
382+
// duplicate.
383+
func marshalStructuredText(value any) ([]byte, error) {
384+
var buffer bytes.Buffer
385+
encoder := json.NewEncoder(&buffer)
386+
encoder.SetEscapeHTML(false)
387+
if err := encoder.Encode(value); err != nil {
388+
return nil, err
389+
}
390+
// Pre-encoded json.RawMessage values, including SDK typed output, keep
391+
// their HTML escapes even when the encoder does not add new ones.
392+
return unescapeJSONHTML(bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))), nil
393+
}
394+
395+
// unescapeJSONHTML replaces the \u003c, \u003e and \u0026 escapes that
396+
// encoding/json emits inside strings with the literal characters. In valid JSON
397+
// a backslash only starts a string escape, so consuming escapes pairwise
398+
// cannot misread an escaped backslash followed by "u003c".
399+
func unescapeJSONHTML(data []byte) []byte {
400+
if !bytes.Contains(data, []byte(`\u00`)) {
401+
return data
402+
}
403+
out := make([]byte, 0, len(data))
404+
for i := 0; i < len(data); i++ {
405+
if data[i] != '\\' || i+1 == len(data) {
406+
out = append(out, data[i])
407+
continue
408+
}
409+
if data[i+1] == 'u' && i+6 <= len(data) {
410+
switch string(data[i+2 : i+6]) {
411+
case "003c", "003C":
412+
out = append(out, '<')
413+
i += 5
414+
continue
415+
case "003e", "003E":
416+
out = append(out, '>')
417+
i += 5
418+
continue
419+
case "0026":
420+
out = append(out, '&')
421+
i += 5
422+
continue
423+
}
424+
}
425+
out = append(out, data[i], data[i+1])
426+
i++
427+
}
428+
return out
429+
}
430+
379431
func removeSDKOutputFallback(result *mcp.CallToolResult, state *typedCallState) error {
380432
if result.StructuredContent == nil || len(result.Content) <= state.contentLengthBeforeSDK {
381433
return nil

‎pkg/inventory/typed_output_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,52 @@ func TestTypedScalarOutputProtocolGatePreservesHandlerText(t *testing.T) {
16861686
assert.Equal(t, "custom legacy text", result.Content[0].(*mcp.TextContent).Text)
16871687
}
16881688

1689+
func TestTypedModernTextDoesNotEscapeHTML(t *testing.T) {
1690+
type output struct {
1691+
HTML string `json:"html"`
1692+
Literal string `json:"literal"`
1693+
}
1694+
tool := NewServerToolWithContextHandler(
1695+
mcp.Tool{Name: "typed_html_tool"},
1696+
testToolsetMetadata("test"),
1697+
func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) {
1698+
return &mcp.CallToolResult{
1699+
Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}},
1700+
}, output{HTML: "<b>a & b</b>", Literal: `\u003c`}, nil
1701+
},
1702+
)
1703+
server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil)
1704+
tool.RegisterFunc(server, nil)
1705+
modern := connectTypedTestClient(t, server, "")
1706+
1707+
result, err := modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_html_tool"})
1708+
require.NoError(t, err)
1709+
require.Len(t, result.Content, 1)
1710+
text := result.Content[0].(*mcp.TextContent).Text
1711+
assert.Equal(t, `{"html":"<b>a & b</b>","literal":"\\u003c"}`, text,
1712+
"modern text matches a standard re-serialization of structuredContent")
1713+
1714+
var reserialized bytes.Buffer
1715+
encoder := json.NewEncoder(&reserialized)
1716+
encoder.SetEscapeHTML(false)
1717+
require.NoError(t, encoder.Encode(result.StructuredContent))
1718+
assert.Equal(t, strings.TrimSuffix(reserialized.String(), "\n"), text)
1719+
}
1720+
1721+
func TestUnescapeJSONHTML(t *testing.T) {
1722+
for _, test := range []struct{ input, want string }{
1723+
{`{"a":"plain"}`, `{"a":"plain"}`},
1724+
{`"\u003cb\u003e \u0026 \u003C\u003E"`, `"<b> & <>"`},
1725+
{`"\\u003c"`, `"\\u003c"`},
1726+
{`"\\\u003c"`, `"\\<"`},
1727+
{`"\u00e9\u2028\"\u0026"`, `"\u00e9\u2028\"&"`},
1728+
{`"\u003`, `"\u003`},
1729+
{`"\`, `"\`},
1730+
} {
1731+
assert.Equal(t, test.want, string(unescapeJSONHTML([]byte(test.input))), test.input)
1732+
}
1733+
}
1734+
16891735
func TestTypedLegacyOutputGateRemovesFallbackWithEmptyHandlerContent(t *testing.T) {
16901736
tool := NewServerToolWithContextHandler(
16911737
mcp.Tool{Name: "typed_empty_content_tool"},

0 commit comments

Comments
 (0)