fix: support MCP user message attachments #1824
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user