|
| 1 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go | | 2 | index 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 | "", | | 26 | diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go | | 27 | index 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 | +} | | 300 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go | | 301 | index 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| owner | c01503e8035f |
|---|
| visibility | public |
|---|
| head | 658134d0db7f |
|---|
Maintainers- c01503e8035f owner
- ba1b5beed5b5 agent hermes
Clonehttps://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git
|