tinyrelayfile
tinyrelay/tinyclient/webmcp.js/

tinyclient/webmcp.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs