From 96c914f6b044d04d65a30a53b729eb9126815d37 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 30 Oct 2026 13:16:28 -0400 Subject: [PATCH 1/2] feat(buzz-mcp): let agents make announce channels with join_role channel_create and channel_update now take join_role (member or guest), so an agent with only the in-app tools can make an announce channel: an open channel where anyone can join and read, but only members can post. channel_get shows "join as guest" for such a channel. The relay takes join_role only on kind:9002, so channel_create sets it with a second event after the channel exists, and after the template's agents are added: an add with no role tag gives join_role, so those agents would otherwise join as guests. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/buzz-mcp/tools.test.ts | 112 ++++++++++++++++++++++++++++++++++++- src/buzz-mcp/tools.ts | 50 +++++++++++++++-- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index be6d611b5b..11fb1ef0b5 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1720,9 +1720,19 @@ it("refuses channel arguments that do not fit, before publishing", async () => { await expect( channel("channel_create", { name: "x", visibility: "open", ttl: 1.5 }), ).rejects.toThrow("ttl must be a whole number of seconds."); + await expect( + channel("channel_create", { + name: "x", + visibility: "open", + join_role: "admin", + }), + ).rejects.toThrow("join_role must be member or guest."); await expect(channel("channel_update", {})).rejects.toThrow( - "Give name, about, visibility, ttl, topic or purpose.", + "Give name, about, visibility, ttl, join_role, topic or purpose.", ); + await expect( + channel("channel_update", { join_role: "owner" }), + ).rejects.toThrow("join_role must be member or guest."); await expect( callTool(client, { channel: "chan" }, "channel_leave", {}), ).rejects.toThrow("Invalid channel ID"); @@ -1760,6 +1770,106 @@ it("shows a channel's details and changes them as the channel shows", async () = ); }); +it("makes a channel an announce channel and back", async () => { + const { client, published } = relay(); + const context = { channel: room }; + await expect( + callTool(client, context, "channel_update", { join_role: "guest" }), + ).resolves.toBe(`updated ${room}`); + await expect(callTool(client, context, "channel_get", {})).resolves.toBe( + `${room} plans (stream, open, join as guest)\nabout: roadmap\nyou: owner`, + ); + await expect( + callTool(client, context, "channel_update", { join_role: "member" }), + ).resolves.toBe(`updated ${room}`); + expect(published.map((template) => template.tags)).toEqual([ + [ + ["h", room], + ["join_role", "guest"], + ], + [ + ["h", room], + ["join_role", "member"], + ], + ]); +}); + +it("creates an announce channel after adding its template's agents", async () => { + const { client, published } = relay(); + client.templates = vi.fn(async () => [ + { name: "Team", canvas: "", agents: [alice] }, + ]); + const answer = await callTool(client, {}, "channel_create", { + name: "team", + visibility: "open", + join_role: "guest", + template: "Team", + }); + const id = published[0]?.tags[0]?.[1] as string; + // Set last: an agent added with no role would otherwise join as a guest. + expect(published.map((template) => [template.kind, template.tags])).toEqual([ + [ + 9007, + [ + ["h", id], + ["name", "team"], + ["visibility", "open"], + ["channel_type", "stream"], + ], + ], + [ + 9000, + [ + ["h", id], + ["p", alice], + ], + ], + [ + 9002, + [ + ["h", id], + ["join_role", "guest"], + ], + ], + ]); + expect(answer).toBe( + [ + `created ${id}`, + `added ${alice} Alice Smith`, + "new people join as guests", + ].join("\n"), + ); + await expect( + callTool(client, { channel: id }, "channel_get", {}), + ).resolves.toBe(`${id} team (stream, open, join as guest)\nyou: owner`); +}); + +it("reports a new channel whose join_role was refused", async () => { + const { client, published } = relay(); + const publish = client.publish; + client.publish = vi.fn(async (template: Template) => { + if (template.kind === 9002) throw new Error("restricted: admins only"); + return publish(template); + }); + const answer = await callTool(client, {}, "channel_create", { + name: "news", + visibility: "open", + join_role: "guest", + }); + const id = published[0]?.tags[0]?.[1] as string; + expect(published.map((template) => template.kind)).toEqual([9007]); + expect(answer).toBe( + `created ${id}\njoin_role not set: restricted: admins only`, + ); + // member is the default, so it needs no second event. + await callTool(client, {}, "channel_create", { + name: "chat", + visibility: "open", + join_role: "member", + }); + expect(published.map((template) => template.kind)).toEqual([9007, 9007]); +}); + it("leaves, joins, archives, unarchives and deletes a channel", async () => { const { client, published } = relay(); const context = { channel: room }; diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 63649ac2b4..ecd827db90 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -25,6 +25,7 @@ import { attestedOwner } from "../features/agents/owner-attestation"; import type { BuzzClient, BuzzEvent, + ChannelTemplate, Context, Filter, Memory, @@ -51,6 +52,12 @@ const ttl = { type: "integer", description: "Seconds without a message before it archives; 0: never.", } as const; +const joinRole = { + type: "string", + enum: ["member", "guest"], + description: + "The role people get when they join. guest on an open channel makes an announce channel: anyone can read, only members can post.", +} as const; const tool = ( name: string, description: string, @@ -185,6 +192,7 @@ export const TOOLS: readonly Tool[] = [ "system: left out of ordinary channel lists; anyone can still list an open one.", }, ttl, + join_role: joinRole, template: str, }, ["name", "visibility"], @@ -195,6 +203,7 @@ export const TOOLS: readonly Tool[] = [ about: str, visibility: { type: "string", enum: ["open", "private"] }, ttl, + join_role: joinRole, topic: str, purpose: str, }), @@ -1356,6 +1365,7 @@ class Tools { tag(metadata, "ttl") ? `archives after ${tag(metadata, "ttl")}s idle` : "", + tag(metadata, "join_role") === "guest" ? "join as guest" : "", ].filter(Boolean); return [ `${channel} ${tag(metadata, "name") ?? ""} (${flags.join(", ")})`, @@ -1376,6 +1386,7 @@ class Tools { const type = args.type ?? "stream"; if (type !== "stream" && type !== "forum" && type !== "system") throw new Error("type must be stream, forum or system."); + const role = join(args); // Found first, so a wrong name creates nothing. const template = args.template === undefined @@ -1396,9 +1407,25 @@ class Tools { }); const shown = await until(() => this.metadata(id), Boolean).catch(unread); if (!shown || shown instanceof Error) - return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}.`; + return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}${role === "guest" ? "; join_role was not set" : ""}.`; const lines = [`created ${id}`]; - if (!template) return lines.join("\n"); + if (template) lines.push(...(await this.apply(id, template))); + // Last, so the template's agents join as members, not guests. + if (role === "guest") + try { + const answer = await this.update(id, { join_role: role }); + lines.push( + answer === `updated ${id}` ? "new people join as guests" : answer, + ); + } catch (error) { + lines.push(`join_role not set: ${reason(error)}`); + } + return lines.join("\n"); + } + + /** Starts a new channel's Canvas and adds its agents from `template`. */ + async apply(id: string, template: ChannelTemplate) { + const lines: string[] = []; // The template's own agents, as Buzz adds them; each is tried. const agents = template.agents.filter((key) => key !== this.client.pubkey); if (template.canvas.trim()) @@ -1417,10 +1444,10 @@ class Tools { lines.push( `Canvas not started${agents.length ? ", so no agents were added" : ""}: ${reason(error)}`, ); - return lines.join("\n"); + return lines; } if (agents.length) lines.push(...(await this.change(id, agents, [])).lines); - return lines.join("\n"); + return lines; } /** The owner's template named `name`, as one, without guessing. */ @@ -1461,8 +1488,12 @@ class Tools { } if (args.ttl !== undefined) wanted.push(["ttl", String(seconds(args.ttl) ?? "")]); + const role = join(args); + if (role) wanted.push(["join_role", role]); if (!wanted.length) - throw new Error("Give name, about, visibility, ttl, topic or purpose."); + throw new Error( + "Give name, about, visibility, ttl, join_role, topic or purpose.", + ); const value = (key: string) => wanted.find((t) => t[0] === key)?.[1]; checkDetails( value("name"), @@ -2118,6 +2149,15 @@ function seconds(value: unknown) { return value; } +/** A `join_role` argument: undefined when absent. */ +function join(args: Args) { + const value = args.join_role; + if (value === undefined) return undefined; + if (value !== "member" && value !== "guest") + throw new Error("join_role must be member or guest."); + return value; +} + /** A `since` or `before` argument: a Unix time in seconds, inclusive. */ function unixTime(args: Args, key: string) { const value = args[key]; From 0d06af4fb61c942f43d532f79ef03d086bf5e82a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 30 Oct 2026 13:22:18 -0400 Subject: [PATCH 2/2] fix(buzz-mcp): say when an add makes a guest; keep guest to open channels From Auto's review of #1996: - members: an add with no role gets the channel's join_role, so on an announce channel the person becomes a guest. The answer now says "added as guest" (any role the roster shows other than member). - channel_create and channel_update refuse join_role guest on a private channel, as the channel details UI does not offer it there. - The join_role description says it also applies to adds with no role. - The test relay gives a no-role add the channel's join_role, and new tests cover guest with a refused Canvas and an unreadable channel. - Rename the join(args) helper to joinRoleArg. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/buzz-mcp/tools.test.ts | 67 ++++++++++++++++++++++++++++++++++++-- src/buzz-mcp/tools.ts | 25 +++++++++++--- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 11fb1ef0b5..978e8e6072 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1006,7 +1006,15 @@ function relay(refused: Readonly> = {}) { } if (kind === 9000 || kind === 9021) { const key = kind === 9021 ? me : target; - const role = template.tags.find((t) => t[0] === "role")?.[1] ?? "member"; + // Like the relay, with no role asked for a member keeps theirs and a + // new one gets the channel's join_role. + const tags = (kind: number) => + state.find((e) => e.kind === kind && e.tags[0]?.[1] === id)?.tags ?? []; + const role = + template.tags.find((t) => t[0] === "role")?.[1] ?? + tags(39002).find((t) => t[0] === "p" && t[1] === key)?.[3] ?? + tags(39000).find((t) => t[0] === "join_role")?.[1] ?? + "member"; put(39002, id, (tags) => [...without(tags, key), ["p", key, "", role]]); } if (kind === 9001 || kind === 9022) @@ -1727,6 +1735,16 @@ it("refuses channel arguments that do not fit, before publishing", async () => { join_role: "admin", }), ).rejects.toThrow("join_role must be member or guest."); + await expect( + channel("channel_create", { + name: "x", + visibility: "private", + join_role: "guest", + }), + ).rejects.toThrow("join_role guest is for open channels."); + await expect( + channel("channel_update", { visibility: "private", join_role: "guest" }), + ).rejects.toThrow("join_role guest is for open channels."); await expect(channel("channel_update", {})).rejects.toThrow( "Give name, about, visibility, ttl, join_role, topic or purpose.", ); @@ -1795,7 +1813,7 @@ it("makes a channel an announce channel and back", async () => { }); it("creates an announce channel after adding its template's agents", async () => { - const { client, published } = relay(); + const { client, published, state } = relay(); client.templates = vi.fn(async () => [ { name: "Team", canvas: "", agents: [alice] }, ]); @@ -1842,6 +1860,51 @@ it("creates an announce channel after adding its template's agents", async () => await expect( callTool(client, { channel: id }, "channel_get", {}), ).resolves.toBe(`${id} team (stream, open, join as guest)\nyou: owner`); + const roster = () => + state.find((e) => e.kind === 39002 && e.tags[0]?.[1] === id)?.tags; + expect(roster()).toContainEqual(["p", alice, "", "member"]); + // Now anyone added with no role is a guest, and the answer says so. + await expect( + callTool(client, { channel: id }, "members", { add: [bob] }), + ).resolves.toBe(`added ${bob} as guest`); + expect(roster()).toContainEqual(["p", bob, "", "guest"]); +}); + +it("sets join_role when the template's Canvas is refused, and says when it cannot", async () => { + const { client, published } = relay(); + const publish = client.publish; + client.publish = vi.fn(async (template: Template) => { + if (template.kind === 40100) throw new Error("canvas refused"); + return publish(template); + }); + client.templates = vi.fn(async () => [ + { name: "Team", canvas: "# Plan", agents: [alice] }, + ]); + const create = () => + callTool(client, {}, "channel_create", { + name: "team", + visibility: "open", + join_role: "guest", + template: "Team", + }); + let answer = await create(); + let id = published[0]?.tags[0]?.[1] as string; + expect(published.map((template) => template.kind)).toEqual([9007, 9002]); + expect(answer).toBe( + `created ${id}\nCanvas not started, so no agents were added: canvas refused\nnew people join as guests`, + ); + const query = client.query; + client.query = vi.fn(async (filters: readonly Filter[]) => { + if (filters.some((f) => (f.kinds as number[]).includes(39000))) + throw new Error("kind 39000 unavailable"); + return query(filters); + }); + answer = await create(); + id = published.at(-1)?.tags[0]?.[1] as string; + expect(published.at(-1)?.kind).toBe(9007); + expect(answer).toBe( + `The community accepted channel ${id} but its state could not be read back: kind 39000 unavailable; the template was not applied; join_role was not set.`, + ); }); it("reports a new channel whose join_role was refused", async () => { diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index ecd827db90..ef4980c0c1 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -56,7 +56,7 @@ const joinRole = { type: "string", enum: ["member", "guest"], description: - "The role people get when they join. guest on an open channel makes an announce channel: anyone can read, only members can post.", + "Role for people who join or are added without one. guest on an open channel: anyone reads, only members post.", } as const; const tool = ( name: string, @@ -1386,7 +1386,9 @@ class Tools { const type = args.type ?? "stream"; if (type !== "stream" && type !== "forum" && type !== "system") throw new Error("type must be stream, forum or system."); - const role = join(args); + const role = joinRoleArg(args); + if (role === "guest" && args.visibility === "private") + throw new Error("join_role guest is for open channels."); // Found first, so a wrong name creates nothing. const template = args.template === undefined @@ -1488,7 +1490,13 @@ class Tools { } if (args.ttl !== undefined) wanted.push(["ttl", String(seconds(args.ttl) ?? "")]); - const role = join(args); + const role = joinRoleArg(args); + if ( + role === "guest" && + (args.visibility ?? (has(metadata, "private") ? "private" : "open")) === + "private" + ) + throw new Error("join_role guest is for open channels."); if (role) wanted.push(["join_role", role]); if (!wanted.length) throw new Error( @@ -1562,13 +1570,20 @@ class Tools { ); const who = (key: string) => names.has(key) ? `${key} ${names.get(key)}` : key; + // With no role asked for, the relay gives a new member the channel's + // join_role, which is guest on an announce channel. + const shown = (key: string) => + role ?? + (roster instanceof Error + ? undefined + : roster.find((m) => m.pubkey === key)?.role); const report = (key: string, verb: string, done?: typeof added) => refused.has(key) ? `not ${verb} ${who(key)}: ${refused.get(key)}` : roster instanceof Error ? `${who(key)}: the community accepted it but ${roster.message}` : done?.(roster, key) - ? `${verb} ${who(key)}${verb === "added" && role ? ` as ${role}` : ""}` + ? `${verb} ${who(key)}${verb === "added" && (role || shown(key) !== "member") ? ` as ${shown(key)}` : ""}` : `${who(key)}: the community accepted it but does not show it yet`; return { changed: refused.size < add.length + remove.length, @@ -2150,7 +2165,7 @@ function seconds(value: unknown) { } /** A `join_role` argument: undefined when absent. */ -function join(args: Args) { +function joinRoleArg(args: Args) { const value = args.join_role; if (value === undefined) return undefined; if (value !== "member" && value !== "guest")