tinyrelaycommit

Commit 658134d0db7f

browse files at this commit | history

1diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
2index a548f05..4c73a97 100644
3--- a/internal/daemon/mcp_http.go
4+++ b/internal/daemon/mcp_http.go
5@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
6 t.mcp = &mcp.Server{
7 Tools: tools,
8 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},
9- 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.",
10+ 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.",
11 Authenticate: t.mcpAuthenticate,
12 Challenge: mcpChallenge,
13 Origin: t.mcpOrigin,
14@@ -90,7 +90,10 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
15 "",
16 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",
17 "- 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+"`.",
18- "- 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.",
19+ "- Methods: server/discover, tools/list and tools/call.",
20+ "- 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.",
21+ "- 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.",
22+ "- 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.",
23 "",
24 "## HTTP bridge",
25 "",
26diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go
27index e51b2fb..6e2048f 100644
28--- a/internal/daemon/mcp_test.go
29+++ b/internal/daemon/mcp_test.go
30@@ -5,6 +5,7 @@ import (
31 "encoding/json"
32 "net/http"
33 "net/http/httptest"
34+ "strconv"
35 "strings"
36 "testing"
37 "time"
38@@ -20,6 +21,7 @@ type mcpCall struct {
39 meta string
40 version string
41 sign bool
42+ secret string
43 headers map[string]string
44 }
45
46@@ -57,7 +59,9 @@ func (c mcpCall) do(t *testing.T, app *App, path string) (*httptest.ResponseReco
47 for key, value := range c.headers {
48 r.Header.Set(key, value)
49 }
50- if c.sign {
51+ if c.secret != "" {
52+ signRequestWithSecret(t, r, string(raw), c.secret)
53+ } else if c.sign {
54 signRequest(t, r, string(raw))
55 }
56 w := httptest.NewRecorder()
57@@ -232,7 +236,241 @@ func TestMCPRejectsUnauthorizedForeignAndMismatchedRequests(t *testing.T) {
58 r := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/llms.txt", nil)
59 w = httptest.NewRecorder()
60 app.ServeHTTP(w, r)
61- 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) {
62+ 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") {
63 t.Fatalf("llms.txt: %d %s", w.Code, w.Body.String())
64 }
65 }
66+
67+// mcpSigned signs an event with the secret and returns it as the JSON object
68+// a client passes in the event argument.
69+func mcpSigned(t *testing.T, secret string, kind int, tags [][]string, content string) map[string]any {
70+ t.Helper()
71+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}
72+ if err := event.Sign(&e, secret); err != nil {
73+ t.Fatal(err)
74+ }
75+ raw, _ := json.Marshal(e)
76+ var value map[string]any
77+ _ = json.Unmarshal(raw, &value)
78+ return value
79+}
80+
81+func TestMCPRoomWikiAndAgentTools(t *testing.T) {
82+ app, tenant := testTenant(t)
83+ ctx := context.Background()
84+ owner := tenant.Policy().Owner
85+ member, _ := event.PublicKey(testMemberSecret)
86+ agent, _ := event.PublicKey(testAgentSecret)
87+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
88+ t.Fatal(err)
89+ }
90+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
91+ t.Helper()
92+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
93+ if w.Code != http.StatusOK {
94+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
95+ }
96+ return mcpToolResult(t, response)
97+ }
98+ structured := func(result map[string]any) map[string]any {
99+ value, _ := result["structuredContent"].(map[string]any)
100+ return value
101+ }
102+ text := func(result map[string]any) string {
103+ content, _ := result["content"].([]any)
104+ if len(content) == 0 {
105+ return ""
106+ }
107+ return content[0].(map[string]any)["text"].(string)
108+ }
109+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
110+ names := map[string]bool{}
111+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
112+ names[tool.(map[string]any)["name"].(string)] = true
113+ }
114+ 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"} {
115+ if !names[want] {
116+ t.Fatalf("missing tool %s", want)
117+ }
118+ }
119+
120+ // Rooms: the template carries the room tags, the signed event creates
121+ // the room and the list shows it.
122+ result, isError := call("create_room", map[string]any{"room": "general", "name": "General", "about": "Talk", "visibility": "open"}, "")
123+ unsigned, _ := structured(result)["unsigned"].(map[string]any)
124+ tags, _ := json.Marshal(unsigned["tags"])
125+ if isError || unsigned["kind"] != float64(9007) || string(tags) != `[["h","general"],["name","General"],["about","Talk"],["visibility","open"]]` {
126+ t.Fatalf("create_room template: %v %s", isError, text(result))
127+ }
128+ result, isError = call("create_room", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9007, [][]string{{"h", "general"}, {"name", "General"}, {"visibility", "open"}}, "")}, "")
129+ if isError || structured(result)["accepted"] != true {
130+ t.Fatalf("create_room publish: %s", text(result))
131+ }
132+ result, isError = call("list_rooms", map[string]any{}, "")
133+ found := false
134+ for _, item := range structured(result)["items"].([]any) {
135+ room := item.(map[string]any)
136+ if room["id"] == "general" && room["name"] == "General" && room["access"] == "open" {
137+ found = true
138+ }
139+ }
140+ if isError || !found {
141+ t.Fatalf("list_rooms: %s", text(result))
142+ }
143+ // post_message: the template, then the signed message read back.
144+ result, isError = call("post_message", map[string]any{"room": "general", "content": "hello room", "mentions": []any{member, member}}, testMemberSecret)
145+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
146+ tags, _ = json.Marshal(unsigned["tags"])
147+ if isError || unsigned["kind"] != float64(9) || unsigned["content"] != "hello room" || string(tags) != `[["h","general"],["p","`+member+`"]]` {
148+ t.Fatalf("post_message template: %s", text(result))
149+ }
150+ result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{{"h", "general"}, {"p", member}}, "hello room")}, testMemberSecret)
151+ messageID, _ := structured(result)["event_id"].(string)
152+ if isError || structured(result)["accepted"] != true || messageID == "" {
153+ t.Fatalf("post_message publish: %s", text(result))
154+ }
155+ result, isError = call("post_message", map[string]any{"event": mcpSigned(t, testMemberSecret, 9, [][]string{}, "no room")}, testMemberSecret)
156+ if !isError || !strings.Contains(text(result), "the h tag must name the room") {
157+ t.Fatalf("post_message without room: %s", text(result))
158+ }
159+ result, isError = call("read_room", map[string]any{"id": "general"}, "")
160+ messages, _ := structured(result)["messages"].([]any)
161+ if isError || len(messages) != 1 || messages[0].(map[string]any)["id"] != messageID {
162+ t.Fatalf("read_room: %s", text(result))
163+ }
164+ result, isError = call("reply_in_thread", map[string]any{"room": "general", "root": messageID, "root_pubkey": member, "content": "and back"}, "")
165+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
166+ tags, _ = json.Marshal(unsigned["tags"])
167+ if isError || unsigned["kind"] != float64(12) || string(tags) != `[["h","general"],["e","`+messageID+`"],["p","`+member+`"]]` {
168+ t.Fatalf("reply_in_thread template: %s", text(result))
169+ }
170+ result, isError = call("reply_in_thread", map[string]any{"event": mcpSigned(t, testOwnerSecret, 12, [][]string{{"h", "general"}, {"e", messageID}, {"p", member}}, "and back")}, "")
171+ if isError || structured(result)["accepted"] != true {
172+ t.Fatalf("reply_in_thread publish: %s", text(result))
173+ }
174+ result, isError = call("read_thread", map[string]any{"id": "general", "event": messageID}, "")
175+ replies, _ := structured(result)["replies"].([]any)
176+ if isError || len(replies) != 1 || structured(result)["root"].(map[string]any)["id"] != messageID {
177+ t.Fatalf("read_thread: %s", text(result))
178+ }
179+
180+ // react: content is +, - or one emoji, whether templated or signed.
181+ result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "great", "room": "general"}, "")
182+ if !isError || !strings.Contains(text(result), "content must be +, - or one emoji") {
183+ t.Fatalf("react template validation: %s", text(result))
184+ }
185+ result, isError = call("react", map[string]any{"target": messageID, "target_pubkey": member, "content": "\U0001F44D", "room": "general"}, "")
186+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
187+ tags, _ = json.Marshal(unsigned["tags"])
188+ if isError || unsigned["kind"] != float64(7) || unsigned["content"] != "\U0001F44D" || string(tags) != `[["e","`+messageID+`"],["p","`+member+`"],["h","general"]]` {
189+ t.Fatalf("react template: %s", text(result))
190+ }
191+ result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "great")}, "")
192+ if !isError || !strings.Contains(text(result), `Expected a signed kind 7 event`) {
193+ t.Fatalf("react signed validation: %s", text(result))
194+ }
195+ result, isError = call("react", map[string]any{"event": mcpSigned(t, testOwnerSecret, 7, [][]string{{"e", messageID}, {"p", member}, {"h", "general"}}, "+")}, "")
196+ if isError || structured(result)["accepted"] != true {
197+ t.Fatalf("react publish: %s", text(result))
198+ }
199+
200+ // Wiki: the template normalizes the name; the signed page reads back.
201+ result, isError = call("publish_wiki_page", map[string]any{"d": "Bitcoin Basics", "title": "Bitcoin Basics", "summary": "A primer", "content": "Digital cash."}, "")
202+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
203+ tags, _ = json.Marshal(unsigned["tags"])
204+ if isError || unsigned["kind"] != float64(30818) || string(tags) != `[["d","bitcoin-basics"],["title","Bitcoin Basics"],["summary","A primer"]]` {
205+ t.Fatalf("publish_wiki_page template: %s", text(result))
206+ }
207+ result, isError = call("publish_wiki_page", map[string]any{"event": mcpSigned(t, testOwnerSecret, 30818, [][]string{{"d", "Bitcoin Basics"}, {"title", "Bitcoin Basics"}}, "Digital cash.")}, "")
208+ if !isError || !strings.Contains(text(result), "normalized page name") {
209+ t.Fatalf("publish_wiki_page unnormalized: %s", text(result))
210+ }
211+ 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.")}, "")
212+ pageID, _ := structured(result)["event_id"].(string)
213+ if isError || structured(result)["accepted"] != true {
214+ t.Fatalf("publish_wiki_page publish: %s", text(result))
215+ }
216+ result, isError = call("read_wiki_page", map[string]any{"d": "Bitcoin Basics"}, testMemberSecret)
217+ page := structured(result)
218+ version, _ := page["version"].(map[string]any)
219+ if isError || page["d"] != "bitcoin-basics" || page["title"] != "Bitcoin Basics" || version["id"] != pageID || version["content"] != "Digital cash." || page["preferred_by"] != "owner" {
220+ t.Fatalf("read_wiki_page: %s", text(result))
221+ }
222+ result, isError = call("list_wiki", map[string]any{"q": "primer"}, "")
223+ if items, _ := structured(result)["items"].([]any); isError || len(items) != 1 {
224+ t.Fatalf("list_wiki: %s", text(result))
225+ }
226+ result, isError = call("propose_wiki_merge", map[string]any{"d": "Bitcoin Basics", "destination": owner, "source": pageID, "content": "Take my edits."}, testMemberSecret)
227+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
228+ tags, _ = json.Marshal(unsigned["tags"])
229+ if isError || unsigned["kind"] != float64(818) || string(tags) != `[["a","30818:`+owner+`:bitcoin-basics"],["p","`+owner+`"],["e","`+pageID+`","","source"]]` {
230+ t.Fatalf("propose_wiki_merge template: %s", text(result))
231+ }
232+ 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)
233+ mergeID, _ := structured(result)["event_id"].(string)
234+ if isError || mergeID == "" {
235+ t.Fatalf("propose_wiki_merge publish: %s", text(result))
236+ }
237+ result, isError = call("read_merge_request", map[string]any{"id": mergeID}, "")
238+ merge, _ := structured(result)["merge"].(map[string]any)
239+ if isError || merge["id"] != mergeID || merge["status"] != "open" {
240+ t.Fatalf("read_merge_request: %s", text(result))
241+ }
242+
243+ // Agents: owners and moderators read and control them, members do not.
244+ now := time.Now().Unix()
245+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {
246+ t.Fatal(err)
247+ }
248+ result, isError = call("list_agents", map[string]any{}, testMemberSecret)
249+ if !isError || !strings.Contains(text(result), "restricted") {
250+ t.Fatalf("list_agents as member: %v %s", isError, text(result))
251+ }
252+ result, isError = call("list_agents", map[string]any{}, "")
253+ agents, _ := structured(result)["result"].([]any)
254+ if isError || len(agents) != 1 || agents[0].(map[string]any)["pubkey"] != agent || agents[0].(map[string]any)["paused"] != false {
255+ t.Fatalf("list_agents as owner: %s", text(result))
256+ }
257+ result, isError = call("pause_agent", map[string]any{"agent": agent}, testMemberSecret)
258+ if !isError {
259+ t.Fatalf("pause_agent as member: %s", text(result))
260+ }
261+ result, isError = call("pause_agent", map[string]any{"agent": agent}, "")
262+ if isError {
263+ t.Fatalf("pause_agent: %s", text(result))
264+ }
265+ result, _ = call("list_agents", map[string]any{}, "")
266+ if agents, _ = structured(result)["result"].([]any); agents[0].(map[string]any)["paused"] != true {
267+ t.Fatalf("list_agents after pause: %s", text(result))
268+ }
269+ result, isError = call("resume_all_agents", map[string]any{}, "")
270+ if isError || structured(result)["result"].(map[string]any)["changed"] != float64(1) {
271+ t.Fatalf("resume_all_agents: %s", text(result))
272+ }
273+ result, isError = call("revoke_agent", map[string]any{"agent": agent}, "")
274+ if isError {
275+ t.Fatalf("revoke_agent: %s", text(result))
276+ }
277+
278+ // request_decision: a room message or a comment carrying the request.
279+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "approve", "content": "Ship 1.2?", "room": "general", "expiration": now + 3600, "subject": "Release"}, "")
280+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
281+ tags, _ = json.Marshal(unsigned["tags"])
282+ if isError || unsigned["kind"] != float64(9) || string(tags) != `[["h","general"],["request","approve"],["p","`+member+`"],["expiration","`+strconv.FormatInt(now+3600, 10)+`"],["subject","Release"]]` {
283+ t.Fatalf("request_decision room template: %s", text(result))
284+ }
285+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "question", "content": "Which name?", "root": pageID, "root_kind": 30818, "root_pubkey": owner}, "")
286+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
287+ tags, _ = json.Marshal(unsigned["tags"])
288+ 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+`"]]` {
289+ t.Fatalf("request_decision comment template: %s", text(result))
290+ }
291+ result, isError = call("request_decision", map[string]any{"pubkey": member, "request": "decide", "content": "Pick one."}, "")
292+ if !isError || !strings.Contains(text(result), "room or root is required") {
293+ t.Fatalf("request_decision without target: %s", text(result))
294+ }
295+ result, isError = call("request_decision", map[string]any{"event": mcpSigned(t, testOwnerSecret, 9, [][]string{{"h", "general"}, {"request", "approve"}, {"p", member}}, "Ship 1.2?")}, "")
296+ if isError || structured(result)["accepted"] != true {
297+ t.Fatalf("request_decision publish: %s", text(result))
298+ }
299+}
300diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
301index be9db0a..884f469 100644
302--- a/internal/daemon/mcp_tools.go
303+++ b/internal/daemon/mcp_tools.go
304@@ -11,10 +11,13 @@ import (
305 "strconv"
306 "strings"
307 "time"
308+ "unicode"
309
310+ "github.com/FelineStateMachine/tinyrelay/internal/community"
311 "github.com/FelineStateMachine/tinyrelay/internal/event"
312 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
313 "github.com/FelineStateMachine/tinyrelay/internal/relay"
314+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"
315 )
316
317 // The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.
318@@ -32,14 +35,20 @@ var (
319 mcpCommit = map[string]any{"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "Full Git commit ID."}
320 mcpEvent = map[string]any{"type": "object", "description": "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig."}
321 mcpRootKind = map[string]any{"type": "integer", "enum": []int{1617, 1618, 1621}, "description": "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch."}
322+ mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}
323+ mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}
324+ mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}
325 mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}
326 mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}
327+ mcpControls = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
328 mcpSettings = map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}
329 mcpPublishes = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}
330
331- mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)
332- mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}
333- mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}
334+ mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)
335+ mcpWikiCoordinate = regexp.MustCompile(`^30818:[0-9a-f]{64}:.+$`)
336+ mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}
337+ mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}
338+ mcpRequestKinds = []string{"approve", "decide", "question"}
339 )
340
341 const (
342@@ -47,7 +56,16 @@ const (
343 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.`
344 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.`
345 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>"].`
346+ mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`
347+ mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`
348+ 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.`
349+ 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.`
350+ 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.`
351+ 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.`
352+ mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`
353+ 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.`
354 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."
355+ 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."
356 )
357
358 // mcpUnsigned is the event body a client signs before calling a write tool
359@@ -90,6 +108,15 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
360 }
361 return t.mcpManage(ctx, call, method)
362 })
363+ 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"))
364+ 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"))
365+ 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"))
366+ 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"))
367+ 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"))
368+ 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"))
369+ 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) {
370+ return t.mcpManage(ctx, call, "listagents")
371+ })
372
373 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) {
374 return t.mcpManage(ctx, call, "runjob", call.String("id"))
375@@ -115,12 +142,35 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
376 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) {
377 return t.mcpManage(ctx, call, "notifytest")
378 })
379+ 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) {
380+ return t.mcpManage(ctx, call, "pauseagent", call.String("agent"))
381+ })
382+ 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) {
383+ return t.mcpManage(ctx, call, "resumeagent", call.String("agent"))
384+ })
385+ 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) {
386+ return t.mcpManage(ctx, call, "revokeagent", call.String("agent"))
387+ })
388+ 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) {
389+ return t.mcpManage(ctx, call, "pauseallagents")
390+ })
391+ 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) {
392+ return t.mcpManage(ctx, call, "resumeallagents")
393+ })
394
395 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."))
396 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))
397 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))
398 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))
399 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))
400+ 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))
401+ 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))
402+ 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))
403+ 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))
404+ 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))
405+ 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))
406+ 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))
407+ 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))
408 return registry, err
409 }
410
411@@ -396,3 +446,343 @@ func merge(base, extra map[string]any) map[string]any {
412 }
413 return out
414 }
415+
416+// Room, wiki and decision builders. Each returns the unsigned event for the
417+// caller to sign; the matching check accepts the signed event back.
418+
419+func mcpRoomTag(call mcp.Call) ([][]string, error) {
420+ room := call.String("room")
421+ if !community.ValidRoomID(room) {
422+ return nil, errors.New("room is required and must be a room id")
423+ }
424+ return [][]string{{"h", room}}, nil
425+}
426+
427+func mcpPubKeys(call mcp.Call, key string, tags [][]string) [][]string {
428+ seen := map[string]bool{}
429+ if values, ok := call.Arguments[key].([]any); ok {
430+ for _, value := range values {
431+ text, _ := value.(string)
432+ if hex64(text) && !seen[text] {
433+ seen[text] = true
434+ tags = append(tags, []string{"p", text})
435+ }
436+ }
437+ }
438+ return tags
439+}
440+
441+func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) {
442+ tags, err := mcpRoomTag(call)
443+ if err != nil {
444+ return mcpUnsigned{}, err
445+ }
446+ content := call.String("content")
447+ if strings.TrimSpace(content) == "" {
448+ return mcpUnsigned{}, errors.New("content is required")
449+ }
450+ return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions", tags), Content: content}, nil
451+}
452+
453+func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) {
454+ tags, err := mcpRoomTag(call)
455+ if err != nil {
456+ return mcpUnsigned{}, err
457+ }
458+ title, content := strings.TrimSpace(call.String("title")), call.String("content")
459+ if strings.TrimSpace(content) == "" {
460+ return mcpUnsigned{}, errors.New("content is required")
461+ }
462+ if title != "" {
463+ tags = append(tags, []string{"subject", title})
464+ }
465+ return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
466+}
467+
468+func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) {
469+ tags, err := mcpRoomTag(call)
470+ if err != nil {
471+ return mcpUnsigned{}, err
472+ }
473+ root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")
474+ if root == "" || strings.TrimSpace(content) == "" {
475+ return mcpUnsigned{}, errors.New("root and content are required")
476+ }
477+ tags = append(tags, []string{"e", root})
478+ if rootPubKey != "" {
479+ tags = append(tags, []string{"p", rootPubKey})
480+ }
481+ return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
482+}
483+
484+func mcpBuildReact(call mcp.Call) (mcpUnsigned, error) {
485+ target, targetPubKey, content, room := call.String("target"), call.String("target_pubkey"), call.String("content"), call.String("room")
486+ if target == "" || targetPubKey == "" || content == "" {
487+ return mcpUnsigned{}, errors.New("target, target_pubkey and content are required")
488+ }
489+ if !mcpReaction(content) {
490+ return mcpUnsigned{}, errors.New("content must be +, - or one emoji")
491+ }
492+ tags := [][]string{{"e", target}, {"p", targetPubKey}}
493+ if room != "" {
494+ tags = append(tags, []string{"h", room})
495+ }
496+ return mcpUnsigned{Kind: 7, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
497+}
498+
499+func mcpBuildWikiPage(call mcp.Call) (mcpUnsigned, error) {
500+ d, title, content := wiki.Normalize(call.String("d")), strings.TrimSpace(call.String("title")), call.String("content")
501+ if d == "" || title == "" || strings.TrimSpace(content) == "" {
502+ return mcpUnsigned{}, errors.New("d, title and content are required")
503+ }
504+ tags := [][]string{{"d", d}, {"title", title}}
505+ if summary := strings.TrimSpace(call.String("summary")); summary != "" {
506+ tags = append(tags, []string{"summary", summary})
507+ }
508+ forkAuthor, forkEvent := call.String("fork_author"), call.String("fork_event")
509+ if (forkAuthor == "") != (forkEvent == "") {
510+ return mcpUnsigned{}, errors.New("a fork needs both fork_author and fork_event")
511+ }
512+ if forkAuthor != "" {
513+ tags = append(tags, []string{"a", "30818:" + forkAuthor + ":" + d, "", "fork"}, []string{"e", forkEvent, "", "fork"})
514+ }
515+ return mcpUnsigned{Kind: kindWikiArticle, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
516+}
517+
518+func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) {
519+ d, destination, source, base, content := wiki.Normalize(call.String("d")), call.String("destination"), call.String("source"), call.String("base"), call.String("content")
520+ if d == "" || destination == "" || source == "" || strings.TrimSpace(content) == "" {
521+ return mcpUnsigned{}, errors.New("d, destination, source and content are required")
522+ }
523+ tags := [][]string{{"a", "30818:" + destination + ":" + d}, {"p", destination}, {"e", source, "", "source"}}
524+ if base != "" {
525+ tags = append(tags, []string{"e", base})
526+ }
527+ return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
528+}
529+
530+func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) {
531+ tags, err := mcpRoomTag(call)
532+ if err != nil {
533+ return mcpUnsigned{}, err
534+ }
535+ name := strings.TrimSpace(call.String("name"))
536+ if name == "" {
537+ return mcpUnsigned{}, errors.New("name is required")
538+ }
539+ tags = append(tags, []string{"name", name})
540+ if about := strings.TrimSpace(call.String("about")); about != "" {
541+ tags = append(tags, []string{"about", about})
542+ }
543+ if visibility := call.String("visibility"); visibility != "" {
544+ tags = append(tags, []string{"visibility", visibility})
545+ }
546+ return mcpUnsigned{Kind: event.KIND_CREATE_GROUP, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil
547+}
548+
549+func mcpBuildRequest(call mcp.Call) (mcpUnsigned, error) {
550+ pubkey, request, content := call.String("pubkey"), call.String("request"), call.String("content")
551+ if pubkey == "" || !contains(mcpRequestKinds, request) || strings.TrimSpace(content) == "" {
552+ return mcpUnsigned{}, errors.New("pubkey, request and content are required")
553+ }
554+ room, root, rootPubKey, rootKind := call.String("room"), call.String("root"), call.String("root_pubkey"), call.Int("root_kind")
555+ var kind int
556+ var tags [][]string
557+ switch {
558+ case room != "" && root != "":
559+ return mcpUnsigned{}, errors.New("pass either room or root, not both")
560+ case room != "":
561+ kind, tags = event.KIND_CHAT, [][]string{{"h", room}}
562+ case root != "":
563+ if rootPubKey == "" || rootKind <= 0 {
564+ return mcpUnsigned{}, errors.New("root needs root_kind and root_pubkey")
565+ }
566+ k := strconv.Itoa(rootKind)
567+ kind, tags = 1111, [][]string{{"E", root, "", rootPubKey}, {"K", k}, {"P", rootPubKey}, {"e", root, "", rootPubKey}, {"k", k}}
568+ if rootPubKey != pubkey {
569+ tags = append(tags, []string{"p", rootPubKey})
570+ }
571+ default:
572+ return mcpUnsigned{}, errors.New("room or root is required")
573+ }
574+ tags = append(tags, []string{"request", request}, []string{"p", pubkey})
575+ if expiration := call.Int("expiration"); expiration > 0 {
576+ if int64(expiration) <= time.Now().Unix() {
577+ return mcpUnsigned{}, errors.New("expiration must be in the future")
578+ }
579+ tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})
580+ }
581+ if subject := strings.TrimSpace(call.String("subject")); subject != "" {
582+ tags = append(tags, []string{"subject", subject})
583+ }
584+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
585+}
586+
587+func mcpCheckRoomEvent(e event.Event, kind int, what string) error {
588+ if e.Kind != kind {
589+ return fmt.Errorf("kind %d is not a %s", e.Kind, what)
590+ }
591+ if !community.ValidRoomID(event.Tag(e, "h")) {
592+ return errors.New("the h tag must name the room")
593+ }
594+ return nil
595+}
596+
597+func mcpCheckMessage(e event.Event) error {
598+ if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {
599+ return err
600+ }
601+ if strings.TrimSpace(e.Content) == "" {
602+ return errors.New("content must not be empty")
603+ }
604+ return nil
605+}
606+
607+func mcpCheckThread(e event.Event) error {
608+ if err := mcpCheckRoomEvent(e, event.KIND_THREAD, "thread"); err != nil {
609+ return err
610+ }
611+ if strings.TrimSpace(e.Content) == "" {
612+ return errors.New("content must not be empty")
613+ }
614+ return nil
615+}
616+
617+func mcpCheckReply(e event.Event) error {
618+ if err := mcpCheckRoomEvent(e, event.KIND_THREAD_REPLY, "thread reply"); err != nil {
619+ return err
620+ }
621+ if !hex64(event.Tag(e, "e")) {
622+ return errors.New("the e tag must name the thread root")
623+ }
624+ if strings.TrimSpace(e.Content) == "" {
625+ return errors.New("content must not be empty")
626+ }
627+ return nil
628+}
629+
630+func mcpCheckReact(e event.Event) error {
631+ if e.Kind != 7 {
632+ return fmt.Errorf("kind %d is not a reaction", e.Kind)
633+ }
634+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {
635+ return errors.New("the e and p tags must name the event and its author")
636+ }
637+ if room := event.Tag(e, "h"); room != "" && !community.ValidRoomID(room) {
638+ return errors.New("the h tag must name a room")
639+ }
640+ if !mcpReaction(e.Content) {
641+ return errors.New("content must be +, - or one emoji")
642+ }
643+ return nil
644+}
645+
646+func mcpCheckWikiPage(e event.Event) error {
647+ if e.Kind != kindWikiArticle {
648+ return fmt.Errorf("kind %d is not a wiki page", e.Kind)
649+ }
650+ d := event.Tag(e, "d")
651+ if d == "" || d != wiki.Normalize(d) {
652+ return errors.New("the d tag must carry the normalized page name")
653+ }
654+ if strings.TrimSpace(event.Tag(e, "title")) == "" || strings.TrimSpace(e.Content) == "" {
655+ return errors.New("the title tag and content must not be empty")
656+ }
657+ for _, tag := range e.Tags {
658+ if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") {
659+ continue
660+ }
661+ if (tag[0] == "a" && !mcpWikiCoordinate.MatchString(tag[1])) || (tag[0] == "e" && !hex64(tag[1])) {
662+ return errors.New("fork and defer tags must name a 30818:<author>:<page> coordinate or a version id")
663+ }
664+ }
665+ return nil
666+}
667+
668+func mcpCheckWikiMerge(e event.Event) error {
669+ if e.Kind != kindWikiMerge {
670+ return fmt.Errorf("kind %d is not a merge request", e.Kind)
671+ }
672+ coordinate := event.Tag(e, "a")
673+ if !mcpWikiCoordinate.MatchString(coordinate) {
674+ return errors.New("the a tag must name the target as 30818:<destination>:<page>")
675+ }
676+ if destination := event.Tag(e, "p"); !hex64(destination) || destination != strings.Split(coordinate, ":")[1] {
677+ return errors.New("the p tag must name the destination author of the a tag")
678+ }
679+ source := ""
680+ for _, tag := range e.Tags {
681+ if len(tag) >= 4 && tag[0] == "e" && tag[3] == "source" {
682+ source = tag[1]
683+ }
684+ }
685+ if !hex64(source) {
686+ return errors.New("an e tag with the source marker must name the proposed version")
687+ }
688+ if strings.TrimSpace(e.Content) == "" {
689+ return errors.New("content must not be empty")
690+ }
691+ return nil
692+}
693+
694+func mcpCheckRoom(e event.Event) error {
695+ if err := mcpCheckRoomEvent(e, event.KIND_CREATE_GROUP, "room creation"); err != nil {
696+ return err
697+ }
698+ if strings.TrimSpace(event.Tag(e, "name")) == "" {
699+ return errors.New("the name tag must not be empty")
700+ }
701+ if visibility := event.Tag(e, "visibility"); visibility != "" && visibility != community.RoomOpen && visibility != community.RoomMembers {
702+ return errors.New("the visibility tag must be open or members")
703+ }
704+ return nil
705+}
706+
707+func mcpCheckRequest(e event.Event) error {
708+ switch e.Kind {
709+ case event.KIND_CHAT:
710+ if err := mcpCheckRoomEvent(e, event.KIND_CHAT, "chat message"); err != nil {
711+ return err
712+ }
713+ case 1111:
714+ if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || event.Tag(e, "K") == "" || !hex64(event.Tag(e, "e")) || event.Tag(e, "k") == "" {
715+ return errors.New("the E, K, P, e and k tags must name the root event, its kind and its author")
716+ }
717+ default:
718+ return fmt.Errorf("kind %d is not a chat message or comment", e.Kind)
719+ }
720+ if !contains(mcpRequestKinds, event.Tag(e, "request")) {
721+ return errors.New("the request tag must be approve, decide or question")
722+ }
723+ asked := event.TagValues(e, "p")
724+ if len(asked) == 0 || !hex64(asked[len(asked)-1]) {
725+ return errors.New("a p tag must name the person asked")
726+ }
727+ if expiration := event.Tag(e, "expiration"); expiration != "" {
728+ if at, err := strconv.ParseInt(expiration, 10, 64); err != nil || at <= time.Now().Unix() {
729+ return errors.New("the expiration tag must be a Unix time in the future")
730+ }
731+ }
732+ if strings.TrimSpace(e.Content) == "" {
733+ return errors.New("content must not be empty")
734+ }
735+ return nil
736+}
737+
738+// mcpReaction accepts NIP-25 content: +, - or one emoji, which may be a
739+// sequence of a few non-ASCII code points such as a flag or a skin tone.
740+func mcpReaction(content string) bool {
741+ if content == "+" || content == "-" {
742+ return true
743+ }
744+ runes := []rune(content)
745+ if len(runes) == 0 || len(runes) > 10 {
746+ return false
747+ }
748+ for _, r := range runes {
749+ if r < 0x80 || unicode.IsSpace(r) || unicode.IsLetter(r) || unicode.IsDigit(r) {
750+ return false
751+ }
752+ }
753+ return true
754+}

About

ownerc01503e8035f
visibilitypublic
head658134d0db7f

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs