mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user