| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | type 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. |
| 31 | var mcpSequence int |
| 32 | |
| 33 | func (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 | |
| 79 | func 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 | |
| 89 | func 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 | |
| 135 | func 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 | |
| 220 | func 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. |
| 264 | func 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. |
| 279 | func 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 | |
| 351 | func 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 | } |