diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index d350a9f7d5..cc227b727e 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -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), diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index 0cf36852bb..95b40e4408 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -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()