clone

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

feat/ui-updates
tinyrelay / internal / daemon / mcp_test.go
mcp_test.go 32.4K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strconv"9 "strings"10 "testing"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14 "github.com/FelineStateMachine/tinyrelay/internal/mcp"15 "github.com/FelineStateMachine/tinyrelay/internal/sites"16)1718type mcpCall struct {19 method string20 name string21 arguments map[string]any22 meta string23 version string24 sign bool25 secret string26 headers map[string]string27}2829// mcpSequence keeps request bodies distinct so identical calls signed in30// the same second do not share a NIP-98 token id.31var mcpSequence int3233func (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.meta39 }40 if c.name != "" {41 params["name"] = c.name42 params["arguments"] = c.arguments43 }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.version52 if version == "" {53 version = mcp.Version54 }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.Response71 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, response77}7879func 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, isError87}8889func TestMCPListsAndCallsToolsAsTheSignedKey(t *testing.T) {90 app, tenant := testTenant(t)91 policy := tenant.Policy()92 policy.Features.Grasp = true93 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)] = true106 }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}134135func TestMCPWriteToolsBuildValidateAndPublish(t *testing.T) {136 app, tenant := testTenant(t)137 policy := tenant.Policy()138 policy.Features.Grasp = true139 if err := tenant.applyPolicy(context.Background(), policy); err != nil {140 t.Fatal(err)141 }142 owner := tenant.Policy().Owner143 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]any160 _ = json.Unmarshal(raw, &value)161 return value162 }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.Event215 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}219220func 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}261262// mcpSigned signs an event with the secret and returns it as the JSON object263// 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]any272 _ = json.Unmarshal(raw, &value)273 return value274}275276// TestMCPPublishSiteBuildsChecksAndPublishes covers the site manifest tool:277// the template for the caller's own site and a named site, refusals of278// 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().Owner282 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 value294 }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() + 86400305 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}350351func TestMCPRoomWikiAndAgentTools(t *testing.T) {352 app, tenant := testTenant(t)353 ctx := context.Background()354 owner := tenant.Policy().Owner355 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 value371 }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)] = true383 }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 }389390 // Rooms: the template carries the room tags, the signed event creates391 // 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 := false404 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 = true408 }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 the431 // room projection has run, so count chat messages only.432 var chat []map[string]any433 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 }469470 // 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 }489490 // 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 }532533 // 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 }567568 // 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}