| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "mime" |
| 9 | "net/url" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "unicode" |
| 13 | |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 16 | ) |
| 17 | |
| 18 | var mcpAttachment = mcp.Object(map[string]any{ |
| 19 | "url" : mcpText, |
| 20 | "sha256" : mcpHash, |
| 21 | "size" : map[string]any{"type" : "integer" , "minimum" : 0}, |
| 22 | "type" : mcpText, |
| 23 | "filename" : mcpText, |
| 24 | "room" : mcpRoomID, |
| 25 | "uploaded" : mcpUnixTime, |
| 26 | "nip94" : map[string]any{"type" : "array" }, |
| 27 | }, "url" , "sha256" , "size" , "type" ) |
| 28 | |
| 29 | func mcpAttachmentURL(raw string) error { |
| 30 | if strings.ContainsAny(raw, "\\<>\"'()" ) || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { |
| 31 | return errors.New("attachment URL contains unsafe characters; percent-encode them" ) |
| 32 | } |
| 33 | u, err := url.Parse(raw) |
| 34 | if err != nil || (u.Scheme != "http" && u.Scheme != "https" ) || u.Hostname() == "" || u.User != nil { |
| 35 | return errors.New("attachment URL must be absolute HTTP or HTTPS without credentials" ) |
| 36 | } |
| 37 | return nil |
| 38 | } |
| 39 | |
| 40 | func mcpAttachmentMIME(raw string) error { |
| 41 | typ, _, err := mime.ParseMediaType(raw) |
| 42 | if err != nil || !strings.Contains(typ, "/" ) || strings.IndexFunc(raw, unicode.IsControl) >= 0 { |
| 43 | return errors.New("attachment type must be a MIME type" ) |
| 44 | } |
| 45 | return nil |
| 46 | } |
| 47 | |
| 48 | func mcpAttachmentTags(call mcp.Call) (string, [][]string, error) { |
| 49 | values, _ := call.Arguments["attachments" ].([]any) |
| 50 | if len(values) > 8 { |
| 51 | return "" , nil, errors.New("attach up to 8 files per message" ) |
| 52 | } |
| 53 | var links []string |
| 54 | var tags [][]string |
| 55 | for _, raw := range values { |
| 56 | item, ok := raw.(map[string]any) |
| 57 | if !ok { |
| 58 | return "" , nil, errors.New("each attachment must be an object" ) |
| 59 | } |
| 60 | link, _ := item["url" ].(string) |
| 61 | hash, _ := item["sha256" ].(string) |
| 62 | typ, _ := item["type" ].(string) |
| 63 | size, ok := item["size" ].(float64) |
| 64 | if !ok || size < 0 || size != float64(int64(size)) || !hex64(hash) { |
| 65 | return "" , nil, errors.New("attachment needs url, sha256, non-negative integer size and type" ) |
| 66 | } |
| 67 | if err := mcpAttachmentURL(link); err != nil { |
| 68 | return "" , nil, err |
| 69 | } |
| 70 | if err := mcpAttachmentMIME(typ); err != nil { |
| 71 | return "" , nil, err |
| 72 | } |
| 73 | if room, _ := item["room" ].(string); room != "" && room != call.String("room" ) { |
| 74 | return "" , nil, errors.New("upload the attachment to the destination room first" ) |
| 75 | } |
| 76 | filename, _ := item["filename" ].(string) |
| 77 | filename = roomAttachmentFilename(filename) |
| 78 | tags = append(tags, []string{"imeta" , "url " + link, "m " + typ, "x " + hash, "size " + strconv.FormatInt(int64(size), 10), "filename " + filename}) |
| 79 | mediaType, _, _ := mime.ParseMediaType(typ) |
| 80 | switch { |
| 81 | case strings.HasPrefix(mediaType, "image/" ): |
| 82 | links = append(links, "" ) |
| 83 | case strings.HasPrefix(mediaType, "video/" ): |
| 84 | links = append(links, "" ) |
| 85 | default: |
| 86 | label := strings.NewReplacer("\\" , "\\\\" , "[" , "\\[" , "]" , "\\]" ).Replace(filename) |
| 87 | links = append(links, "[" +label+"](" +link+")" ) |
| 88 | } |
| 89 | } |
| 90 | return strings.Join(links, "\n" ), tags, nil |
| 91 | } |
| 92 | |
| 93 | func mcpRoomContent(call mcp.Call) (string, [][]string, error) { |
| 94 | content, tags, err := mcpAttachmentTags(call) |
| 95 | if err != nil { |
| 96 | return "" , nil, err |
| 97 | } |
| 98 | if text := call.String("content" ); strings.TrimSpace(text) != "" { |
| 99 | if content != "" { |
| 100 | content = text + "\n" + content |
| 101 | } else { |
| 102 | content = text |
| 103 | } |
| 104 | } |
| 105 | return content, tags, nil |
| 106 | } |
| 107 | |
| 108 | func mcpCheckAttachments(e event.Event) error { |
| 109 | for _, tag := range e.Tags { |
| 110 | if len(tag) == 0 || tag[0] != "imeta" { |
| 111 | continue |
| 112 | } |
| 113 | values := map[string]string{} |
| 114 | for _, field := range tag[1:] { |
| 115 | parts := strings.SplitN(field, " " , 2) |
| 116 | if len(parts) == 2 { |
| 117 | values[parts[0]] = parts[1] |
| 118 | } |
| 119 | } |
| 120 | if values["url" ] == "" || !hex64(values["x" ]) || strings.TrimSpace(values["m" ]) == "" { |
| 121 | return errors.New("imeta attachment needs url, m and x fields" ) |
| 122 | } |
| 123 | if err := mcpAttachmentURL(values["url" ]); err != nil { |
| 124 | return err |
| 125 | } |
| 126 | if err := mcpAttachmentMIME(values["m" ]); err != nil { |
| 127 | return err |
| 128 | } |
| 129 | if size := values["size" ]; size != "" { |
| 130 | n, err := strconv.ParseInt(size, 10, 64) |
| 131 | if err != nil || n < 0 { |
| 132 | return errors.New("imeta size must be a non-negative integer" ) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | return nil |
| 137 | } |
| 138 | |
| 139 | func (t *Tenant) mcpUploadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 140 | if !t.Policy().Features.Files || t.blobs == nil { |
| 141 | return mcp.Failure("file storage is disabled" , nil), nil |
| 142 | } |
| 143 | room := call.String("room" ) |
| 144 | if len(call.String("data" )) > 700*1024 { |
| 145 | return mcp.Failure("Base64 data exceeds 700 KiB; use the HTTP room attachment endpoint for larger files" , nil), nil |
| 146 | } |
| 147 | raw, err := base64.StdEncoding.DecodeString(call.String("data" )) |
| 148 | if err != nil { |
| 149 | return mcp.Failure("data must be standard Base64" , nil), nil |
| 150 | } |
| 151 | if int64(len(raw)) > roomAttachmentMaxBytes { |
| 152 | return mcp.Failure("attachment exceeds the 32 MiB room limit" , nil), nil |
| 153 | } |
| 154 | typ := strings.TrimSpace(call.String("type" )) |
| 155 | if typ == "" || strings.ContainsAny(typ, "\r\n" ) { |
| 156 | return mcp.Failure("type is required" , nil), nil |
| 157 | } |
| 158 | descriptor, err := t.storeRoomAttachment(ctx, call.Actor, room, roomAttachmentInput{Body: raw, Type: typ, Filename: call.String("filename" )}) |
| 159 | if err != nil { |
| 160 | return mcp.Failure("upload failed: " +err.Error(), nil), nil |
| 161 | } |
| 162 | return mcp.Value(descriptor), nil |
| 163 | } |
| 164 | |
| 165 | func (t *Tenant) mcpReadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 166 | if !t.Policy().Features.Files || t.blobs == nil { |
| 167 | return mcp.Failure("file storage is disabled" , nil), nil |
| 168 | } |
| 169 | if err := t.browseRead(ctx, call.Actor); err != nil { |
| 170 | return mcp.Failure("attachment unavailable: " +err.Error(), nil), nil |
| 171 | } |
| 172 | if err := t.fileReadAccess(ctx, call.Actor, call.String("sha256" )); err != nil { |
| 173 | return mcp.Failure("attachment unavailable: " +err.Error(), nil), nil |
| 174 | } |
| 175 | if err := t.roomAttachmentAccess(ctx, call.Actor, call.String("sha256" )); err != nil { |
| 176 | return mcp.Failure("attachment unavailable: " +err.Error(), nil), nil |
| 177 | } |
| 178 | entry, body, err := t.blobs.Get(ctx, call.String("sha256" )) |
| 179 | if err != nil { |
| 180 | return mcp.Failure("attachment unavailable: " +err.Error(), nil), nil |
| 181 | } |
| 182 | defer body.Close() |
| 183 | max := call.Int("max_bytes" ) |
| 184 | if max == 0 { |
| 185 | max = 4 * 1024 * 1024 |
| 186 | } |
| 187 | if max < 1 || max > 4*1024*1024 { |
| 188 | return mcp.Failure("max_bytes must be between 1 and 4194304" , nil), nil |
| 189 | } |
| 190 | if entry.Size > int64(max) { |
| 191 | return mcp.Failure("attachment exceeds max_bytes" , map[string]any{"size" : entry.Size}), nil |
| 192 | } |
| 193 | raw, err := io.ReadAll(io.LimitReader(body, int64(max)+1)) |
| 194 | if err != nil { |
| 195 | return mcp.Result{}, err |
| 196 | } |
| 197 | if len(raw) > max { |
| 198 | return mcp.Failure("attachment exceeds max_bytes" , nil), nil |
| 199 | } |
| 200 | descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/" ), entry) |
| 201 | descriptor["data" ] = base64.StdEncoding.EncodeToString(raw) |
| 202 | if strings.HasPrefix(entry.Type, "text/" ) { |
| 203 | descriptor["text" ] = string(raw) |
| 204 | } |
| 205 | result := mcp.Result{StructuredContent: descriptor} |
| 206 | mediaType := "" |
| 207 | if strings.HasPrefix(entry.Type, "image/" ) { |
| 208 | mediaType = "image" |
| 209 | } |
| 210 | if strings.HasPrefix(entry.Type, "audio/" ) { |
| 211 | mediaType = "audio" |
| 212 | } |
| 213 | if mediaType != "" { |
| 214 | result.Content = []mcp.Content{{Type: mediaType, Data: base64.StdEncoding.EncodeToString(raw), MimeType: entry.Type}} |
| 215 | } else if strings.HasPrefix(entry.Type, "text/" ) { |
| 216 | result.Content = []mcp.Content{{Type: "text" , Text: string(raw)}} |
| 217 | } else { |
| 218 | result.Content = []mcp.Content{{Type: "text" , Text: "File bytes are in structuredContent.data as Base64." }} |
| 219 | } |
| 220 | return result, nil |
| 221 | } |