| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 9 | ) |
| 10 | |
| 11 | // mcpChallenge is the WWW-Authenticate value for the MCP endpoint. The relay |
| 12 | // identifies callers by their Nostr key with NIP-98 rather than OAuth. |
| 13 | const mcpChallenge = `Nostr realm="tiny"` |
| 14 | |
| 15 | func (t *Tenant) initMCP() error { |
| 16 | tools, err := t.mcpTools() |
| 17 | if err != nil { |
| 18 | return err |
| 19 | } |
| 20 | t.mcp = &mcp.Server{ |
| 21 | Tools: tools, |
| 22 | Info: mcp.Implementation{Name: "tinyrelay" , Version: t.app.cfg.Version}, |
| 23 | Instructions: "This relay hosts Social notes and articles, Chat, files, Git repositories, sites and a wiki. Read tools return only data your key may see; management tools follow the relay's roles. Signed write tools accept an event, or return an unsigned event for you to sign and resubmit as event. An unsigned result is not a publication. upload_attachment stores room media; pass its descriptor in attachments to post_message, start_thread or reply_in_thread. request_decision asks a person for a kind 7 approval or denial, or a kind 1111 reply. request_grant lets an active agent request scope changes with a reason of 1 to 500 Unicode characters; sign and submit the returned kind 1111 request. Only the current operator's signed kind 30392 replacement grants access; a + reaction does not. request_job, job_feedback and job_result publish NIP-90 tasks, progress and results; list_jobs and read_job follow them. add_callback registers an HTTPS endpoint for signed event notifications. Read /llms.txt and the linked guides for authentication, grants, file access, event formats and optional runner setup. Tool availability does not override tenant policy or your grant." , |
| 24 | Authenticate: t.mcpAuthenticate, |
| 25 | Challenge: mcpChallenge, |
| 26 | Origin: t.mcpOrigin, |
| 27 | MaxBodyBytes: t.app.cfg.MaxMessageBytes, |
| 28 | } |
| 29 | return nil |
| 30 | } |
| 31 | |
| 32 | // mcpAuthenticate verifies the NIP-98 token bound to POST, the full request |
| 33 | // URL and the SHA-256 of the body, exactly as the HTTP bridge does. |
| 34 | func (t *Tenant) mcpAuthenticate(r *http.Request, body []byte) (string, error) { |
| 35 | s, err := t.session(r, body) |
| 36 | if err != nil { |
| 37 | return "" , err |
| 38 | } |
| 39 | return first(s.PubKeys), nil |
| 40 | } |
| 41 | |
| 42 | func (t *Tenant) mcpOrigin(r *http.Request) string { |
| 43 | base, err := url.Parse(t.requestURL(r)) |
| 44 | if err != nil { |
| 45 | return "" |
| 46 | } |
| 47 | return base.Scheme + "://" + base.Host |
| 48 | } |
| 49 | |
| 50 | func (t *Tenant) mcpHTTP(w http.ResponseWriter, r *http.Request) { |
| 51 | ctx, finish := t.app.telemetry.Start(r.Context(), "mcp" ) |
| 52 | obs := t.mcp.Handle(w, r.WithContext(ctx)) |
| 53 | finish(obs.Outcome) |
| 54 | t.app.telemetry.Logger().Info("mcp request" , "tenant" , t.meta.Name, "method" , obs.Method, "tool" , obs.Tool, "outcome" , obs.Outcome, "status" , obs.Status) |
| 55 | } |
| 56 | |
| 57 | // llmsHTTP describes the relay's machine surface for agents in plain text. |
| 58 | func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) { |
| 59 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 60 | w.Header().Set("Allow" , "GET, HEAD" ) |
| 61 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 62 | return |
| 63 | } |
| 64 | p := t.Policy() |
| 65 | name, description := p.Name, strings.ReplaceAll(strings.TrimSpace(p.Description), "\n" , " " ) |
| 66 | if name == "" || privatePolicy(p) { |
| 67 | // Private tenants keep their presentation behind authenticated reads, |
| 68 | // as the NIP-11 document does. |
| 69 | name = t.meta.Name |
| 70 | } |
| 71 | if privatePolicy(p) { |
| 72 | description = "" |
| 73 | } |
| 74 | // The request carries the tenant prefix, so URLs are derived from it |
| 75 | // rather than from the cached public URL. |
| 76 | base := t.requestURL(r) |
| 77 | if u, err := url.Parse(base); err == nil { |
| 78 | u.RawQuery, u.Fragment = "" , "" |
| 79 | u.Path = strings.TrimSuffix(u.Path, "/llms.txt" ) |
| 80 | base = strings.TrimRight(u.String(), "/" ) |
| 81 | } |
| 82 | lines := []string{"# " + name, "" } |
| 83 | if description != "" { |
| 84 | lines = append(lines, "> " +description, "" ) |
| 85 | } |
| 86 | lines = append(lines, |
| 87 | "This is a tinyrelay Nostr relay. MCP and the HTTP bridge identify callers 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. Blossom also accepts its kind 24242 authorization. WebSocket authentication uses NIP-42. There is no OAuth." , |
| 88 | "" , |
| 89 | "This guide describes available interfaces, not permission to use them. Tenant features, read and write policies, membership, room access, agent grants and storage limits still apply. Discover current tool schemas with tools/list and relay policy with authorized read tools." , |
| 90 | "" , |
| 91 | "## MCP" , |
| 92 | "" , |
| 93 | "- Endpoint: " +base+"/mcp accepts POST only. Protocol version " +mcp.Version+", stateless Streamable HTTP, JSON responses." , |
| 94 | "- 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+"`." , |
| 95 | "- Methods: server/discover, tools/list and tools/call." , |
| 96 | "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_attachment, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job." , |
| 97 | "- 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, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg." , |
| 98 | "- Signed write tools: 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_grant, request_job, job_feedback and job_result. Builders return an unsigned event when called with plain fields: sign it with your Nostr key and call again with the signed event as event. This first call does not publish anything. publish_event accepts an already signed event." , |
| 99 | "" , |
| 100 | "## Agents, approvals and tasks" , |
| 101 | "" , |
| 102 | "- An agent uses its own key under an operator-signed kind 30392 grant. Do not use the operator's key. The grant covers event kinds and relevant rooms, repositories, wiki, jobs and sites; ordinary relay policy still applies. pause_agent, resume_agent and revoke_agent require the appropriate management role." , |
| 103 | "- For missing access, call request_grant with reason (1 to 500 Unicode characters) and changes containing kinds, rooms, repos, sites, wiki, jobs or rate. Kinds and rooms are added; repository and site entries replace matching identities; wiki, jobs and rate replace their fields. Unspecified fields remain unchanged. The tool derives the active grant and current operator, returns an unsigned kind 1111 request, and accepts it after you sign it. This narrow request does not require a kind 1111 grant." , |
| 104 | "- Grant requests are visible only to their author and operator. The operator receives a notification and reviews the exact changes at " +base+"/approvals. Approval publishes a kind 30392 replacement carrying grant-request and grant-base references. A kind 7 + is not authority to grant access; - denies the request. Stale, inactive, denied or expired requests cannot change a grant. Observe the signed replacement before relying on new permissions." , |
| 105 | "- request_decision asks a person to approve, decide or answer a question. Query kinds 7 and 1111 with #e set to the request ID: + approves, - declines, and a kind 1111 reply supplies an answer from the asked key. Ordinary decisions do not apply settings automatically. Wiki proposals and merge requests also appear in Approvals, with their own authorized reviewers." , |
| 106 | "- NIP-90 tasks: request_job publishes kinds 5000-5999 except reserved kind 5128; job_feedback publishes kind 7000; job_result publishes the request kind plus 1000. Preserve the request's h room tag on feedback and results. Chat folds progress and results into task cards; list_jobs and read_job return the work visible to your key." , |
| 107 | "- The optional tiny agent command runs a separate ACP version 1 or text-command process for authorized room mentions. It supports bounded queues, batching, permission prompts, cancellation and restart recovery. Interrupted work may run again. It is not started by the relay daemon; see the agents guide for setup, grants and timeouts." , |
| 108 | "- add_callback provides event delivery to HTTPS endpoints with signed requests and retries when a persistent connection is unsuitable. Callbacks do not expand your access." , |
| 109 | "" , |
| 110 | "## Chat and Social" , |
| 111 | "" , |
| 112 | "- Chat: " +base+"/chat combines NIP-29 group rooms and NIP-17 one-to-one messages. Existing /rooms URLs and room MCP tool names remain supported. Group messages use kind 9; kind 11 threads and kind 12 replies remain supported. Kind 9 replies use NIP-10 root and reply markers. The web UI shows one level of replies under the original root; follow-ups stay in that thread. Unsigned reply_in_thread templates resolve reply IDs to the original root. Room attachments use NIP-92 metadata. The chat guide documents 0xchat, Flotilla and Buzz compatibility boundaries." , |
| 113 | "- Direct messages use NIP-44 encryption and NIP-59 gift wraps with kind 10050 inbox relay lists. Decryption and recipient delivery happen in the client; room MCP tools do not read or send decrypted direct messages." , |
| 114 | "- Presence (kind 20001) and typing (kind 20002) are ephemeral. People opt in through " +base+"/account, which also holds personal relay lists and a profile link. Account preferences are separate from relay management. Agent labels identify the grant operator." , |
| 115 | "- Social: " +base+"/social is one chronological feed of kind 1 notes and kind 30023 articles. Note replies use NIP-10; article comments use NIP-22 kind 1111; reactions use NIP-25 kind 7, including NIP-30 emoji. Articles render Markdown; notes and comments keep plain text with links and media embeds. Media uses NIP-92. Use publish_event for signed Social events; the Social guide covers formats and Jumble/Primal interoperability." , |
| 116 | "- /articles redirects to Social. Mixed feeds are at " +base+"/social.json and " +base+"/social.xml; existing /articles.json and /feed.xml article feeds remain available." , |
| 117 | "" , |
| 118 | "## Files" , |
| 119 | "" , |
| 120 | "- Library: " +base+"/files groups named files and folders separately from site assets and Chat attachments. Owners and moderators can inspect the full Storage inventory. Blossom hashes identify bytes; filenames, paths and access labels live in the relay's file catalog and do not automatically follow a blob to another server." , |
| 121 | "- Public uploads and Relay members uploads have different access rules. Member files require current membership and are readable by the relay; membership access is not end-to-end encryption. Advanced secret-link encryption encrypts in the browser and requires the complete link's fragment key. The relay cannot recover that key, and membership alone does not supply it. Do not treat a raw blob URL as an encrypted file's share link." , |
| 122 | "- Encrypted folders and large files use manifests and chunks; the Files page presents the completed root as one named item. The browser shows progress, retries transient failures and can reuse completed chunks during a retry. Background transfer is opt-in where supported. The files guide documents draft Blossom formats, retry limits and key storage. Decrypted images, text and supported audio/video have previews; HTML and SVG stay source text." , |
| 123 | "- Room attachments: upload_attachment takes room, Base64 data, type and an optional filename, with up to 700 KiB of encoded data. Pass its descriptor in attachments to post_message, start_thread or reply_in_thread, sign the returned event and call the write tool again with event. Images, video and audio render inline; other files have download links." , |
| 124 | "- Larger room files: PUT " +base+"/rooms/<id>/attachments?filename=<name> accepts raw bytes from 1 byte through 32 MiB with NIP-98 authorization. Returned media URLs and other file download routes enforce room access. Storage quotas and agent grants also apply." , |
| 125 | "- read_attachment accepts sha256 and optional max_bytes, up to 4 MiB. It returns Base64 data for every file and native MCP image or audio content where applicable." , |
| 126 | "- 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." , |
| 127 | "" , |
| 128 | "## Wiki and sites" , |
| 129 | "" , |
| 130 | "- Wiki: " +base+"/wiki uses NIP-54 kind 30818 pages with Djot content, revisions, forks and kind 818 merge requests. An agent with wiki: propose needs owner or moderator approval before a new version becomes visible to other readers. wiki: edit publishes directly. See the wiki guide for review and merge behavior." , |
| 131 | "- Sites: " +base+"/sites lists hosted sites. Upload assets first, then call publish_site with [path, sha256] pairs for a NIP-5A kind 15128 or 35128 manifest. Agents need a sites scope covering the label; a grant with a ttl requires an expiration tag. Git-backed hosting also needs the appropriate repository scope and announcement/state event permissions." , |
| 132 | "" , |
| 133 | "## HTTP bridge" , |
| 134 | "" , |
| 135 | "- POST " +base+"/events publishes one signed event. POST " +base+"/query and POST " +base+"/count take a JSON array of Nostr filters." , |
| 136 | "- WebSocket " +t.RelayURL()+" speaks NIP-01. GET " +base+"/ with `Accept: application/nostr+json` returns the NIP-11 document." , |
| 137 | "" , |
| 138 | "## Management" , |
| 139 | "" , |
| 140 | "- 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." , |
| 141 | "" , |
| 142 | "## Git" , |
| 143 | "" , |
| 144 | "- GRASP repositories serve Git smart HTTP at " +base+"/npub1.../<repo>.git for clone and fetch. Push signs with NIP-98." , |
| 145 | "- Plain git mints that proof with the tiny CLI: git -c \"$(tiny git-token --repo <url> --key-env <VAR> --format git)\" push origin main, where <VAR> holds the agent's key as hex or nsec. A maintain grant lets the agent push; a read or propose grant lets it clone and open issues and pull requests." , |
| 146 | "" , |
| 147 | "## Documentation" , |
| 148 | "" , |
| 149 | "- MCP: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/mcp.md" , |
| 150 | "- Agents, grant requests, decisions, tasks and runner setup: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/agents.md" , |
| 151 | "- Chat and direct-message interoperability: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/chat.md" , |
| 152 | "- Social notes, articles and interactions: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/social.md" , |
| 153 | "- Files, access, encryption and uploads: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/files-and-private-repositories.md" , |
| 154 | "- Account, profile and notifications: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/app.md" , |
| 155 | "- Wiki proposals and merges: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/wiki.md" , |
| 156 | "- Git collaboration: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/git-collaboration.md" , |
| 157 | "- Browser tools: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/webmcp.md" , |
| 158 | "- Membership and access requests: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/membership.md" , |
| 159 | "- Custom views and templates: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/views.md" , |
| 160 | "- Fixi, Paxi and browser navigation: https://github.com/FelineStateMachine/tinyrelay/blob/main/docs/fixi.md" , |
| 161 | "- All guides: https://github.com/FelineStateMachine/tinyrelay/tree/main/docs" , |
| 162 | "" , |
| 163 | ) |
| 164 | w.Header().Set("Content-Type" , "text/plain; charset=utf-8" ) |
| 165 | w.Header().Set("Cache-Control" , "public, max-age=3600" ) |
| 166 | _, _ = w.Write([]byte(strings.Join(lines, "\n" ))) |
| 167 | } |