| 1 | (function () { |
| 2 | "use strict" ; |
| 3 | // WebMCP registers read, write and navigation tools for the browser model |
| 4 | // context. Read tools call the relay query surface, write tools use the |
| 5 | // signed management or event boundary, and navigation tools open relay |
| 6 | // pages in the current tab. |
| 7 | |
| 8 | 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; } |
| 15 | |
| 16 | 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 = []; |
| 33 | |
| 34 | 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 | } |
| 42 | |
| 43 | 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" , signal |
| 47 | }); |
| 48 | return responseJSON(response); |
| 49 | } |
| 50 | |
| 51 | 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 | } |
| 58 | |
| 59 | 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 | } |
| 83 | |
| 84 | 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)); |
| 125 | |
| 126 | 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)); |
| 133 | |
| 134 | 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 | }); |
| 145 | |
| 146 | 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)); |
| 154 | |
| 155 | function open(path) { |
| 156 | const url = new URL(local(path), location.href); |
| 157 | location.assign(url.href); |
| 158 | return {opened: url.href}; |
| 159 | } |
| 160 | function repoURL(input) { |
| 161 | const params = new URLSearchParams(); |
| 162 | for (const key of ["owner" , "repo" , "ref" , "path" , "view" , "id" ]) { |
| 163 | if (input[key] !== undefined) params.set(key, input[key]); |
| 164 | } |
| 165 | return "/repo?" + params; |
| 166 | } |
| 167 | register("tiny.open_repository" , "Open a repository in this tab for browsing code, history or activity." , |
| 168 | object({...repository, id: hash, view: {type: "string" , enum: ["tree" , "file" , "history" , "commit" , "activity" , "issues" , "prs" , "issue" , "pr" ]}}, ["owner" , "repo" ]), {}, input => open(repoURL(input))); |
| 169 | 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." , |
| 170 | object({hash}, ["hash" ]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash))); |
| 171 | register("tiny.open_status" , "Open the relay health page in this tab: build versions, service status and browser tool readiness." , object(), {}, () => open("/manage/health" )); |
| 172 | register("tiny.open_files" , "Open the Files page in this tab, where files and folders upload and shared items wait." , object(), {}, () => open("/files" )); |
| 173 | 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." , |
| 174 | object({id: hash}), {}, input => open("/approvals" + (input.id ? "?id=" + encodeURIComponent(input.id) : "" ))); |
| 175 | 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." , |
| 176 | object({d: pageName, version: hash, merge: hash, edit: {type: "boolean" }}), {}, input => { |
| 177 | if (!input.d) return open("/wiki" ); |
| 178 | const params = new URLSearchParams(); |
| 179 | if (input.version) params.set("version" , input.version); |
| 180 | if (input.merge) params.set("merge" , input.merge); |
| 181 | if (input.edit) params.set("edit" , "1" ); |
| 182 | const search = params.toString(); |
| 183 | return open("/wiki/" + encodeURIComponent(input.d) + (search ? "?" + search : "" )); |
| 184 | }); |
| 185 | register("tiny.open_room" , "Open a chat room in this tab, or the rooms list when no id is given." , |
| 186 | object({id: room}), {}, input => open(input.id ? "/rooms/" + encodeURIComponent(input.id) : "/rooms" )); |
| 187 | |
| 188 | // post_message signs a chat message with the connected signer and |
| 189 | // publishes it once. Keys named in mentions become p tags. |
| 190 | const publishEvent = async (unsigned, signal) => { |
| 191 | if (typeof window.tiny?.signing?.publish !== "function" ) throw new Error("The event signing API is still loading. Try again." ); |
| 192 | return window.tiny.signing.publish(unsigned, {signal}); |
| 193 | }; |
| 194 | async function postMessage(input, signal) { |
| 195 | const content = String(input.text || "" ).trim(); |
| 196 | if (!content) throw new Error("Enter a message." ); |
| 197 | if (!window.tinySignedFetch) throw new Error("The signer is still loading. Try again." ); |
| 198 | const tags = [["h" , input.room]]; |
| 199 | for (const key of input.mentions || []) if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p" , key]); |
| 200 | const event = await publishEvent({kind: 9, created_at: Math.floor(Date.now() / 1000), tags, content}, signal); |
| 201 | return {id: event.id, room: input.room, created_at: event.created_at}; |
| 202 | } |
| 203 | register("tiny.post_message" , "Post a chat message to a room as the connected signer. mentions lists 64-character hex keys to notify." , |
| 204 | object({room, text: {type: "string" , minLength: 1, maxLength: 4000}, mentions: {type: "array" , items: pubkey}}, ["room" , "text" ]), changes, postMessage); |
| 205 | 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." , |
| 206 | object({target: {type: "string" , minLength: 1, maxLength: 512}}, ["target" ]), {}, input => open("/open?target=" + encodeURIComponent(input.target))); |
| 207 | register("tiny.read_notifications" , "Read whether this device receives relay notifications, which categories it chose and the browser permission state." , |
| 208 | object(), {readOnlyHint: true}, () => { |
| 209 | const categories = (localStorage.getItem("tiny.push.categories" ) || "" ).split("," ).filter(Boolean); |
| 210 | return { |
| 211 | supported: "PushManager" in window && "Notification" in window, |
| 212 | permission: typeof Notification === "function" ? Notification.permission : "unsupported" , |
| 213 | enabled: Boolean(localStorage.getItem("tiny.push" )), |
| 214 | categories: categories.length ? categories : ["messages" , "replies" , "mentions" , "approvals" , "relay" ], |
| 215 | note: "Enabling notifications needs a person to press Enable on this device under Inbox." |
| 216 | }; |
| 217 | }); |
| 218 | |
| 219 | function control(name, description, schema, method, params) { |
| 220 | register(name, description, schema, changes, (input, signal) => manage(method, params(input), signal)); |
| 221 | } |
| 222 | control("tiny.send_test_notification" , "Send the relay's test notice to the owner's inbox and enabled devices. Owner only." , object(), "notifytest" , () => []); |
| 223 | control("tiny.run_job" , "Queue an existing job to run now. Inspect job status to check its completion." , |
| 224 | object({id}, ["id" ]), "runjob" , input => [input.id]); |
| 225 | 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." , |
| 226 | object({id, kind: {type: "string" , enum: ["pull" , "push" , "import" , "mirror" , "dump" , "backup" ]}, |
| 227 | relays: {type: "array" , items: text}, filter: {...text, description: "Nostr filter encoded as a JSON object string." }, |
| 228 | every: {type: "integer" , minimum: 0, description: "Interval in hours. Zero runs once." }, discoverPubKey: pubkey}, ["id" , "kind" ]), |
| 229 | "addjob" , input => [input]); |
| 230 | control("tiny.remove_job" , "Remove a job and cancel its pending runs." , object({id}, ["id" ]), "removejob" , input => [input.id]); |
| 231 | control("tiny.pause_agent" , "Pause an agent. Its grant stays and the relay rejects its events until it is resumed." , |
| 232 | object({agent: pubkey}, ["agent" ]), "pauseagent" , input => [input.agent]); |
| 233 | control("tiny.resume_agent" , "Resume a paused agent so it may publish again." , |
| 234 | object({agent: pubkey}, ["agent" ]), "resumeagent" , input => [input.agent]); |
| 235 | control("tiny.revoke_agent" , "Revoke an agent's grant. The agent loses its role at once; only a new grant restores it." , |
| 236 | object({agent: pubkey}, ["agent" ]), "revokeagent" , input => [input.agent]); |
| 237 | 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." , |
| 238 | object({pubkey}, ["pubkey" ]), "approvejoin" , input => [input.pubkey]); |
| 239 | control("tiny.deny_join" , "Deny an access request. The key stays outside the relay and may ask again. Owner or moderator." , |
| 240 | object({pubkey}, ["pubkey" ]), "denyjoin" , input => [input.pubkey]); |
| 241 | 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." , |
| 242 | object({id}, ["id" ]), "pausecallback" , input => [input.id]); |
| 243 | control("tiny.resume_callback" , "Resume a paused event callback by id and clear its failure count." , |
| 244 | object({id}, ["id" ]), "resumecallback" , input => [input.id]); |
| 245 | control("tiny.remove_callback" , "Delete an event callback by id." , |
| 246 | object({id}, ["id" ]), "removecallback" , input => [input.id]); |
| 247 | control("tiny.backup_now" , "Queue a backup of relay data. Inspect jobs and backups to check completion." , object(), "backupnow" , () => []); |
| 248 | control("tiny.dump_now" , "Queue an event export. Inspect jobs and dumps to check completion." , object(), "dumpnow" , () => []); |
| 249 | control("tiny.set_connections" , "Replace the relay's connection list. Read the current list before editing it." , |
| 250 | object({connections: {type: "array" , items: {type: "object" }}}, ["connections" ]), "setconnections" , input => [input.connections]); |
| 251 | control("tiny.set_policy" , "Apply a partial relay policy update. Read the current policy before changing access, delivery or features. Requires the owner signer." , |
| 252 | object({patch: {type: "object" , minProperties: 1}}, ["patch" ]), "setpolicy" , input => [input.patch]); |
| 253 | |
| 254 | state.ready = Promise.all(pending).then(() => { state.registered.sort(); announce(); }); |
| 255 | })(); |