Skip to content

Commit 36592a6

Browse files
MCP Resource functionality
1 parent 3a0b9d3 commit 36592a6

File tree

13 files changed

+775
-82
lines changed

13 files changed

+775
-82
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Add refresh title feature to refresh chat title from LLM
1313
- Implement MCP Prompts functionality
1414
- Add `MCPClient` interface for testing purposes
15+
- Implement MCP Resource handling
1516

1617
### Fixed
1718

internal/handlers/chat.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,37 @@ func (m Main) HandleChats(w http.ResponseWriter, r *http.Request) {
123123
userMessages = []models.Message{m.processUserMessage(msg)}
124124
}
125125

126+
// Handle attached resources
127+
attachedResourcesJSON := r.FormValue("attached_resources")
128+
if attachedResourcesJSON != "" && attachedResourcesJSON != "[]" {
129+
var resourceURIs []string
130+
if err := json.Unmarshal([]byte(attachedResourcesJSON), &resourceURIs); err != nil {
131+
m.logger.Error("Failed to unmarshal attached resources",
132+
slog.String("attachedResources", attachedResourcesJSON),
133+
slog.String(errLoggerKey, err.Error()))
134+
http.Error(w, "Invalid attached resources format", http.StatusBadRequest)
135+
return
136+
}
137+
138+
// Process resources and add resource contents to user message
139+
if len(resourceURIs) > 0 {
140+
resourceContents, err := m.processAttachedResources(r.Context(), resourceURIs)
141+
if err != nil {
142+
m.logger.Error("Failed to process attached resources",
143+
slog.String("resourceURIs", fmt.Sprintf("%v", resourceURIs)),
144+
slog.String(errLoggerKey, err.Error()))
145+
http.Error(w, err.Error(), http.StatusInternalServerError)
146+
return
147+
}
148+
149+
// Add resource contents to the last user message
150+
if len(userMessages) > 0 {
151+
lastMsgIdx := len(userMessages) - 1
152+
userMessages[lastMsgIdx].Contents = append(userMessages[lastMsgIdx].Contents, resourceContents...)
153+
}
154+
}
155+
}
156+
126157
// Add all user messages to the chat
127158
for _, msg := range userMessages {
128159
msgID, err := m.store.AddMessage(r.Context(), chatID, msg)
@@ -342,6 +373,33 @@ func (m Main) processUserMessage(message string) models.Message {
342373
}
343374
}
344375

376+
// processAttachedResources processes attached resource URIs from the form data
377+
// and returns content objects for each resource.
378+
func (m Main) processAttachedResources(ctx context.Context, resourceURIs []string) ([]models.Content, error) {
379+
var contents []models.Content
380+
381+
for _, uri := range resourceURIs {
382+
clientIdx, ok := m.resourcesMap[uri]
383+
if !ok {
384+
return nil, fmt.Errorf("resource not found: %s", uri)
385+
}
386+
387+
result, err := m.mcpClients[clientIdx].ReadResource(ctx, mcp.ReadResourceParams{
388+
URI: uri,
389+
})
390+
if err != nil {
391+
return nil, fmt.Errorf("failed to read resource %s: %w", uri, err)
392+
}
393+
394+
contents = append(contents, models.Content{
395+
Type: models.ContentTypeResource,
396+
ResourceContents: result.Contents,
397+
})
398+
}
399+
400+
return contents, nil
401+
}
402+
345403
// renderNewChatResponse renders the complete chatbox for new chats.
346404
func (m Main) renderNewChatResponse(w http.ResponseWriter, chatID string, messages []models.Message, aiMsgID string) {
347405
msgs := make([]message, len(messages))
@@ -593,6 +651,9 @@ func (m Main) chat(chatID string, messages []models.Message) {
593651
callTool = true
594652
aiMsg.Contents = append(aiMsg.Contents, content)
595653
contentIdx++
654+
case models.ContentTypeResource:
655+
m.logger.Error("Content type resource is not allowed")
656+
return
596657
case models.ContentTypeToolResult:
597658
m.logger.Error("Content type tool results is not allowed")
598659
return

internal/handlers/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type MCPClient interface {
4747
PromptServerSupported() bool
4848
ListTools(ctx context.Context, params mcp.ListToolsParams) (mcp.ListToolsResult, error)
4949
ListResources(ctx context.Context, params mcp.ListResourcesParams) (mcp.ListResourcesResult, error)
50+
ReadResource(ctx context.Context, params mcp.ReadResourceParams) (mcp.ReadResourceResult, error)
5051
ListPrompts(ctx context.Context, params mcp.ListPromptsParams) (mcp.ListPromptResult, error)
5152
GetPrompt(ctx context.Context, params mcp.GetPromptParams) (mcp.GetPromptResult, error)
5253
CallTool(ctx context.Context, params mcp.CallToolParams) (mcp.CallToolResult, error)

internal/handlers/main_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,10 @@ func (m *mockMCPClient) ListResources(_ context.Context, _ mcp.ListResourcesPara
675675
return mcp.ListResourcesResult{Resources: m.resources}, nil
676676
}
677677

678+
func (m *mockMCPClient) ReadResource(_ context.Context, _ mcp.ReadResourceParams) (mcp.ReadResourceResult, error) {
679+
return mcp.ReadResourceResult{}, nil
680+
}
681+
678682
func (m *mockMCPClient) ListPrompts(_ context.Context, _ mcp.ListPromptsParams) (mcp.ListPromptResult, error) {
679683
if m.err != nil {
680684
return mcp.ListPromptResult{}, m.err

internal/models/chat.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88
"time"
99

10+
"github.com/MegaGrindStone/go-mcp"
1011
"github.com/yuin/goldmark"
1112
highlighting "github.com/yuin/goldmark-highlighting"
1213
"github.com/yuin/goldmark/extension"
@@ -37,6 +38,9 @@ type Content struct {
3738
// Text would be filled if Type is ContentTypeText.
3839
Text string
3940

41+
// ResourceContents would be filled if Type is ContentTypeResource.
42+
ResourceContents []mcp.ResourceContents
43+
4044
// ToolName would be filled if Type is ContentTypeCallTool.
4145
ToolName string
4246
// ToolInput would be filled if Type is ContentTypeCallTool.
@@ -59,20 +63,31 @@ type Role string
5963
type ContentType string
6064

6165
const (
62-
// RoleUser represents a user message. A message with this role would only contain text content.
66+
// RoleUser represents a user message. A message with this role would only contain text or resource content.
6367
RoleUser Role = "user"
64-
// RoleAssistant represents an assistant message. A message with this role would contain text content
65-
// and potentially other types of content.
68+
// RoleAssistant represents an assistant message. A message with this role would contain
69+
// all types of content but resource.
6670
RoleAssistant Role = "assistant"
6771

6872
// ContentTypeText represents text content.
6973
ContentTypeText ContentType = "text"
74+
// ContentTypeResource represents a resource content.
75+
ContentTypeResource ContentType = "resource"
7076
// ContentTypeCallTool represents a call to a tool.
7177
ContentTypeCallTool ContentType = "call_tool"
7278
// ContentTypeToolResult represents the result of a tool call.
7379
ContentTypeToolResult ContentType = "tool_result"
7480
)
7581

82+
var mimeTypeToLanguage = map[string]string{
83+
"text/x-go": "go",
84+
"text/golang": "go",
85+
"application/json": "json",
86+
"text/javascript": "javascript",
87+
"text/html": "html",
88+
"text/css": "css",
89+
}
90+
7691
// RenderContents renders contents into a markdown string.
7792
func RenderContents(contents []Content) (string, error) {
7893
var sb strings.Builder
@@ -83,6 +98,41 @@ func RenderContents(contents []Content) (string, error) {
8398
continue
8499
}
85100
sb.WriteString(content.Text)
101+
case ContentTypeResource:
102+
if len(content.ResourceContents) == 0 {
103+
continue
104+
}
105+
for _, resource := range content.ResourceContents {
106+
sb.WriteString(" \n\n<details>\n")
107+
sb.WriteString(fmt.Sprintf("<summary>Resource: %s</summary>\n\n", resource.URI))
108+
109+
if resource.MimeType != "" {
110+
sb.WriteString(fmt.Sprintf("MIME Type: `%s`\n\n", resource.MimeType))
111+
}
112+
113+
if resource.Text != "" {
114+
// Use map for language determination
115+
language := "text"
116+
if lang, exists := mimeTypeToLanguage[resource.MimeType]; exists {
117+
language = lang
118+
}
119+
120+
sb.WriteString(fmt.Sprintf("```%s\n%s\n```\n", language, resource.Text))
121+
} else if resource.Blob != "" {
122+
// Handle binary content
123+
if strings.HasPrefix(resource.MimeType, "image/") {
124+
// Display images inline
125+
sb.WriteString(fmt.Sprintf("<img src=\"data:%s;base64,%s\" alt=\"%s\" />\n",
126+
resource.MimeType, resource.Blob, resource.URI))
127+
} else {
128+
// Provide download link for other binary content
129+
sb.WriteString(fmt.Sprintf("<a href=\"data:%s;base64,%s\" download=\"%s\">Download %s</a>\n",
130+
resource.MimeType, resource.Blob, resource.URI, resource.URI))
131+
}
132+
}
133+
134+
sb.WriteString("\n</details> \n\n")
135+
}
86136
case ContentTypeCallTool:
87137
sb.WriteString(" \n\n<details>\n")
88138
sb.WriteString(fmt.Sprintf("<summary>Calling Tool: %s</summary>\n\n", content.ToolName))

0 commit comments

Comments
 (0)