clone

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

serve MCP tools at /mcp with NIP-98 and describe the machine surface at /llms.txt

Dami committed 258831e
commit
258831e820ece12fea3b8a22e3a9b62838456ffc
parent
5175718db6359d58fc4293742d81010a3d570d91
author
Dami <dami.etoile@gmail.com>
7 files changed, +768 -2
Minternal/daemon/http.go10+-
Ainternal/daemon/mcp_http.go117+++++++++
Ainternal/daemon/mcp_test.go238++++++++++++++++++
Ainternal/daemon/mcp_tools.go398++++++++++++++++++++++++++++++
Minternal/daemon/services.go3+
Minternal/daemon/tenant.go2+
Minternal/telemetry/telemetry.go2+-
M internal/daemon/http.go +9 -1
A internal/daemon/mcp_http.go +117 -0
@@ -0,0 +1,117 @@
1+package daemon2+3+import (4+ "net/http"5+ "net/url"6+ "strings"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"9+)10+11+// mcpChallenge is the WWW-Authenticate value for the MCP endpoint. The relay12+// identifies callers by their Nostr key with NIP-98 rather than OAuth.13+const mcpChallenge = `Nostr realm="tiny"`14+15+func (t *Tenant) initMCP() error {16+ tools, err := t.mcpTools()17+ if err != nil {18+ return err19+ }20+ t.mcp = &mcp.Server{21+ Tools: tools,22+ Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},23+ Instructions: "This relay hosts Nostr events, files and Git repositories. Read tools return the data your key may see; management tools follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields.",24+ Authenticate: t.mcpAuthenticate,25+ Challenge: mcpChallenge,26+ Origin: t.mcpOrigin,27+ MaxBodyBytes: t.app.cfg.MaxMessageBytes,28+ }29+ return nil30+}31+32+// mcpAuthenticate verifies the NIP-98 token bound to POST, the full request33+// URL and the SHA-256 of the body, exactly as the HTTP bridge does.34+func (t *Tenant) mcpAuthenticate(r *http.Request, body []byte) (string, error) {35+ s, err := t.session(r, body)36+ if err != nil {37+ return "", err38+ }39+ return first(s.PubKeys), nil40+}41+42+func (t *Tenant) mcpOrigin(r *http.Request) string {43+ base, err := url.Parse(t.requestURL(r))44+ if err != nil {45+ return ""46+ }47+ return base.Scheme + "://" + base.Host48+}49+50+func (t *Tenant) mcpHTTP(w http.ResponseWriter, r *http.Request) {51+ ctx, finish := t.app.telemetry.Start(r.Context(), "mcp")52+ obs := t.mcp.Handle(w, r.WithContext(ctx))53+ finish(obs.Outcome)54+ t.app.telemetry.Logger().Info("mcp request", "tenant", t.meta.Name, "method", obs.Method, "tool", obs.Tool, "outcome", obs.Outcome, "status", obs.Status)55+}56+57+// llmsHTTP describes the relay's machine surface for agents in plain text.58+func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {59+ if r.Method != http.MethodGet && r.Method != http.MethodHead {60+ w.Header().Set("Allow", "GET, HEAD")61+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)62+ return63+ }64+ p := t.Policy()65+ name, description := p.Name, strings.ReplaceAll(strings.TrimSpace(p.Description), "\n", " ")66+ if name == "" || privatePolicy(p) {67+ // Private tenants keep their presentation behind authenticated reads,68+ // as the NIP-11 document does.69+ name = t.meta.Name70+ }71+ if privatePolicy(p) {72+ description = ""73+ }74+ // The request carries the tenant prefix, so URLs are derived from it75+ // rather than from the cached public URL.76+ base := t.requestURL(r)77+ if u, err := url.Parse(base); err == nil {78+ u.RawQuery, u.Fragment = "", ""79+ u.Path = strings.TrimSuffix(u.Path, "/llms.txt")80+ base = strings.TrimRight(u.String(), "/")81+ }82+ lines := []string{"# " + name, ""}83+ if description != "" {84+ lines = append(lines, "> "+description, "")85+ }86+ lines = append(lines,87+ "This is a tinyrelay Nostr relay. Every authenticated surface below identifies the caller with NIP-98: send `Authorization: Nostr <base64 kind 27235 event>` signed for the HTTP method, the full request URL and the SHA-256 of the body. There is no OAuth.",88+ "",89+ "## MCP",90+ "",91+ "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",92+ "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",93+ "- Methods: server/discover, tools/list and tools/call. Tools read repositories, issues, pull requests, files and relay status, run management actions and publish signed events.",94+ "",95+ "## HTTP bridge",96+ "",97+ "- POST "+base+"/events publishes one signed event. POST "+base+"/query and POST "+base+"/count take a JSON array of Nostr filters.",98+ "- WebSocket "+t.RelayURL()+" speaks NIP-01. GET "+base+"/ with `Accept: application/nostr+json` returns the NIP-11 document.",99+ "",100+ "## Management",101+ "",102+ "- POST "+base+"/ with `Content-Type: application/nostr+json+rpc` and a NIP-86 body {\"method\": ..., \"params\": [...]}. The supportedmethods method lists what your key may call.",103+ "",104+ "## Git",105+ "",106+ "- GRASP repositories serve Git smart HTTP at "+base+"/npub1.../<repo>.git for clone and fetch. Push signs with NIP-98.",107+ "",108+ "## Documentation",109+ "",110+ "- MCP: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/mcp.md",111+ "- All guides: https://github.com/FelineStateMachine/tinyrelay/tree/main/docs",112+ "",113+ )114+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")115+ w.Header().Set("Cache-Control", "public, max-age=3600")116+ _, _ = w.Write([]byte(strings.Join(lines, "\n")))117+}
A internal/daemon/mcp_test.go +238 -0
@@ -0,0 +1,238 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"14+)15+16+type mcpCall struct {17+ method string18+ name string19+ arguments map[string]any20+ meta string21+ version string22+ sign bool23+ headers map[string]string24+}25+26+// mcpSequence keeps request bodies distinct so identical calls signed in27+// the same second do not share a NIP-98 token id.28+var mcpSequence int29+30+func (c mcpCall) do(t *testing.T, app *App, path string) (*httptest.ResponseRecorder, mcp.Response) {31+ t.Helper()32+ mcpSequence++33+ params := map[string]any{"_meta": map[string]any{mcp.MetaProtocolVersion: mcp.Version, mcp.MetaClientCapabilities: map[string]any{}}}34+ if c.meta != "" {35+ params["_meta"].(map[string]any)[mcp.MetaProtocolVersion] = c.meta36+ }37+ if c.name != "" {38+ params["name"] = c.name39+ params["arguments"] = c.arguments40+ }41+ raw, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": mcpSequence, "method": c.method, "params": params})42+ if err != nil {43+ t.Fatal(err)44+ }45+ r := httptest.NewRequest(http.MethodPost, "http://relay.test"+path, strings.NewReader(string(raw)))46+ r.Header.Set("Content-Type", "application/json")47+ r.Header.Set("Accept", "application/json, text/event-stream")48+ version := c.version49+ if version == "" {50+ version = mcp.Version51+ }52+ r.Header.Set(mcp.HeaderProtocolVersion, version)53+ r.Header.Set(mcp.HeaderMethod, c.method)54+ if c.name != "" {55+ r.Header.Set(mcp.HeaderName, c.name)56+ }57+ for key, value := range c.headers {58+ r.Header.Set(key, value)59+ }60+ if c.sign {61+ signRequest(t, r, string(raw))62+ }63+ w := httptest.NewRecorder()64+ app.ServeHTTP(w, r)65+ var response mcp.Response66+ if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {67+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {68+ t.Fatalf("decode %s: %v", w.Body.String(), err)69+ }70+ }71+ return w, response72+}73+74+func mcpToolResult(t *testing.T, response mcp.Response) (map[string]any, bool) {75+ t.Helper()76+ result, _ := response.Result.(map[string]any)77+ if result == nil {78+ t.Fatalf("no result: %+v", response)79+ }80+ isError, _ := result["isError"].(bool)81+ return result, isError82+}83+84+func TestMCPListsAndCallsToolsAsTheSignedKey(t *testing.T) {85+ app, tenant := testTenant(t)86+ policy := tenant.Policy()87+ policy.Features.Grasp = true88+ if err := tenant.applyPolicy(context.Background(), policy); err != nil {89+ t.Fatal(err)90+ }91+ for _, path := range []string{"/mcp", "/r/main/mcp"} {92+ w, response := mcpCall{method: "tools/list", sign: true}.do(t, app, path)93+ result, _ := response.Result.(map[string]any)94+ tools, _ := result["tools"].([]any)95+ if w.Code != http.StatusOK || len(tools) < 20 || result["resultType"] != "complete" || result["cacheScope"] != "private" || w.Header().Get("Mcp-Session-Id") != "" {96+ t.Fatalf("%s tools/list: %d %s", path, w.Code, w.Body.String())97+ }98+ names := map[string]bool{}99+ for _, tool := range tools {100+ names[tool.(map[string]any)["name"].(string)] = true101+ }102+ for _, want := range []string{"list_repositories", "read_issue", "read_management", "run_job", "set_policy", "publish_event", "create_issue", "create_pull_request", "comment", "set_status"} {103+ if !names[want] {104+ t.Fatalf("%s missing tool %s", path, want)105+ }106+ }107+ }108+ w, response := mcpCall{method: "tools/call", name: "read_management", arguments: map[string]any{"method": "getpolicy"}, sign: true}.do(t, app, "/mcp")109+ result, isError := mcpToolResult(t, response)110+ structured, _ := result["structuredContent"].(map[string]any)111+ policyResult, _ := structured["result"].(map[string]any)112+ if w.Code != http.StatusOK || isError || policyResult["owner"] != tenant.Policy().Owner {113+ t.Fatalf("read_management: %d %s", w.Code, w.Body.String())114+ }115+ if content, _ := result["content"].([]any); len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), tenant.Policy().Owner) {116+ t.Fatalf("read_management content %v", result["content"])117+ }118+ w, response = mcpCall{method: "tools/call", name: "list_repositories", arguments: map[string]any{}, sign: true}.do(t, app, "/mcp")119+ result, isError = mcpToolResult(t, response)120+ if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["next_cursor"] != "" {121+ t.Fatalf("list_repositories: %d %s", w.Code, w.Body.String())122+ }123+ w, response = mcpCall{method: "tools/call", name: "read_file", arguments: map[string]any{"hash": "short"}, sign: true}.do(t, app, "/mcp")124+ result, isError = mcpToolResult(t, response)125+ if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "hash does not match") {126+ t.Fatalf("schema validation: %d %s", w.Code, w.Body.String())127+ }128+}129+130+func TestMCPWriteToolsBuildValidateAndPublish(t *testing.T) {131+ app, tenant := testTenant(t)132+ policy := tenant.Policy()133+ policy.Features.Grasp = true134+ if err := tenant.applyPolicy(context.Background(), policy); err != nil {135+ t.Fatal(err)136+ }137+ owner := tenant.Policy().Owner138+ w, response := mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"owner": owner, "repo": "tiny", "title": "Broken build", "content": "It fails.", "labels": []any{"bug", "bug"}}, sign: true}.do(t, app, "/mcp")139+ result, isError := mcpToolResult(t, response)140+ unsigned, _ := result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)141+ if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1621) || unsigned["content"] != "It fails." {142+ t.Fatalf("create_issue template: %d %s", w.Code, w.Body.String())143+ }144+ tags, _ := json.Marshal(unsigned["tags"])145+ if string(tags) != `[["a","30617:`+owner+`:tiny"],["p","`+owner+`"],["subject","Broken build"],["t","bug"]]` {146+ t.Fatalf("create_issue tags %s", tags)147+ }148+ sign := func(kind int, tags [][]string, content string) map[string]any {149+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}150+ if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {151+ t.Fatal(err)152+ }153+ raw, _ := json.Marshal(e)154+ var value map[string]any155+ _ = json.Unmarshal(raw, &value)156+ return value157+ }158+ w, response = mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"event": sign(1, [][]string{}, "not an issue")}, sign: true}.do(t, app, "/mcp")159+ result, isError = mcpToolResult(t, response)160+ if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"]`) {161+ t.Fatalf("create_issue malformed: %d %s", w.Code, w.Body.String())162+ }163+ w, response = mcpCall{method: "tools/call", name: "create_issue", arguments: map[string]any{"event": sign(1621, [][]string{{"a", "30617:" + owner + ":tiny"}, {"p", owner}, {"subject", "Broken build"}}, "It fails.")}, sign: true}.do(t, app, "/mcp")164+ result, isError = mcpToolResult(t, response)165+ published, _ := result["structuredContent"].(map[string]any)166+ if w.Code != http.StatusOK || isError || published["accepted"] != true || published["event_id"] == "" {167+ t.Fatalf("create_issue publish: %d %s", w.Code, w.Body.String())168+ }169+ rootID, _ := published["event_id"].(string)170+ w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_kind": 1621, "root_pubkey": owner, "content": "On it."}, sign: true}.do(t, app, "/mcp")171+ result, isError = mcpToolResult(t, response)172+ unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)173+ tags, _ = json.Marshal(unsigned["tags"])174+ if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1111) || string(tags) != `[["a","30617:`+owner+`:tiny"],["E","`+rootID+`","","`+owner+`"],["K","1621"],["P","`+owner+`"],["e","`+rootID+`","","`+owner+`"],["k","1621"],["p","`+owner+`"]]` {175+ t.Fatalf("comment template: %d %s", w.Code, w.Body.String())176+ }177+ w, response = mcpCall{method: "tools/call", name: "set_status", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_pubkey": owner, "status": "resolved"}, sign: true}.do(t, app, "/mcp")178+ result, isError = mcpToolResult(t, response)179+ unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)180+ if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1631) {181+ t.Fatalf("set_status template: %d %s", w.Code, w.Body.String())182+ }183+ w, response = mcpCall{method: "tools/call", name: "publish_event", arguments: map[string]any{"event": sign(1, [][]string{}, "hello agents")}, sign: true}.do(t, app, "/mcp")184+ result, isError = mcpToolResult(t, response)185+ if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["accepted"] != true {186+ t.Fatalf("publish_event: %d %s", w.Code, w.Body.String())187+ }188+ query := httptest.NewRequest(http.MethodPost, "http://relay.test/query", strings.NewReader(`[{"kinds":[1,1621]}]`))189+ signRequest(t, query, `[{"kinds":[1,1621]}]`)190+ queried := httptest.NewRecorder()191+ app.ServeHTTP(queried, query)192+ var rows []event.Event193+ if err := json.Unmarshal(queried.Body.Bytes(), &rows); err != nil || len(rows) != 2 {194+ t.Fatalf("query after publish: %s %v", queried.Body.String(), err)195+ }196+}197+198+func TestMCPRejectsUnauthorizedForeignAndMismatchedRequests(t *testing.T) {199+ app, _ := testTenant(t)200+ w, response := mcpCall{method: "tools/list"}.do(t, app, "/mcp")201+ if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != `Nostr realm="tiny"` || response.Error == nil {202+ t.Fatalf("unsigned: %d %q %s", w.Code, w.Header().Get("WWW-Authenticate"), w.Body.String())203+ }204+ w, response = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "https://foreign.test"}}.do(t, app, "/mcp")205+ if w.Code != http.StatusForbidden || response.Error == nil {206+ t.Fatalf("foreign origin: %d %s", w.Code, w.Body.String())207+ }208+ w, _ = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "http://relay.test"}}.do(t, app, "/mcp")209+ if w.Code != http.StatusOK {210+ t.Fatalf("same origin: %d %s", w.Code, w.Body.String())211+ }212+ w, response = mcpCall{method: "tools/list", sign: true, meta: "2025-11-25"}.do(t, app, "/mcp")213+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeHeaderMismatch {214+ t.Fatalf("version mismatch: %d %s", w.Code, w.Body.String())215+ }216+ w, response = mcpCall{method: "tools/list", sign: true, version: "2025-11-25", meta: "2025-11-25"}.do(t, app, "/mcp")217+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeUnsupportedProtocolVersion {218+ t.Fatalf("unsupported version: %d %s", w.Code, w.Body.String())219+ }220+ w, response = mcpCall{method: "initialize", sign: true}.do(t, app, "/mcp")221+ if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != mcp.CodeMethodNotFound {222+ t.Fatalf("initialize: %d %s", w.Code, w.Body.String())223+ }224+ for _, method := range []string{http.MethodGet, http.MethodDelete} {225+ r := httptest.NewRequest(method, "http://relay.test/mcp", nil)226+ w := httptest.NewRecorder()227+ app.ServeHTTP(w, r)228+ if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != "POST" {229+ t.Fatalf("%s: %d", method, w.Code)230+ }231+ }232+ r := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/llms.txt", nil)233+ w = httptest.NewRecorder()234+ app.ServeHTTP(w, r)235+ if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") || !strings.Contains(w.Body.String(), "http://relay.test/r/main/mcp") || !strings.Contains(w.Body.String(), mcp.Version) {236+ t.Fatalf("llms.txt: %d %s", w.Code, w.Body.String())237+ }238+}
A internal/daemon/mcp_tools.go +398 -0
@@ -0,0 +1,398 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "net"9+ "net/url"10+ "regexp"11+ "strconv"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/event"16+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"17+ "github.com/FelineStateMachine/tinyrelay/internal/relay"18+)19+20+// The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.21+// Reads run through the browse methods, controls through the NIP-8622+// management path, and writes through the same publish path as POST /events,23+// so every tool is executed as the signed-in pubkey with the tenant's24+// permission checks.25+26+var (27+ mcpText = map[string]any{"type": "string"}28+ mcpPubKey = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hex public key."}29+ mcpHash = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$"}30+ mcpID = map[string]any{"type": "string", "minLength": 1}31+ mcpLimit = map[string]any{"type": "integer", "minimum": 1, "maximum": 100}32+ mcpCommit = map[string]any{"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "Full Git commit ID."}33+ mcpEvent = map[string]any{"type": "object", "description": "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig."}34+ mcpRootKind = map[string]any{"type": "integer", "enum": []int{1617, 1618, 1621}, "description": "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch."}35+ mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}36+ mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}37+ mcpSettings = map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}38+ mcpPublishes = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}39+40+ mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)41+ mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}42+ mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}43+)44+45+const (46+ mcpIssueShape = `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"] and optional ["t","<label>"] tags, with the Markdown description in content.`47+ 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.`48+ 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.`49+ 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>"].`50+ mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."51+)52+53+// mcpUnsigned is the event body a client signs before calling a write tool54+// again.55+type mcpUnsigned struct {56+ Kind int `json:"kind"`57+ CreatedAt int64 `json:"created_at"`58+ Tags [][]string `json:"tags"`59+ Content string `json:"content"`60+}61+62+func (t *Tenant) mcpTools() (*mcp.Registry, error) {63+ registry := &mcp.Registry{}64+ var err error65+ add := func(name, description string, schema, annotations map[string]any, handler mcp.Handler) {66+ if err == nil {67+ err = registry.Add(mcp.Tool{Name: name, Description: description, InputSchema: schema, Annotations: annotations, Handler: handler})68+ }69+ }70+ repository := map[string]any{"owner": mcpPubKey, "repo": mcpID, "ref": mcpText, "path": mcpText, "view": map[string]any{"type": "string", "enum": []string{"tree", "file", "history", "commit", "activity"}}}71+ collaborationList := mcp.Object(map[string]any{"owner": mcpPubKey, "repo": mcpID, "cursor": mcpText, "limit": mcpLimit, "q": mcpText, "label": mcpText, "state": map[string]any{"type": "string", "enum": []string{"open", "resolved", "merged", "closed", "draft"}}}, "owner", "repo")72+ collaborationDetail := mcp.Object(map[string]any{"owner": mcpPubKey, "repo": mcpID, "event": mcpHash, "cursor": mcpText, "limit": mcpLimit}, "owner", "repo", "event")73+74+ add("list_repositories", "List repositories visible to your account. Supports search and pagination.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "q": mcpText}), mcpReads, t.mcpBrowse("browserepos"))75+ add("read_repository", "Read a repository tree, source file, history, commit diff or activity. Use ref to select a branch, tag or commit.", mcp.Object(merge(repository, map[string]any{"offset": map[string]any{"type": "integer", "minimum": 0}, "limit": mcpLimit}), "owner", "repo"), mcpReads, t.mcpBrowse("browserepo"))76+ add("list_issues", "List repository issues with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browseissues"))77+ add("read_issue", "Read an issue, its replies and authorized status changes.", collaborationDetail, mcpReads, t.mcpBrowse("browseissue"))78+ add("list_pull_requests", "List repository pull requests with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browsepulls"))79+ add("read_pull_request", "Read a pull request, its replies, authorized updates and available diff.", collaborationDetail, mcpReads, t.mcpBrowse("browsepull"))80+ add("list_files", "List stored files visible to your account.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "q": mcpText}), mcpReads, t.mcpBrowse("browsefiles"))81+ add("read_file", "Read a stored file's metadata and available preview by SHA-256 hash.", mcp.Object(map[string]any{"hash": mcpHash}, "hash"), mcpReads, t.mcpBrowse("browsefile"))82+ add("read_status", "Read service health, storage and job status. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus"))83+ add("read_management", "Read relay configuration, jobs, backups, delivery status or members with your key's permissions. gitstorage requires owner and repo.", mcp.Object(map[string]any{"method": map[string]any{"type": "string", "enum": mcpReadMethods}, "owner": mcpPubKey, "repo": mcpID}, "method"), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {84+ method := call.String("method")85+ if method == "gitstorage" {86+ if call.String("owner") == "" || call.String("repo") == "" {87+ return mcp.Failure("gitstorage needs owner and repo.", nil), nil88+ }89+ return t.mcpManage(ctx, call, method, call.String("owner"), call.String("repo"))90+ }91+ return t.mcpManage(ctx, call, method)92+ })93+94+ 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) {95+ return t.mcpManage(ctx, call, "runjob", call.String("id"))96+ })97+ add("add_job", "Add a background job. every is the interval in hours; 0 runs once. Pull and push jobs need ws:// or wss:// relay URLs; pull may instead discover relays for a public key. Dump and backup jobs may omit relays.", mcp.Object(map[string]any{"id": mcpID, "kind": map[string]any{"type": "string", "enum": []string{"pull", "push", "import", "mirror", "dump", "backup"}}, "relays": map[string]any{"type": "array", "items": mcpText}, "filter": map[string]any{"type": "string", "description": "Nostr filter encoded as a JSON object string."}, "every": map[string]any{"type": "integer", "minimum": 0, "description": "Interval in hours. Zero runs once."}, "discoverPubKey": mcpPubKey}, "id", "kind"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {98+ return t.mcpManage(ctx, call, "addjob", call.Arguments)99+ })100+ add("remove_job", "Remove a job and cancel its pending runs.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {101+ return t.mcpManage(ctx, call, "removejob", call.String("id"))102+ })103+ add("backup_now", "Queue a backup of relay data. Read jobs and backups through read_management to check completion.", mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {104+ return t.mcpManage(ctx, call, "backupnow")105+ })106+ add("dump_now", "Queue an event export. Read jobs and dumps through read_management to check completion.", mcp.Object(nil), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {107+ return t.mcpManage(ctx, call, "dumpnow")108+ })109+ add("set_policy", "Apply a partial relay policy update. Read the current policy before changing access, delivery or features. Requires the owner key.", mcp.Object(map[string]any{"patch": map[string]any{"type": "object", "minProperties": 1}}, "patch"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {110+ return t.mcpManage(ctx, call, "setpolicy", call.Arguments["patch"])111+ })112+ add("set_connections", "Replace the relay's connection list. Read the current list with read_management listconnections before editing it.", mcp.Object(map[string]any{"connections": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}}, "connections"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {113+ return t.mcpManage(ctx, call, "setconnections", call.Arguments["connections"])114+ })115+ 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) {116+ return t.mcpManage(ctx, call, "notifytest")117+ })118+119+ 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."))120+ 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))121+ 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))122+ 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))123+ 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))124+ return registry, err125+}126+127+func (t *Tenant) mcpBrowse(method string) mcp.Handler {128+ return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {129+ raw, err := json.Marshal(call.Arguments)130+ if err != nil {131+ return mcp.Result{}, err132+ }133+ result, err := t.Execute(ctx, call.Actor, method, []json.RawMessage{raw})134+ if err != nil {135+ return mcp.Result{}, err136+ }137+ return mcp.Value(result), nil138+ }139+}140+141+func (t *Tenant) mcpManage(ctx context.Context, call mcp.Call, method string, params ...any) (mcp.Result, error) {142+ raw := make([]json.RawMessage, 0, len(params))143+ for _, param := range params {144+ encoded, err := json.Marshal(param)145+ if err != nil {146+ return mcp.Result{}, err147+ }148+ raw = append(raw, encoded)149+ }150+ result, err := t.Execute(ctx, call.Actor, method, raw)151+ if err != nil {152+ return mcp.Result{}, err153+ }154+ return mcp.Value(map[string]any{"result": result}), nil155+}156+157+// mcpWrite serves a write tool. A signed event argument is checked against158+// the expected shape and published; without one the builder returns the159+// unsigned event for the client to sign.160+func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func(event.Event) error, shape string) mcp.Handler {161+ return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {162+ if raw, present := call.Arguments["event"]; present && raw != nil {163+ encoded, err := json.Marshal(raw)164+ if err != nil {165+ return mcp.Result{}, err166+ }167+ e, err := event.Parse(encoded)168+ if err == nil && check != nil {169+ err = check(e)170+ }171+ if err != nil {172+ return mcp.Failure("The event is not acceptable: "+err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil173+ }174+ return t.mcpPublish(ctx, call, e), nil175+ }176+ if build == nil {177+ return mcp.Failure("event is required. "+shape, map[string]any{"expected": shape}), nil178+ }179+ unsigned, err := build(call)180+ if err != nil {181+ return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil182+ }183+ return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil184+ }185+}186+187+func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result {188+ s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}}189+ if call.Request != nil {190+ if ip, _, err := net.SplitHostPort(call.Request.RemoteAddr); err == nil {191+ s.RemoteIP = ip192+ } else {193+ s.RemoteIP = call.Request.RemoteAddr194+ }195+ }196+ message, err := t.router.Publish(ctx, e, s)197+ if err != nil {198+ return mcp.Failure("The relay rejected the event: "+err.Error(), map[string]any{"event_id": e.ID, "accepted": false, "message": err.Error()})199+ }200+ return mcp.Value(map[string]any{"event_id": e.ID, "accepted": true, "message": message})201+}202+203+func mcpRepositoryTags(call mcp.Call) (string, string, [][]string, error) {204+ owner, repo := call.String("owner"), call.String("repo")205+ if owner == "" || repo == "" {206+ return "", "", nil, errors.New("owner and repo are required")207+ }208+ coordinate := "30617:" + owner + ":" + repo209+ return owner, coordinate, [][]string{{"a", coordinate}}, nil210+}211+212+func mcpLabels(call mcp.Call, tags [][]string) [][]string {213+ seen := map[string]bool{}214+ if labels, ok := call.Arguments["labels"].([]any); ok {215+ for _, label := range labels {216+ text, _ := label.(string)217+ text = strings.TrimSpace(text)218+ if text != "" && !seen[text] {219+ seen[text] = true220+ tags = append(tags, []string{"t", text})221+ }222+ }223+ }224+ return tags225+}226+227+func mcpBuildIssue(call mcp.Call) (mcpUnsigned, error) {228+ owner, _, tags, err := mcpRepositoryTags(call)229+ if err != nil {230+ return mcpUnsigned{}, err231+ }232+ title, content := strings.TrimSpace(call.String("title")), call.String("content")233+ if title == "" || strings.TrimSpace(content) == "" {234+ return mcpUnsigned{}, errors.New("title and content are required")235+ }236+ tags = mcpLabels(call, append(tags, []string{"p", owner}, []string{"subject", title}))237+ return mcpUnsigned{Kind: event.KIND_GIT_ISSUE, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil238+}239+240+func mcpBuildPull(call mcp.Call) (mcpUnsigned, error) {241+ owner, _, tags, err := mcpRepositoryTags(call)242+ if err != nil {243+ return mcpUnsigned{}, err244+ }245+ title, commit, clone, base := strings.TrimSpace(call.String("title")), call.String("commit"), call.String("clone"), call.String("merge_base")246+ if title == "" || commit == "" || clone == "" {247+ return mcpUnsigned{}, errors.New("title, commit and clone are required")248+ }249+ if err := mcpCloneURL(clone); err != nil {250+ return mcpUnsigned{}, err251+ }252+ tags = append(tags, []string{"p", owner}, []string{"subject", title}, []string{"c", commit}, []string{"clone", clone})253+ if base != "" {254+ tags = append(tags, []string{"merge-base", base})255+ }256+ return mcpUnsigned{Kind: event.KIND_GIT_PR, CreatedAt: time.Now().Unix(), Tags: mcpLabels(call, tags), Content: call.String("content")}, nil257+}258+259+func mcpBuildComment(call mcp.Call) (mcpUnsigned, error) {260+ _, _, tags, err := mcpRepositoryTags(call)261+ if err != nil {262+ return mcpUnsigned{}, err263+ }264+ root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")265+ rootKind := call.Int("root_kind")266+ if root == "" || rootPubKey == "" || rootKind == 0 || strings.TrimSpace(content) == "" {267+ return mcpUnsigned{}, errors.New("root, root_kind, root_pubkey and content are required")268+ }269+ parent, parentPubKey, parentKind := call.String("parent"), call.String("parent_pubkey"), call.Int("parent_kind")270+ if parent == "" {271+ parent, parentPubKey, parentKind = root, rootPubKey, rootKind272+ }273+ if parentPubKey == "" || parentKind == 0 {274+ return mcpUnsigned{}, errors.New("parent needs parent_kind and parent_pubkey")275+ }276+ tags = append(tags, []string{"E", root, "", rootPubKey}, []string{"K", strconv.Itoa(rootKind)}, []string{"P", rootPubKey}, []string{"e", parent, "", parentPubKey}, []string{"k", strconv.Itoa(parentKind)}, []string{"p", parentPubKey})277+ return mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil278+}279+280+func mcpBuildStatus(call mcp.Call) (mcpUnsigned, error) {281+ owner, _, tags, err := mcpRepositoryTags(call)282+ if err != nil {283+ return mcpUnsigned{}, err284+ }285+ root, rootPubKey, status := call.String("root"), call.String("root_pubkey"), call.String("status")286+ kind, known := mcpStatusKinds[status]287+ if root == "" || rootPubKey == "" || !known {288+ return mcpUnsigned{}, errors.New("root, root_pubkey and status are required")289+ }290+ tags = append(tags, []string{"e", root, "", "root"}, []string{"p", rootPubKey})291+ if owner != rootPubKey {292+ tags = append(tags, []string{"p", owner})293+ }294+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil295+}296+297+func mcpCoordinateOwner(e event.Event) (string, error) {298+ coordinate := event.Tag(e, "a")299+ if !mcpCoordinate.MatchString(coordinate) {300+ return "", errors.New(`the a tag must name the repository as 30617:<owner>:<repo>`)301+ }302+ return strings.Split(coordinate, ":")[1], nil303+}304+305+func mcpCheckIssue(e event.Event) error {306+ if e.Kind != event.KIND_GIT_ISSUE {307+ return fmt.Errorf("kind %d is not an issue", e.Kind)308+ }309+ return mcpCheckRoot(e)310+}311+312+func mcpCheckPull(e event.Event) error {313+ if e.Kind != event.KIND_GIT_PR {314+ return fmt.Errorf("kind %d is not a pull request", e.Kind)315+ }316+ if err := mcpCheckRoot(e); err != nil {317+ return err318+ }319+ if !hex40(event.Tag(e, "c")) || (event.Tag(e, "merge-base") != "" && !hex40(event.Tag(e, "merge-base"))) {320+ return errors.New("the c tag (and merge-base, when present) must be a full 40-hex commit ID")321+ }322+ return mcpCloneURL(event.Tag(e, "clone"))323+}324+325+func mcpCheckRoot(e event.Event) error {326+ owner, err := mcpCoordinateOwner(e)327+ if err != nil {328+ return err329+ }330+ if !contains(event.TagValues(e, "p"), owner) {331+ return errors.New("a p tag must name the repository owner")332+ }333+ if strings.TrimSpace(event.Tag(e, "subject")) == "" {334+ return errors.New("the subject tag carries the title and must not be empty")335+ }336+ if e.Kind == event.KIND_GIT_ISSUE && strings.TrimSpace(e.Content) == "" {337+ return errors.New("content must not be empty")338+ }339+ return nil340+}341+342+func mcpCheckComment(e event.Event) error {343+ if e.Kind != 1111 {344+ return fmt.Errorf("kind %d is not a comment", e.Kind)345+ }346+ if _, err := mcpCoordinateOwner(e); err != nil {347+ return err348+ }349+ rootKind := event.Tag(e, "K")350+ if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || !contains([]string{"1617", "1618", "1621"}, rootKind) {351+ return errors.New("the E, K and P tags must name the root event, its kind (1617, 1618 or 1621) and its author")352+ }353+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) || !contains([]string{"1111", rootKind}, event.Tag(e, "k")) {354+ return errors.New("the e, k and p tags must name the parent event, its kind and its author")355+ }356+ if strings.TrimSpace(e.Content) == "" {357+ return errors.New("content must not be empty")358+ }359+ return nil360+}361+362+func mcpCheckStatus(e event.Event) error {363+ if e.Kind < 1630 || e.Kind > 1633 {364+ return fmt.Errorf("kind %d is not a status", e.Kind)365+ }366+ if _, err := mcpCoordinateOwner(e); err != nil {367+ return err368+ }369+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {370+ return errors.New("the e and p tags must name the issue or pull request and its author")371+ }372+ return nil373+}374+375+func mcpCloneURL(raw string) error {376+ clone, err := url.Parse(raw)377+ if err != nil || (clone.Scheme != "http" && clone.Scheme != "https") || clone.Host == "" || clone.User != nil || clone.Fragment != "" {378+ return errors.New("clone must be an HTTP or HTTPS URL without credentials")379+ }380+ return nil381+}382+383+func hex64(value string) bool { return len(value) == 64 && isHexLower(value) }384+func hex40(value string) bool { return len(value) == 40 && isHexLower(value) }385+func isHexLower(value string) bool {386+ return value != "" && strings.Trim(value, "0123456789abcdef") == ""387+}388+389+func merge(base, extra map[string]any) map[string]any {390+ out := make(map[string]any, len(base)+len(extra))391+ for key, value := range base {392+ out[key] = value393+ }394+ for key, value := range extra {395+ out[key] = value396+ }397+ return out398+}
M internal/daemon/services.go +3 -0
M internal/daemon/tenant.go +2 -0
M internal/telemetry/telemetry.go +1 -1
@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}