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 (kind 43001) visible to your account, each with its room, requester, assignees, subject, state (open, done, failed or cancelled), status (queued, accepted, running, done, failed or cancelled), newest progress, result with its artifacts, error and cancel time. state narrows the list; mine lists only your own requests.",114 object({cursor: text, limit, state: {type: "string", enum: ["open", "done", "failed", "cancelled", "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 settled state and its answers oldest first: accepted, progress, result, cancel and error events.",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})();