From 7999924edfcd9bfee212f00042be98667124d355 Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Sun, 24 May 2026 18:44:15 -0700 Subject: [PATCH] fix: support MCP user message attachments --- workspace-server/internal/handlers/mcp.go | 26 +++++++ .../internal/handlers/mcp_test.go | 69 +++++++++++++++++++ .../internal/handlers/mcp_tools.go | 60 ++++++++++++++-- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/workspace-server/internal/handlers/mcp.go b/workspace-server/internal/handlers/mcp.go index 5d21c637e..960eb5f38 100644 --- a/workspace-server/internal/handlers/mcp.go +++ b/workspace-server/internal/handlers/mcp.go @@ -192,6 +192,32 @@ var mcpAllTools = []mcpTool{ "type": "string", "description": "The message to send to the user", }, + "attachments": map[string]interface{}{ + "type": "array", + "description": "Optional files to render as canvas chat attachments. Each item must include uri and name; mimeType and size are optional.", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "uri": map[string]interface{}{ + "type": "string", + "description": "Workspace attachment URI, usually workspace:/absolute/path", + }, + "name": map[string]interface{}{ + "type": "string", + "description": "Display filename", + }, + "mimeType": map[string]interface{}{ + "type": "string", + "description": "Optional MIME type", + }, + "size": map[string]interface{}{ + "type": "number", + "description": "Optional file size in bytes", + }, + }, + "required": []string{"uri", "name"}, + }, + }, }, "required": []string{"message"}, }, diff --git a/workspace-server/internal/handlers/mcp_test.go b/workspace-server/internal/handlers/mcp_test.go index 88f371d50..314c2cdc7 100644 --- a/workspace-server/internal/handlers/mcp_test.go +++ b/workspace-server/internal/handlers/mcp_test.go @@ -937,6 +937,75 @@ func TestMCPHandler_SendMessageToUser_PersistsToActivityLog(t *testing.T) { } } +func TestMCPHandler_SendMessageToUser_WithAttachments_PersistsFileParts(t *testing.T) { + t.Setenv("MOLECULE_MCP_ALLOW_SEND_MESSAGE", "true") + h, mock := newMCPHandler(t) + + mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces"). + WithArgs("ws-mcp-attach"). + WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Hermes Agent", true)) + + mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`). + WithArgs( + "ws-mcp-attach", + sqlmock.AnyArg(), + jsonMatcher{ + desc: "MCP send_message_to_user response_body has result + file parts", + predicate: func(parsed map[string]any) bool { + if parsed["result"] != "see attached" { + return false + } + parts, ok := parsed["parts"].([]any) + if !ok || len(parts) != 1 { + return false + } + part, ok := parts[0].(map[string]any) + if !ok || part["kind"] != "file" { + return false + } + file, ok := part["file"].(map[string]any) + return ok && + file["uri"] == "workspace:/workspace/org_chart_v2.png" && + file["name"] == "org_chart_v2.png" && + file["mimeType"] == "image/png" && + file["size"] == float64(12345) + }, + }, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + + w := mcpPost(t, h, "ws-mcp-attach", map[string]interface{}{ + "jsonrpc": "2.0", + "id": 102, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "send_message_to_user", + "arguments": map[string]interface{}{ + "message": "see attached", + "attachments": []map[string]interface{}{ + { + "uri": "workspace:/workspace/org_chart_v2.png", + "name": "org_chart_v2.png", + "mimeType": "image/png", + "size": 12345, + }, + }, + }, + }, + }) + + var resp mcpResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response was not valid JSON-RPC: %v\nbody=%s", err, w.Body.String()) + } + if resp.Error != nil { + t.Errorf("unexpected JSON-RPC error: %+v", resp.Error) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("MCP attachment response_body drift: %v", err) + } +} + // ───────────────────────────────────────────────────────────────────────────── // Parse error // ───────────────────────────────────────────────────────────────────────────── diff --git a/workspace-server/internal/handlers/mcp_tools.go b/workspace-server/internal/handlers/mcp_tools.go index 2cf118e93..588cf120f 100644 --- a/workspace-server/internal/handlers/mcp_tools.go +++ b/workspace-server/internal/handlers/mcp_tools.go @@ -348,12 +348,12 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri // activity.go:Notify is what produced the reno-stars data-loss // regression; both paths now route through the same writer. // - // MCP send_message_to_user does not currently surface attachments - // (the tool args don't accept them); pass nil. If a future tool - // schema adds an attachments arg, build []AgentMessageAttachment - // and pass through. + attachments, err := parseAgentMessageAttachments(args["attachments"]) + if err != nil { + return "", err + } writer := NewAgentMessageWriter(h.database, h.broadcaster) - if err := writer.Send(ctx, workspaceID, message, nil); err != nil { + if err := writer.Send(ctx, workspaceID, message, attachments); err != nil { if errors.Is(err, ErrWorkspaceNotFound) { return "", fmt.Errorf("workspace not found") } @@ -362,6 +362,56 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri return "Message sent.", nil } +func parseAgentMessageAttachments(raw interface{}) ([]AgentMessageAttachment, error) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]interface{}) + if !ok { + return nil, fmt.Errorf("attachments must be an array") + } + if len(items) == 0 { + return nil, nil + } + attachments := make([]AgentMessageAttachment, 0, len(items)) + for i, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("attachment[%d]: must be an object", i) + } + uri, _ := m["uri"].(string) + name, _ := m["name"].(string) + if uri == "" || name == "" { + return nil, fmt.Errorf("attachment[%d]: uri and name are required", i) + } + att := AgentMessageAttachment{ + URI: uri, + Name: name, + } + if mimeType, ok := m["mimeType"].(string); ok { + att.MimeType = mimeType + } + if size, ok := numericInt64(m["size"]); ok { + att.Size = size + } + attachments = append(attachments, att) + } + return attachments, nil +} + +func numericInt64(raw interface{}) (int64, bool) { + switch v := raw.(type) { + case int: + return int64(v), true + case int64: + return v, true + case float64: + return int64(v), true + default: + return 0, false + } +} + func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) { // Issue #1733 — v2 memory plugin is now the only path. The legacy // SQL fallback on `agent_memories` is gone; an unconfigured plugin -- 2.52.0