tinyrelayfile
tinyrelay/internal/daemon/mcp_attachments.go/

internal/daemon/mcp_attachments.go raw
1package daemon
2
3import (
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
18var 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
29func 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
40func 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
48func 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, "![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, nil
91}
92
93func 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
108func 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
139func (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
165func (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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs