1import assert from "node:assert/strict";2import { readFile } from "node:fs/promises";3import test from "node:test";4import vm from "node:vm";56const script = await readFile(new URL("../tinyclient/webmcp.js", import.meta.url), "utf8");78async function browser({path = "/tools", failRegistration = "", fetch, signedFetch} = {}) {9 const tools = new Map();10 const sandbox = {11 document: {modelContext: {registerTool(tool) {12 if (tool.name === failRegistration) return Promise.reject(new Error("Registration denied"));13 tools.set(tool.name, tool);14 }}},15 navigator: {}, URL, URLSearchParams, CustomEvent,16 location: {pathname: path, href: "https://tiny.example" + path,17 assign(url) { sandbox.opened = url; }},18 dispatchEvent() {}, fetch,19 tinySignedFetch: signedFetch20 };21 sandbox.window = sandbox;22 const root = (path.match(/^\/r\/[^/]+/) || [""])[0];23 sandbox.tiny = {signing: {publish: async (unsigned, {signal} = {}) => {24 if (!sandbox.nostr?.signEvent) throw new Error("Connect a Nostr signer first.");25 if (typeof sandbox.NostrSigner?.verifyEvent !== "function") throw new Error("The signer verifier is still loading. Try again.");26 const expected = JSON.stringify(unsigned);27 const event = await sandbox.nostr.signEvent(JSON.parse(expected));28 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});29 if (actual !== expected || sandbox.NostrSigner.verifyEvent(event) !== true) throw new Error("The signer returned an invalid or changed event.");30 if (signal?.aborted) throw new Error("Sending canceled.");31 if (!signedFetch) throw new Error("The signer is still loading. Try again.");32 const response = await signedFetch(root + "/events", "POST", JSON.stringify(event), {contentType: "application/json", signal});33 const value = JSON.parse(await response.text());34 if (value.accepted !== true) throw new Error(value.message || "The relay rejected the message.");35 return event;36 }}};37 vm.runInNewContext(script, sandbox);38 await sandbox.tinyWebMCP.ready;39 return {tools, sandbox};40}4142test("read tools keep tenant scope and return backend data", async () => {43 let request;44 const {tools} = await browser({path: "/r/work/tools", fetch: async (url, options) => {45 request = {url, options};46 return new Response(JSON.stringify({items: [{identifier: "notes"}]}));47 }});48 const result = await tools.get("tiny.list_repositories").execute({q: "notes"});49 const url = new URL(request.url, "https://tiny.example");50 assert.equal(url.pathname, "/r/work/webmcp/query");51 assert.equal(request.options.method, "GET");52 assert.equal(url.searchParams.get("method"), "browserepos");53 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{q: "notes"}]);54 assert.equal(result.structuredContent.result.items[0].identifier, "notes");55});5657test("backup and policy controls await signed JSON RPC and preserve parameter types", async () => {58 const requests = [];59 let finish;60 const completed = new Promise(resolve => { finish = resolve; });61 const {tools, sandbox} = await browser({signedFetch: async (url, method, body, options) => {62 requests.push({url, method, body: JSON.parse(body), options});63 await completed;64 return new Response(JSON.stringify({result: {id: "backup-1"}}));65 }});66 const pending = tools.get("tiny.backup_now").execute({});67 assert.equal(sandbox.tinyWebMCP.lastOperation, "tiny.backup_now: running");68 finish();69 const result = await pending;70 assert.equal(result.structuredContent.result.id, "backup-1");71 assert.deepEqual(requests[0].body, {method: "backupnow", params: []});72 assert.equal(requests[0].options.contentType, "application/json");73 await tools.get("tiny.set_policy").execute({patch: {delivery: {enabled: true}}});74 assert.deepEqual(requests[1].body.params, [{delivery: {enabled: true}}]);75 await tools.get("tiny.set_connections").execute({connections: []});76 assert.deepEqual(requests[2].body.params, [[]]);77});7879test("server refusal fails the tool and is visible in operation status", async () => {80 const {tools, sandbox} = await browser({signedFetch: async () =>81 new Response(JSON.stringify({error: "owner required"}), {status: 403})});82 await assert.rejects(tools.get("tiny.set_policy").execute({patch: {writes: "owner"}}), /owner required/);83 assert.match(sandbox.tinyWebMCP.lastOperation, /owner required/);84});8586test("repository storage reads pass the selected owner and repository", async () => {87 let request;88 const {tools} = await browser({signedFetch: async (_, __, body) => {89 request = JSON.parse(body);90 return new Response(JSON.stringify({result: {bytes: 123}}));91 }});92 const read = tools.get("tiny.read_management");93 await assert.rejects(read.execute({method: "gitstorage"}), /repository owner and name/);94 await read.execute({method: "gitstorage", owner: "a".repeat(64), repo: "notes"});95 assert.deepEqual(request.params, ["a".repeat(64), "notes"]);96});9798test("navigation actually opens the selected repository without changing tenant", async () => {99 const {tools, sandbox} = await browser({path: "/r/work/tools"});100 await tools.get("tiny.open_repository").execute({owner: "a".repeat(64), repo: "notes", path: "hello world.go", view: "file"});101 const url = new URL(sandbox.opened);102 assert.equal(url.pathname, "/r/work/repo");103 assert.equal(url.searchParams.get("path"), "hello world.go");104 assert.equal(url.searchParams.get("view"), "file");105});106107test("registration failures stay visible while other tools remain available", async () => {108 const {sandbox} = await browser({failRegistration: "tiny.backup_now"});109 assert.equal(sandbox.tinyWebMCP.errors.length, 1);110 assert.match(sandbox.tinyWebMCP.errors[0], /tiny.backup_now: Registration denied/);111 assert.ok(sandbox.tinyWebMCP.registered.includes("tiny.read_status"));112 assert.ok(!sandbox.tinyWebMCP.registered.includes("tiny.backup_now"));113});114115test("cancellation reaches the fetch request", async () => {116 const controller = new AbortController();117 let received;118 const {tools} = await browser({fetch: async (_, options) => {119 received = options.signal;120 return new Response("{}");121 }});122 await tools.get("tiny.list_files").execute({}, {signal: controller.signal});123 assert.equal(received, controller.signal);124});125126test("collaboration tools preserve repository, event and filter scope", async () => {127 const requests = [];128 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {129 requests.push(new URL(url, "https://tiny.example"));130 return new Response(JSON.stringify({items: [], item: {title: "Issue"}}));131 }});132 const repo = {owner: "a".repeat(64), repo: "notes"};133 for (const [name, method, extra] of [134 ["tiny.list_issues", "browseissues", {q: "bug", state: "resolved", limit: 5}],135 ["tiny.read_issue", "browseissue", {event: "b".repeat(64)}],136 ["tiny.list_pull_requests", "browsepulls", {state: "merged"}],137 ["tiny.read_pull_request", "browsepull", {event: "c".repeat(64)}]138 ]) {139 const tool = tools.get(name);140 assert.equal(tool.annotations.readOnlyHint, true);141 await tool.execute({...repo, ...extra});142 const url = requests.at(-1);143 assert.equal(url.pathname, "/r/work/webmcp/query");144 assert.equal(url.searchParams.get("method"), method);145 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);146 }147});148149test("approval tools list, read and open requests for a decision", async () => {150 const requests = [];151 const {tools, sandbox} = await browser({path: "/r/work/approvals", fetch: async url => {152 requests.push(new URL(url, "https://tiny.example"));153 return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open"}], counts: {open: 1}}));154 }});155 const list = tools.get("tiny.list_approvals");156 assert.equal(list.annotations.readOnlyHint, true);157 const listed = await list.execute({state: "open", limit: 5});158 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");159 assert.equal(requests.at(-1).searchParams.get("method"), "browseapprovals");160 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", limit: 5}]);161 assert.equal(listed.structuredContent.result.counts.open, 1);162 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,answered,expired,all");163 const read = tools.get("tiny.read_approval");164 assert.equal(read.annotations.readOnlyHint, true);165 assert.equal(read.inputSchema.required.join(","), "id");166 await read.execute({id: "b".repeat(64)});167 assert.equal(requests.at(-1).searchParams.get("method"), "browseapproval");168 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);169 await tools.get("tiny.open_approvals").execute({});170 assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals");171 await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)});172 const opened = new URL(sandbox.opened);173 assert.equal(opened.pathname, "/r/work/approvals");174 assert.equal(opened.searchParams.get("id"), "c".repeat(64));175 assert.equal(opened.searchParams.get("answer"), null);176});177178test("job tools list and read long tasks through the session", async () => {179 const requests = [];180 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {181 requests.push(new URL(url, "https://tiny.example"));182 return new Response(JSON.stringify({items: [{id: "a".repeat(64), kind: 5001, state: "open", status: "processing"}], next_cursor: "",183 item: {id: "b".repeat(64), kind: 5002, state: "done"}, feedback: [{status: "processing"}], results: [{kind: 6002}]}));184 }});185 const list = tools.get("tiny.list_jobs");186 assert.equal(list.annotations.readOnlyHint, true);187 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,all");188 assert.equal(list.inputSchema.properties.mine.type, "boolean");189 const listed = await list.execute({state: "open", mine: true, limit: 5});190 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");191 assert.equal(requests.at(-1).searchParams.get("method"), "browsejobs");192 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", mine: true, limit: 5}]);193 assert.equal(listed.structuredContent.result.items[0].status, "processing");194 const read = tools.get("tiny.read_job");195 assert.equal(read.annotations.readOnlyHint, true);196 assert.deepEqual([...read.inputSchema.required], ["id"]);197 const detail = await read.execute({id: "b".repeat(64)});198 assert.equal(requests.at(-1).searchParams.get("method"), "browsejob");199 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);200 assert.equal(detail.structuredContent.result.item.state, "done");201 assert.equal(detail.structuredContent.result.results[0].kind, 6002);202});203204test("link, files and notification tools reach the new surfaces", async () => {205 const calls = [];206 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {207 calls.push({url, method, body: JSON.parse(body)});208 return new Response(JSON.stringify({result: true}), {status: 200});209 }});210 sandbox.localStorage = {getItem: key => ({"tiny.push": "https://push.example/x", "tiny.push.categories": "replies,relay"})[key] ?? null};211 await tools.get("tiny.open_link").execute({target: "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2"});212 const opened = new URL(sandbox.opened);213 assert.equal(opened.pathname, "/r/work/open");214 assert.equal(opened.searchParams.get("target"), "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2");215 await tools.get("tiny.open_files").execute({});216 assert.equal(new URL(sandbox.opened).pathname, "/r/work/files");217 const status = await tools.get("tiny.read_notifications").execute({});218 assert.equal(status.structuredContent.result.enabled, true);219 assert.equal(status.structuredContent.result.categories.join(","), "replies,relay");220 assert.equal(status.structuredContent.result.permission, "unsupported");221 await tools.get("tiny.send_test_notification").execute({});222 assert.equal(calls[0].url, "/r/work/manage/rpc");223 assert.equal(calls[0].body.method, "notifytest");224});225226test("agent tools list through the signer, read through the session and control through management", async () => {227 const calls = [], queries = [];228 const {tools} = await browser({path: "/r/work/manage/agents", fetch: async url => {229 queries.push(new URL(url, "https://tiny.example"));230 return new Response(JSON.stringify({agent: {name: "hermes"}, events: []}));231 }, signedFetch: async (url, method, body) => {232 calls.push({url, method, body: JSON.parse(body)});233 return new Response(JSON.stringify({result: [{name: "hermes", paused: false}]}));234 }});235 const agent = "a".repeat(64);236 const list = tools.get("tiny.list_agents");237 assert.equal(list.annotations.readOnlyHint, true);238 const listed = await list.execute({});239 assert.equal(listed.structuredContent.result[0].name, "hermes");240 assert.deepEqual(calls[0].body, {method: "listagents", params: []});241 assert.equal(calls[0].url, "/r/work/manage/rpc");242 const read = await tools.get("tiny.read_agent").execute({agent});243 assert.equal(queries[0].pathname, "/r/work/webmcp/query");244 assert.equal(queries[0].searchParams.get("method"), "browseagent");245 assert.deepEqual(JSON.parse(queries[0].searchParams.get("params")), [{agent}]);246 assert.equal(read.structuredContent.result.agent.name, "hermes");247 for (const [name, method] of [["tiny.pause_agent", "pauseagent"], ["tiny.resume_agent", "resumeagent"], ["tiny.revoke_agent", "revokeagent"]]) {248 const tool = tools.get(name);249 assert.equal(tool.annotations.consequentialHint, true);250 assert.deepEqual([...tool.inputSchema.required], ["agent"]);251 await tool.execute({agent});252 assert.deepEqual(calls.at(-1).body, {method, params: [agent]});253 }254});255256test("access request tools list through the signer and decide by pubkey", async () => {257 const calls = [];258 const asker = "b".repeat(64);259 const {tools} = await browser({path: "/r/work/manage/people", signedFetch: async (url, method, body) => {260 calls.push({url, method, body: JSON.parse(body)});261 const request = JSON.parse(body);262 if (request.method === "listjoinrequests") {263 return new Response(JSON.stringify({result: [{pubkey: asker, reason: "Met you at the meetup", requested_at: 1700000000, status: "pending", decided_by: "", decided_at: 0}]}));264 }265 return new Response(JSON.stringify({result: {pubkey: asker, status: request.method === "approvejoin" ? "approved" : "denied"}}));266 }});267 const list = tools.get("tiny.list_join_requests");268 assert.equal(list.annotations.readOnlyHint, true);269 const listed = await list.execute({});270 assert.equal(listed.structuredContent.result[0].pubkey, asker);271 assert.equal(listed.structuredContent.result[0].status, "pending");272 assert.equal(calls[0].url, "/r/work/manage/rpc");273 assert.equal(calls[0].method, "POST");274 assert.deepEqual(calls[0].body, {method: "listjoinrequests", params: []});275 for (const [name, method, status] of [["tiny.approve_join", "approvejoin", "approved"], ["tiny.deny_join", "denyjoin", "denied"]]) {276 const tool = tools.get(name);277 assert.equal(tool.annotations.consequentialHint, true);278 assert.deepEqual([...tool.inputSchema.required], ["pubkey"]);279 assert.equal(tool.inputSchema.properties.pubkey.pattern, "^[0-9a-f]{64}$");280 const result = await tool.execute({pubkey: asker});281 assert.deepEqual(calls.at(-1).body, {method, params: [asker]});282 assert.equal(result.structuredContent.result.status, status);283 }284});285286test("callback tools list through the signer and control by id", async () => {287 const calls = [];288 const {tools} = await browser({path: "/r/work/manage/agents", signedFetch: async (url, method, body) => {289 calls.push({url, method, body: JSON.parse(body)});290 return new Response(JSON.stringify({result: [{id: "cb-1", host: "hooks.example", paused: false}]}));291 }});292 const list = tools.get("tiny.list_callbacks");293 assert.equal(list.annotations.readOnlyHint, true);294 const listed = await list.execute({});295 assert.equal(listed.structuredContent.result[0].host, "hooks.example");296 assert.equal(calls[0].url, "/r/work/manage/rpc");297 assert.deepEqual(calls[0].body, {method: "listcallbacks", params: []});298 assert.equal(tools.get("tiny.add_callback"), undefined);299 for (const [name, method] of [["tiny.pause_callback", "pausecallback"], ["tiny.resume_callback", "resumecallback"], ["tiny.remove_callback", "removecallback"]]) {300 const tool = tools.get(name);301 assert.equal(tool.annotations.consequentialHint, true);302 assert.deepEqual([...tool.inputSchema.required], ["id"]);303 await tool.execute({id: "cb-1"});304 assert.deepEqual(calls.at(-1).body, {method, params: ["cb-1"]});305 }306});307308test("wiki tools read pages and merge requests and open pages in the tenant", async () => {309 const requests = [];310 const {tools, sandbox} = await browser({path: "/r/work/wiki", fetch: async url => {311 requests.push(new URL(url, "https://tiny.example"));312 return new Response(JSON.stringify({items: [{d: "release-notes-1-4"}], d: "release-notes-1-4", merge: {status: "open"}}));313 }});314 for (const [name, method, input] of [315 ["tiny.list_wiki", "browsewiki", {q: "release", limit: 10}],316 ["tiny.read_wiki_page", "browsewikipage", {d: "Release Notes 1.4", version: "b".repeat(64)}],317 ["tiny.read_merge_request", "browsewikimerge", {id: "c".repeat(64)}]318 ]) {319 const tool = tools.get(name);320 assert.equal(tool.annotations.readOnlyHint, true);321 const result = await tool.execute(input);322 const url = requests.at(-1);323 assert.equal(url.pathname, "/r/work/webmcp/query");324 assert.equal(url.searchParams.get("method"), method);325 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);326 assert.equal(result.structuredContent.result.d, "release-notes-1-4");327 }328 const open = tools.get("tiny.open_wiki_page");329 assert.equal(open.annotations.readOnlyHint, undefined);330 await open.execute({});331 assert.equal(new URL(sandbox.opened).pathname, "/r/work/wiki");332 await open.execute({d: "日本語 Article", merge: "c".repeat(64)});333 let opened = new URL(sandbox.opened);334 assert.equal(opened.pathname, "/r/work/wiki/" + encodeURIComponent("日本語 Article"));335 assert.equal(opened.searchParams.get("merge"), "c".repeat(64));336 await open.execute({d: "release-notes-1-4", edit: true, version: "b".repeat(64)});337 opened = new URL(sandbox.opened);338 assert.equal(opened.pathname, "/r/work/wiki/release-notes-1-4");339 assert.equal(opened.searchParams.get("edit"), "1");340 assert.equal(opened.searchParams.get("version"), "b".repeat(64));341});342343test("room tools read rooms and threads, open rooms and post signed messages", async () => {344 const {finalizeEvent, generateSecretKey, verifyEvent} = await import("nostr-tools/pure");345 const requests = [], calls = [];346 const {tools, sandbox} = await browser({path: "/r/work/tools", fetch: async url => {347 requests.push(new URL(url, "https://tiny.example"));348 return new Response(JSON.stringify({items: [{id: "build"}]}));349 }, signedFetch: async (url, method, body, options) => {350 calls.push({url, method, body: JSON.parse(body), options});351 return new Response(JSON.stringify(calls.length > 1 ? {accepted: false, message: "restricted: members only"} : {accepted: true}));352 }});353 const secret = generateSecretKey();354 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), secret)};355 sandbox.NostrSigner = {verifyEvent};356 for (const [name, method, input] of [357 ["tiny.list_rooms", "browserooms", {limit: 5}],358 ["tiny.read_room", "browseroom", {id: "build", cursor: "c1"}],359 ["tiny.read_thread", "browsethread", {id: "build", event: "a".repeat(64)}]360 ]) {361 const tool = tools.get(name);362 assert.equal(tool.annotations.readOnlyHint, true);363 await tool.execute(input);364 const url = requests.at(-1);365 assert.equal(url.pathname, "/r/work/webmcp/query");366 assert.equal(url.searchParams.get("method"), method);367 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);368 }369 await tools.get("tiny.open_room").execute({id: "build"});370 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms/build");371 await tools.get("tiny.open_room").execute({});372 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms");373 const post = tools.get("tiny.post_message");374 assert.equal(post.annotations.consequentialHint, true);375 const result = await post.execute({room: "build", text: "hello", mentions: ["b".repeat(64), "b".repeat(64)]});376 assert.equal(calls[0].url, "/r/work/events");377 assert.equal(calls[0].method, "POST");378 assert.equal(calls[0].options.contentType, "application/json");379 assert.equal(calls[0].body.kind, 9);380 assert.deepEqual(calls[0].body.tags, [["h", "build"], ["p", "b".repeat(64)]]);381 assert.equal(verifyEvent(calls[0].body), true);382 assert.equal(result.structuredContent.result.id, calls[0].body.id);383 await assert.rejects(post.execute({room: "build", text: "again"}), /members only/);384 await assert.rejects(post.execute({room: "build", text: " "}), /Enter a message/);385});386387test("the profile tool reads through the session and defaults to the signed-in key", async () => {388 const requests = [];389 const {tools} = await browser({path: "/r/work/profile", fetch: async url => { requests.push(new URL(url, "https://tiny.example")); return new Response(JSON.stringify({pubkey: "a".repeat(64), profile: {name: "dami"}, relays: []})); }});390 const tool = tools.get("tiny.read_profile");391 assert.equal(tool.annotations.readOnlyHint, true);392 const own = await tool.execute({});393 assert.equal(requests[0].searchParams.get("method"), "browseprofile");394 assert.deepEqual(JSON.parse(requests[0].searchParams.get("params")), [{}]);395 assert.equal(own.structuredContent.result.profile.name, "dami");396 await tool.execute({pubkey: "b".repeat(64)});397 assert.deepEqual(JSON.parse(requests[1].searchParams.get("params")), [{pubkey: "b".repeat(64)}]);398});399400test("post_message fails closed when the signer verifier is unavailable", async () => {401 const calls = [];402 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (...args) => {403 calls.push(args);404 return new Response(JSON.stringify({accepted: true}));405 }});406 const {finalizeEvent, generateSecretKey} = await import("nostr-tools/pure");407 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), generateSecretKey())};408 await assert.rejects(tools.get("tiny.post_message").execute({room: "build", text: "hello"}), /verifier is still loading/);409 assert.equal(calls.length, 0);410});