fix(coderd/x/chatd/mcpclient): handle EmbeddedResource and ResourceLink in MCP tool results (#23569)

## Problem

When an MCP tool returns an `EmbeddedResource` content item (e.g. GitHub
MCP server returning file contents via `get_file_contents`), the
`convertCallResult` function falls through to the `default` case,
producing:

```
[unsupported content type: mcp.EmbeddedResource]
```

This loses the actual resource content and shows an unhelpful message in
the chat UI.

## Root Cause

The type switch in `convertCallResult` handles `TextContent`,
`ImageContent`, and `AudioContent`, but not the other two `mcp.Content`
implementations from `mcp-go`:
- `mcp.EmbeddedResource` — wraps a `ResourceContents` (either
`TextResourceContents` or `BlobResourceContents`)
- `mcp.ResourceLink` — contains a URI, name, and description

## Fix

Add two new cases to the type switch:

1. **`mcp.EmbeddedResource`**: nested type switch on `.Resource`:
   - `TextResourceContents` → append `.Text` to `textParts`
- `BlobResourceContents` → base64-decode `.Blob` as binary (type
`"image"` or `"media"` based on MIME)
   - Unknown → fallback `[unsupported embedded resource type: ...]`

2. **`mcp.ResourceLink`**: render as `[resource: Name (URI)]` text

## Testing

Added 3 new test cases (all passing, full suite 23/23 PASS):
- `TestConnectAll_EmbeddedResourceText` — text resource extraction
- `TestConnectAll_EmbeddedResourceBlob` — binary blob decoding
- `TestConnectAll_ResourceLink` — resource link rendering
This commit is contained in:
Kyle Carberry
2026-03-25 12:31:17 +00:00
committed by GitHub
parent a25f9293a1
commit f784b230ba
2 changed files with 263 additions and 2 deletions
+56 -2
View File
@@ -491,8 +491,9 @@ func (t *mcpToolWrapper) SetProviderOptions(
// convertCallResult translates an MCP CallToolResult into a
// fantasy.ToolResponse. The fantasy response model supports a
// single content type per response, so we prioritize text. All
// text items are collected first. Binary items (image or audio)
// are only returned when no text content is available.
// text items are collected first. Binary items (image, audio,
// or embedded blob) are only returned when no text content is
// available.
func convertCallResult(
result *mcp.CallToolResult,
) fantasy.ToolResponse {
@@ -546,6 +547,59 @@ func convertCallResult(
}
binaryResult = &r
}
case mcp.EmbeddedResource:
// Embedded resources wrap either text or blob
// content from an MCP resource. We handle each
// variant so the LLM receives the content
// regardless of form.
switch r := c.Resource.(type) {
case mcp.TextResourceContents:
textParts = append(textParts, r.Text)
case mcp.BlobResourceContents:
data, err := base64.StdEncoding.DecodeString(
r.Blob,
)
if err != nil {
textParts = append(textParts,
"[blob decode error: "+err.Error()+"]",
)
continue
}
if binaryResult == nil {
blobType := "media"
if strings.HasPrefix(r.MIMEType, "image/") {
blobType = "image"
}
res := fantasy.ToolResponse{
Type: blobType,
Data: data,
MediaType: r.MIMEType,
IsError: result.IsError,
}
binaryResult = &res
}
default:
textParts = append(textParts,
fmt.Sprintf(
"[unsupported embedded resource type: %T]",
c.Resource,
),
)
}
case mcp.ResourceLink:
// Resource links point to content the LLM can
// reference by URI. Surface the URI so the model
// can use it in follow-ups.
label := c.URI
if c.Name != "" {
label = fmt.Sprintf("%s (%s)", c.Name, c.URI)
}
if c.Description != "" {
label += ": " + c.Description
}
textParts = append(textParts,
fmt.Sprintf("[resource: %s]", label),
)
default:
textParts = append(textParts,
fmt.Sprintf("[unsupported content type: %T]", c),
+207
View File
@@ -3,6 +3,7 @@ package mcpclient_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http/httptest"
"sync"
@@ -743,6 +744,212 @@ func TestConnectAll_MCPToolIdentifier_MultipleServers(t *testing.T) {
assert.Equal(t, configID2, idByName["srv-b__greet"])
}
// TestConnectAll_EmbeddedResourceText verifies that a tool returning
// an EmbeddedResource with TextResourceContents has its text extracted
// into the response content.
func TestConnectAll_EmbeddedResourceText(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
srv := mcpserver.NewMCPServer("embedded-text-server", "1.0.0")
srv.AddTools(mcpserver.ServerTool{
Tool: mcp.NewTool("fetch_doc",
mcp.WithDescription("Returns an embedded text resource"),
),
Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: "successfully downloaded text file",
},
mcp.EmbeddedResource{
Type: "resource",
Resource: mcp.TextResourceContents{
URI: "file:///example.txt",
MIMEType: "text/plain",
Text: "Hello from embedded resource",
},
},
},
}, nil
},
})
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
ts := httptest.NewServer(httpSrv)
t.Cleanup(ts.Close)
cfg := makeConfig("embed-txt", ts.URL)
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
t.Cleanup(cleanup)
require.Len(t, tools, 1)
resp, err := tools[0].Run(ctx, fantasy.ToolCall{
ID: "call-embed-txt",
Name: "embed-txt__fetch_doc",
Input: "{}",
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Contains(t, resp.Content, "Hello from embedded resource")
assert.Contains(t, resp.Content, "successfully downloaded text file")
assert.NotContains(t, resp.Content, "unsupported content type")
}
// TestConnectAll_EmbeddedResourceBlob verifies that a tool returning
// an EmbeddedResource with BlobResourceContents has its blob decoded
// into the binary response path, with the Type field reflecting the
// MIME type.
func TestConnectAll_EmbeddedResourceBlob(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mimeType string
expectedType string
}{
{"image", "image/png", "image"},
{"non-image", "application/pdf", "media"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
blobData := base64.StdEncoding.EncodeToString([]byte("binary-content"))
mime := tt.mimeType
srv := mcpserver.NewMCPServer("embedded-blob-server", "1.0.0")
srv.AddTools(mcpserver.ServerTool{
Tool: mcp.NewTool("fetch_blob",
mcp.WithDescription("Returns an embedded blob resource"),
),
Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.EmbeddedResource{
Type: "resource",
Resource: mcp.BlobResourceContents{
URI: "file:///blob",
MIMEType: mime,
Blob: blobData,
},
},
},
}, nil
},
})
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
ts := httptest.NewServer(httpSrv)
t.Cleanup(ts.Close)
cfg := makeConfig("embed-blob", ts.URL)
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
t.Cleanup(cleanup)
require.Len(t, tools, 1)
resp, err := tools[0].Run(ctx, fantasy.ToolCall{
ID: "call-embed-blob",
Name: "embed-blob__fetch_blob",
Input: "{}",
})
require.NoError(t, err)
assert.False(t, resp.IsError)
// The blob is the only content item, so the binary
// path is taken: Content is empty and the decoded
// bytes land in Data.
assert.Empty(t, resp.Content, "binary-only response should have empty Content")
assert.Equal(t, tt.expectedType, resp.Type)
assert.Equal(t, []byte("binary-content"), resp.Data)
assert.Equal(t, tt.mimeType, resp.MediaType)
})
}
}
// TestConnectAll_ResourceLink verifies that a tool returning a
// ResourceLink renders it as human-readable text containing the
// resource name, URI, and description when present.
func TestConnectAll_ResourceLink(t *testing.T) {
t.Parallel()
tests := []struct {
name string
link mcp.ResourceLink
contains []string
notContains []string
}{
{
name: "with_name",
link: mcp.ResourceLink{
Type: "resource_link",
Name: "Example Resource",
URI: "https://example.com/resource",
},
contains: []string{"Example Resource", "https://example.com/resource"},
notContains: []string{"unsupported content type"},
},
{
name: "with_description",
link: mcp.ResourceLink{
Type: "resource_link",
Name: "Deploy Log",
URI: "file:///var/log/deploy.log",
Description: "Latest deployment log",
},
contains: []string{"Deploy Log", "file:///var/log/deploy.log", "Latest deployment log"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
link := tt.link
srv := mcpserver.NewMCPServer("resource-link-server", "1.0.0")
srv.AddTools(mcpserver.ServerTool{
Tool: mcp.NewTool("get_link",
mcp.WithDescription("Returns a resource link"),
),
Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{link},
}, nil
},
})
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
ts := httptest.NewServer(httpSrv)
t.Cleanup(ts.Close)
cfg := makeConfig("res-link", ts.URL)
tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil)
t.Cleanup(cleanup)
require.Len(t, tools, 1)
resp, err := tools[0].Run(ctx, fantasy.ToolCall{
ID: "call-res-link",
Name: "res-link__get_link",
Input: "{}",
})
require.NoError(t, err)
assert.False(t, resp.IsError)
for _, s := range tt.contains {
assert.Contains(t, resp.Content, s)
}
for _, s := range tt.notContains {
assert.NotContains(t, resp.Content, s)
}
})
}
}
func TestConnectAll_CallToolError(t *testing.T) {
t.Parallel()
ctx := context.Background()