clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / daemon / mcp_attachments.go
mcp_attachments.go 7.3K
1package daemon23import (4 "context"5 "encoding/base64"6 "errors"7 "io"8 "mime"9 "net/url"10 "strconv"11 "strings"12 "unicode"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/mcp"16)1718var 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")2829func 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 nil38}3940func 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 nil46}4748func 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 []string54 var tags [][]string55 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, err69 }70 if err := mcpAttachmentMIME(typ); err != nil {71 return "", nil, err72 }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, "![image]("+link+")")83 case strings.HasPrefix(mediaType, "video/"):84 links = append(links, "![video]("+link+")")85 default:86 label := strings.NewReplacer("\\", "\\\\", "[", "\\[", "]", "\\]").Replace(filename)87 links = append(links, "["+label+"]("+link+")")88 }89 }90 return strings.Join(links, "\n"), tags, nil91}9293func mcpRoomContent(call mcp.Call) (string, [][]string, error) {94 content, tags, err := mcpAttachmentTags(call)95 if err != nil {96 return "", nil, err97 }98 if text := call.String("content"); strings.TrimSpace(text) != "" {99 if content != "" {100 content = text + "\n" + content101 } else {102 content = text103 }104 }105 return content, tags, nil106}107108func mcpCheckAttachments(e event.Event) error {109 for _, tag := range e.Tags {110 if len(tag) == 0 || tag[0] != "imeta" {111 continue112 }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 err125 }126 if err := mcpAttachmentMIME(values["m"]); err != nil {127 return err128 }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 nil137}138139func (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), nil142 }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), nil146 }147 raw, err := base64.StdEncoding.DecodeString(call.String("data"))148 if err != nil {149 return mcp.Failure("data must be standard Base64", nil), nil150 }151 if int64(len(raw)) > roomAttachmentMaxBytes {152 return mcp.Failure("attachment exceeds the 32 MiB room limit", nil), nil153 }154 typ := strings.TrimSpace(call.String("type"))155 if typ == "" || strings.ContainsAny(typ, "\r\n") {156 return mcp.Failure("type is required", nil), nil157 }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), nil161 }162 return mcp.Value(descriptor), nil163}164165func (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), nil168 }169 if err := t.browseRead(ctx, call.Actor); err != nil {170 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil171 }172 if err := t.fileReadAccess(ctx, call.Actor, call.String("sha256")); err != nil {173 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil174 }175 if err := t.roomAttachmentAccess(ctx, call.Actor, call.String("sha256")); err != nil {176 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil177 }178 entry, body, err := t.blobs.Get(ctx, call.String("sha256"))179 if err != nil {180 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil181 }182 defer body.Close()183 max := call.Int("max_bytes")184 if max == 0 {185 max = 4 * 1024 * 1024186 }187 if max < 1 || max > 4*1024*1024 {188 return mcp.Failure("max_bytes must be between 1 and 4194304", nil), nil189 }190 if entry.Size > int64(max) {191 return mcp.Failure("attachment exceeds max_bytes", map[string]any{"size": entry.Size}), nil192 }193 raw, err := io.ReadAll(io.LimitReader(body, int64(max)+1))194 if err != nil {195 return mcp.Result{}, err196 }197 if len(raw) > max {198 return mcp.Failure("attachment exceeds max_bytes", nil), nil199 }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, nil221}