tinyrelaycommit

Commit 258831e820ec

browse files at this commit | history

1diff --git a/internal/daemon/http.go b/internal/daemon/http.go
2index c9a1e10..16209c9 100644
3--- a/internal/daemon/http.go
4+++ b/internal/daemon/http.go
5@@ -21,7 +21,7 @@ import (
6
7 func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8 w.Header().Set("Access-Control-Allow-Origin", "*")
9- w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-SHA-256, X-Content-Length, X-Content-Type, Upload-Type, Upload-Length, Upload-Offset")
10+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-SHA-256, X-Content-Length, X-Content-Type, Upload-Type, Upload-Length, Upload-Offset")
11 w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")
12 w.Header().Set("Access-Control-Expose-Headers", "Allow, X-Reason")
13 w.Header().Set("X-Content-Type-Options", "nosniff")
14@@ -68,6 +68,14 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
15 t.pushHTTP(w, r)
16 return
17 }
18+ if r.URL.Path == "/mcp" {
19+ t.mcpHTTP(w, r)
20+ return
21+ }
22+ if r.URL.Path == "/llms.txt" {
23+ t.llmsHTTP(w, r)
24+ return
25+ }
26 if t.tryBrowseHTTP(w, r) {
27 return
28 }
29diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
30new file mode 100644
31index 0000000..a548f05
32--- /dev/null
33+++ b/internal/daemon/mcp_http.go
34@@ -0,0 +1,117 @@
35+package daemon
36+
37+import (
38+ "net/http"
39+ "net/url"
40+ "strings"
41+
42+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
43+)
44+
45+// mcpChallenge is the WWW-Authenticate value for the MCP endpoint. The relay
46+// identifies callers by their Nostr key with NIP-98 rather than OAuth.
47+const mcpChallenge = `Nostr realm="tiny"`
48+
49+func (t *Tenant) initMCP() error {
50+ tools, err := t.mcpTools()
51+ if err != nil {
52+ return err
53+ }
54+ t.mcp = &mcp.Server{
55+ Tools: tools,
56+ Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},
57+ 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.",
58+ Authenticate: t.mcpAuthenticate,
59+ Challenge: mcpChallenge,
60+ Origin: t.mcpOrigin,
61+ MaxBodyBytes: t.app.cfg.MaxMessageBytes,
62+ }
63+ return nil
64+}
65+
66+// mcpAuthenticate verifies the NIP-98 token bound to POST, the full request
67+// URL and the SHA-256 of the body, exactly as the HTTP bridge does.
68+func (t *Tenant) mcpAuthenticate(r *http.Request, body []byte) (string, error) {
69+ s, err := t.session(r, body)
70+ if err != nil {
71+ return "", err
72+ }
73+ return first(s.PubKeys), nil
74+}
75+
76+func (t *Tenant) mcpOrigin(r *http.Request) string {
77+ base, err := url.Parse(t.requestURL(r))
78+ if err != nil {
79+ return ""
80+ }
81+ return base.Scheme + "://" + base.Host
82+}
83+
84+func (t *Tenant) mcpHTTP(w http.ResponseWriter, r *http.Request) {
85+ ctx, finish := t.app.telemetry.Start(r.Context(), "mcp")
86+ obs := t.mcp.Handle(w, r.WithContext(ctx))
87+ finish(obs.Outcome)
88+ t.app.telemetry.Logger().Info("mcp request", "tenant", t.meta.Name, "method", obs.Method, "tool", obs.Tool, "outcome", obs.Outcome, "status", obs.Status)
89+}
90+
91+// llmsHTTP describes the relay's machine surface for agents in plain text.
92+func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
93+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
94+ w.Header().Set("Allow", "GET, HEAD")
95+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
96+ return
97+ }
98+ p := t.Policy()
99+ name, description := p.Name, strings.ReplaceAll(strings.TrimSpace(p.Description), "\n", " ")
100+ if name == "" || privatePolicy(p) {
101+ // Private tenants keep their presentation behind authenticated reads,
102+ // as the NIP-11 document does.
103+ name = t.meta.Name
104+ }
105+ if privatePolicy(p) {
106+ description = ""
107+ }
108+ // The request carries the tenant prefix, so URLs are derived from it
109+ // rather than from the cached public URL.
110+ base := t.requestURL(r)
111+ if u, err := url.Parse(base); err == nil {
112+ u.RawQuery, u.Fragment = "", ""
113+ u.Path = strings.TrimSuffix(u.Path, "/llms.txt")
114+ base = strings.TrimRight(u.String(), "/")
115+ }
116+ lines := []string{"# " + name, ""}
117+ if description != "" {
118+ lines = append(lines, "> "+description, "")
119+ }
120+ lines = append(lines,
121+ "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.",
122+ "",
123+ "## MCP",
124+ "",
125+ "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",
126+ "- 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+"`.",
127+ "- 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.",
128+ "",
129+ "## HTTP bridge",
130+ "",
131+ "- POST "+base+"/events publishes one signed event. POST "+base+"/query and POST "+base+"/count take a JSON array of Nostr filters.",
132+ "- WebSocket "+t.RelayURL()+" speaks NIP-01. GET "+base+"/ with `Accept: application/nostr+json` returns the NIP-11 document.",
133+ "",
134+ "## Management",
135+ "",
136+ "- 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.",
137+ "",
138+ "## Git",
139+ "",
140+ "- GRASP repositories serve Git smart HTTP at "+base+"/npub1.../<repo>.git for clone and fetch. Push signs with NIP-98.",
141+ "",
142+ "## Documentation",
143+ "",
144+ "- MCP: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/mcp.md",
145+ "- All guides: https://github.com/FelineStateMachine/tinyrelay/tree/main/docs",
146+ "",
147+ )
148+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
149+ w.Header().Set("Cache-Control", "public, max-age=3600")
150+ _, _ = w.Write([]byte(strings.Join(lines, "\n")))
151+}
152diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go
153new file mode 100644
154index 0000000..e51b2fb
155--- /dev/null
156+++ b/internal/daemon/mcp_test.go
157@@ -0,0 +1,238 @@
158+package daemon
159+
160+import (
161+ "context"
162+ "encoding/json"
163+ "net/http"
164+ "net/http/httptest"
165+ "strings"
166+ "testing"
167+ "time"
168+
169+ "github.com/FelineStateMachine/tinyrelay/internal/event"
170+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
171+)
172+
173+type mcpCall struct {
174+ method string
175+ name string
176+ arguments map[string]any
177+ meta string
178+ version string
179+ sign bool
180+ headers map[string]string
181+}
182+
183+// mcpSequence keeps request bodies distinct so identical calls signed in
184+// the same second do not share a NIP-98 token id.
185+var mcpSequence int
186+
187+func (c mcpCall) do(t *testing.T, app *App, path string) (*httptest.ResponseRecorder, mcp.Response) {
188+ t.Helper()
189+ mcpSequence++
190+ params := map[string]any{"_meta": map[string]any{mcp.MetaProtocolVersion: mcp.Version, mcp.MetaClientCapabilities: map[string]any{}}}
191+ if c.meta != "" {
192+ params["_meta"].(map[string]any)[mcp.MetaProtocolVersion] = c.meta
193+ }
194+ if c.name != "" {
195+ params["name"] = c.name
196+ params["arguments"] = c.arguments
197+ }
198+ raw, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": mcpSequence, "method": c.method, "params": params})
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ r := httptest.NewRequest(http.MethodPost, "http://relay.test"+path, strings.NewReader(string(raw)))
203+ r.Header.Set("Content-Type", "application/json")
204+ r.Header.Set("Accept", "application/json, text/event-stream")
205+ version := c.version
206+ if version == "" {
207+ version = mcp.Version
208+ }
209+ r.Header.Set(mcp.HeaderProtocolVersion, version)
210+ r.Header.Set(mcp.HeaderMethod, c.method)
211+ if c.name != "" {
212+ r.Header.Set(mcp.HeaderName, c.name)
213+ }
214+ for key, value := range c.headers {
215+ r.Header.Set(key, value)
216+ }
217+ if c.sign {
218+ signRequest(t, r, string(raw))
219+ }
220+ w := httptest.NewRecorder()
221+ app.ServeHTTP(w, r)
222+ var response mcp.Response
223+ if strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
224+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
225+ t.Fatalf("decode %s: %v", w.Body.String(), err)
226+ }
227+ }
228+ return w, response
229+}
230+
231+func mcpToolResult(t *testing.T, response mcp.Response) (map[string]any, bool) {
232+ t.Helper()
233+ result, _ := response.Result.(map[string]any)
234+ if result == nil {
235+ t.Fatalf("no result: %+v", response)
236+ }
237+ isError, _ := result["isError"].(bool)
238+ return result, isError
239+}
240+
241+func TestMCPListsAndCallsToolsAsTheSignedKey(t *testing.T) {
242+ app, tenant := testTenant(t)
243+ policy := tenant.Policy()
244+ policy.Features.Grasp = true
245+ if err := tenant.applyPolicy(context.Background(), policy); err != nil {
246+ t.Fatal(err)
247+ }
248+ for _, path := range []string{"/mcp", "/r/main/mcp"} {
249+ w, response := mcpCall{method: "tools/list", sign: true}.do(t, app, path)
250+ result, _ := response.Result.(map[string]any)
251+ tools, _ := result["tools"].([]any)
252+ if w.Code != http.StatusOK || len(tools) < 20 || result["resultType"] != "complete" || result["cacheScope"] != "private" || w.Header().Get("Mcp-Session-Id") != "" {
253+ t.Fatalf("%s tools/list: %d %s", path, w.Code, w.Body.String())
254+ }
255+ names := map[string]bool{}
256+ for _, tool := range tools {
257+ names[tool.(map[string]any)["name"].(string)] = true
258+ }
259+ for _, want := range []string{"list_repositories", "read_issue", "read_management", "run_job", "set_policy", "publish_event", "create_issue", "create_pull_request", "comment", "set_status"} {
260+ if !names[want] {
261+ t.Fatalf("%s missing tool %s", path, want)
262+ }
263+ }
264+ }
265+ w, response := mcpCall{method: "tools/call", name: "read_management", arguments: map[string]any{"method": "getpolicy"}, sign: true}.do(t, app, "/mcp")
266+ result, isError := mcpToolResult(t, response)
267+ structured, _ := result["structuredContent"].(map[string]any)
268+ policyResult, _ := structured["result"].(map[string]any)
269+ if w.Code != http.StatusOK || isError || policyResult["owner"] != tenant.Policy().Owner {
270+ t.Fatalf("read_management: %d %s", w.Code, w.Body.String())
271+ }
272+ if content, _ := result["content"].([]any); len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), tenant.Policy().Owner) {
273+ t.Fatalf("read_management content %v", result["content"])
274+ }
275+ w, response = mcpCall{method: "tools/call", name: "list_repositories", arguments: map[string]any{}, sign: true}.do(t, app, "/mcp")
276+ result, isError = mcpToolResult(t, response)
277+ if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["next_cursor"] != "" {
278+ t.Fatalf("list_repositories: %d %s", w.Code, w.Body.String())
279+ }
280+ w, response = mcpCall{method: "tools/call", name: "read_file", arguments: map[string]any{"hash": "short"}, sign: true}.do(t, app, "/mcp")
281+ result, isError = mcpToolResult(t, response)
282+ if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "hash does not match") {
283+ t.Fatalf("schema validation: %d %s", w.Code, w.Body.String())
284+ }
285+}
286+
287+func TestMCPWriteToolsBuildValidateAndPublish(t *testing.T) {
288+ app, tenant := testTenant(t)
289+ policy := tenant.Policy()
290+ policy.Features.Grasp = true
291+ if err := tenant.applyPolicy(context.Background(), policy); err != nil {
292+ t.Fatal(err)
293+ }
294+ owner := tenant.Policy().Owner
295+ 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")
296+ result, isError := mcpToolResult(t, response)
297+ unsigned, _ := result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
298+ if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1621) || unsigned["content"] != "It fails." {
299+ t.Fatalf("create_issue template: %d %s", w.Code, w.Body.String())
300+ }
301+ tags, _ := json.Marshal(unsigned["tags"])
302+ if string(tags) != `[["a","30617:`+owner+`:tiny"],["p","`+owner+`"],["subject","Broken build"],["t","bug"]]` {
303+ t.Fatalf("create_issue tags %s", tags)
304+ }
305+ sign := func(kind int, tags [][]string, content string) map[string]any {
306+ e := event.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}
307+ if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
308+ t.Fatal(err)
309+ }
310+ raw, _ := json.Marshal(e)
311+ var value map[string]any
312+ _ = json.Unmarshal(raw, &value)
313+ return value
314+ }
315+ 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")
316+ result, isError = mcpToolResult(t, response)
317+ 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>"]`) {
318+ t.Fatalf("create_issue malformed: %d %s", w.Code, w.Body.String())
319+ }
320+ 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")
321+ result, isError = mcpToolResult(t, response)
322+ published, _ := result["structuredContent"].(map[string]any)
323+ if w.Code != http.StatusOK || isError || published["accepted"] != true || published["event_id"] == "" {
324+ t.Fatalf("create_issue publish: %d %s", w.Code, w.Body.String())
325+ }
326+ rootID, _ := published["event_id"].(string)
327+ 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")
328+ result, isError = mcpToolResult(t, response)
329+ unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
330+ tags, _ = json.Marshal(unsigned["tags"])
331+ 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+`"]]` {
332+ t.Fatalf("comment template: %d %s", w.Code, w.Body.String())
333+ }
334+ 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")
335+ result, isError = mcpToolResult(t, response)
336+ unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
337+ if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1631) {
338+ t.Fatalf("set_status template: %d %s", w.Code, w.Body.String())
339+ }
340+ 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")
341+ result, isError = mcpToolResult(t, response)
342+ if w.Code != http.StatusOK || isError || result["structuredContent"].(map[string]any)["accepted"] != true {
343+ t.Fatalf("publish_event: %d %s", w.Code, w.Body.String())
344+ }
345+ query := httptest.NewRequest(http.MethodPost, "http://relay.test/query", strings.NewReader(`[{"kinds":[1,1621]}]`))
346+ signRequest(t, query, `[{"kinds":[1,1621]}]`)
347+ queried := httptest.NewRecorder()
348+ app.ServeHTTP(queried, query)
349+ var rows []event.Event
350+ if err := json.Unmarshal(queried.Body.Bytes(), &rows); err != nil || len(rows) != 2 {
351+ t.Fatalf("query after publish: %s %v", queried.Body.String(), err)
352+ }
353+}
354+
355+func TestMCPRejectsUnauthorizedForeignAndMismatchedRequests(t *testing.T) {
356+ app, _ := testTenant(t)
357+ w, response := mcpCall{method: "tools/list"}.do(t, app, "/mcp")
358+ if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != `Nostr realm="tiny"` || response.Error == nil {
359+ t.Fatalf("unsigned: %d %q %s", w.Code, w.Header().Get("WWW-Authenticate"), w.Body.String())
360+ }
361+ w, response = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "https://foreign.test"}}.do(t, app, "/mcp")
362+ if w.Code != http.StatusForbidden || response.Error == nil {
363+ t.Fatalf("foreign origin: %d %s", w.Code, w.Body.String())
364+ }
365+ w, _ = mcpCall{method: "tools/list", sign: true, headers: map[string]string{"Origin": "http://relay.test"}}.do(t, app, "/mcp")
366+ if w.Code != http.StatusOK {
367+ t.Fatalf("same origin: %d %s", w.Code, w.Body.String())
368+ }
369+ w, response = mcpCall{method: "tools/list", sign: true, meta: "2025-11-25"}.do(t, app, "/mcp")
370+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeHeaderMismatch {
371+ t.Fatalf("version mismatch: %d %s", w.Code, w.Body.String())
372+ }
373+ w, response = mcpCall{method: "tools/list", sign: true, version: "2025-11-25", meta: "2025-11-25"}.do(t, app, "/mcp")
374+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != mcp.CodeUnsupportedProtocolVersion {
375+ t.Fatalf("unsupported version: %d %s", w.Code, w.Body.String())
376+ }
377+ w, response = mcpCall{method: "initialize", sign: true}.do(t, app, "/mcp")
378+ if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != mcp.CodeMethodNotFound {
379+ t.Fatalf("initialize: %d %s", w.Code, w.Body.String())
380+ }
381+ for _, method := range []string{http.MethodGet, http.MethodDelete} {
382+ r := httptest.NewRequest(method, "http://relay.test/mcp", nil)
383+ w := httptest.NewRecorder()
384+ app.ServeHTTP(w, r)
385+ if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != "POST" {
386+ t.Fatalf("%s: %d", method, w.Code)
387+ }
388+ }
389+ r := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/llms.txt", nil)
390+ w = httptest.NewRecorder()
391+ app.ServeHTTP(w, r)
392+ 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) {
393+ t.Fatalf("llms.txt: %d %s", w.Code, w.Body.String())
394+ }
395+}
396diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
397new file mode 100644
398index 0000000..be9db0a
399--- /dev/null
400+++ b/internal/daemon/mcp_tools.go
401@@ -0,0 +1,398 @@
402+package daemon
403+
404+import (
405+ "context"
406+ "encoding/json"
407+ "errors"
408+ "fmt"
409+ "net"
410+ "net/url"
411+ "regexp"
412+ "strconv"
413+ "strings"
414+ "time"
415+
416+ "github.com/FelineStateMachine/tinyrelay/internal/event"
417+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
418+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
419+)
420+
421+// The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.
422+// Reads run through the browse methods, controls through the NIP-86
423+// management path, and writes through the same publish path as POST /events,
424+// so every tool is executed as the signed-in pubkey with the tenant's
425+// permission checks.
426+
427+var (
428+ mcpText = map[string]any{"type": "string"}
429+ mcpPubKey = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hex public key."}
430+ mcpHash = map[string]any{"type": "string", "pattern": "^[0-9a-f]{64}$"}
431+ mcpID = map[string]any{"type": "string", "minLength": 1}
432+ mcpLimit = map[string]any{"type": "integer", "minimum": 1, "maximum": 100}
433+ mcpCommit = map[string]any{"type": "string", "pattern": "^[0-9a-f]{40}$", "description": "Full Git commit ID."}
434+ mcpEvent = map[string]any{"type": "object", "description": "A signed Nostr event: id, pubkey, created_at, kind, tags, content and sig."}
435+ mcpRootKind = map[string]any{"type": "integer", "enum": []int{1617, 1618, 1621}, "description": "Kind of the conversation root: 1621 issue, 1618 pull request, 1617 patch."}
436+ mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}
437+ mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}
438+ mcpSettings = map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}
439+ mcpPublishes = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}
440+
441+ mcpCoordinate = regexp.MustCompile(`^30617:[0-9a-f]{64}:.+$`)
442+ mcpReadMethods = []string{"stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"}
443+ mcpStatusKinds = map[string]int{"open": 1630, "resolved": 1631, "merged": 1631, "closed": 1632, "draft": 1633}
444+)
445+
446+const (
447+ 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.`
448+ 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.`
449+ 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.`
450+ 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>"].`
451+ mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."
452+)
453+
454+// mcpUnsigned is the event body a client signs before calling a write tool
455+// again.
456+type mcpUnsigned struct {
457+ Kind int `json:"kind"`
458+ CreatedAt int64 `json:"created_at"`
459+ Tags [][]string `json:"tags"`
460+ Content string `json:"content"`
461+}
462+
463+func (t *Tenant) mcpTools() (*mcp.Registry, error) {
464+ registry := &mcp.Registry{}
465+ var err error
466+ add := func(name, description string, schema, annotations map[string]any, handler mcp.Handler) {
467+ if err == nil {
468+ err = registry.Add(mcp.Tool{Name: name, Description: description, InputSchema: schema, Annotations: annotations, Handler: handler})
469+ }
470+ }
471+ 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"}}}
472+ 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")
473+ collaborationDetail := mcp.Object(map[string]any{"owner": mcpPubKey, "repo": mcpID, "event": mcpHash, "cursor": mcpText, "limit": mcpLimit}, "owner", "repo", "event")
474+
475+ 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"))
476+ 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"))
477+ add("list_issues", "List repository issues with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browseissues"))
478+ add("read_issue", "Read an issue, its replies and authorized status changes.", collaborationDetail, mcpReads, t.mcpBrowse("browseissue"))
479+ add("list_pull_requests", "List repository pull requests with search, status filters and pagination.", collaborationList, mcpReads, t.mcpBrowse("browsepulls"))
480+ add("read_pull_request", "Read a pull request, its replies, authorized updates and available diff.", collaborationDetail, mcpReads, t.mcpBrowse("browsepull"))
481+ 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"))
482+ 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"))
483+ add("read_status", "Read service health, storage and job status. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus"))
484+ 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) {
485+ method := call.String("method")
486+ if method == "gitstorage" {
487+ if call.String("owner") == "" || call.String("repo") == "" {
488+ return mcp.Failure("gitstorage needs owner and repo.", nil), nil
489+ }
490+ return t.mcpManage(ctx, call, method, call.String("owner"), call.String("repo"))
491+ }
492+ return t.mcpManage(ctx, call, method)
493+ })
494+
495+ 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) {
496+ return t.mcpManage(ctx, call, "runjob", call.String("id"))
497+ })
498+ 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) {
499+ return t.mcpManage(ctx, call, "addjob", call.Arguments)
500+ })
501+ 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) {
502+ return t.mcpManage(ctx, call, "removejob", call.String("id"))
503+ })
504+ 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) {
505+ return t.mcpManage(ctx, call, "backupnow")
506+ })
507+ 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) {
508+ return t.mcpManage(ctx, call, "dumpnow")
509+ })
510+ 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) {
511+ return t.mcpManage(ctx, call, "setpolicy", call.Arguments["patch"])
512+ })
513+ 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) {
514+ return t.mcpManage(ctx, call, "setconnections", call.Arguments["connections"])
515+ })
516+ 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) {
517+ return t.mcpManage(ctx, call, "notifytest")
518+ })
519+
520+ 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."))
521+ 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))
522+ 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))
523+ 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))
524+ 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))
525+ return registry, err
526+}
527+
528+func (t *Tenant) mcpBrowse(method string) mcp.Handler {
529+ return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
530+ raw, err := json.Marshal(call.Arguments)
531+ if err != nil {
532+ return mcp.Result{}, err
533+ }
534+ result, err := t.Execute(ctx, call.Actor, method, []json.RawMessage{raw})
535+ if err != nil {
536+ return mcp.Result{}, err
537+ }
538+ return mcp.Value(result), nil
539+ }
540+}
541+
542+func (t *Tenant) mcpManage(ctx context.Context, call mcp.Call, method string, params ...any) (mcp.Result, error) {
543+ raw := make([]json.RawMessage, 0, len(params))
544+ for _, param := range params {
545+ encoded, err := json.Marshal(param)
546+ if err != nil {
547+ return mcp.Result{}, err
548+ }
549+ raw = append(raw, encoded)
550+ }
551+ result, err := t.Execute(ctx, call.Actor, method, raw)
552+ if err != nil {
553+ return mcp.Result{}, err
554+ }
555+ return mcp.Value(map[string]any{"result": result}), nil
556+}
557+
558+// mcpWrite serves a write tool. A signed event argument is checked against
559+// the expected shape and published; without one the builder returns the
560+// unsigned event for the client to sign.
561+func (t *Tenant) mcpWrite(build func(mcp.Call) (mcpUnsigned, error), check func(event.Event) error, shape string) mcp.Handler {
562+ return func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
563+ if raw, present := call.Arguments["event"]; present && raw != nil {
564+ encoded, err := json.Marshal(raw)
565+ if err != nil {
566+ return mcp.Result{}, err
567+ }
568+ e, err := event.Parse(encoded)
569+ if err == nil && check != nil {
570+ err = check(e)
571+ }
572+ if err != nil {
573+ return mcp.Failure("The event is not acceptable: "+err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil
574+ }
575+ return t.mcpPublish(ctx, call, e), nil
576+ }
577+ if build == nil {
578+ return mcp.Failure("event is required. "+shape, map[string]any{"expected": shape}), nil
579+ }
580+ unsigned, err := build(call)
581+ if err != nil {
582+ return mcp.Failure(err.Error()+"\n"+shape, map[string]any{"expected": shape}), nil
583+ }
584+ return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil
585+ }
586+}
587+
588+func (t *Tenant) mcpPublish(ctx context.Context, call mcp.Call, e event.Event) mcp.Result {
589+ s := relay.Session{RelayURL: t.RelayURL(), PubKeys: []string{call.Actor}}
590+ if call.Request != nil {
591+ if ip, _, err := net.SplitHostPort(call.Request.RemoteAddr); err == nil {
592+ s.RemoteIP = ip
593+ } else {
594+ s.RemoteIP = call.Request.RemoteAddr
595+ }
596+ }
597+ message, err := t.router.Publish(ctx, e, s)
598+ if err != nil {
599+ return mcp.Failure("The relay rejected the event: "+err.Error(), map[string]any{"event_id": e.ID, "accepted": false, "message": err.Error()})
600+ }
601+ return mcp.Value(map[string]any{"event_id": e.ID, "accepted": true, "message": message})
602+}
603+
604+func mcpRepositoryTags(call mcp.Call) (string, string, [][]string, error) {
605+ owner, repo := call.String("owner"), call.String("repo")
606+ if owner == "" || repo == "" {
607+ return "", "", nil, errors.New("owner and repo are required")
608+ }
609+ coordinate := "30617:" + owner + ":" + repo
610+ return owner, coordinate, [][]string{{"a", coordinate}}, nil
611+}
612+
613+func mcpLabels(call mcp.Call, tags [][]string) [][]string {
614+ seen := map[string]bool{}
615+ if labels, ok := call.Arguments["labels"].([]any); ok {
616+ for _, label := range labels {
617+ text, _ := label.(string)
618+ text = strings.TrimSpace(text)
619+ if text != "" && !seen[text] {
620+ seen[text] = true
621+ tags = append(tags, []string{"t", text})
622+ }
623+ }
624+ }
625+ return tags
626+}
627+
628+func mcpBuildIssue(call mcp.Call) (mcpUnsigned, error) {
629+ owner, _, tags, err := mcpRepositoryTags(call)
630+ if err != nil {
631+ return mcpUnsigned{}, err
632+ }
633+ title, content := strings.TrimSpace(call.String("title")), call.String("content")
634+ if title == "" || strings.TrimSpace(content) == "" {
635+ return mcpUnsigned{}, errors.New("title and content are required")
636+ }
637+ tags = mcpLabels(call, append(tags, []string{"p", owner}, []string{"subject", title}))
638+ return mcpUnsigned{Kind: event.KIND_GIT_ISSUE, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
639+}
640+
641+func mcpBuildPull(call mcp.Call) (mcpUnsigned, error) {
642+ owner, _, tags, err := mcpRepositoryTags(call)
643+ if err != nil {
644+ return mcpUnsigned{}, err
645+ }
646+ title, commit, clone, base := strings.TrimSpace(call.String("title")), call.String("commit"), call.String("clone"), call.String("merge_base")
647+ if title == "" || commit == "" || clone == "" {
648+ return mcpUnsigned{}, errors.New("title, commit and clone are required")
649+ }
650+ if err := mcpCloneURL(clone); err != nil {
651+ return mcpUnsigned{}, err
652+ }
653+ tags = append(tags, []string{"p", owner}, []string{"subject", title}, []string{"c", commit}, []string{"clone", clone})
654+ if base != "" {
655+ tags = append(tags, []string{"merge-base", base})
656+ }
657+ return mcpUnsigned{Kind: event.KIND_GIT_PR, CreatedAt: time.Now().Unix(), Tags: mcpLabels(call, tags), Content: call.String("content")}, nil
658+}
659+
660+func mcpBuildComment(call mcp.Call) (mcpUnsigned, error) {
661+ _, _, tags, err := mcpRepositoryTags(call)
662+ if err != nil {
663+ return mcpUnsigned{}, err
664+ }
665+ root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")
666+ rootKind := call.Int("root_kind")
667+ if root == "" || rootPubKey == "" || rootKind == 0 || strings.TrimSpace(content) == "" {
668+ return mcpUnsigned{}, errors.New("root, root_kind, root_pubkey and content are required")
669+ }
670+ parent, parentPubKey, parentKind := call.String("parent"), call.String("parent_pubkey"), call.Int("parent_kind")
671+ if parent == "" {
672+ parent, parentPubKey, parentKind = root, rootPubKey, rootKind
673+ }
674+ if parentPubKey == "" || parentKind == 0 {
675+ return mcpUnsigned{}, errors.New("parent needs parent_kind and parent_pubkey")
676+ }
677+ 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})
678+ return mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
679+}
680+
681+func mcpBuildStatus(call mcp.Call) (mcpUnsigned, error) {
682+ owner, _, tags, err := mcpRepositoryTags(call)
683+ if err != nil {
684+ return mcpUnsigned{}, err
685+ }
686+ root, rootPubKey, status := call.String("root"), call.String("root_pubkey"), call.String("status")
687+ kind, known := mcpStatusKinds[status]
688+ if root == "" || rootPubKey == "" || !known {
689+ return mcpUnsigned{}, errors.New("root, root_pubkey and status are required")
690+ }
691+ tags = append(tags, []string{"e", root, "", "root"}, []string{"p", rootPubKey})
692+ if owner != rootPubKey {
693+ tags = append(tags, []string{"p", owner})
694+ }
695+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil
696+}
697+
698+func mcpCoordinateOwner(e event.Event) (string, error) {
699+ coordinate := event.Tag(e, "a")
700+ if !mcpCoordinate.MatchString(coordinate) {
701+ return "", errors.New(`the a tag must name the repository as 30617:<owner>:<repo>`)
702+ }
703+ return strings.Split(coordinate, ":")[1], nil
704+}
705+
706+func mcpCheckIssue(e event.Event) error {
707+ if e.Kind != event.KIND_GIT_ISSUE {
708+ return fmt.Errorf("kind %d is not an issue", e.Kind)
709+ }
710+ return mcpCheckRoot(e)
711+}
712+
713+func mcpCheckPull(e event.Event) error {
714+ if e.Kind != event.KIND_GIT_PR {
715+ return fmt.Errorf("kind %d is not a pull request", e.Kind)
716+ }
717+ if err := mcpCheckRoot(e); err != nil {
718+ return err
719+ }
720+ if !hex40(event.Tag(e, "c")) || (event.Tag(e, "merge-base") != "" && !hex40(event.Tag(e, "merge-base"))) {
721+ return errors.New("the c tag (and merge-base, when present) must be a full 40-hex commit ID")
722+ }
723+ return mcpCloneURL(event.Tag(e, "clone"))
724+}
725+
726+func mcpCheckRoot(e event.Event) error {
727+ owner, err := mcpCoordinateOwner(e)
728+ if err != nil {
729+ return err
730+ }
731+ if !contains(event.TagValues(e, "p"), owner) {
732+ return errors.New("a p tag must name the repository owner")
733+ }
734+ if strings.TrimSpace(event.Tag(e, "subject")) == "" {
735+ return errors.New("the subject tag carries the title and must not be empty")
736+ }
737+ if e.Kind == event.KIND_GIT_ISSUE && strings.TrimSpace(e.Content) == "" {
738+ return errors.New("content must not be empty")
739+ }
740+ return nil
741+}
742+
743+func mcpCheckComment(e event.Event) error {
744+ if e.Kind != 1111 {
745+ return fmt.Errorf("kind %d is not a comment", e.Kind)
746+ }
747+ if _, err := mcpCoordinateOwner(e); err != nil {
748+ return err
749+ }
750+ rootKind := event.Tag(e, "K")
751+ if !hex64(event.Tag(e, "E")) || !hex64(event.Tag(e, "P")) || !contains([]string{"1617", "1618", "1621"}, rootKind) {
752+ return errors.New("the E, K and P tags must name the root event, its kind (1617, 1618 or 1621) and its author")
753+ }
754+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) || !contains([]string{"1111", rootKind}, event.Tag(e, "k")) {
755+ return errors.New("the e, k and p tags must name the parent event, its kind and its author")
756+ }
757+ if strings.TrimSpace(e.Content) == "" {
758+ return errors.New("content must not be empty")
759+ }
760+ return nil
761+}
762+
763+func mcpCheckStatus(e event.Event) error {
764+ if e.Kind < 1630 || e.Kind > 1633 {
765+ return fmt.Errorf("kind %d is not a status", e.Kind)
766+ }
767+ if _, err := mcpCoordinateOwner(e); err != nil {
768+ return err
769+ }
770+ if !hex64(event.Tag(e, "e")) || !hex64(event.Tag(e, "p")) {
771+ return errors.New("the e and p tags must name the issue or pull request and its author")
772+ }
773+ return nil
774+}
775+
776+func mcpCloneURL(raw string) error {
777+ clone, err := url.Parse(raw)
778+ if err != nil || (clone.Scheme != "http" && clone.Scheme != "https") || clone.Host == "" || clone.User != nil || clone.Fragment != "" {
779+ return errors.New("clone must be an HTTP or HTTPS URL without credentials")
780+ }
781+ return nil
782+}
783+
784+func hex64(value string) bool { return len(value) == 64 && isHexLower(value) }
785+func hex40(value string) bool { return len(value) == 40 && isHexLower(value) }
786+func isHexLower(value string) bool {
787+ return value != "" && strings.Trim(value, "0123456789abcdef") == ""
788+}
789+
790+func merge(base, extra map[string]any) map[string]any {
791+ out := make(map[string]any, len(base)+len(extra))
792+ for key, value := range base {
793+ out[key] = value
794+ }
795+ for key, value := range extra {
796+ out[key] = value
797+ }
798+ return out
799+}
800diff --git a/internal/daemon/services.go b/internal/daemon/services.go
801index 985721c..6c7a924 100644
802--- a/internal/daemon/services.go
803+++ b/internal/daemon/services.go
804@@ -127,6 +127,9 @@ func (t *Tenant) initServices(ctx context.Context) error {
805 if err != nil {
806 return err
807 }
808+ if err := t.initMCP(); err != nil {
809+ return err
810+ }
811 t.workCtx, t.workCancel = context.WithCancel(context.Background())
812 t.workWG.Add(1)
813 go func() {
814diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
815index 59190b2..1d049f2 100644
816--- a/internal/daemon/tenant.go
817+++ b/internal/daemon/tenant.go
818@@ -20,6 +20,7 @@ import (
819 "github.com/FelineStateMachine/tinyrelay/internal/event"
820 "github.com/FelineStateMachine/tinyrelay/internal/gates"
821 "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
822+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
823 "github.com/FelineStateMachine/tinyrelay/internal/policy"
824 "github.com/FelineStateMachine/tinyrelay/internal/records"
825 "github.com/FelineStateMachine/tinyrelay/internal/relay"
826@@ -54,6 +55,7 @@ type Tenant struct {
827 replication *replication.Service
828 git *gitrelay.GitRelay
829 ui *webui.App
830+ mcp *mcp.Server
831 schedulerWake chan struct{}
832 workCtx context.Context
833 workCancel context.CancelFunc
834diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
835index 8fd8c9d..efb28bd 100644
836--- a/internal/telemetry/telemetry.go
837+++ b/internal/telemetry/telemetry.go
838@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
839 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
840 }
841
842-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": {}}
843+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": {}}
844 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}
845 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
846 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": {}}

About

ownerc01503e8035f
visibilitypublic
head258831e820ec

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs