| 1 | diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go |
| 2 | index 85d12b1..84a48b5 100644 |
| 3 | --- a/internal/webui/webmcp.go |
| 4 | +++ b/internal/webui/webmcp.go |
| 5 | @@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) { |
| 6 | return |
| 7 | } |
| 8 | } |
| 9 | - allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true} |
| 10 | + allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true} |
| 11 | if !allowed[method] { |
| 12 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"}) |
| 13 | return |
| 14 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 15 | index 1f3da64..3405416 100644 |
| 16 | --- a/internal/webui/webmcp.js |
| 17 | +++ b/internal/webui/webmcp.js |
| 18 | @@ -112,6 +112,11 @@ |
| 19 | return manage(input.method, [], signal); |
| 20 | }); |
| 21 | |
| 22 | + 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.", |
| 23 | + object(), reads, (input, signal) => manage("listagents", [], signal)); |
| 24 | + register("tiny.read_agent", "Read one agent's grant and its ten newest events. Requires a signed-in owner or moderator session.", |
| 25 | + object({agent: pubkey}, ["agent"]), reads, (input, signal) => query("browseagent", input, signal)); |
| 26 | + |
| 27 | function open(path) { |
| 28 | const url = new URL(local(path), location.href); |
| 29 | location.assign(url.href); |
| 30 | @@ -156,6 +161,12 @@ |
| 31 | every: {type: "integer", minimum: 0, description: "Interval in hours. Zero runs once."}, discoverPubKey: pubkey}, ["id", "kind"]), |
| 32 | "addjob", input => [input]); |
| 33 | control("tiny.remove_job", "Remove a job and cancel its pending runs.", object({id}, ["id"]), "removejob", input => [input.id]); |
| 34 | + control("tiny.pause_agent", "Pause an agent. Its grant stays and the relay rejects its events until it is resumed.", |
| 35 | + object({agent: pubkey}, ["agent"]), "pauseagent", input => [input.agent]); |
| 36 | + control("tiny.resume_agent", "Resume a paused agent so it may publish again.", |
| 37 | + object({agent: pubkey}, ["agent"]), "resumeagent", input => [input.agent]); |
| 38 | + control("tiny.revoke_agent", "Revoke an agent's grant. The agent loses its role at once; only a new grant restores it.", |
| 39 | + object({agent: pubkey}, ["agent"]), "revokeagent", input => [input.agent]); |
| 40 | control("tiny.backup_now", "Queue a backup of relay data. Inspect jobs and backups to check completion.", object(), "backupnow", () => []); |
| 41 | control("tiny.dump_now", "Queue an event export. Inspect jobs and dumps to check completion.", object(), "dumpnow", () => []); |
| 42 | control("tiny.set_connections", "Replace the relay's connection list. Read the current list before editing it.", |
| 43 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 44 | index 06e4c9b..f18c63c 100644 |
| 45 | --- a/scripts/webmcp.test.mjs |
| 46 | +++ b/scripts/webmcp.test.mjs |
| 47 | @@ -152,3 +152,33 @@ test("link, files and notification tools reach the new surfaces", async () => { |
| 48 | assert.equal(calls[0].url, "/r/work/manage/rpc"); |
| 49 | assert.equal(calls[0].body.method, "notifytest"); |
| 50 | }); |
| 51 | + |
| 52 | +test("agent tools list through the signer, read through the session and control through management", async () => { |
| 53 | + const calls = [], queries = []; |
| 54 | + const {tools} = await browser({path: "/r/work/manage/agents", fetch: async url => { |
| 55 | + queries.push(new URL(url, "https://tiny.example")); |
| 56 | + return new Response(JSON.stringify({agent: {name: "hermes"}, events: []})); |
| 57 | + }, signedFetch: async (url, method, body) => { |
| 58 | + calls.push({url, method, body: JSON.parse(body)}); |
| 59 | + return new Response(JSON.stringify({result: [{name: "hermes", paused: false}]})); |
| 60 | + }}); |
| 61 | + const agent = "a".repeat(64); |
| 62 | + const list = tools.get("tiny.list_agents"); |
| 63 | + assert.equal(list.annotations.readOnlyHint, true); |
| 64 | + const listed = await list.execute({}); |
| 65 | + assert.equal(listed.structuredContent.result[0].name, "hermes"); |
| 66 | + assert.deepEqual(calls[0].body, {method: "listagents", params: []}); |
| 67 | + assert.equal(calls[0].url, "/r/work/manage/rpc"); |
| 68 | + const read = await tools.get("tiny.read_agent").execute({agent}); |
| 69 | + assert.equal(queries[0].pathname, "/r/work/webmcp/query"); |
| 70 | + assert.equal(queries[0].searchParams.get("method"), "browseagent"); |
| 71 | + assert.deepEqual(JSON.parse(queries[0].searchParams.get("params")), [{agent}]); |
| 72 | + assert.equal(read.structuredContent.result.agent.name, "hermes"); |
| 73 | + for (const [name, method] of [["tiny.pause_agent", "pauseagent"], ["tiny.resume_agent", "resumeagent"], ["tiny.revoke_agent", "revokeagent"]]) { |
| 74 | + const tool = tools.get(name); |
| 75 | + assert.equal(tool.annotations.consequentialHint, true); |
| 76 | + assert.deepEqual([...tool.inputSchema.required], ["agent"]); |
| 77 | + await tool.execute({agent}); |
| 78 | + assert.deepEqual(calls.at(-1).body, {method, params: [agent]}); |
| 79 | + } |
| 80 | +}); |