tinyrelaycommit

Commit 5324ed74b4e6

browse files at this commit | history

1diff --git a/docs/mcp.md b/docs/mcp.md
2index e124529..29b68fa 100644
3--- a/docs/mcp.md
4+++ b/docs/mcp.md
5@@ -108,6 +108,7 @@ Write tools, which publish through the same path as `POST /events`:
6 | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. |
7 | `publish_wiki_page` | Publish or replace the key's version of a kind 30818 wiki page, with `fork_author` and `fork_event` to record a fork. |
8 | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. |
9+| `publish_site` | Publish a static site manifest, kind 15128 for the key's own site or kind 35128 for a named site, from `paths` given as `[path, sha256]` pairs and an optional `expiration`. |
10 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |
11 | `request_decision` | Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a `request` tag. |
12 | `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5999 carrying its inputs, output type, parameters, bid and relays. |
13@@ -126,6 +127,10 @@ The relay never signs on a caller's behalf. Call a write tool with plain fields,
14
15 The person answers with a kind 7 reaction to the published event from the asked key: `+` approves, `-` declines and any other content is their reply. Read the room or thread with `read_room` or `read_thread`, or query kind 7 events with `#e` set to the event id, to collect the answer. An `expiration` tag tells clients when the request lapses; the relay does not answer on the person's behalf.
16
17+### Static sites
18+
19+`publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).
20+
21 ### Long tasks
22
23 `request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag.
24diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
25index b984c2e..294b327 100644
26--- a/internal/daemon/mcp_http.go
27+++ b/internal/daemon/mcp_http.go
28@@ -93,7 +93,11 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
29 "- Methods: server/discover, tools/list and tools/call.",
30 "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_jobs and read_job.",
31 "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, add_callback, remove_callback, pause_callback and resume_callback. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",
32- "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000.",
33+ "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",
34+ "",
35+ "## Files",
36+ "",
37+ "- Blossom: PUT "+base+"/upload stores a file by its SHA-256 with a kind 24242 authorization; GET "+base+"/<sha256> serves it. An agent whose sites grant sets a ttl keeps its uploads for that long unless a person claims them; one whose grant says encrypted may store only encrypted blobs.",
38 "",
39 "## HTTP bridge",
40 "",
41diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go
42index 87a8dee..6e2e5eb 100644
43--- a/internal/daemon/mcp_test.go
44+++ b/internal/daemon/mcp_test.go
45@@ -12,6 +12,7 @@ import (
46
47 "github.com/FelineStateMachine/tinyrelay/internal/event"
48 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
49+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
50 )
51
52 type mcpCall struct {
53@@ -272,6 +273,81 @@ func mcpSigned(t *testing.T, secret string, kind int, tags [][]string, content s
54 return value
55 }
56
57+// TestMCPPublishSiteBuildsChecksAndPublishes covers the site manifest tool:
58+// the template for the caller's own site and a named site, refusals of
59+// labels under other keys and bad paths, and publishing the signed event.
60+func TestMCPPublishSiteBuildsChecksAndPublishes(t *testing.T) {
61+ app, tenant := testTenant(t)
62+ owner := tenant.Policy().Owner
63+ member, _ := event.PublicKey(testMemberSecret)
64+ call := func(name string, arguments map[string]any) (map[string]any, bool) {
65+ t.Helper()
66+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true}.do(t, app, "/mcp")
67+ if w.Code != http.StatusOK {
68+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
69+ }
70+ return mcpToolResult(t, response)
71+ }
72+ structured := func(result map[string]any) map[string]any {
73+ value, _ := result["structuredContent"].(map[string]any)
74+ return value
75+ }
76+ text := func(result map[string]any) string {
77+ content, _ := result["content"].([]any)
78+ if len(content) == 0 {
79+ return ""
80+ }
81+ return content[0].(map[string]any)["text"].(string)
82+ }
83+ hash := strings.Repeat("a", 64)
84+ paths := []any{[]any{"/index.html", hash}, []any{"/style.css", strings.ToUpper(hash)}}
85+ expiration := time.Now().Unix() + 86400
86+ result, isError := call("publish_site", map[string]any{"paths": paths, "expiration": expiration})
87+ unsigned, _ := structured(result)["unsigned"].(map[string]any)
88+ tags, _ := json.Marshal(unsigned["tags"])
89+ if isError || unsigned["kind"] != float64(sites.KindSite) || string(tags) != `[["path","/index.html","`+hash+`"],["path","/style.css","`+hash+`"],["expiration","`+strconv.FormatInt(expiration, 10)+`"]]` {
90+ t.Fatalf("publish_site template: %s", text(result))
91+ }
92+ base, _ := sites.Base36(owner)
93+ result, isError = call("publish_site", map[string]any{"label": base + "docs", "paths": paths})
94+ unsigned, _ = structured(result)["unsigned"].(map[string]any)
95+ tags, _ = json.Marshal(unsigned["tags"])
96+ if isError || unsigned["kind"] != float64(sites.KindNamedSite) || !strings.HasPrefix(string(tags), `[["d","docs"],["path",`) {
97+ t.Fatalf("publish_site named template: %s", text(result))
98+ }
99+ other, _ := sites.Base36(member)
100+ for name, arguments := range map[string]map[string]any{
101+ "other key": {"label": other + "docs", "paths": paths},
102+ "bad label": {"label": "nope", "paths": paths},
103+ "no paths": {},
104+ "odd pair": {"paths": []any{[]any{"/index.html"}}},
105+ "bad path": {"paths": []any{[]any{"index.html", hash}}},
106+ "bad hash": {"paths": []any{[]any{"/index.html", "short"}}},
107+ "duplicate": {"paths": []any{[]any{"/index.html", hash}, []any{"/index.html", hash}}},
108+ "past expiry": {"paths": paths, "expiration": 1},
109+ } {
110+ if result, isError := call("publish_site", arguments); !isError || !strings.Contains(text(result), "kind 15128") {
111+ t.Fatalf("%s accepted: %s", name, text(result))
112+ }
113+ }
114+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/../x.js", hash}}, "")})
115+ if !isError || !strings.Contains(text(result), "site path") {
116+ t.Fatalf("publish_site bad signed path: %s", text(result))
117+ }
118+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, 1, [][]string{}, "not a site")})
119+ if !isError || !strings.Contains(text(result), "not a site manifest") {
120+ t.Fatalf("publish_site wrong kind: %s", text(result))
121+ }
122+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/index.html", hash}, {"expiration", strconv.FormatInt(expiration, 10)}}, "")})
123+ if isError || structured(result)["accepted"] != true {
124+ t.Fatalf("publish_site publish: %s", text(result))
125+ }
126+ manifest, err := tenant.sites.Manifest(context.Background(), sites.Site{Kind: sites.KindSite, PubKey: owner})
127+ if err != nil || manifest.Kind != sites.KindSite {
128+ t.Fatalf("published manifest not indexed: %+v %v", manifest, err)
129+ }
130+}
131+
132 func TestMCPRoomWikiAndAgentTools(t *testing.T) {
133 app, tenant := testTenant(t)
134 ctx := context.Background()
135diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
136index d3aae14..7ed7b80 100644
137--- a/internal/daemon/mcp_tools.go
138+++ b/internal/daemon/mcp_tools.go
139@@ -19,6 +19,7 @@ import (
140 "github.com/FelineStateMachine/tinyrelay/internal/gates"
141 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
142 "github.com/FelineStateMachine/tinyrelay/internal/relay"
143+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
144 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
145 )
146
147@@ -73,6 +74,7 @@ const (
148 mcpJobRequestShape = `Expected a signed event of kind 5000 to 5999 with optional tags ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`
149 mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"] and optional ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`
150 mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.`
151+ mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.`
152 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."
153 mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it."
154 )
155@@ -195,6 +197,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
156 add("react", "React to an event with a kind 7 reaction: + to like or approve, - to dislike or decline, or one emoji. Pass target, target_pubkey and content (plus room for a room message) to receive the unsigned event, sign it, then call again with the signed event. A + or - from a wiki merge request's destination author answers the request.", mcp.Object(map[string]any{"event": mcpEvent, "target": mcpHash, "target_pubkey": mcpPubKey, "content": map[string]any{"type": "string", "minLength": 1, "description": "+, - or one emoji."}, "room": mcpRoomID}), mcpPublishes, t.mcpWrite(mcpBuildReact, mcpCheckReact, mcpReactShape))
157 add("publish_wiki_page", "Publish or replace your version of a kind 30818 wiki page in Djot markup. Pass d (the page name), title and content, plus optional summary and, to fork another author's version, fork_author and fork_event, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "title": mcpText, "summary": mcpText, "content": mcpText, "fork_author": mcpPubKey, "fork_event": mcpHash}), mcpPublishes, t.mcpWrite(mcpBuildWikiPage, mcpCheckWikiPage, mcpWikiShape))
158 add("propose_wiki_merge", "Ask a wiki author to take in changes from another version with a kind 818 merge request. Pass d, destination (the author asked), source (the proposed version's event id) and content, plus an optional base version id, to receive the unsigned event, sign it, then call again with the signed event. The destination author answers with a + or - reaction.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "destination": mcpPubKey, "source": mcpHash, "base": mcpHash, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildWikiMerge, mcpCheckWikiMerge, mcpMergeShape))
159+ add("publish_site", "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration.", mcp.Object(map[string]any{"event": mcpEvent, "label": map[string]any{"type": "string", "minLength": 1, "description": "Site label: your npub, or a named site label under your key. Defaults to your own site."}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": mcpText}, "description": "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]."}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape))
160 add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape))
161 add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape))
162 add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5999) and inputs, plus optional output, params, bid in millisats, relays and expiration, to receive the unsigned event, sign it, then call again with the signed event. A serving agent answers with job feedback and a result naming the request; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))
163@@ -603,6 +606,59 @@ func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) {
164 return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
165 }
166
167+// mcpBuildSite builds a site manifest for the caller's own site or a named
168+// site under its key. The template passes the same validation as the signed
169+// event so a bad path is reported before anything is signed.
170+func mcpBuildSite(call mcp.Call) (mcpUnsigned, error) {
171+ kind, tags := sites.KindSite, [][]string{}
172+ if label := strings.TrimSpace(call.String("label")); label != "" {
173+ site, ok := sites.ParseSite(label)
174+ if !ok || site.Kind == sites.KindSiteSnapshot {
175+ return mcpUnsigned{}, errors.New("label must be your npub or a named site label under your key")
176+ }
177+ if site.PubKey != call.Actor {
178+ return mcpUnsigned{}, errors.New("label " + label + " is not a site under your key")
179+ }
180+ kind = site.Kind
181+ if site.Kind == sites.KindNamedSite {
182+ tags = append(tags, []string{"d", site.D})
183+ }
184+ }
185+ pairs, _ := call.Arguments["paths"].([]any)
186+ if len(pairs) == 0 {
187+ return mcpUnsigned{}, errors.New("paths is required: one [path, sha256] pair per file")
188+ }
189+ for _, pair := range pairs {
190+ values, _ := pair.([]any)
191+ if len(values) != 2 {
192+ return mcpUnsigned{}, errors.New("each entry of paths is a [path, sha256] pair")
193+ }
194+ path, _ := values[0].(string)
195+ hash, _ := values[1].(string)
196+ tags = append(tags, []string{"path", path, strings.ToLower(strings.TrimSpace(hash))})
197+ }
198+ if expiration := call.Int("expiration"); expiration > 0 {
199+ if int64(expiration) <= time.Now().Unix() {
200+ return mcpUnsigned{}, errors.New("expiration must be in the future")
201+ }
202+ tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})
203+ }
204+ if err := sites.ValidateManifest(event.Event{Kind: kind, PubKey: call.Actor, Tags: tags}); err != nil {
205+ return mcpUnsigned{}, errors.New(strings.TrimPrefix(err.Error(), "invalid: "))
206+ }
207+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil
208+}
209+
210+func mcpCheckSite(e event.Event) error {
211+ if e.Kind != sites.KindSite && e.Kind != sites.KindNamedSite {
212+ return fmt.Errorf("kind %d is not a site manifest", e.Kind)
213+ }
214+ if err := sites.ValidateManifest(e); err != nil {
215+ return errors.New(strings.TrimPrefix(err.Error(), "invalid: "))
216+ }
217+ return nil
218+}
219+
220 func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) {
221 tags, err := mcpRoomTag(call)
222 if err != nil {

About

ownerc01503e8035f
visibilitypublic
head5324ed74b4e6

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs