fix: support MCP user message attachments #1824

Merged
agent-dev-a merged 1 commits from fix/hermes-user-attachments-core into main 2026-05-25 01:54:16 +00:00
3 changed files with 150 additions and 5 deletions
+26
View File
@@ -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"},
},
@@ -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
// ─────────────────────────────────────────────────────────────────────────────
@@ -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