tinyrelayfile
tinyrelay/internal/daemon/mcp_test.go/

internal/daemon/mcp_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/event"
14 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
15 "github.com/FelineStateMachine/tinyrelay/internal/sites"
16)
17
18type mcpCall struct {
19 method string
20 name string
21 arguments map[string]any
22 meta string
23 version string
24 sign bool
25 secret string
26 headers map[string]string
27}
28
29// mcpSequence keeps request bodies distinct so identical calls signed in
30// the same second do not share a NIP-98 token id.
31var mcpSequence int
32
33func (c mcpCall) do(t *testing.T, app *App, path string) (*httptest.ResponseRecorder, mcp.Response) {
34 t.Helper()
35 mcpSequence++
36 params := map[string]any{"_meta": map[string]any{mcp.MetaProtocolVersion: mcp.Version, mcp.MetaClientCapabilities: map[string]any{}}}
37 if c.meta != "" {
38 params["_meta"].(map[string]any)[mcp.MetaProtocolVersion] = c.meta
39 }
40 if c.name != "" {
41 params["name"] = c.name
42 params["arguments"] = c.arguments
43 }
44 raw, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": mcpSequence, "method": c.method, "params": params})
45 if err != nil {
46 t.Fatal(err)
47 }
48 r := httptest.NewRequest(http.MethodPost, "http://relay.test"+path, strings.NewReader(string(raw)))
49 r.Header.Set("Content-Type", "application/json")
50 r.Header.Set("Accept", "application/json, text/event-stream")
51 version := c.version
52 if version == "" {
53 version = mcp.Version
54 }
55 r.Header.Set(mcp.HeaderProtocolVersion, version)
56 r.Header.Set(mcp.HeaderMethod, c.method)
57 if c.name != "" {
58 r.Header.Set(mcp.HeaderName, c.name)
59 }
60 for key, value := range c.headers {
61 r.Header.Set(key, value)
62 }
63 if c.secret != "" {
64 signRequestWithSecret(t, r, string(raw), c.secret)
65 } else if c.sign {
66 signRequest(t, r, string(raw))
67 }
68 w := httptest.NewRecorder()
69 app.ServeHTTP(w, r)
70 var response mcp.Response
71 if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
72 if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
73 t.Fatalf("decode %s: %v", w.Body.String(), err)
74 }
75 }
76 return w, response
77}
78
79func mcpToolResult(t *testing.T, response mcp.Response) (map[string]any, bool) {
80 t.Helper()
81 result, _ := response.Result.(map[string]any)
82 if result == nil {
83 t.Fatalf("no result: %+v", response)
84 }
85 isError, _ := result["isError"].(bool)
86 return result, isError
87}
88
89func TestMCPListsAndCallsToolsAsTheSignedKey(t *testing.T) {
90 app, tenant := testTenant(t)
91 policy := tenant.Policy()
92 policy.Features.Grasp = true
93 if err := tenant.applyPolicy(context.Background(), policy); err != nil {
94 t.Fatal(err)
95 }
96 for _, path := range []string{"/mcp", "/r/main/mcp"} {
97 w, response := mcpCall{method: "tools/list", sign: true}.do(t, app, path)
98 result, _ := response.Result.(map[string]any)
99 tools, _ := result["tools"].([]any)
100 if w.Code != http.StatusOK || len(tools) < 20 || result["resultType"] != "complete" || result["cacheScope"] != "private" || w.Header().Get("Mcp-Session-Id") != "" {
101 t.Fatalf("%s tools/list: %d %s", path, w.Code, w.Body.String())
102 }
103 names := map[string]bool{}
104 for _, tool := range tools {
105 names[tool.(map[string]any)["name"].(string)] = true
106 }
107 for _, want := range []string{"list_repositories", "read_issue", "read_management", "run_job", "set_policy", "publish_event", "create_issue", "create_pull_request", "comment", "set_status"} {
108 if !names[want] {
109 t.Fatalf("%s missing tool %s", path, want)
110 }
111 }
112 }
113 w, response := mcpCall{method: "tools/call", name: "read_management", arguments: map[string]any{"method": "getpolicy"}, sign: true}.do(t, app, "/mcp")
114 result, isError := mcpToolResult(t, response)
115 structured, _ := result["structuredContent"].(map[string]any)
116 policyResult, _ := structured["result"].(map[string]any)
117 if w.Code != http.StatusOK || isError || policyResult["owner"] != tenant.Policy().Owner {
118 t.Fatalf("read_management: %d %s", w.Code, w.Body.String())
119 }
120 if content, _ := result["content"].([]any); len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), tenant.Policy().Owner) {
121 t.Fatalf("read_management content %v", result["content"])
122 }
123 w, response = mcpCall{method: "tools/call", name: "list_repositories", arguments: map[string]any{}, sign: true}.do(t, app, "/mcp")
124 result, isError = mcpToolResult(t, response)
125 if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["next_cursor"] != "" {
126 t.Fatalf("list_repositories: %d %s", w.Code, w.Body.String())
127 }
128 w, response = mcpCall{method: "tools/call", name: "read_file", arguments: map[string]any{"hash": "short"}, sign: true}.do(t, app, "/mcp")
129 result, isError = mcpToolResult(t, response)
130 if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "hash does not match") {
131 t.Fatalf("schema validation: %d %s", w.Code, w.Body.String())
132 }
133}
134
135func TestMCPWriteToolsBuildValidateAndPublish(t *testing.T) {
136 app, tenant := testTenant(t)
137 policy := tenant.Policy()
138 policy.Features.Grasp = true
139 if err := tenant.applyPolicy(context.Background(), policy); err != nil {
140 t.Fatal(err)
141 }
142 owner := tenant.Policy().Owner
143 w, response := mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"owner": owner, "repo": "tiny", "title": "Broken build", "content": "It fails.", "labels": []any{"bug", "bug"}}, sign: true}.do(t, app, "/mcp")
144 result, isError := mcpToolResult(t, response)
145 unsigned, _ := result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
146 if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1621) || unsigned["content"] != "It fails." {
147 t.Fatalf("create_issue template: %d %s", w.Code, w.Body.String())
148 }
149 tags, _ := json.Marshal(unsigned["tags"])
150 if string(tags) != `[["a","30617:`+owner+`:tiny"],["p","`+owner+`"],["subject","Broken build"],["t","bug"]]` {
151 t.Fatalf("create_issue tags %s", tags)
152 }
153 sign := func(kind int, tags [][]string, content string) map[string]any {
154 e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}
155 if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
156 t.Fatal(err)
157 }
158 raw, _ := json.Marshal(e)
159 var value map[string]any
160 _ = json.Unmarshal(raw, &value)
161 return value
162 }
163 w, response = mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"event": sign(1, [][]string{}, "not an issue")}, sign: true}.do(t, app, "/mcp")
164 result, isError = mcpToolResult(t, response)
165 if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"]`) {
166 t.Fatalf("create_issue malformed: %d %s", w.Code, w.Body.String())
167 }
168 w, response = mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"event": sign(1621, [][]string{{"a", "30617:" + owner + ":tiny"}, {"p", owner}, {"subject", "Broken build"}}, "It fails.")}, sign: true}.do(t, app, "/mcp")
169 result, isError = mcpToolResult(t, response)
170 published, _ := result["structuredContent"].(map[string]any)
171 if w.Code != http.StatusOK || isError || published["accepted"] != true || published["event_id"] == "" {
172 t.Fatalf("create_issue publish: %d %s", w.Code, w.Body.String())
173 }
174 rootID, _ := published["event_id"].(string)
175 w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_kind": 1621, "root_pubkey": owner, "content": "On it."}, sign: true}.do(t, app, "/mcp")
176 result, isError = mcpToolResult(t, response)
177 unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
178 tags, _ = json.Marshal(unsigned["tags"])
179 if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1111) || string(tags) != `[["a","30617:`+owner+`:tiny"],["E","`+rootID+`","","`+owner+`"],["K","1621"],["P","`+owner+`"],["e","`+rootID+`","","`+owner+`"],["k","1621"],["p","`+owner+`"]]` {
180 t.Fatalf("comment template: %d %s", w.Code, w.Body.String())
181 }
182 w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_kind": 1621, "root_pubkey": owner, "content": "Here.", "file": "README", "line": 2, "side": "new"}, sign: true}.do(t, app, "/mcp")
183 result, isError = mcpToolResult(t, response)
184 if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "pull request or patch") {
185 t.Fatalf("comment anchor under an issue: %d %s", w.Code, w.Body.String())
186 }
187 w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": strings.Repeat("d", 64), "root_kind": 1618, "root_pubkey": owner, "content": "Here.", "file": "README", "line": 2, "side": "new"}, sign: true}.do(t, app, "/mcp")
188 result, isError = mcpToolResult(t, response)
189 unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
190 tags, _ = json.Marshal(unsigned["tags"])
191 if w.Code != http.StatusOK || isError || !strings.HasSuffix(string(tags), `["file","README"],["line","2","new"]]`) {
192 t.Fatalf("comment anchor template: %d %s", w.Code, w.Body.String())
193 }
194 w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"event": sign(1111, [][]string{{"a", "30617:" + owner + ":tiny"}, {"E", strings.Repeat("d", 64), "", owner}, {"K", "1618"}, {"P", owner}, {"e", strings.Repeat("d", 64), "", owner}, {"k", "1618"}, {"p", owner}, {"file", "README"}, {"line", "0", "new"}}, "Here.")}, sign: true}.do(t, app, "/mcp")
195 result, isError = mcpToolResult(t, response)
196 if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "positive integer") {
197 t.Fatalf("comment anchor check: %d %s", w.Code, w.Body.String())
198 }
199 w, response = mcpCall{method: "tools/call", name: "set_status", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_pubkey": owner, "status": "resolved"}, sign: true}.do(t, app, "/mcp")
200 result, isError = mcpToolResult(t, response)
201 unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
202 if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1631) {
203 t.Fatalf("set_status template: %d %s", w.Code, w.Body.String())
204 }
205 w, response = mcpCall{method: "tools/call", name: "publish_event", arguments: map[string]any{"event": sign(1, [][]string{}, "hello agents")}, sign: true}.do(t, app, "/mcp")
206 result, isError = mcpToolResult(t, response)
207 if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["accepted"] != true {
208 t.Fatalf("publish_event: %d %s", w.Code, w.Body.String())
209 }
210 query := httptest.NewRequest(http.MethodPost, "http://relay.test/query", strings.NewReader(`[{"kinds":[1,1621]}]`))
211 signRequest(t, query, `[{"kinds":[1,1621]}]`)
212 queried := httptest.NewRecorder()
213 app.ServeHTTP(queried, query)
214 var rows []event.Event
215 if err := json.Unmarshal(queried.Body.Bytes(), &rows); err != nil || len(rows) != 2 {
216 t.Fatalf("query after publish: %s %v", queried.Body.String(), err)
217 }
218}
219
220func TestMCPRejectsUnauthorizedForeignAndMismatchedRequests(t *testing.T) {
221 app, _ := testTenant(t)
222 w, response := mcpCall{method: "tools/list"}.do(t, app, "/mcp")
223 if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != `Nostr realm="tiny"` || response.Error == nil {
224 t.Fatalf("unsigned: %d %q %s", w.Code, w.Header().Get("WWW-Authenticate"), w.Body.String())
225 }
226 w, response = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "https://foreign.test"}}.do(t, app, "/mcp")
227 if w.Code != http.StatusForbidden || response.Error == nil {
228 t.Fatalf("foreign origin: %d %s", w.Code, w.Body.String())
229 }
230 w, _ = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "http://relay.test"}}.do(t, app, "/mcp")
231 if w.Code != http.StatusOK {
232 t.Fatalf("same origin: %d %s", w.Code, w.Body.String())
233 }
234 w, response = mcpCall{method: "tools/list", sign: true, meta: "2025-11-25"}.do(t, app, "/mcp")
235 if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeHeaderMismatch {
236 t.Fatalf("version mismatch: %d %s", w.Code, w.Body.String())
237 }
238 w, response = mcpCall{method: "tools/list", sign: true, version: "2025-11-25", meta: "2025-11-25"}.do(t, app, "/mcp")
239 if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeUnsupportedProtocolVersion {
240 t.Fatalf("unsupported version: %d %s", w.Code, w.Body.String())
241 }
242 w, response = mcpCall{method: "initialize", sign: true}.do(t, app, "/mcp")
243 if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != mcp.CodeMethodNotFound {
244 t.Fatalf("initialize: %d %s", w.Code, w.Body.String())
245 }
246 for _, method := range []string{http.MethodGet, http.MethodDelete} {
247 r := httptest.NewRequest(method, "http://relay.test/mcp", nil)
248 w := httptest.NewRecorder()
249 app.ServeHTTP(w, r)
250 if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != "POST" {
251 t.Fatalf("%s: %d", method, w.Code)
252 }
253 }
254 r := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/llms.txt", nil)
255 w = httptest.NewRecorder()
256 app.ServeHTTP(w, r)
257 if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") || !strings.Contains(w.Body.String(), "http://relay.test/r/main/mcp") || !strings.Contains(w.Body.String(), mcp.Version) || !strings.Contains(w.Body.String(), "list_rooms") || !strings.Contains(w.Body.String(), "request_decision") {
258 t.Fatalf("llms.txt: %d %s", w.Code, w.Body.String())
259 }
260}
261
262// mcpSigned signs an event with the secret and returns it as the JSON object
263// a client passes in the event argument.
264func mcpSigned(t *testing.T, secret string, kind int, tags [][]string, content string) map[string]any {
265 t.Helper()
266 e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}
267 if err := event.Sign(&e, secret); err != nil {
268 t.Fatal(err)
269 }
270 raw, _ := json.Marshal(e)
271 var value map[string]any
272 _ = json.Unmarshal(raw, &value)
273 return value
274}
275
276// TestMCPPublishSiteBuildsChecksAndPublishes covers the site manifest tool:
277// the template for the caller's own site and a named site, refusals of
278// labels under other keys and bad paths, and publishing the signed event.
279func TestMCPPublishSiteBuildsChecksAndPublishes(t *testing.T) {
280 app, tenant := testTenant(t)
281 owner := tenant.Policy().Owner
282 member, _ := event.PublicKey(testMemberSecret)
283 call := func(name string, arguments map[string]any) (map[string]any, bool) {
284 t.Helper()
285 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true}.do(t, app, "/mcp")
286 if w.Code != http.StatusOK {
287 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
288 }
289 return mcpToolResult(t, response)
290 }
291 structured := func(result map[string]any) map[string]any {
292 value, _ := result["structuredContent"].(map[string]any)
293 return value
294 }
295 text := func(result map[string]any) string {
296 content, _ := result["content"].([]any)
297 if len(content) == 0 {
298 return ""
299 }
300 return content[0].(map[string]any)["text"].(string)
301 }
302 hash := strings.Repeat("a", 64)
303 paths := []any{[]any{"/index.html", hash}, []any{"/style.css", strings.ToUpper(hash)}}
304 expiration := time.Now().Unix() + 86400
305 result, isError := call("publish_site", map[string]any{"paths": paths, "expiration": expiration})
306 unsigned, _ := structured(result)["unsigned"].(map[string]any)
307 tags, _ := json.Marshal(unsigned["tags"])
308 if isError || unsigned["kind"] != float64(sites.KindSite) || string(tags) != `[["path","/index.html","`+hash+`"],["path","/style.css","`+hash+`"],["expiration","`+strconv.FormatInt(expiration, 10)+`"]]` {
309 t.Fatalf("publish_site template: %s", text(result))
310 }
311 base, _ := sites.Base36(owner)
312 result, isError = call("publish_site", map[string]any{"label": base + "docs", "paths": paths})
313 unsigned, _ = structured(result)["unsigned"].(map[string]any)
314 tags, _ = json.Marshal(unsigned["tags"])
315 if isError || unsigned["kind"] != float64(sites.KindNamedSite) || !strings.HasPrefix(string(tags), `[["d","docs"],["path",`) {
316 t.Fatalf("publish_site named template: %s", text(result))
317 }
318 other, _ := sites.Base36(member)
319 for name, arguments := range map[string]map[string]any{
320 "other key": {"label": other + "docs", "paths": paths},
321 "bad label": {"label": "nope", "paths": paths},
322 "no paths": {},
323 "odd pair": {"paths": []any{[]any{"/index.html"}}},
324 "bad path": {"paths": []any{[]any{"index.html", hash}}},
325 "bad hash": {"paths": []any{[]any{"/index.html", "short"}}},
326 "duplicate": {"paths": []any{[]any{"/index.html", hash}, []any{"/index.html", hash}}},
327 "past expiry": {"paths": paths, "expiration": 1},
328 } {
329 if result, isError := call("publish_site", arguments); !isError || !strings.Contains(text(result), "kind 15128") {
330 t.Fatalf("%s accepted: %s", name, text(result))
331 }
332 }
333 result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/../x.js", hash}}, "")})
334 if !isError || !strings.Contains(text(result), "site path") {
335 t.Fatalf("publish_site bad signed path: %s", text(result))
336 }
337 result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, 1, [][]string{}, "not a site")})
338 if !isError || !strings.Contains(text(result), "not a site manifest") {
339 t.Fatalf("publish_site wrong kind: %s", text(result))
340 }
341 result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/index.html", hash}, {"expiration", strconv.FormatInt(expiration, 10)}}, "")})
342 if isError || structured(result)["accepted"] != true {
343 t.Fatalf("publish_site publish: %s", text(result))
344 }
345 manifest, err := tenant.sites.Manifest(context.Background(), sites.Site{Kind: sites.KindSite, PubKey: owner})
346 if err != nil || manifest.Kind != sites.KindSite {
347 t.Fatalf("published manifest not indexed: %+v %v", manifest, err)
348 }
349}
350
351func TestMCPRoomWikiAndAgentTools(t *testing.T) {
352 app, tenant := testTenant(t)
353 ctx := context.Background()
354 owner := tenant.Policy().Owner
355 member, _ := event.PublicKey(testMemberSecret)
356 agent, _ := event.PublicKey(testAgentSecret)
357 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
358 t.Fatal(err)
359 }
360 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
361 t.Helper()
362 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
363 if w.Code != http.StatusOK {
364 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
365 }
366 return mcpToolResult(t, response)
367 }
368 structured := func(result map[string]any) map[string]any {
369 value, _ := result["structuredContent"].(map[string]any)
370 return value
371 }
372 text := func(result map[string]any) string {
373 content, _ := result["content"].([]any)
374 if len(content) == 0 {
375 return ""
376 }
377 return content[0].(map[string]any)["text"].(string)
378 }
379 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
380 names := map[string]bool{}
381 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
382 names[tool.(map[string]any)["name"].(string)] = true
383 }
384 for _, want := range []string{"list_rooms", "read_room", "read_thread", "list_wiki", "read_wiki_page", "read_merge_request", "list_agents", "post_message", "start_thread", "reply_in_thread", "react", "publish_wiki_page", "propose_wiki_merge", "create_room", "pause_agent", "resume_agent", "revoke_agent", "pause_all_agents", "resume_all_agents", "request_decision"} {
385 if !names[want] {
386 t.Fatalf("missing tool %s", want)
387 }
388 }
389
390 // Rooms: the template carries the room tags, the signed event creates
391 // the room and the list shows it.
392 result, isError := call("create_room", map[string]any{"room": "general", "name": "General", "about": "Talk", "visibility": "open"}, "")
393 unsigned, _ := structured(result)["unsigned"].(map[string]any)
394 tags, _ := json.Marshal(unsigned["tags"])
395 if isError || unsigned["kind"] != float64(9007) || string(tags) != `[["h","general"],["name","General"],["about","Talk"],["visibility","open"]]` {
396 t.Fatalf("create_room template: %v %s", isError, text(result))
397 }
398 result, isError = call("create_room", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9007, [][]string{{"h", "general"}, {"name", "General"}, {"visibility", "open"}}, "")}, "")
399 if isError || structured(result)["accepted"] != true {
400 t.Fatalf("create_room publish: %s", text(result))
401 }
402 result, isError = call("list_rooms", map[string]any{}, "")
403 found := false
404 for _, item := range structured(result)["items"].([]any) {
405 room := item.(map[string]any)
406 if room["id"] == "general" && room["name"] == "General" && room["access"] == "open" {
407 found = true
408 }
409 }
410 if isError || !found {
411 t.Fatalf("list_rooms: %s", text(result))
412 }
413 // post_message: the template, then the signed message read back.
414 result, isError = call("post_message", map[string]any{"room": "general", "content": "hello room", "mentions": []any{member, member}}, testMemberSecret)
415 unsigned, _ = structured(result)["unsigned"].(map[string]any)
416 tags, _ = json.Marshal(unsigned["tags"])
417 if isError || unsigned["kind"] != float64(9) || unsigned["content"] != "hello room" || string(tags) != `[["h","general"],["p","`+member+`"]]` {
418 t.Fatalf("post_message template: %s", text(result))
419 }
420 result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{{"h", "general"}, {"p", member}}, "hello room")}, testMemberSecret)
421 messageID, _ := structured(result)["event_id"].(string)
422 if isError || structured(result)["accepted"] != true || messageID == "" {
423 t.Fatalf("post_message publish: %s", text(result))
424 }
425 result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{}, "no room")}, testMemberSecret)
426 if !isError || !strings.Contains(text(result), "the h tag must name the room") {
427 t.Fatalf("post_message without room: %s", text(result))
428 }
429 result, isError = call("read_room", map[string]any{"id": "general"}, "")
430 // The relay's own member notices (kind 44100) join the list whenever the
431 // room projection has run, so count chat messages only.
432 var chat []map[string]any
433 items, _ := structured(result)["messages"].([]any)
434 for _, item := range items {
435 if m, _ := item.(map[string]any); m["kind"] == float64(9) {
436 chat = append(chat, m)
437 }
438 }
439 if isError || len(chat) != 1 || chat[0]["id"] != messageID {
440 t.Fatalf("read_room: %s", text(result))
441 }
442 result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": messageID, "root_pubkey": member, "content": "and back"}, "")
443 unsigned, _ = structured(result)["unsigned"].(map[string]any)
444 tags, _ = json.Marshal(unsigned["tags"])
445 if isError || unsigned["kind"] != float64(12) || string(tags) != `[["h","general"],["e","`+messageID+`"],["p","`+member+`"]]` {
446 t.Fatalf("reply_in_thread template: %s", text(result))
447 }
448 result, isError = call("reply_in_thread", map[string]any{"event": mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "and back")}, "")
449 if isError || structured(result)["accepted"] != true {
450 t.Fatalf("reply_in_thread publish: %s", text(result))
451 }
452 result, isError = call("read_thread", map[string]any{"id": "general", "event": messageID}, "")
453 replies, _ := structured(result)["replies"].([]any)
454 if isError || len(replies) != 1 || structured(result)["root"].(map[string]any)["id"] != messageID {
455 t.Fatalf("read_thread: %s", text(result))
456 }
457 nestedReply := mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "follow-up")
458 nestedResult, nestedError := call("reply_in_thread", map[string]any{"event": nestedReply}, testOwnerSecret)
459 nestedID, _ := structured(nestedResult)["event_id"].(string)
460 if nestedError || nestedID == "" {
461 t.Fatalf("nested reply publish: %s", text(nestedResult))
462 }
463 result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": nestedID, "root_pubkey": member, "content": "canonical follow-up"}, "")
464 unsigned, _ = structured(result)["unsigned"].(map[string]any)
465 tags, _ = json.Marshal(unsigned["tags"])
466 if isError || !strings.Contains(string(tags), `"e","`+messageID+`"`) || strings.Contains(string(tags), nestedID) {
467 t.Fatalf("reply_in_thread did not flatten nested root: %s", text(result))
468 }
469
470 // react: content is +, - or one emoji, whether templated or signed.
471 result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "great", "room": "general"}, "")
472 if !isError || !strings.Contains(text(result), "content must be +, - or one emoji") {
473 t.Fatalf("react template validation: %s", text(result))
474 }
475 result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "\U0001F44D", "room": "general"}, "")
476 unsigned, _ = structured(result)["unsigned"].(map[string]any)
477 tags, _ = json.Marshal(unsigned["tags"])
478 if isError || unsigned["kind"] != float64(7) || unsigned["content"] != "\U0001F44D" || string(tags) != `[["e","`+messageID+`"],["p","`+member+`"],["h","general"]]` {
479 t.Fatalf("react template: %s", text(result))
480 }
481 result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "great")}, "")
482 if !isError || !strings.Contains(text(result), `Expected a signed kind 7 event`) {
483 t.Fatalf("react signed validation: %s", text(result))
484 }
485 result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "+")}, "")
486 if isError || structured(result)["accepted"] != true {
487 t.Fatalf("react publish: %s", text(result))
488 }
489
490 // Wiki: the template normalizes the name; the signed page reads back.
491 result, isError = call("publish_wiki_page", map[string]any{"d": "Bitcoin Basics", "title": "Bitcoin Basics", "summary": "A primer", "content": "Digital cash."}, "")
492 unsigned, _ = structured(result)["unsigned"].(map[string]any)
493 tags, _ = json.Marshal(unsigned["tags"])
494 if isError || unsigned["kind"] != float64(30818) || string(tags) != `[["d","bitcoin-basics"],["title","Bitcoin Basics"],["summary","A primer"]]` {
495 t.Fatalf("publish_wiki_page template: %s", text(result))
496 }
497 result, isError = call("publish_wiki_page", map[string]any{"event": mcpSigned(t, testOwnerSecret, 30818, [][]string{{"d", "Bitcoin Basics"}, {"title", "Bitcoin Basics"}}, "Digital cash.")}, "")
498 if !isError || !strings.Contains(text(result), "normalized page name") {
499 t.Fatalf("publish_wiki_page unnormalized: %s", text(result))
500 }
501 result, isError = call("publish_wiki_page", map[string]any{"event": mcpSigned(t, testOwnerSecret, 30818, [][]string{{"d", "bitcoin-basics"}, {"title", "Bitcoin Basics"}, {"summary", "A primer"}}, "Digital cash.")}, "")
502 pageID, _ := structured(result)["event_id"].(string)
503 if isError || structured(result)["accepted"] != true {
504 t.Fatalf("publish_wiki_page publish: %s", text(result))
505 }
506 result, isError = call("read_wiki_page", map[string]any{"d": "Bitcoin Basics"}, testMemberSecret)
507 page := structured(result)
508 version, _ := page["version"].(map[string]any)
509 if isError || page["d"] != "bitcoin-basics" || page["title"] != "Bitcoin Basics" || version["id"] != pageID || version["content"] != "Digital cash." || page["preferred_by"] != "owner" {
510 t.Fatalf("read_wiki_page: %s", text(result))
511 }
512 result, isError = call("list_wiki", map[string]any{"q": "primer"}, "")
513 if items, _ := structured(result)["items"].([]any); isError || len(items) != 1 {
514 t.Fatalf("list_wiki: %s", text(result))
515 }
516 result, isError = call("propose_wiki_merge", map[string]any{"d": "Bitcoin Basics", "destination": owner, "source": pageID, "content": "Take my edits."}, testMemberSecret)
517 unsigned, _ = structured(result)["unsigned"].(map[string]any)
518 tags, _ = json.Marshal(unsigned["tags"])
519 if isError || unsigned["kind"] != float64(818) || string(tags) != `[["a","30818:`+owner+`:bitcoin-basics"],["p","`+owner+`"],["e","`+pageID+`","","source"]]` {
520 t.Fatalf("propose_wiki_merge template: %s", text(result))
521 }
522 result, isError = call("propose_wiki_merge", map[string]any{"event": mcpSigned(t, testMemberSecret, 818, [][]string{{"a", "30818:" + owner + ":bitcoin-basics"}, {"p", owner}, {"e", pageID, "", "source"}}, "Take my edits.")}, testMemberSecret)
523 mergeID, _ := structured(result)["event_id"].(string)
524 if isError || mergeID == "" {
525 t.Fatalf("propose_wiki_merge publish: %s", text(result))
526 }
527 result, isError = call("read_merge_request", map[string]any{"id": mergeID}, "")
528 merge, _ := structured(result)["merge"].(map[string]any)
529 if isError || merge["id"] != mergeID || merge["status"] != "open" {
530 t.Fatalf("read_merge_request: %s", text(result))
531 }
532
533 // Agents: owners and moderators read and control them, members do not.
534 now := time.Now().Unix()
535 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {
536 t.Fatal(err)
537 }
538 result, isError = call("list_agents", map[string]any{}, testMemberSecret)
539 if !isError || !strings.Contains(text(result), "restricted") {
540 t.Fatalf("list_agents as member: %v %s", isError, text(result))
541 }
542 result, isError = call("list_agents", map[string]any{}, "")
543 agents, _ := structured(result)["result"].([]any)
544 if isError || len(agents) != 1 || agents[0].(map[string]any)["pubkey"] != agent || agents[0].(map[string]any)["paused"] != false {
545 t.Fatalf("list_agents as owner: %s", text(result))
546 }
547 result, isError = call("pause_agent", map[string]any{"agent": agent}, testMemberSecret)
548 if !isError {
549 t.Fatalf("pause_agent as member: %s", text(result))
550 }
551 result, isError = call("pause_agent", map[string]any{"agent": agent}, "")
552 if isError {
553 t.Fatalf("pause_agent: %s", text(result))
554 }
555 result, _ = call("list_agents", map[string]any{}, "")
556 if agents, _ = structured(result)["result"].([]any); agents[0].(map[string]any)["paused"] != true {
557 t.Fatalf("list_agents after pause: %s", text(result))
558 }
559 result, isError = call("resume_all_agents", map[string]any{}, "")
560 if isError || structured(result)["result"].(map[string]any)["changed"] != float64(1) {
561 t.Fatalf("resume_all_agents: %s", text(result))
562 }
563 result, isError = call("revoke_agent", map[string]any{"agent": agent}, "")
564 if isError {
565 t.Fatalf("revoke_agent: %s", text(result))
566 }
567
568 // request_decision: a room message or a comment carrying the request.
569 result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Ship 1.2?", "room": "general", "expiration": now + 3600, "subject": "Release"}, "")
570 unsigned, _ = structured(result)["unsigned"].(map[string]any)
571 tags, _ = json.Marshal(unsigned["tags"])
572 if isError || unsigned["kind"] != float64(9) || string(tags) != `[["h","general"],["request","approve"],["p","`+member+`"],["expiration","`+strconv.FormatInt(now+3600, 10)+`"],["subject","Release"]]` {
573 t.Fatalf("request_decision room template: %s", text(result))
574 }
575 result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Approve this thread?", "room": "general", "root": messageID}, "")
576 unsigned, _ = structured(result)["unsigned"].(map[string]any)
577 tags, _ = json.Marshal(unsigned["tags"])
578 if isError || unsigned["kind"] != float64(9) || !strings.Contains(string(tags), `"e","`+messageID+`","","root"`) {
579 t.Fatalf("request_decision room thread template: %s", text(result))
580 }
581 result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "question", "content": "Which name?", "root": pageID, "root_kind": 30818, "root_pubkey": owner}, "")
582 unsigned, _ = structured(result)["unsigned"].(map[string]any)
583 tags, _ = json.Marshal(unsigned["tags"])
584 if isError || unsigned["kind"] != float64(1111) || string(tags) != `[["E","`+pageID+`","","`+owner+`"],["K","30818"],["P","`+owner+`"],["e","`+pageID+`","","`+owner+`"],["k","30818"],["p","`+owner+`"],["request","question"],["p","`+member+`"]]` {
585 t.Fatalf("request_decision comment template: %s", text(result))
586 }
587 result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "decide", "content": "Pick one."}, "")
588 if !isError || !strings.Contains(text(result), "room or root is required") {
589 t.Fatalf("request_decision without target: %s", text(result))
590 }
591 result, isError = call("request_decision", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9, [][]string{{"h", "general"}, {"request", "approve"}, {"p", member}}, "Ship 1.2?")}, "")
592 if isError || structured(result)["accepted"] != true {
593 t.Fatalf("request_decision publish: %s", text(result))
594 }
595}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs