- commit
- 658134d0db7f598bbea49a92391f6b19027f6e4f
- parent
- 16227de5d22f27afae5fe3d5aadf0bc0fbc5c2e4
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 7 | +- |
| M | internal/ | 242 | ++++++++++++++++++- |
| M | internal/ | 396 | ++++++++++++++++++++++++++++++- |
@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
2020 t.mcp = &mcp.Server{2121 Tools: tools,2222 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},23- Instructions: "This relay hosts Nostr events, files and Git repositories. Read tools return the data your key may see; management tools follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields.",23+ Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. request_decision asks a person for an answer that arrives as a kind 7 reaction.",2424 Authenticate: t.mcpAuthenticate,2525 Challenge: mcpChallenge,2626 Origin: t.mcpOrigin,@@ -90,7 +90,10 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9090 "",9191 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",9292 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",93- "- Methods: server/discover, tools/list and tools/call. Tools read repositories, issues, pull requests, files and relay status, run management actions and publish signed events.",93+ "- Methods: server/discover, tools/list and tools/call.",94+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request and list_agents.",95+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents and resume_all_agents.",96+ "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, create_room and request_decision. A decision request is answered by a kind 7 reaction from the person asked.",9497 "",9598 "## HTTP bridge",9699 "",@@ -5,6 +5,7 @@ import (
55 "encoding/json"66 "net/http"77 "net/http/httptest"8+ "strconv"89 "strings"910 "testing"1011 "time"@@ -20,6 +21,7 @@ type mcpCall struct {
2021 meta string2122 version string2223 sign bool24+ secret string2325 headers map[string]string2426 }2527 @@ -57,7 +59,9 @@ func (c mcpCall) do(t *testing.T, app *App, path string) (*httptest.ResponseReco
5759 for key, value := range c.headers {5860 r.Header.Set(key, value)5961 }60- if c.sign {62+ if c.secret != "" {63+ signRequestWithSecret(t, r, string(raw), c.secret)64+ } else if c.sign {6165 signRequest(t, r, string(raw))6266 }6367 w := httptest.NewRecorder()@@ -232,7 +236,241 @@ func TestMCPRejectsUnauthorizedForeignAndMismatchedRequests(t *testing.T) {
232236 r := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/llms.txt", nil)233237 w = httptest.NewRecorder()234238 app.ServeHTTP(w, r)235- 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) {239+ 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") {236240 t.Fatalf("llms.txt: %d %s", w.Code, w.Body.String())237241 }238242 }243+244+// mcpSigned signs an event with the secret and returns it as the JSON object245+// a client passes in the event argument.246+func mcpSigned(t *testing.T, secret string, kind int, tags [][]string, content string) map[string]any {247+ t.Helper()248+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}249+ if err := event.Sign(&e, secret); err != nil {250+ t.Fatal(err)251+ }252+ raw, _ := json.Marshal(e)253+ var value map[string]any254+ _ = json.Unmarshal(raw, &value)255+ return value256+}257+258+func TestMCPRoomWikiAndAgentTools(t *testing.T) {259+ app, tenant := testTenant(t)260+ ctx := context.Background()261+ owner := tenant.Policy().Owner262+ member, _ := event.PublicKey(testMemberSecret)263+ agent, _ := event.PublicKey(testAgentSecret)264+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {265+ t.Fatal(err)266+ }267+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {268+ t.Helper()269+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")270+ if w.Code != http.StatusOK {271+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())272+ }273+ return mcpToolResult(t, response)274+ }275+ structured := func(result map[string]any) map[string]any {276+ value, _ := result["structuredContent"].(map[string]any)277+ return value278+ }279+ text := func(result map[string]any) string {280+ content, _ := result["content"].([]any)281+ if len(content) == 0 {282+ return ""283+ }284+ return content[0].(map[string]any)["text"].(string)285+ }286+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")287+ names := map[string]bool{}288+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {289+ names[tool.(map[string]any)["name"].(string)] = true290+ }291+ 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"} {292+ if !names[want] {293+ t.Fatalf("missing tool %s", want)294+ }295+ }296+297+ // Rooms: the template carries the room tags, the signed event creates298+ // the room and the list shows it.299+ result, isError := call("create_room", map[string]any{"room": "general", "name": "General", "about": "Talk", "visibility": "open"}, "")300+ unsigned, _ := structured(result)["unsigned"].(map[string]any)301+ tags, _ := json.Marshal(unsigned["tags"])302+ if isError || unsigned["kind"] != float64(9007) || string(tags) != `[["h","general"],["name","General"],["about","Talk"],["visibility","open"]]` {303+ t.Fatalf("create_room template: %v %s", isError, text(result))304+ }305+ result, isError = call("create_room", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9007, [][]string{{"h", "general"}, {"name", "General"}, {"visibility", "open"}}, "")}, "")306+ if isError || structured(result)["accepted"] != true {307+ t.Fatalf("create_room publish: %s", text(result))308+ }309+ result, isError = call("list_rooms", map[string]any{}, "")310+ found := false311+ for _, item := range structured(result)["items"].([]any) {312+ room := item.(map[string]any)313+ if room["id"] == "general" && room["name"] == "General" && room["access"] == "open" {314+ found = true315+ }316+ }317+ if isError || !found {318+ t.Fatalf("list_rooms: %s", text(result))319+ }320+ // post_message: the template, then the signed message read back.321+ result, isError = call("post_message", map[string]any{"room": "general", "content": "hello room", "mentions": []any{member, member}}, testMemberSecret)322+ unsigned, _ = structured(result)["unsigned"].(map[string]any)323+ tags, _ = json.Marshal(unsigned["tags"])324+ if isError || unsigned["kind"] != float64(9) || unsigned["content"] != "hello room" || string(tags) != `[["h","general"],["p","`+member+`"]]` {325+ t.Fatalf("post_message template: %s", text(result))326+ }327+ result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{{"h", "general"}, {"p", member}}, "hello room")}, testMemberSecret)328+ messageID, _ := structured(result)["event_id"].(string)329+ if isError || structured(result)["accepted"] != true || messageID == "" {330+ t.Fatalf("post_message publish: %s", text(result))331+ }332+ result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{}, "no room")}, testMemberSecret)333+ if !isError || !strings.Contains(text(result), "the h tag must name the room") {334+ t.Fatalf("post_message without room: %s", text(result))335+ }336+ result, isError = call("read_room", map[string]any{"id": "general"}, "")337+ messages, _ := structured(result)["messages"].([]any)338+ if isError || len(messages) != 1 || messages[0].(map[string]any)["id"] != messageID {339+ t.Fatalf("read_room: %s", text(result))340+ }341+ result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": messageID, "root_pubkey": member, "content": "and back"}, "")342+ unsigned, _ = structured(result)["unsigned"].(map[string]any)343+ tags, _ = json.Marshal(unsigned["tags"])344+ if isError || unsigned["kind"] != float64(12) || string(tags) != `[["h","general"],["e","`+messageID+`"],["p","`+member+`"]]` {345+ t.Fatalf("reply_in_thread template: %s", text(result))346+ }347+ result, isError = call("reply_in_thread", map[string]any{"event": mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "and back")}, "")348+ if isError || structured(result)["accepted"] != true {349+ t.Fatalf("reply_in_thread publish: %s", text(result))350+ }351+ result, isError = call("read_thread", map[string]any{"id": "general", "event": messageID}, "")352+ replies, _ := structured(result)["replies"].([]any)353+ if isError || len(replies) != 1 || structured(result)["root"].(map[string]any)["id"] != messageID {354+ t.Fatalf("read_thread: %s", text(result))355+ }356+357+ // react: content is +, - or one emoji, whether templated or signed.358+ result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "great", "room": "general"}, "")359+ if !isError || !strings.Contains(text(result), "content must be +, - or one emoji") {360+ t.Fatalf("react template validation: %s", text(result))361+ }362+ result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "\U0001F44D", "room": "general"}, "")363+ unsigned, _ = structured(result)["unsigned"].(map[string]any)364+ tags, _ = json.Marshal(unsigned["tags"])365+ if isError || unsigned["kind"] != float64(7) || unsigned["content"] != "\U0001F44D" || string(tags) != `[["e","`+messageID+`"],["p","`+member+`"],["h","general"]]` {366+ t.Fatalf("react template: %s", text(result))367+ }368+ result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "great")}, "")369+ if !isError || !strings.Contains(text(result), `Expected a signed kind 7 event`) {370+ t.Fatalf("react signed validation: %s", text(result))371+ }372+ result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "+")}, "")373+ if isError || structured(result)["accepted"] != true {374+ t.Fatalf("react publish: %s", text(result))375+ }376+377+ // Wiki: the template normalizes the name; the signed page reads back.378+ result, isError = call("publish_wiki_page", map[string]any{"d": "Bitcoin Basics", "title": "Bitcoin Basics", "summary": "A primer", "content": "Digital cash."}, "")379+ unsigned, _ = structured(result)["unsigned"].(map[string]any)380+ tags, _ = json.Marshal(unsigned["tags"])381+ if isError || unsigned["kind"] != float64(30818) || string(tags) != `[["d","bitcoin-basics"],["title","Bitcoin Basics"],["summary","A primer"]]` {382+ t.Fatalf("publish_wiki_page template: %s", text(result))383+ }384+ result, isError = call("publish_wiki_page", map[string]any{"event": mcpSigned(t, testOwnerSecret, 30818, [][]string{{"d", "Bitcoin Basics"}, {"title", "Bitcoin Basics"}}, "Digital cash.")}, "")385+ if !isError || !strings.Contains(text(result), "normalized page name") {386+ t.Fatalf("publish_wiki_page unnormalized: %s", text(result))387+ }388+ 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.")}, "")389+ pageID, _ := structured(result)["event_id"].(string)390+ if isError || structured(result)["accepted"] != true {391+ t.Fatalf("publish_wiki_page publish: %s", text(result))392+ }393+ result, isError = call("read_wiki_page", map[string]any{"d": "Bitcoin Basics"}, testMemberSecret)394+ page := structured(result)395+ version, _ := page["version"].(map[string]any)396+ if isError || page["d"] != "bitcoin-basics" || page["title"] != "Bitcoin Basics" || version["id"] != pageID || version["content"] != "Digital cash." || page["preferred_by"] != "owner" {397+ t.Fatalf("read_wiki_page: %s", text(result))398+ }399+ result, isError = call("list_wiki", map[string]any{"q": "primer"}, "")400+ if items, _ := structured(result)["items"].([]any); isError || len(items) != 1 {401+ t.Fatalf("list_wiki: %s", text(result))402+ }403+ result, isError = call("propose_wiki_merge", map[string]any{"d": "Bitcoin Basics", "destination": owner, "source": pageID, "content": "Take my edits."}, testMemberSecret)404+ unsigned, _ = structured(result)["unsigned"].(map[string]any)405+ tags, _ = json.Marshal(unsigned["tags"])406+ if isError || unsigned["kind"] != float64(818) || string(tags) != `[["a","30818:`+owner+`:bitcoin-basics"],["p","`+owner+`"],["e","`+pageID+`","","source"]]` {407+ t.Fatalf("propose_wiki_merge template: %s", text(result))408+ }409+ 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)410+ mergeID, _ := structured(result)["event_id"].(string)411+ if isError || mergeID == "" {412+ t.Fatalf("propose_wiki_merge publish: %s", text(result))413+ }414+ result, isError = call("read_merge_request", map[string]any{"id": mergeID}, "")415+ merge, _ := structured(result)["merge"].(map[string]any)416+ if isError || merge["id"] != mergeID || merge["status"] != "open" {417+ t.Fatalf("read_merge_request: %s", text(result))418+ }419+420+ // Agents: owners and moderators read and control them, members do not.421+ now := time.Now().Unix()422+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {423+ t.Fatal(err)424+ }425+ result, isError = call("list_agents", map[string]any{}, testMemberSecret)426+ if !isError || !strings.Contains(text(result), "restricted") {427+ t.Fatalf("list_agents as member: %v %s", isError, text(result))428+ }429+ result, isError = call("list_agents", map[string]any{}, "")430+ agents, _ := structured(result)["result"].([]any)431+ if isError || len(agents) != 1 || agents[0].(map[string]any)["pubkey"] != agent || agents[0].(map[string]any)["paused"] != false {432+ t.Fatalf("list_agents as owner: %s", text(result))433+ }434+ result, isError = call("pause_agent", map[string]any{"agent": agent}, testMemberSecret)435+ if !isError {436+ t.Fatalf("pause_agent as member: %s", text(result))437+ }438+ result, isError = call("pause_agent", map[string]any{"agent": agent}, "")439+ if isError {440+ t.Fatalf("pause_agent: %s", text(result))441+ }442+ result, _ = call("list_agents", map[string]any{}, "")443+ if agents, _ = structured(result)["result"].([]any); agents[0].(map[string]any)["paused"] != true {444+ t.Fatalf("list_agents after pause: %s", text(result))445+ }446+ result, isError = call("resume_all_agents", map[string]any{}, "")447+ if isError || structured(result)["result"].(map[string]any)["changed"] != float64(1) {448+ t.Fatalf("resume_all_agents: %s", text(result))449+ }450+ result, isError = call("revoke_agent", map[string]any{"agent": agent}, "")451+ if isError {452+ t.Fatalf("revoke_agent: %s", text(result))453+ }454+455+ // request_decision: a room message or a comment carrying the request.456+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Ship 1.2?", "room": "general", "expiration": now + 3600, "subject": "Release"}, "")457+ unsigned, _ = structured(result)["unsigned"].(map[string]any)458+ tags, _ = json.Marshal(unsigned["tags"])459+ if isError || unsigned["kind"] != float64(9) || string(tags) != `[["h","general"],["request","approve"],["p","`+member+`"],["expiration","`+strconv.FormatInt(now+3600, 10)+`"],["subject","Release"]]` {460+ t.Fatalf("request_decision room template: %s", text(result))461+ }462+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "question", "content": "Which name?", "root": pageID, "root_kind": 30818, "root_pubkey": owner}, "")463+ unsigned, _ = structured(result)["unsigned"].(map[string]any)464+ tags, _ = json.Marshal(unsigned["tags"])465+ 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+`"]]` {466+ t.Fatalf("request_decision comment template: %s", text(result))467+ }468+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "decide", "content": "Pick one."}, "")469+ if !isError || !strings.Contains(text(result), "room or root is required") {470+ t.Fatalf("request_decision without target: %s", text(result))471+ }472+ result, isError = call("request_decision", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9, [][]string{{"h", "general"}, {"request", "approve"}, {"p", member}}, "Ship 1.2?")}, "")473+ if isError || structured(result)["accepted"] != true {474+ t.Fatalf("request_decision publish: %s", text(result))475+ }476+}@@ -11,10 +11,13 @@ import (
1111 "strconv"1212 "strings"1313 "time"14+ "unicode"1415 16+ "github.com/FelineStateMachine/tinyrelay/internal/community"1517 "github.com/FelineStateMachine/tinyrelay/internal/event"1618 "github.com/FelineStateMachine/tinyrelay/internal/mcp"1719 "github.com/FelineStateMachine/tinyrelay/internal/relay"20+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"1821 )1922 2023 // The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.@@ -32,14 +35,20 @@ var (
3235 mcpCommit = map[string]any{"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "Full Git commit ID."}3336 mcpEvent = map[string]any{"type": "object", "description": "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig."}3437 mcpRootKind = map[string]any{"type": "integer", "enum": []int{1617, 1618, 1621}, "description": "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch."}38+ mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}39+ mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}40+ mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}3541 mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}3642 mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}43+ mcpControls = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}3744 mcpSettings = map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}3845 mcpPublishes = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}3946 40- mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)41- mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}42- mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}47+ mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)48+ mcpWikiCoordinate = regexp.MustCompile(`^30818:[0-9a-f]{64}:.+$`)49+ mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}50+ mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}51+ mcpRequestKinds = []string{"approve", "decide", "question"}4352 )4453 4554 const (@@ -47,7 +56,16 @@ const (
4756 mcpPullShape = `Expected a signed kind 1618 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"], ["c","<40-hex commit>"], ["clone","<http or https URL>"], optional ["merge-base","<40-hex commit>"] and optional ["t","<label>"] tags, with the description in content.`4857 mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment.`4958 mcpStatusShape = `Expected a signed event of kind 1630 (open), 1631 (resolved or merged), 1632 (closed) or 1633 (draft) with tags ["a","30617:<owner>:<repo>"], ["e","<root id>","","root"] and ["p","<root pubkey>"].`59+ mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`60+ mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`61+ mcpReplyShape = `Expected a signed kind 12 event with tags ["h","<room id>"], ["e","<thread root id>"] and optional ["p","<root author>"], with the reply in content.`62+ mcpReactShape = `Expected a signed kind 7 event with tags ["e","<event id>"], ["p","<event author>"] and, for a room message, ["h","<room id>"], with "+", "-" or one emoji in content.`63+ mcpWikiShape = `Expected a signed kind 30818 event with tags ["d","<normalized page name>"], ["title","<title>"], optional ["summary","<summary>"] and, for a fork, ["a","30818:<author>:<page name>","","fork"] and ["e","<version id>","","fork"], with the Djot article in content.`64+ mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.`65+ mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`66+ mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.`5067 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."68+ mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it."5169 )5270 5371 // mcpUnsigned is the event body a client signs before calling a write tool@@ -90,6 +108,15 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
90108 }91109 return t.mcpManage(ctx, call, method)92110 })111+ add("list_rooms", "List chat rooms visible to your account with member counts and the time of the last message.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit}), mcpReads, t.mcpBrowse("browserooms"))112+ add("read_room", "Read a room, its members and its newest messages, threads and reactions. Pass cursor from next_cursor for older messages.", mcp.Object(map[string]any{"id": mcpRoomID, "cursor": mcpText, "limit": mcpLimit}, "id"), mcpReads, t.mcpBrowse("browseroom"))113+ add("read_thread", "Read a thread root and its replies in a room, newest first.", mcp.Object(map[string]any{"id": mcpRoomID, "event": mcpHash, "cursor": mcpText, "limit": mcpLimit}, "id", "event"), mcpReads, t.mcpBrowse("browsethread"))114+ add("list_wiki", "List wiki pages with their preferred version. q searches titles and summaries; author prefers that key's versions.", mcp.Object(map[string]any{"q": mcpText, "author": mcpPubKey, "cursor": mcpText, "limit": mcpLimit}), mcpReads, t.mcpBrowse("browsewiki"))115+ add("read_wiki_page", "Read a wiki page: its preferred version with content, every version, merge requests and redirects. Pass author to prefer that key's version or version to open one by event id.", mcp.Object(map[string]any{"d": mcpPageName, "author": mcpPubKey, "version": mcpHash}, "d"), mcpReads, t.mcpBrowse("browsewikipage"))116+ add("read_merge_request", "Read a wiki merge request with its status, the proposed version and the target version.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsewikimerge"))117+ add("list_agents", "List granted agents with their name, key, owner, expiration, paused and revoked state, scope and last event. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {118+ return t.mcpManage(ctx, call, "listagents")119+ })93120 94121 add("run_job", "Queue an existing job to run now. Read jobs through read_management to check its completion.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {95122 return t.mcpManage(ctx, call, "runjob", call.String("id"))@@ -115,12 +142,35 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
115142 add("send_test_notification", "Send the relay's test notice to the owner's inbox and enabled devices. Owner only.", mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {116143 return t.mcpManage(ctx, call, "notifytest")117144 })145+ add("pause_agent", "Stop an agent from publishing until it is resumed. The grant stays in place. Requires an owner or moderator key.", mcp.Object(map[string]any{"agent": mcpPubKey}, "agent"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {146+ return t.mcpManage(ctx, call, "pauseagent", call.String("agent"))147+ })148+ add("resume_agent", "Let a paused agent publish again. Requires an owner or moderator key.", mcp.Object(map[string]any{"agent": mcpPubKey}, "agent"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {149+ return t.mcpManage(ctx, call, "resumeagent", call.String("agent"))150+ })151+ add("revoke_agent", "End an agent's grant and remove its agent role. The grant event stays stored for audit; a fresh grant restores access. Requires an owner or moderator key.", mcp.Object(map[string]any{"agent": mcpPubKey}, "agent"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {152+ return t.mcpManage(ctx, call, "revokeagent", call.String("agent"))153+ })154+ add("pause_all_agents", "Pause every active agent at once. Requires an owner or moderator key.", mcp.Object(nil), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {155+ return t.mcpManage(ctx, call, "pauseallagents")156+ })157+ add("resume_all_agents", "Resume every paused agent. Requires an owner or moderator key.", mcp.Object(nil), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {158+ return t.mcpManage(ctx, call, "resumeallagents")159+ })118160 119161 add("publish_event", "Publish a signed Nostr event to this relay. The relay's access rules apply as they do for POST /events.", mcp.Object(map[string]any{"event": mcpEvent}, "event"), mcpPublishes, t.mcpWrite(nil, nil, "Expected a signed Nostr event."))120162 add("create_issue", "Open an issue on a hosted repository. Pass owner, repo, title and content to receive the unsigned kind 1621 event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "title": mcpText, "content": mcpText, "labels": map[string]any{"type": "array", "items": mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildIssue, mcpCheckIssue, mcpIssueShape))121163 add("create_pull_request", "Open a pull request on a hosted repository. Pass owner, repo, title, commit and clone (plus optional content, merge_base and labels) to receive the unsigned kind 1618 event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "title": mcpText, "content": mcpText, "commit": mcpCommit, "clone": map[string]any{"type": "string", "description": "HTTP or HTTPS clone URL without credentials."}, "merge_base": mcpCommit, "labels": map[string]any{"type": "array", "items": mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildPull, mcpCheckPull, mcpPullShape))122164 add("comment", "Reply to an issue, pull request or comment with a NIP-22 kind 1111 event. Pass owner, repo, root, root_kind, root_pubkey and content (plus parent, parent_kind and parent_pubkey to answer a comment) to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_kind": mcpRootKind, "root_pubkey": mcpPubKey, "parent": mcpHash, "parent_kind": map[string]any{"type": "integer", "enum": []int{1111, 1617, 1618, 1621}}, "parent_pubkey": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildComment, mcpCheckComment, mcpCommentShape))123165 add("set_status", "Change the status of an issue or pull request. The author, repository owner and maintainers may do this. Pass owner, repo, root, root_pubkey and status to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_pubkey": mcpPubKey, "status": map[string]any{"type": "string", "enum": []string{"open", "resolved", "merged", "closed", "draft"}}}), mcpPublishes, t.mcpWrite(mcpBuildStatus, mcpCheckStatus, mcpStatusShape))166+ add("post_message", "Post a kind 9 chat message in a room. Pass room and content (plus mentions, a list of public keys) to receive the unsigned event, sign it, then call again with the signed event. Posting in an open room joins it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "content": mcpText, "mentions": map[string]any{"type": "array", "items": mcpPubKey}}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape))167+ add("start_thread", "Start a kind 11 thread in a room. Pass room and content (plus an optional title) to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "title": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape))168+ add("reply_in_thread", "Reply to a thread in a room with a kind 12 event. Pass room, root (the thread's event id), root_pubkey and content to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "root": mcpHash, "root_pubkey": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape))169+ add("react", "React to an event with a kind 7 reaction: + to like or approve, - to dislike or decline, or one emoji. Pass target, target_pubkey and content (plus room for a room message) to receive the unsigned event, sign it, then call again with the signed event. A + or - from a wiki merge request's destination author answers the request.", mcp.Object(map[string]any{"event": mcpEvent, "target": mcpHash, "target_pubkey": mcpPubKey, "content": map[string]any{"type": "string", "minLength": 1, "description": "+, - or one emoji."}, "room": mcpRoomID}), mcpPublishes, t.mcpWrite(mcpBuildReact, mcpCheckReact, mcpReactShape))170+ add("publish_wiki_page", "Publish or replace your version of a kind 30818 wiki page in Djot markup. Pass d (the page name), title and content, plus optional summary and, to fork another author's version, fork_author and fork_event, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "title": mcpText, "summary": mcpText, "content": mcpText, "fork_author": mcpPubKey, "fork_event": mcpHash}), mcpPublishes, t.mcpWrite(mcpBuildWikiPage, mcpCheckWikiPage, mcpWikiShape))171+ add("propose_wiki_merge", "Ask a wiki author to take in changes from another version with a kind 818 merge request. Pass d, destination (the author asked), source (the proposed version's event id) and content, plus an optional base version id, to receive the unsigned event, sign it, then call again with the signed event. The destination author answers with a + or - reaction.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "destination": mcpPubKey, "source": mcpHash, "base": mcpHash, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildWikiMerge, mcpCheckWikiMerge, mcpMergeShape))172+ add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape))173+ add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape))124174 return registry, err125175 }126176 @@ -396,3 +446,343 @@ func merge(base, extra map[string]any) map[string]any {
396446 }397447 return out398448 }449+450+// Room, wiki and decision builders. Each returns the unsigned event for the451+// caller to sign; the matching check accepts the signed event back.452+453+func mcpRoomTag(call mcp.Call) ([][]string, error) {454+ room := call.String("room")455+ if !community.ValidRoomID(room) {456+ return nil, errors.New("room is required and must be a room id")457+ }458+ return [][]string{{"h", room}}, nil459+}460+461+func mcpPubKeys(call mcp.Call, key string, tags [][]string) [][]string {462+ seen := map[string]bool{}463+ if values, ok := call.Arguments[key].([]any); ok {464+ for _, value := range values {465+ text, _ := value.(string)466+ if hex64(text) && !seen[text] {467+ seen[text] = true468+ tags = append(tags, []string{"p", text})469+ }470+ }471+ }472+ return tags473+}474+475+func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) {476+ tags, err := mcpRoomTag(call)477+ if err != nil {478+ return mcpUnsigned{}, err479+ }480+ content := call.String("content")481+ if strings.TrimSpace(content) == "" {482+ return mcpUnsigned{}, errors.New("content is required")483+ }484+ return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions", tags), Content: content}, nil485+}486+487+func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) {488+ tags, err := mcpRoomTag(call)489+ if err != nil {490+ return mcpUnsigned{}, err491+ }492+ title, content := strings.TrimSpace(call.String("title")), call.String("content")493+ if strings.TrimSpace(content) == "" {494+ return mcpUnsigned{}, errors.New("content is required")495+ }496+ if title != "" {497+ tags = append(tags, []string{"subject", title})498+ }499+ return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil500+}501+502+func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) {503+ tags, err := mcpRoomTag(call)504+ if err != nil {505+ return mcpUnsigned{}, err506+ }507+ root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")508+ if root == "" || strings.TrimSpace(content) == "" {509+ return mcpUnsigned{}, errors.New("root and content are required")510+ }511+ tags = append(tags, []string{"e", root})512+ if rootPubKey != "" {513+ tags = append(tags, []string{"p", rootPubKey})514+ }515+ return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil516+}517+518+func mcpBuildReact(call mcp.Call) (mcpUnsigned, error) {519+ target, targetPubKey, content, room := call.String("target"), call.String("target_pubkey"), call.String("content"), call.String("room")520+ if target == "" || targetPubKey == "" || content == "" {521+ return mcpUnsigned{}, errors.New("target, target_pubkey and content are required")522+ }523+ if !mcpReaction(content) {524+ return mcpUnsigned{}, errors.New("content must be +, - or one emoji")525+ }526+ tags := [][]string{{"e", target}, {"p", targetPubKey}}527+ if room != "" {528+ tags = append(tags, []string{"h", room})529+ }530+ return mcpUnsigned{Kind: 7, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil531+}532+533+func mcpBuildWikiPage(call mcp.Call) (mcpUnsigned, error) {534+ d, title, content := wiki.Normalize(call.String("d")), strings.TrimSpace(call.String("title")), call.String("content")535+ if d == "" || title == "" || strings.TrimSpace(content) == "" {536+ return mcpUnsigned{}, errors.New("d, title and content are required")537+ }538+ tags := [][]string{{"d", d}, {"title", title}}539+ if summary := strings.TrimSpace(call.String("summary")); summary != "" {540+ tags = append(tags, []string{"summary", summary})541+ }542+ forkAuthor, forkEvent := call.String("fork_author"), call.String("fork_event")543+ if (forkAuthor == "") != (forkEvent == "") {544+ return mcpUnsigned{}, errors.New("a fork needs both fork_author and fork_event")545+ }546+ if forkAuthor != "" {547+ tags = append(tags, []string{"a", "30818:" + forkAuthor + ":" + d, "", "fork"}, []string{"e", forkEvent, "", "fork"})548+ }549+ return mcpUnsigned{Kind: kindWikiArticle, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil550+}551+552+func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) {553+ d, destination, source, base, content := wiki.Normalize(call.String("d")), call.String("destination"), call.String("source"), call.String("base"), call.String("content")554+ if d == "" || destination == "" || source == "" || strings.TrimSpace(content) == "" {555+ return mcpUnsigned{}, errors.New("d, destination, source and content are required")556+ }557+ tags := [][]string{{"a", "30818:" + destination + ":" + d}, {"p", destination}, {"e", source, "", "source"}}558+ if base != "" {559+ tags = append(tags, []string{"e", base})560+ }561+ return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil562+}563+564+func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) {565+ tags, err := mcpRoomTag(call)566+ if err != nil {567+ return mcpUnsigned{}, err568+ }569+ name := strings.TrimSpace(call.String("name"))570+ if name == "" {571+ return mcpUnsigned{}, errors.New("name is required")572+ }573+ tags = append(tags, []string{"name", name})574+ if about := strings.TrimSpace(call.String("about")); about != "" {575+ tags = append(tags, []string{"about", about})576+ }577+ if visibility := call.String("visibility"); visibility != "" {578+ tags = append(tags, []string{"visibility", visibility})579+ }580+ return mcpUnsigned{Kind: event.KIND_CREATE_GROUP, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil581+}582+583+func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) {584+ pubkey, request, content := call.String("pubkey"), call.String("request"), call.String("content")585+ if pubkey == "" || !contains(mcpRequestKinds, request) || strings.TrimSpace(content) == "" {586+ return mcpUnsigned{}, errors.New("pubkey, request and content are required")587+ }588+ room, root, rootPubKey, rootKind := call.String("room"), call.String("root"), call.String("root_pubkey"), call.Int("root_kind")589+ var kind int590+ var tags [][]string591+ switch {592+ case room != "" && root != "":593+ return mcpUnsigned{}, errors.New("pass either room or root, not both")594+ case room != "":595+ kind, tags = event.KIND_CHAT, [][]string{{"h", room}}596+ case root != "":597+ if rootPubKey == "" || rootKind <= 0 {598+ return mcpUnsigned{}, errors.New("root needs root_kind and root_pubkey")599+ }600+ k := strconv.Itoa(rootKind)601+ kind, tags = 1111, [][]string{{"E", root, "", rootPubKey}, {"K", k}, {"P", rootPubKey}, {"e", root, "", rootPubKey}, {"k", k}}602+ if rootPubKey != pubkey {603+ tags = append(tags, []string{"p", rootPubKey})604+ }605+ default:606+ return mcpUnsigned{}, errors.New("room or root is required")607+ }608+ tags = append(tags, []string{"request", request}, []string{"p", pubkey})609+ if expiration := call.Int("expiration"); expiration > 0 {610+ if int64(expiration) <= time.Now().Unix() {611+ return mcpUnsigned{}, errors.New("expiration must be in the future")612+ }613+ tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})614+ }615+ if subject := strings.TrimSpace(call.String("subject")); subject != "" {616+ tags = append(tags, []string{"subject", subject})617+ }618+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil619+}620+621+func mcpCheckRoomEvent(e event.Event, kind int, what string) error {622+ if e.Kind != kind {623+ return fmt.Errorf("kind %d is not a %s", e.Kind, what)624+ }625+ if !community.ValidRoomID(event.Tag(e, "h")) {626+ return errors.New("the h tag must name the room")627+ }628+ return nil629+}630+631+func mcpCheckMessage(e event.Event) error {632+ if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {633+ return err634+ }635+ if strings.TrimSpace(e.Content) == "" {636+ return errors.New("content must not be empty")637+ }638+ return nil639+}640+641+func mcpCheckThread(e event.Event) error {642+ if err := mcpCheckRoomEvent(e, event.KIND_THREAD, "thread"); err != nil {643+ return err644+ }645+ if strings.TrimSpace(e.Content) == "" {646+ return errors.New("content must not be empty")647+ }648+ return nil649+}650+651+func mcpCheckReply(e event.Event) error {652+ if err := mcpCheckRoomEvent(e, event.KIND_THREAD_REPLY, "thread reply"); err != nil {653+ return err654+ }655+ if !hex64(event.Tag(e, "e")) {656+ return errors.New("the e tag must name the thread root")657+ }658+ if strings.TrimSpace(e.Content) == "" {659+ return errors.New("content must not be empty")660+ }661+ return nil662+}663+664+func mcpCheckReact(e event.Event) error {665+ if e.Kind != 7 {666+ return fmt.Errorf("kind %d is not a reaction", e.Kind)667+ }668+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {669+ return errors.New("the e and p tags must name the event and its author")670+ }671+ if room := event.Tag(e, "h"); room != "" && !community.ValidRoomID(room) {672+ return errors.New("the h tag must name a room")673+ }674+ if !mcpReaction(e.Content) {675+ return errors.New("content must be +, - or one emoji")676+ }677+ return nil678+}679+680+func mcpCheckWikiPage(e event.Event) error {681+ if e.Kind != kindWikiArticle {682+ return fmt.Errorf("kind %d is not a wiki page", e.Kind)683+ }684+ d := event.Tag(e, "d")685+ if d == "" || d != wiki.Normalize(d) {686+ return errors.New("the d tag must carry the normalized page name")687+ }688+ if strings.TrimSpace(event.Tag(e, "title")) == "" || strings.TrimSpace(e.Content) == "" {689+ return errors.New("the title tag and content must not be empty")690+ }691+ for _, tag := range e.Tags {692+ if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") {693+ continue694+ }695+ if (tag[0] == "a" && !mcpWikiCoordinate.MatchString(tag[1])) || (tag[0] == "e" && !hex64(tag[1])) {696+ return errors.New("fork and defer tags must name a 30818:<author>:<page> coordinate or a version id")697+ }698+ }699+ return nil700+}701+702+func mcpCheckWikiMerge(e event.Event) error {703+ if e.Kind != kindWikiMerge {704+ return fmt.Errorf("kind %d is not a merge request", e.Kind)705+ }706+ coordinate := event.Tag(e, "a")707+ if !mcpWikiCoordinate.MatchString(coordinate) {708+ return errors.New("the a tag must name the target as 30818:<destination>:<page>")709+ }710+ if destination := event.Tag(e, "p"); !hex64(destination) || destination != strings.Split(coordinate, ":")[1] {711+ return errors.New("the p tag must name the destination author of the a tag")712+ }713+ source := ""714+ for _, tag := range e.Tags {715+ if len(tag) >= 4 && tag[0] == "e" && tag[3] == "source" {716+ source = tag[1]717+ }718+ }719+ if !hex64(source) {720+ return errors.New("an e tag with the source marker must name the proposed version")721+ }722+ if strings.TrimSpace(e.Content) == "" {723+ return errors.New("content must not be empty")724+ }725+ return nil726+}727+728+func mcpCheckRoom(e event.Event) error {729+ if err := mcpCheckRoomEvent(e, event.KIND_CREATE_GROUP, "room creation"); err != nil {730+ return err731+ }732+ if strings.TrimSpace(event.Tag(e, "name")) == "" {733+ return errors.New("the name tag must not be empty")734+ }735+ if visibility := event.Tag(e, "visibility"); visibility != "" && visibility != community.RoomOpen && visibility != community.RoomMembers {736+ return errors.New("the visibility tag must be open or members")737+ }738+ return nil739+}740+741+func mcpCheckRequest(e event.Event) error {742+ switch e.Kind {743+ case event.KIND_CHAT:744+ if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {745+ return err746+ }747+ case 1111:748+ if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || event.Tag(e, "K") == "" || !hex64(event.Tag(e, "e")) || event.Tag(e, "k") == "" {749+ return errors.New("the E, K, P, e and k tags must name the root event, its kind and its author")750+ }751+ default:752+ return fmt.Errorf("kind %d is not a chat message or comment", e.Kind)753+ }754+ if !contains(mcpRequestKinds, event.Tag(e, "request")) {755+ return errors.New("the request tag must be approve, decide or question")756+ }757+ asked := event.TagValues(e, "p")758+ if len(asked) == 0 || !hex64(asked[len(asked)-1]) {759+ return errors.New("a p tag must name the person asked")760+ }761+ if expiration := event.Tag(e, "expiration"); expiration != "" {762+ if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() {763+ return errors.New("the expiration tag must be a Unix time in the future")764+ }765+ }766+ if strings.TrimSpace(e.Content) == "" {767+ return errors.New("content must not be empty")768+ }769+ return nil770+}771+772+// mcpReaction accepts NIP-25 content: +, - or one emoji, which may be a773+// sequence of a few non-ASCII code points such as a flag or a skin tone.774+func mcpReaction(content string) bool {775+ if content == "+" || content == "-" {776+ return true777+ }778+ runes := []rune(content)779+ if len(runes) == 0 || len(runes) > 10 {780+ return false781+ }782+ for _, r := range runes {783+ if r < 0x80 || unicode.IsSpace(r) || unicode.IsLetter(r) || unicode.IsDigit(r) {784+ return false785+ }786+ }787+ return true788+}