clone

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

add the publish_site MCP tool for NIP-5A manifests

Dami committed 5324ed7
commit
5324ed74b4e629b95a5d43487d9b6774e9505192
parent
5fc70d5620a41205192bc8d37dd127dae8f81829
author
Dami <dami.etoile@gmail.com>
4 files changed, +142 -1
Mdocs/mcp.md5++
Minternal/daemon/mcp_http.go6++-
Minternal/daemon/mcp_test.go76++++++++++++++++++++++++++++++
Minternal/daemon/mcp_tools.go56++++++++++++++++++++++
M docs/mcp.md +5 -0
@@ -108,6 +108,7 @@ Write tools, which publish through the same path as `POST /events`:
108108 | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. |109109 | `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. |110110 | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. |111+| `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`. |111112 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |112113 | `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. |113114 | `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. |
@@ -126,6 +127,10 @@ The relay never signs on a caller's behalf. Call a write tool with plain fields,
126127 127128 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.128129 130+### Static sites131+132+`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).133+129134 ### Long tasks130135 131136 `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.
M internal/daemon/mcp_http.go +5 -1
@@ -93,7 +93,11 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9393 "- Methods: server/discover, tools/list and tools/call.",9494 "- 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.",9595 "- 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.",96- "- 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.",96+ "- 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.",97+ "",98+ "## Files",99+ "",100+ "- 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.",97101 "",98102 "## HTTP bridge",99103 "",
M internal/daemon/mcp_test.go +76 -0
@@ -12,6 +12,7 @@ import (
1212 1313 "github.com/FelineStateMachine/tinyrelay/internal/event"1414 "github.com/FelineStateMachine/tinyrelay/internal/mcp"15+ "github.com/FelineStateMachine/tinyrelay/internal/sites"1516 )1617 1718 type mcpCall struct {
@@ -272,6 +273,81 @@ func mcpSigned(t *testing.T, secret string, kind int, tags [][]string, content s
272273 return value273274 }274275 276+// TestMCPPublishSiteBuildsChecksAndPublishes covers the site manifest tool:277+// the template for the caller's own site and a named site, refusals of278+// labels under other keys and bad paths, and publishing the signed event.279+func TestMCPPublishSiteBuildsChecksAndPublishes(t *testing.T) {280+ app, tenant := testTenant(t)281+ owner := tenant.Policy().Owner282+ member, _ := event.PublicKey(testMemberSecret)283+ call := func(name string, arguments map[string]any) (map[string]any, bool) {284+ t.Helper()285+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true}.do(t, app, "/mcp")286+ if w.Code != http.StatusOK {287+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())288+ }289+ return mcpToolResult(t, response)290+ }291+ structured := func(result map[string]any) map[string]any {292+ value, _ := result["structuredContent"].(map[string]any)293+ return value294+ }295+ text := func(result map[string]any) string {296+ content, _ := result["content"].([]any)297+ if len(content) == 0 {298+ return ""299+ }300+ return content[0].(map[string]any)["text"].(string)301+ }302+ hash := strings.Repeat("a", 64)303+ paths := []any{[]any{"/index.html", hash}, []any{"/style.css", strings.ToUpper(hash)}}304+ expiration := time.Now().Unix() + 86400305+ result, isError := call("publish_site", map[string]any{"paths": paths, "expiration": expiration})306+ unsigned, _ := structured(result)["unsigned"].(map[string]any)307+ tags, _ := json.Marshal(unsigned["tags"])308+ if isError || unsigned["kind"] != float64(sites.KindSite) || string(tags) != `[["path","/index.html","`+hash+`"],["path","/style.css","`+hash+`"],["expiration","`+strconv.FormatInt(expiration, 10)+`"]]` {309+ t.Fatalf("publish_site template: %s", text(result))310+ }311+ base, _ := sites.Base36(owner)312+ result, isError = call("publish_site", map[string]any{"label": base + "docs", "paths": paths})313+ unsigned, _ = structured(result)["unsigned"].(map[string]any)314+ tags, _ = json.Marshal(unsigned["tags"])315+ if isError || unsigned["kind"] != float64(sites.KindNamedSite) || !strings.HasPrefix(string(tags), `[["d","docs"],["path",`) {316+ t.Fatalf("publish_site named template: %s", text(result))317+ }318+ other, _ := sites.Base36(member)319+ for name, arguments := range map[string]map[string]any{320+ "other key": {"label": other + "docs", "paths": paths},321+ "bad label": {"label": "nope", "paths": paths},322+ "no paths": {},323+ "odd pair": {"paths": []any{[]any{"/index.html"}}},324+ "bad path": {"paths": []any{[]any{"index.html", hash}}},325+ "bad hash": {"paths": []any{[]any{"/index.html", "short"}}},326+ "duplicate": {"paths": []any{[]any{"/index.html", hash}, []any{"/index.html", hash}}},327+ "past expiry": {"paths": paths, "expiration": 1},328+ } {329+ if result, isError := call("publish_site", arguments); !isError || !strings.Contains(text(result), "kind 15128") {330+ t.Fatalf("%s accepted: %s", name, text(result))331+ }332+ }333+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/../x.js", hash}}, "")})334+ if !isError || !strings.Contains(text(result), "site path") {335+ t.Fatalf("publish_site bad signed path: %s", text(result))336+ }337+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, 1, [][]string{}, "not a site")})338+ if !isError || !strings.Contains(text(result), "not a site manifest") {339+ t.Fatalf("publish_site wrong kind: %s", text(result))340+ }341+ result, isError = call("publish_site", map[string]any{"event": mcpSigned(t, testOwnerSecret, sites.KindSite, [][]string{{"path", "/index.html", hash}, {"expiration", strconv.FormatInt(expiration, 10)}}, "")})342+ if isError || structured(result)["accepted"] != true {343+ t.Fatalf("publish_site publish: %s", text(result))344+ }345+ manifest, err := tenant.sites.Manifest(context.Background(), sites.Site{Kind: sites.KindSite, PubKey: owner})346+ if err != nil || manifest.Kind != sites.KindSite {347+ t.Fatalf("published manifest not indexed: %+v %v", manifest, err)348+ }349+}350+275351 func TestMCPRoomWikiAndAgentTools(t *testing.T) {276352 app, tenant := testTenant(t)277353 ctx := context.Background()
M internal/daemon/mcp_tools.go +56 -0
@@ -19,6 +19,7 @@ import (
1919 "github.com/FelineStateMachine/tinyrelay/internal/gates"2020 "github.com/FelineStateMachine/tinyrelay/internal/mcp"2121 "github.com/FelineStateMachine/tinyrelay/internal/relay"22+ "github.com/FelineStateMachine/tinyrelay/internal/sites"2223 "github.com/FelineStateMachine/tinyrelay/internal/wiki"2324 )2425
@@ -73,6 +74,7 @@ const (
7374 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.`7475 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.`7576 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.`77+ 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.`7678 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."7779 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."7880 )
@@ -195,6 +197,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
195197 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))196198 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))197199 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))200+ 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))198201 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))199202 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))200203 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))
@@ -603,6 +606,59 @@ func mcpBuildWikiMerge(call mcp.Call) (mcpUnsigned, error) {
603606 return mcpUnsigned{Kind: kindWikiMerge, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil604607 }605608 609+// mcpBuildSite builds a site manifest for the caller's own site or a named610+// site under its key. The template passes the same validation as the signed611+// event so a bad path is reported before anything is signed.612+func mcpBuildSite(call mcp.Call) (mcpUnsigned, error) {613+ kind, tags := sites.KindSite, [][]string{}614+ if label := strings.TrimSpace(call.String("label")); label != "" {615+ site, ok := sites.ParseSite(label)616+ if !ok || site.Kind == sites.KindSiteSnapshot {617+ return mcpUnsigned{}, errors.New("label must be your npub or a named site label under your key")618+ }619+ if site.PubKey != call.Actor {620+ return mcpUnsigned{}, errors.New("label " + label + " is not a site under your key")621+ }622+ kind = site.Kind623+ if site.Kind == sites.KindNamedSite {624+ tags = append(tags, []string{"d", site.D})625+ }626+ }627+ pairs, _ := call.Arguments["paths"].([]any)628+ if len(pairs) == 0 {629+ return mcpUnsigned{}, errors.New("paths is required: one [path, sha256] pair per file")630+ }631+ for _, pair := range pairs {632+ values, _ := pair.([]any)633+ if len(values) != 2 {634+ return mcpUnsigned{}, errors.New("each entry of paths is a [path, sha256] pair")635+ }636+ path, _ := values[0].(string)637+ hash, _ := values[1].(string)638+ tags = append(tags, []string{"path", path, strings.ToLower(strings.TrimSpace(hash))})639+ }640+ if expiration := call.Int("expiration"); expiration > 0 {641+ if int64(expiration) <= time.Now().Unix() {642+ return mcpUnsigned{}, errors.New("expiration must be in the future")643+ }644+ tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})645+ }646+ if err := sites.ValidateManifest(event.Event{Kind: kind, PubKey: call.Actor, Tags: tags}); err != nil {647+ return mcpUnsigned{}, errors.New(strings.TrimPrefix(err.Error(), "invalid: "))648+ }649+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil650+}651+652+func mcpCheckSite(e event.Event) error {653+ if e.Kind != sites.KindSite && e.Kind != sites.KindNamedSite {654+ return fmt.Errorf("kind %d is not a site manifest", e.Kind)655+ }656+ if err := sites.ValidateManifest(e); err != nil {657+ return errors.New(strings.TrimPrefix(err.Error(), "invalid: "))658+ }659+ return nil660+}661+606662 func mcpBuildRoom(call mcp.Call) (mcpUnsigned, error) {607663 tags, err := mcpRoomTag(call)608664 if err != nil {