| 1 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 2 | index 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 | } |
| 29 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 30 | new file mode 100644 |
| 31 | index 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 | +} |
| 152 | diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go |
| 153 | new file mode 100644 |
| 154 | index 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 | +} |
| 396 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 397 | new file mode 100644 |
| 398 | index 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 | +} |
| 800 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 801 | index 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() { |
| 814 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 815 | index 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 |
| 834 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 835 | index 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": {}} |