clone

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

feat/ui-updates
tinyrelay / tinyclient / webmcp.js
webmcp.js 20.3K
1(function () {2 "use strict";3 // WebMCP registers read, write and navigation tools for the browser model4 // context. Read tools call the relay query surface, write tools use the5 // signed management or event boundary, and navigation tools open relay6 // pages in the current tab.78 const context = document.modelContext || navigator.modelContext;9 const state = window.tinyWebMCP = {10 supported: typeof context?.registerTool === "function",11 registered: [], errors: [], lastOperation: "", ready: Promise.resolve()12 };13 const announce = () => window.dispatchEvent(new CustomEvent("tiny:webmcp", {detail: state}));14 if (!state.supported) { announce(); return; }1516 const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];17 const local = path => root + path;18 const output = value => ({19 content: [{type: "text", text: JSON.stringify(value)}],20 structuredContent: {result: value}21 });22 const object = (properties = {}, required = []) => ({type: "object", properties, required, additionalProperties: false});23 const text = {type: "string"};24 const pubkey = {type: "string", pattern: "^[0-9a-f]{64}$"};25 const hash = {type: "string", pattern: "^[0-9a-f]{64}$"};26 const id = {type: "string", minLength: 1};27 const limit = {type: "integer", minimum: 1, maximum: 100};28 const view = {type: "string", enum: ["tree", "file", "history", "commit", "activity"]};29 const repository = {owner: pubkey, repo: id, ref: text, path: text, view};30 const reads = {readOnlyHint: true, untrustedContentHint: true};31 const changes = {consequentialHint: true, untrustedContentHint: true};32 const pending = [];3334 async function responseJSON(response) {35 const body = await response.text();36 let value;37 try { value = JSON.parse(body); }38 catch { throw new Error(response.ok ? "The relay returned an invalid response." : body || `Request failed (${response.status}).`); }39 if (!response.ok || value?.error) throw new Error(value?.error || `Request failed (${response.status}).`);40 return value;41 }4243 async function query(method, input, signal) {44 const params = new URLSearchParams({method, params: JSON.stringify([input])});45 const response = await fetch(local("/webmcp/query") + "?" + params, {46 method: "GET", credentials: "same-origin", signal47 });48 return responseJSON(response);49 }5051 async function manage(method, params, signal) {52 if (!window.tinySignedFetch) throw new Error("The signer is still loading. Try again.");53 const response = await window.tinySignedFetch(local("/manage/rpc"), "POST",54 JSON.stringify({method, params}), {contentType: "application/json", signal});55 const value = await responseJSON(response);56 return value.result;57 }5859 function register(name, description, schema, annotations, execute) {60 const tool = {61 name, description, inputSchema: schema, annotations,62 execute: async (input, options = {}) => {63 state.lastOperation = `${name}: running`;64 announce();65 try {66 const value = await execute(input, options.signal);67 state.lastOperation = `${name}: completed`;68 announce();69 return output(value);70 } catch (error) {71 state.lastOperation = `${name}: ${error.message}`;72 announce();73 throw error;74 }75 }76 };77 pending.push(Promise.resolve().then(() => context.registerTool(tool)).then(() => {78 state.registered.push(name);79 }, error => {80 state.errors.push(`${name}: ${error.message || error}`);81 }));82 }8384 register("tiny.list_repositories", "List repositories visible to your account. Supports search and pagination.",85 object({cursor: text, limit, q: text}), reads, (input, signal) => query("browserepos", input, signal));86 register("tiny.read_repository", "Read a repository tree, source file, history, commit diff or activity. Use ref to select a branch, tag or commit.",87 object({...repository, offset: {type: "integer", minimum: 0}, limit}, ["owner", "repo"]), reads,88 (input, signal) => query("browserepo", input, signal));89 const collaborationList = object({owner: pubkey, repo: id, cursor: text, limit, q: text, label: text,90 state: {type: "string", enum: ["open", "resolved", "merged", "closed", "draft"]}}, ["owner", "repo"]);91 const collaborationDetail = object({owner: pubkey, repo: id, event: hash, cursor: text, limit}, ["owner", "repo", "event"]);92 register("tiny.list_issues", "List repository issues with search, status filters and pagination.",93 collaborationList, reads, (input, signal) => query("browseissues", input, signal));94 register("tiny.read_issue", "Read an issue, its replies and authorized status changes.",95 collaborationDetail, reads, (input, signal) => query("browseissue", input, signal));96 register("tiny.list_pull_requests", "List repository pull requests with search, status filters and pagination.",97 collaborationList, reads, (input, signal) => query("browsepulls", input, signal));98 register("tiny.read_pull_request", "Read a pull request, its replies, authorized updates and available diff.",99 collaborationDetail, reads, (input, signal) => query("browsepull", input, signal));100 register("tiny.list_files", "List stored files visible to your account.",101 object({cursor: text, limit, q: text}), reads, (input, signal) => query("browsefiles", input, signal));102 register("tiny.read_file", "Read a stored file's metadata and available preview by SHA-256 hash.",103 object({hash}, ["hash"]), reads, (input, signal) => query("browsefile", input, signal));104 register("tiny.read_status", "Read service health, storage and job status. Requires a signed-in owner or moderator session.",105 object(), reads, (input, signal) => query("browsestatus", input, signal));106 register("tiny.list_approvals", "List requests for a decision addressed to the signed-in person, with each request's asker, subject, expiry, state and answer, plus counts of open, answered and expired requests. Answering is the person's own signed action.",107 object({cursor: text, limit, state: {type: "string", enum: ["open", "answered", "expired", "all"]}}), reads,108 (input, signal) => query("browseapprovals", input, signal));109 register("tiny.read_profile", "Read a profile (kind 0) as this relay holds it, with the write relays from that key's relay list. Omit pubkey for the signed-in person's own profile.",110 object({pubkey}), reads, (input, signal) => query("browseprofile", input, signal));111 register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.",112 object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal));113 register("tiny.list_jobs", "List long task requests (NIP-90 job requests) visible to your account, each with its newest feedback status and its result when one exists. state narrows the list to open, done or all; mine lists only your own requests.",114 object({cursor: text, limit, state: {type: "string", enum: ["open", "done", "all"]}, mine: {type: "boolean"}}), reads,115 (input, signal) => query("browsejobs", input, signal));116 register("tiny.read_job", "Read one long task request by event id with its feedback timeline and results.",117 object({id: hash}, ["id"]), reads, (input, signal) => query("browsejob", input, signal));118 const pageName = {type: "string", minLength: 1, maxLength: 512, description: "Page name or title; the relay normalizes it to the NIP-54 d tag."};119 register("tiny.list_wiki", "List wiki pages with their shown version, version count and open merge requests. Supports search by title or summary, an author filter and pagination.",120 object({q: text, author: pubkey, cursor: text, limit}), reads, (input, signal) => query("browsewiki", input, signal));121 register("tiny.read_wiki_page", "Read one wiki page: the shown version with its Djot content and links, every version, merge requests and redirects. Use author or version to pick a version.",122 object({d: pageName, author: pubkey, version: hash}, ["d"]), reads, (input, signal) => query("browsewikipage", input, signal));123 register("tiny.read_merge_request", "Read a wiki merge request by event id with its answer, the proposed version and the destination author's current version.",124 object({id: hash}, ["id"]), reads, (input, signal) => query("browsewikimerge", input, signal));125126 const room = {type: "string", pattern: "^[a-z0-9_-]{1,64}$"};127 register("tiny.list_rooms", "List the chat rooms you can see with their access rule, member count and last message time.",128 object({cursor: text, limit}), reads, (input, signal) => query("browserooms", input, signal));129 register("tiny.read_room", "Read a room: its members with roles and its newest messages, threads, replies and reactions, newest first. Use cursor for older messages.",130 object({id: room, cursor: text, limit}, ["id"]), reads, (input, signal) => query("browseroom", input, signal));131 register("tiny.read_thread", "Read a thread in a room: the root message and its replies, newest first.",132 object({id: room, event: hash, cursor: text, limit}, ["id", "event"]), reads, (input, signal) => query("browsethread", input, signal));133134 const readMethods = ["stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"];135 register("tiny.read_management", "Read relay configuration, jobs, backups, delivery status or members using your connected signer. gitstorage requires owner and repo.",136 object({method: {type: "string", enum: readMethods}, owner: pubkey, repo: id}, ["method"]), reads,137 (input, signal) => {138 if (!readMethods.includes(input.method)) throw new Error("Unsupported read operation.");139 if (input.method === "gitstorage") {140 if (!input.owner || !input.repo) throw new Error("Choose a repository owner and name.");141 return manage(input.method, [input.owner, input.repo], signal);142 }143 return manage(input.method, [], signal);144 });145146 register("tiny.list_agents", "List agent grants: name, public key, owner, scope, paused or revoked state, expiry and last event time. Uses your connected signer; owner or moderator.",147 object(), reads, (input, signal) => manage("listagents", [], signal));148 register("tiny.read_agent", "Read one agent's grant and its ten newest events. Requires a signed-in owner or moderator session.",149 object({agent: pubkey}, ["agent"]), reads, (input, signal) => query("browseagent", input, signal));150 register("tiny.list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. Uses your connected signer.",151 object(), reads, (input, signal) => manage("listcallbacks", [], signal));152 register("tiny.list_join_requests", "List access requests from people who asked to join without an invite: pubkey, reason, time asked, status (pending, approved or denied), who decided and when. Pending requests come first. Uses your connected signer; owner or moderator.",153 object(), reads, (input, signal) => manage("listjoinrequests", [], signal));154155 function open(path) {156 const url = new URL(local(path), location.href);157 location.assign(url.href);158 return {opened: url.href};159 }160 // Repository pages live at /repos/{owner}/{repo}/{view}...; every segment is161 // percent-encoded except that "/" inside a repository file path stays a separator.162 const segment = value => encodeURIComponent(String(value));163 const filePath = value => String(value).split("/").filter(Boolean).map(segment).join("/");164 function repoURL(input) {165 let path = "/repos/" + segment(input.owner) + "/" + segment(input.repo);166 switch (input.view) {167 case "tree": path += "/tree" + (input.path ? "/" + filePath(input.path) : ""); break;168 case "file": path += "/file/" + filePath(input.path || ""); break;169 case "history": path += "/history"; break;170 case "commit": path += "/commit/" + segment(input.id || ""); break;171 case "activity": path += "/activity"; break;172 case "issues": path += "/issues"; break;173 case "prs": path += "/prs"; break;174 case "issue": path += "/issues/" + segment(input.id || ""); break;175 case "pr": path += "/prs/" + segment(input.id || ""); break;176 }177 return input.ref !== undefined ? path + "?ref=" + encodeURIComponent(String(input.ref)) : path;178 }179 register("tiny.open_repository", "Open a repository in this tab for browsing code, history or activity.",180 object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input)));181 register("tiny.open_file", "Open a stored file by its SHA-256 hash in this tab. For repository source, use tiny.open_repository with view=file.",182 object({hash}, ["hash"]), {}, input => open("/file/" + encodeURIComponent(input.hash)));183 register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health"));184 register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));185 register("tiny.open_approvals", "Open the Approvals page in this tab, where requests for a decision wait. Pass id to focus one request; the person answers with a signed tap.",186 object({id: hash}), {}, input => open("/approvals" + (input.id ? "/" + encodeURIComponent(input.id) : "")));187 register("tiny.open_wiki_page", "Open a wiki page in this tab. Omit d for the page list. version opens one version by event id; merge opens the compare view for a merge request; edit opens the editor.",188 object({d: pageName, version: hash, merge: hash, edit: {type: "boolean"}}), {}, input => {189 if (!input.d) return open("/wiki");190 const params = new URLSearchParams();191 if (input.version) params.set("version", input.version);192 if (input.edit) params.set("edit", "1");193 const search = params.toString();194 const page = "/wiki/" + encodeURIComponent(input.d) + (input.merge ? "/proposals/" + encodeURIComponent(input.merge) : "");195 return open(page + (search ? "?" + search : ""));196 });197 register("tiny.open_room", "Open a chat room in this tab, or the rooms list when no id is given.",198 object({id: room}), {}, input => open(input.id ? "/rooms/" + encodeURIComponent(input.id) : "/rooms"));199200 // post_message signs a chat message with the connected signer and201 // publishes it once. Keys named in mentions become p tags.202 const publishEvent = async (unsigned, signal) => {203 if (typeof window.tiny?.signing?.publish !== "function") throw new Error("The event signing API is still loading. Try again.");204 return window.tiny.signing.publish(unsigned, {signal});205 };206 async function postMessage(input, signal) {207 const content = String(input.text || "").trim();208 if (!content) throw new Error("Enter a message.");209 if (!window.tinySignedFetch) throw new Error("The signer is still loading. Try again.");210 const tags = [["h", input.room]];211 for (const key of input.mentions || []) if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]);212 const event = await publishEvent({kind: 9, created_at: Math.floor(Date.now() / 1000), tags, content}, signal);213 return {id: event.id, room: input.room, created_at: event.created_at};214 }215 register("tiny.post_message", "Post a chat message to a room as the connected signer. mentions lists 64-character hex keys to notify.",216 object({room, text: {type: "string", minLength: 1, maxLength: 4000}, mentions: {type: "array", items: pubkey}}, ["room", "text"]), changes, postMessage);217 register("tiny.open_link", "Open a nostr link in this tab: an npub, nprofile, note, nevent, naddr or 64-character event id, with or without a nostr: or web+nostr: prefix.",218 object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));219 register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.",220 object(), {readOnlyHint: true}, () => {221 const categories = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);222 return {223 supported: "PushManager" in window && "Notification" in window,224 permission: typeof Notification === "function" ? Notification.permission : "unsupported",225 enabled: Boolean(localStorage.getItem("tiny.push")),226 categories: categories.length ? categories : ["messages", "replies", "mentions", "approvals", "relay"],227 note: "Enabling notifications needs a person to press Enable on this device under Inbox."228 };229 });230231 function control(name, description, schema, method, params) {232 register(name, description, schema, changes, (input, signal) => manage(method, params(input), signal));233 }234 control("tiny.send_test_notification", "Send the relay's test notice to the owner's inbox and enabled devices. Owner only.", object(), "notifytest", () => []);235 control("tiny.run_job", "Queue an existing job to run now. Inspect job status to check its completion.",236 object({id}, ["id"]), "runjob", input => [input.id]);237 control("tiny.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.",238 object({id, kind: {type: "string", enum: ["pull", "push", "import", "mirror", "dump", "backup"]},239 relays: {type: "array", items: text}, filter: {...text, description: "Nostr filter encoded as a JSON object string."},240 every: {type: "integer", minimum: 0, description: "Interval in hours. Zero runs once."}, discoverPubKey: pubkey}, ["id", "kind"]),241 "addjob", input => [input]);242 control("tiny.remove_job", "Remove a job and cancel its pending runs.", object({id}, ["id"]), "removejob", input => [input.id]);243 control("tiny.pause_agent", "Pause an agent. Its grant stays and the relay rejects its events until it is resumed.",244 object({agent: pubkey}, ["agent"]), "pauseagent", input => [input.agent]);245 control("tiny.resume_agent", "Resume a paused agent so it may publish again.",246 object({agent: pubkey}, ["agent"]), "resumeagent", input => [input.agent]);247 control("tiny.revoke_agent", "Revoke an agent's grant. The agent loses its role at once; only a new grant restores it.",248 object({agent: pubkey}, ["agent"]), "revokeagent", input => [input.agent]);249 control("tiny.approve_join", "Approve an access request: the key becomes a member, the relay's member list and add-user record follow, and the request is marked approved. Owner or moderator.",250 object({pubkey}, ["pubkey"]), "approvejoin", input => [input.pubkey]);251 control("tiny.deny_join", "Deny an access request. The key stays outside the relay and may ask again. Owner or moderator.",252 object({pubkey}, ["pubkey"]), "denyjoin", input => [input.pubkey]);253 control("tiny.pause_callback", "Pause an event callback by id. Deliveries stop until it is resumed. The callback's owner, the relay owner and moderators may do this.",254 object({id}, ["id"]), "pausecallback", input => [input.id]);255 control("tiny.resume_callback", "Resume a paused event callback by id and clear its failure count.",256 object({id}, ["id"]), "resumecallback", input => [input.id]);257 control("tiny.remove_callback", "Delete an event callback by id.",258 object({id}, ["id"]), "removecallback", input => [input.id]);259 control("tiny.backup_now", "Queue a backup of relay data. Inspect jobs and backups to check completion.", object(), "backupnow", () => []);260 control("tiny.dump_now", "Queue an event export. Inspect jobs and dumps to check completion.", object(), "dumpnow", () => []);261 control("tiny.set_connections", "Replace the relay's connection list. Read the current list before editing it.",262 object({connections: {type: "array", items: {type: "object"}}}, ["connections"]), "setconnections", input => [input.connections]);263 control("tiny.set_policy", "Apply a partial relay policy update. Read the current policy before changing access, delivery or features. Requires the owner signer.",264 object({patch: {type: "object", minProperties: 1}}, ["patch"]), "setpolicy", input => [input.patch]);265266 state.ready = Promise.all(pending).then(() => { state.registered.sort(); announce(); });267})();